trails/openspec/specs/connected-services/spec.md
Ullrich Schäfer 7cd785e937
Archive deepen-connected-services + sync spec deltas
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>
2026-05-08 01:36:19 +02:00

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_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 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 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/<name>/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
  • 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