diff --git a/openspec/changes/deepen-connected-services/.openspec.yaml b/openspec/changes/archive/2026-05-08-deepen-connected-services/.openspec.yaml similarity index 100% rename from openspec/changes/deepen-connected-services/.openspec.yaml rename to openspec/changes/archive/2026-05-08-deepen-connected-services/.openspec.yaml diff --git a/openspec/changes/deepen-connected-services/design.md b/openspec/changes/archive/2026-05-08-deepen-connected-services/design.md similarity index 100% rename from openspec/changes/deepen-connected-services/design.md rename to openspec/changes/archive/2026-05-08-deepen-connected-services/design.md diff --git a/openspec/changes/deepen-connected-services/proposal.md b/openspec/changes/archive/2026-05-08-deepen-connected-services/proposal.md similarity index 100% rename from openspec/changes/deepen-connected-services/proposal.md rename to openspec/changes/archive/2026-05-08-deepen-connected-services/proposal.md diff --git a/openspec/changes/deepen-connected-services/specs/connected-services/spec.md b/openspec/changes/archive/2026-05-08-deepen-connected-services/specs/connected-services/spec.md similarity index 100% rename from openspec/changes/deepen-connected-services/specs/connected-services/spec.md rename to openspec/changes/archive/2026-05-08-deepen-connected-services/specs/connected-services/spec.md diff --git a/openspec/changes/deepen-connected-services/specs/wahoo-import/spec.md b/openspec/changes/archive/2026-05-08-deepen-connected-services/specs/wahoo-import/spec.md similarity index 100% rename from openspec/changes/deepen-connected-services/specs/wahoo-import/spec.md rename to openspec/changes/archive/2026-05-08-deepen-connected-services/specs/wahoo-import/spec.md diff --git a/openspec/changes/deepen-connected-services/specs/wahoo-route-push/spec.md b/openspec/changes/archive/2026-05-08-deepen-connected-services/specs/wahoo-route-push/spec.md similarity index 100% rename from openspec/changes/deepen-connected-services/specs/wahoo-route-push/spec.md rename to openspec/changes/archive/2026-05-08-deepen-connected-services/specs/wahoo-route-push/spec.md diff --git a/openspec/changes/deepen-connected-services/tasks.md b/openspec/changes/archive/2026-05-08-deepen-connected-services/tasks.md similarity index 98% rename from openspec/changes/deepen-connected-services/tasks.md rename to openspec/changes/archive/2026-05-08-deepen-connected-services/tasks.md index c7a9d93..5d7898e 100644 --- a/openspec/changes/deepen-connected-services/tasks.md +++ b/openspec/changes/archive/2026-05-08-deepen-connected-services/tasks.md @@ -46,4 +46,4 @@ - [x] 7.1 Update `CONTEXT.md` if any term was sharpened during implementation. - [x] 7.2 File a follow-up issue/note to amend `openspec/changes/komoot-import/design.md` to use `connected_services` + `web-login` credential kind instead of the drafted `journal.integrations` table. -- [ ] 7.3 At archive time, apply the spec deltas in `specs/connected-services/`, `specs/wahoo-import/`, `specs/wahoo-route-push/` to `openspec/specs/`. +- [x] 7.3 At archive time, apply the spec deltas in `specs/connected-services/`, `specs/wahoo-import/`, `specs/wahoo-route-push/` to `openspec/specs/`. diff --git a/openspec/specs/connected-services/spec.md b/openspec/specs/connected-services/spec.md index 8d6b0ac..8fb5b71 100644 --- a/openspec/specs/connected-services/spec.md +++ b/openspec/specs/connected-services/spec.md @@ -1,7 +1,7 @@ # connected-services Specification ## Purpose -Third-party service connections (Wahoo today; future Strava, Garmin, etc.) that the user opts into from the Journal's settings page. Covers OAuth-based connect / disconnect flows and the storage layout for tokens. Token refresh behavior, webhook ingestion, and the per-service import rules live with each service's own change (e.g. `wahoo-import`). +Third-party service connections (Wahoo today; future Strava, Garmin, Komoot, Apple Health, etc.) that the user opts into from the Journal's settings page. Covers connect / disconnect flows, the storage layout for credentials of any kind, and the capability seams (`Importer`, `RoutePusher`, `WebhookReceiver`) that providers implement. Per-provider mechanics live with each service's own change (e.g. `wahoo-import`). ## Requirements @@ -14,17 +14,45 @@ The Journal SHALL expose a Connected services page at `/settings/connections` (o - **AND** connected state shows a "Disconnect" button that POSTs to `/api/sync/disconnect/` - **AND** disconnected state shows a "Connect Wahoo" button that begins the OAuth handshake -### Requirement: OAuth token storage in `sync_connections` -External-service OAuth tokens SHALL be stored in the `journal.sync_connections` table keyed by `(user_id, provider)`. Each row SHALL persist `access_token`, `refresh_token`, `expires_at`, and the provider-side user id (`provider_user_id`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities. +### Requirement: OAuth token storage in `connected_services` +External-service credentials SHALL be stored in the `journal.connected_services` table (renamed from `sync_connections`) keyed by `(user_id, provider)`. Each row SHALL persist a `credential_kind` discriminator (`oauth | web-login | device`), a `credentials` JSONB blob whose schema is determined by the kind, the provider-side user id (`provider_user_id`, nullable for kinds without one), and OAuth-specific `granted_scopes` as a top-level column (populated only when `credential_kind = 'oauth'`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities. + +For `credential_kind = 'oauth'`, the `credentials` blob SHALL contain `access_token`, `refresh_token`, and `expires_at`. Other kinds (web-login, device) carry their own blob shapes and are introduced by their respective consumer changes; the enum value is reserved from day one so adding a kind does not require a migration. #### Scenario: Wahoo connect persists tokens - **WHEN** a user completes the Wahoo OAuth flow -- **THEN** a `sync_connections` row is upserted with `provider = 'wahoo'`, the access/refresh tokens, the provider user id, and `expires_at` +- **THEN** a `connected_services` row is upserted with `provider = 'wahoo'`, `credential_kind = 'oauth'`, the `credentials` JSONB containing access/refresh tokens and `expires_at`, the provider user id, and the granted scopes #### Scenario: Disconnect removes the row but keeps imports - **WHEN** a user clicks "Disconnect" on a Wahoo connection -- **THEN** the matching `sync_connections` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing) +- **THEN** the matching `connected_services` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing) #### Scenario: Each user has at most one row per provider - **WHEN** a user reconnects an already-connected provider -- **THEN** the existing `sync_connections` row is updated in place with the fresh tokens; no duplicate row is created +- **THEN** the existing `connected_services` row is updated in place with the fresh credentials; no duplicate row is created + +### Requirement: Capability seams for providers +The system SHALL model each external provider as a manifest declaring its `credential_kind` and which capabilities it implements. Capabilities are modelled as separate interfaces — `Importer`, `RoutePusher`, `WebhookReceiver` — and a provider implements only the subset that applies to it. There SHALL NOT be a unified provider interface that lists every capability with optional methods. + +#### Scenario: Add a new provider +- **WHEN** a developer adds a new provider (e.g. Garmin) +- **THEN** they create `providers//manifest.ts` declaring the provider's `credential_kind` and the capability adapters it implements +- **AND** they implement only the relevant capability interfaces (e.g. an OAuth-pull-only provider implements `Importer` only; not `RoutePusher` or `WebhookReceiver`) +- **AND** they register the manifest by importing it in `providers/registry.ts` +- **AND** existing settings UI, webhook routing, and credential lifecycle work without further changes + +### Requirement: Centralized credential lifecycle via `ConnectedServiceManager` +The system SHALL expose a `ConnectedServiceManager` that owns credential lifecycle for all providers. Importers, pushers, and webhook handlers SHALL obtain credentials exclusively via `withFreshCredentials(serviceId, fn)`, which loads the row, refreshes via the kind-appropriate `CredentialAdapter` if expired, calls `fn(credentials)`, and flips `status = needs_relink` if refresh fails. Capability adapters SHALL NOT read the `credentials` JSONB directly. + +#### Scenario: Expired OAuth token is refreshed transparently +- **WHEN** a capability adapter calls `withFreshCredentials` for a `connected_services` row whose `expires_at` has passed +- **THEN** the manager invokes the OAuth `CredentialAdapter`'s refresh flow +- **AND** updates the `credentials` JSONB with the new tokens and `expires_at` +- **AND** invokes `fn` with the fresh credentials +- **AND** the capability adapter never sees the expired token + +#### Scenario: Refresh failure flips status to needs_relink +- **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token) +- **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row +- **AND** raises an error that the caller surfaces as a re-connect prompt +- **AND** subsequent calls for the same service short-circuit until the user re-links diff --git a/openspec/specs/wahoo-import/spec.md b/openspec/specs/wahoo-import/spec.md index 0053c3b..0f056e3 100644 --- a/openspec/specs/wahoo-import/spec.md +++ b/openspec/specs/wahoo-import/spec.md @@ -5,13 +5,14 @@ Provider-agnostic activity sync framework with Wahoo as the first provider, supp ## Requirements ### Requirement: Provider-agnostic sync framework -The system SHALL provide a common interface for external activity sync providers. +The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at `providers//manifest.ts` listing its `credential_kind` (`oauth | web-login | device`) and the capability adapters it implements (`Importer`, `RoutePusher`, `WebhookReceiver`). There SHALL NOT be a unified `SyncProvider` interface containing every capability with optional methods. #### Scenario: Add new provider - **WHEN** a developer wants to add a new sync provider (e.g., Garmin) -- **THEN** they implement the `SyncProvider` interface in a single file -- **AND** register it in the provider registry -- **AND** all OAuth, webhook, import, and settings UI works automatically +- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities +- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider) +- **AND** they register the manifest in `providers/registry.ts` +- **AND** OAuth flows, webhook routing, and settings UI work without further changes ### Requirement: Connect Wahoo account Users SHALL be able to connect their Wahoo account via OAuth2. @@ -20,20 +21,21 @@ Users SHALL be able to connect their Wahoo account via OAuth2. - **WHEN** a user clicks "Connect Wahoo" in journal settings - **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write` - **AND** after granting permission, redirected back to the journal -- **AND** access and refresh tokens are stored in `sync_connections` -- **AND** the granted scopes are recorded in `sync_connections.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo +- **AND** access and refresh tokens are stored in `connected_services` (in the `credentials` JSONB blob, with `credential_kind = 'oauth'`) +- **AND** the granted scopes are recorded in `connected_services.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo #### Scenario: Disconnect Wahoo - **WHEN** a user clicks "Disconnect" next to their Wahoo connection -- **THEN** the stored tokens are deleted from `sync_connections` +- **THEN** the stored credentials are deleted from `connected_services` #### Scenario: Token refresh - **WHEN** a Wahoo API call fails with an expired token -- **THEN** the refresh token is used to obtain a new access token automatically +- **THEN** the OAuth `CredentialAdapter` is invoked via `ConnectedServiceManager.withFreshCredentials` to obtain a new access token automatically +- **AND** the new tokens are written back to the `credentials` JSONB blob #### Scenario: Existing connection without routes_write - **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route) -- **THEN** the system detects the missing scope from `sync_connections.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope +- **THEN** the system detects the missing scope from `connected_services.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope - **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work ### Requirement: Webhook-based automatic sync diff --git a/openspec/specs/wahoo-route-push/spec.md b/openspec/specs/wahoo-route-push/spec.md index be79dcc..f84548e 100644 --- a/openspec/specs/wahoo-route-push/spec.md +++ b/openspec/specs/wahoo-route-push/spec.md @@ -9,12 +9,12 @@ The Journal SHALL show a "Send to Wahoo" button on the route detail page when al #### Scenario: Owner with connected Wahoo and a route with geometry - **WHEN** the route owner loads a route detail page for a route that has geometry -- **AND** the owner has a `sync_connections` row with `provider = 'wahoo'` +- **AND** the owner has a `connected_services` row with `provider = 'wahoo'` - **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action #### Scenario: Owner without a connected Wahoo account - **WHEN** the route owner loads a route detail page -- **AND** the owner has no Wahoo `sync_connections` row +- **AND** the owner has no Wahoo `connected_services` row - **THEN** the "Send to Wahoo" button is not rendered #### Scenario: Non-owner viewing the route @@ -88,7 +88,7 @@ The Journal SHALL detect when a connected Wahoo account lacks the `routes_write` #### Scenario: Existing connection lacks routes_write - **WHEN** the route owner clicks "Send to Wahoo" -- **AND** the user's `sync_connections.granted_scopes` does not include `routes_write` +- **AND** the user's `connected_services.granted_scopes` does not include `routes_write` - **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: , push_after: true }` - **AND** no Wahoo `/v1/routes` call is attempted @@ -135,3 +135,18 @@ The route detail page SHALL show whether the route has been pushed to Wahoo and #### Scenario: Push failed previously shows retry affordance - **WHEN** the route owner views a route whose `sync_pushes` row has `error` set and `pushed_at` null - **THEN** the page shows "Last attempt failed: " with a "Send to Wahoo" button still active + +### Requirement: `RoutePusher` capability seam +The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface. + +#### Scenario: Wahoo pusher implements the seam without leaking workarounds +- **WHEN** the route push action invokes the Wahoo `RoutePusher` +- **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}` +- **AND** the FIT-Course conversion, the `external_id = route:` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter +- **AND** callers do not pass FIT data or Wahoo-specific fields across the seam + +#### Scenario: Future pusher reuses the same seam +- **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin) +- **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape +- **AND** the route push action invokes it identically to the Wahoo pusher +- **AND** their format conversion and HTTP recovery live inside the adapter