Task 7.3: applies the MODIFIED + ADDED requirements from
changes/deepen-connected-services/specs/ into openspec/specs/:
- connected-services/spec.md:
- MODIFIED: OAuth token storage (renamed sync_connections → connected_services
with credential_kind discriminator + JSONB credentials shape).
- ADDED: Capability seams for providers (Importer / RoutePusher /
WebhookReceiver per ADR-0002, no unified SyncProvider).
- ADDED: Centralized credential lifecycle via ConnectedServiceManager.
- wahoo-import/spec.md: Provider-agnostic framework rewritten to reference
capability seams + per-provider manifest. Token refresh now goes through
withFreshCredentials. Renames sync_connections → connected_services
throughout.
- wahoo-route-push/spec.md: Renames sync_connections → connected_services.
ADDED: RoutePusher capability seam — shape (service, route) →
{remoteId, version} per ADR-0003; Wahoo workarounds stay inside the
adapter.
Change directory moved to openspec/changes/archive/2026-05-08-deepen-connected-services/.
29/29 tasks complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.4 KiB
connected-services Specification
Purpose
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
Requirement: Connections settings page at /settings/connections
The Journal SHALL expose a Connected services page at /settings/connections (one of the four sub-pages of /settings). The page SHALL list each external integration the Journal supports, each row showing the connection state (Connect / Disconnect) and the link to start the OAuth flow when not connected.
Scenario: Wahoo connection status renders both states
- WHEN a user loads
/settings/connections - THEN the page lists Wahoo as connected or disconnected
- AND connected state shows a "Disconnect" button that POSTs to
/api/sync/disconnect/<provider> - AND disconnected state shows a "Connect Wahoo" button that begins the OAuth handshake
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
connected_servicesrow is upserted withprovider = 'wahoo',credential_kind = 'oauth', thecredentialsJSONB containing access/refresh tokens andexpires_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
connected_servicesrow 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
connected_servicesrow 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/<name>/manifest.tsdeclaring the provider'scredential_kindand the capability adapters it implements - AND they implement only the relevant capability interfaces (e.g. an OAuth-pull-only provider implements
Importeronly; notRoutePusherorWebhookReceiver) - 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
withFreshCredentialsfor aconnected_servicesrow whoseexpires_athas passed - THEN the manager invokes the OAuth
CredentialAdapter's refresh flow - AND updates the
credentialsJSONB with the new tokens andexpires_at - AND invokes
fnwith 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 theconnected_servicesrow - 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