trails/openspec/specs/connected-services/spec.md
Ullrich Schäfer 0cf87b72ab
Fix medium-severity spec drift across 17 specs
- authentication-methods: document completeAuth mode param ("redirect"|"json");
  clarify add-passkey nudge (no dismiss mechanism, disappears on passkey add)
- journal-auth: session maxAge is 30 days; terms allow-list uses /legal/ prefix
  matching (broader than fixed list of paths)
- session-notes: mark awareness isolation and UndoManager isolation as not yet
  implemented (shared instances in current code)
- activity-feed: add fan-out scenario for visibility change to public
- explore: note that ?perPage is not yet implemented (hardcoded page size)
- multi-day-routes: add per-day GPX track split scenario (splitByDays option);
  document overnight vs isDayBreak naming gap
- osm-poi-overlays: debounce is 800ms + 2000ms min interval (not 500ms);
  retry is not automatic (fires on next viewport change)
- brouter-integration: rate limit corrected to 300/hour; add segment-cache
  requirement (client caches per-pair segments)
- wahoo-route-push: OAuth state shape uses camelCase (returnTo, pushAfter
  object) not snake_case with push_after boolean
- komoot-import: document noop adapter / ConnectedServiceManager bypass;
  note four Komoot-specific routes that bypass the generic OAuth framework
- background-jobs: exponential backoff not wired (retryLimit only); add SIGINT
- connected-services: add revoked status; name ConnectionNotActiveError
- infrastructure: add INTEGRATION_SECRET and SENTRY_DSN to env var lists;
  split secret decryption scenario by workflow (cd-apps vs cd-infra)
- secret-management: correct CD decryption — cd-apps only decrypts app.env;
  cd-infra decrypts both
- transactional-emails: welcome email is async (pg-boss job); magic-link email
  includes 6-digit numeric code
- journal-route-detail: websites are https: links (not mailto:); opening_hours
  is also displayed
- local-dev-environment: add mobile app (Expo) dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:11:31 +02:00

5.6 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 a ConnectionNotActiveError that the caller surfaces as a re-connect prompt
  • AND subsequent calls for the same service short-circuit until the user re-links

Note: the status column has three valid values: active, needs_relink, and revoked. The revoked state is set when the user explicitly disconnects but the row is retained for audit purposes.