Reshape the sync-providers seam before Komoot (web-login) and Apple Health (device) adapters land. Captures the decisions in three ADRs, seeds CONTEXT.md with Connected Services vocabulary, and proposes the OpenSpec change covering schema rename + ConnectedServiceManager + capability seams (Importer / RoutePusher / WebhookReceiver). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.2 KiB
MODIFIED Requirements
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
ADDED Requirements
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