Add deepen-connected-services architecture artifacts

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>
This commit is contained in:
Ullrich Schäfer 2026-05-08 00:10:49 +02:00
parent ede480e652
commit cfba3146e2
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 595 additions and 0 deletions

View file

@ -0,0 +1,46 @@
## 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_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
## 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.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 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

View file

@ -0,0 +1,58 @@
## MODIFIED Requirements
### Requirement: Provider-agnostic sync framework
The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at `providers/<name>/manifest.ts` listing its `credential_kind` (`oauth | web-login | device`) and the capability adapters it implements (`Importer`, `RoutePusher`, `WebhookReceiver`). There SHALL NOT be a unified `SyncProvider` interface containing every capability with optional methods.
#### Scenario: Add new provider
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities
- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider)
- **AND** they register the manifest in `providers/registry.ts`
- **AND** OAuth flows, webhook routing, and settings UI work without further changes
### Requirement: Connect Wahoo account
Users SHALL be able to connect their Wahoo account via OAuth2.
#### Scenario: Connect Wahoo
- **WHEN** a user clicks "Connect Wahoo" in journal settings
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write`
- **AND** after granting permission, redirected back to the journal
- **AND** access and refresh tokens are stored in `connected_services` (in the `credentials` JSONB blob, with `credential_kind = 'oauth'`)
- **AND** the granted scopes are recorded in `connected_services.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
#### Scenario: Disconnect Wahoo
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
- **THEN** the stored credentials are deleted from `connected_services`
#### Scenario: Token refresh
- **WHEN** a Wahoo API call fails with an expired token
- **THEN** the OAuth `CredentialAdapter` is invoked via `ConnectedServiceManager.withFreshCredentials` to obtain a new access token automatically
- **AND** the new tokens are written back to the `credentials` JSONB blob
#### Scenario: Existing connection without routes_write
- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route)
- **THEN** the system detects the missing scope from `connected_services.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work
### Requirement: Webhook-based automatic sync
New Wahoo workouts SHALL be automatically imported when they complete.
#### Scenario: Webhook receives new workout
- **WHEN** Wahoo sends a `workout_summary` webhook to `/api/sync/webhook/wahoo`
- **THEN** the system identifies the user via `provider_user_id`
- **AND** downloads the FIT file from Wahoo's CDN (without auth headers, as CDN URLs are pre-signed)
- **AND** converts it to GPX
- **AND** creates a journal activity with the GPX, stats, and PostGIS geometry
- **AND** records the import in `sync_imports` to prevent duplicates
#### Scenario: Webhook for workout without file
- **WHEN** a webhook arrives for a workout with no FIT file URL
- **THEN** the activity is created without GPX or geometry
#### Scenario: Duplicate webhook
- **WHEN** a webhook arrives for a workout already imported
- **THEN** the import is skipped silently (idempotent)
#### Scenario: Unknown user webhook
- **WHEN** a webhook arrives with a `provider_user_id` not matching any connection
- **THEN** the request is ignored with a 200 response (don't reveal user existence)

View file

@ -0,0 +1,59 @@
## MODIFIED Requirements
### Requirement: Send to Wahoo action on route detail page
The Journal SHALL show a "Send to Wahoo" button on the route detail page when all of the following hold: the viewer owns the route, the viewer has a connected Wahoo account, and the route has stored geometry (`routes.geom` is non-null and `routes.gpx` is non-empty). The button SHALL trigger a server action that pushes the current route version to Wahoo.
#### Scenario: Owner with connected Wahoo and a route with geometry
- **WHEN** the route owner loads a route detail page for a route that has geometry
- **AND** the owner has a `connected_services` row with `provider = 'wahoo'`
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
#### Scenario: Owner without a connected Wahoo account
- **WHEN** the route owner loads a route detail page
- **AND** the owner has no Wahoo `connected_services` row
- **THEN** the "Send to Wahoo" button is not rendered
#### Scenario: Non-owner viewing the route
- **WHEN** any visitor who is not the route owner loads the route detail page
- **THEN** the "Send to Wahoo" button is not rendered, regardless of the viewer's own Wahoo connection
#### Scenario: Route without geometry
- **WHEN** the route owner loads a route detail page for a route whose `geom` is null or whose `gpx` is empty
- **THEN** the "Send to Wahoo" button is not rendered
### Requirement: Re-auth flow when `routes_write` scope is missing
The Journal SHALL detect when a connected Wahoo account lacks the `routes_write` scope before calling Wahoo, redirect the user through OAuth to grant it, and resume the push automatically after the user returns.
#### Scenario: Existing connection lacks routes_write
- **WHEN** the route owner clicks "Send to Wahoo"
- **AND** the user's `connected_services.granted_scopes` does not include `routes_write`
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }`
- **AND** no Wahoo `/v1/routes` call is attempted
#### Scenario: Push completes after re-auth
- **WHEN** the user returns from Wahoo's OAuth callback with `push_after = true` in the state
- **THEN** the connection is updated with the new scopes
- **AND** the push action runs automatically against the route encoded in `return_to`
- **AND** the user lands back on the route detail page with the "Sent to Wahoo" confirmation visible
#### Scenario: User declines the new scope
- **WHEN** the user reaches Wahoo's authorization page and clicks "Deny"
- **THEN** the user is redirected back to the route detail page with an inline notice "Sending to Wahoo needs route permission — please reconnect your account in Settings"
- **AND** no `sync_pushes` row is created
## ADDED Requirements
### Requirement: `RoutePusher` capability seam
The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface.
#### Scenario: Wahoo pusher implements the seam without leaking workarounds
- **WHEN** the route push action invokes the Wahoo `RoutePusher`
- **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}`
- **AND** the FIT-Course conversion, the `external_id = route:<route_id>` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter
- **AND** callers do not pass FIT data or Wahoo-specific fields across the seam
#### Scenario: Future pusher reuses the same seam
- **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin)
- **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape
- **AND** the route push action invokes it identically to the Wahoo pusher
- **AND** their format conversion and HTTP recovery live inside the adapter