- 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>
60 lines
5.6 KiB
Markdown
60 lines
5.6 KiB
Markdown
# 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
|
|
|
|
#### 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 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.
|