From cfba3146e24cd91fe19d7dc8886bb2eb269771a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 8 May 2026 00:10:49 +0200 Subject: [PATCH 1/7] 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) --- CONTEXT.md | 126 ++++++++++++++ ...ected-services-credential-discriminator.md | 19 ++ .../0002-no-unified-syncprovider-interface.md | 19 ++ docs/adr/0003-routepusher-seam-shape.md | 18 ++ .../deepen-connected-services/.openspec.yaml | 2 + .../deepen-connected-services/design.md | 163 ++++++++++++++++++ .../deepen-connected-services/proposal.md | 36 ++++ .../specs/connected-services/spec.md | 46 +++++ .../specs/wahoo-import/spec.md | 58 +++++++ .../specs/wahoo-route-push/spec.md | 59 +++++++ .../deepen-connected-services/tasks.md | 49 ++++++ 11 files changed, 595 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-connected-services-credential-discriminator.md create mode 100644 docs/adr/0002-no-unified-syncprovider-interface.md create mode 100644 docs/adr/0003-routepusher-seam-shape.md create mode 100644 openspec/changes/deepen-connected-services/.openspec.yaml create mode 100644 openspec/changes/deepen-connected-services/design.md create mode 100644 openspec/changes/deepen-connected-services/proposal.md create mode 100644 openspec/changes/deepen-connected-services/specs/connected-services/spec.md create mode 100644 openspec/changes/deepen-connected-services/specs/wahoo-import/spec.md create mode 100644 openspec/changes/deepen-connected-services/specs/wahoo-route-push/spec.md create mode 100644 openspec/changes/deepen-connected-services/tasks.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..e41990c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,126 @@ +# trails.cool domain glossary + +This file names the domain concepts used in the codebase. New terms get added +here as decisions crystallize during architecture work; the goal is that one +concept has one name everywhere — specs, code, conversations. + +If you're naming a new module, a new column, or a new UI surface, look here +first. If the term you need isn't here, propose it (don't invent a synonym). + +--- + +## Connected Services + +The user-facing surface for linking external accounts and devices to a Journal +account. Spec: `openspec/specs/connected-services/`. + +### ConnectedService +A single linked external account or device, owned by one user. Stored in the +`connected_services` table (renamed from `sync_connections`). At most one row +per `(user_id, provider)`. + +### provider +String identifier for the external system: `wahoo`, `komoot`, `apple-health`, +and future `coros`, `garmin`, `strava`. The provider determines the +`credential_kind` and which capabilities (import / push / webhook) the +connection has, via the provider's manifest. + +### credential kind +Discriminator on `connected_services` describing the credential shape stored +in the `credentials` JSONB blob. Three kinds today: + +- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the + expected shape for Coros / Garmin / Strava. +- **web-login** — email + encrypted password + session jar. Komoot. No + official API; we authenticate against the provider's normal web login and + reuse the resulting session cookies. Refresh = re-login. Web-login + breakage (form changes, captchas, password rotation) surfaces at the + import layer, not at the credential layer. +- **device** — no remote credential. Apple Health (and future Health Connect + on Android). Data arrives via authenticated mobile API uploads, not + server-initiated pulls. The `credentials` blob is empty; the connection + exists so the UI can show "Apple Health is paired." + +Credential kind is determined by the provider via its manifest, but stored +explicitly on the row so queries don't need to join the manifest. + +### granted_scopes +Column on `connected_services`, populated only for `credential_kind = oauth`, +NULL otherwise. Lists the OAuth scopes the user actually granted (e.g. +`routes_write`). Feature gates query this column directly; missing a scope +triggers re-authorization. + +### provider_user_id +The external service's identifier for the user. Used to route incoming +webhooks to the right local user. Nullable (Apple Health has none in the +remote-id sense). + +### CredentialAdapter +Per-kind module that knows how to maintain credentials of that kind: + +- `oauth.refresh(creds) → creds | NeedsRelink` +- `web-login.relogin(creds) → creds | InvalidCredentials` +- `device` — no-op + +Adapters do not import data, push routes, or handle webhooks. They only own +the credential lifecycle. + +### ConnectedServiceManager +The deep module callers see. Owns: + +- `link(userId, provider, credentials)` / `unlink(serviceId)` +- `withFreshCredentials(serviceId, fn)` — refreshes via the right + `CredentialAdapter` if expired, calls `fn(creds)`, marks the connection + `needs_relink` if refresh fails. +- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook + layers when they observe a credential failure (e.g. Komoot web-login + fails, Wahoo returns 401 after a successful refresh). + +Per-provider importers / pushers / webhook handlers always go through +`withFreshCredentials` — they never read the `credentials` JSONB directly. + +### provider manifest +Per-provider declaration co-located with the provider's code +(`providers/wahoo/manifest.ts`, etc.). Declares: + +- the provider's `credential_kind` +- which capabilities the provider implements (import? push? webhook?) +- references to the per-capability modules + +A small `providers/registry.ts` imports each manifest. Adding a provider is +one new directory plus one import line. + +## Sync Capabilities + +Three orthogonal capabilities a provider may implement. Each is its own seam +when there are ≥2 adapters; today most are single-adapter and held to a +named shape so the second adapter doesn't reshape the interface. + +### Importer +Pulls workouts / activities from the external service into the Journal. +Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all +implement this, with very different mechanics. Dedup via `sync_imports`. + +### RoutePusher +Pushes a Journal route out to the external service. One adapter today +(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it. + +The public seam is `pushRoute(connectedService, route) → {remoteId, version}`. +Provider-specific concerns — FIT Course conversion, the `route:` +`external_id` convention, the PUT→POST-on-404 fallback — are **handled +internally by the adapter**, not exposed on the seam. Idempotency is tracked +via `sync_pushes`. + +### WebhookReceiver +Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming +webhooks to the right user via `provider_user_id`. Unknown +`provider_user_id` returns 200 and is silently dropped (no leak). + +## Storage tables + +- `connected_services` — user ↔ provider links (renamed from + `sync_connections`). +- `sync_imports` — dedup cache for imported workouts, keyed by + `(user_id, provider, workout_id)`. +- `sync_pushes` — push state per `(user_id, route_id, provider)` → + `remote_id`, `last_pushed_version`. diff --git a/docs/adr/0001-connected-services-credential-discriminator.md b/docs/adr/0001-connected-services-credential-discriminator.md new file mode 100644 index 0000000..836f46c --- /dev/null +++ b/docs/adr/0001-connected-services-credential-discriminator.md @@ -0,0 +1,19 @@ +# Connected Services use a credential-kind discriminator + JSONB blob + +trails.cool integrates with external services whose credentials have +fundamentally different shapes: OAuth2 (Wahoo, future Coros/Garmin/Strava), +web-login session (Komoot — no official API, we authenticate against the +provider's web login with email + password and reuse the cookie jar), +and device pairing (Apple Health, future Health Connect — no remote +credential at all). We considered an OAuth-only schema with nullable extras +and per-kind tables, and rejected both: nullable-OAuth would force Komoot +and Apple Health to leave most columns NULL and bake OAuth assumptions into +queries; per-kind tables would fragment `connected_services` and complicate +the user-facing "Connected Services" list. + +We store all linked external accounts in one `connected_services` table +with a `credential_kind ∈ {oauth, web-login, device}` discriminator and a +`credentials` JSONB blob whose schema is determined by kind. OAuth-specific +`granted_scopes` stays a top-level column because feature gates query it +directly. Per-kind `CredentialAdapter` modules own the credential lifecycle +(refresh / relogin / noop); callers never read the JSONB directly. diff --git a/docs/adr/0002-no-unified-syncprovider-interface.md b/docs/adr/0002-no-unified-syncprovider-interface.md new file mode 100644 index 0000000..9feac38 --- /dev/null +++ b/docs/adr/0002-no-unified-syncprovider-interface.md @@ -0,0 +1,19 @@ +# No unified SyncProvider interface; capabilities are separate seams + +The `connected-services` spec calls for a "provider-agnostic framework," and +the obvious shape is a single `SyncProvider` interface with `connect / +refresh / importOne / pushRoute / handleWebhook`. We rejected this because +the providers we actually need to support are heterogeneous: Komoot is +import-only via web-login (no refresh, no webhooks, no push), Apple Health +is mobile-pushed (no server-initiated anything, no remote credential), +Wahoo has all four. A unified interface would be OAuth-shaped by default +and adapters would fill it with NOPs — shallow at the seam, leaky at the +adapters. + +Instead, capabilities are separate seams: `Importer`, `RoutePusher`, +`WebhookReceiver`. Each provider declares which capabilities it implements +in a co-located manifest (`providers//manifest.ts`); a small +`registry.ts` imports each manifest. Credential lifecycle is the one thing +shared across providers, and lives in `ConnectedServiceManager` + +`CredentialAdapter` (per kind). If we ever genuinely need pan-provider +behaviour, we add it to the manager — not to a provider interface. diff --git a/docs/adr/0003-routepusher-seam-shape.md b/docs/adr/0003-routepusher-seam-shape.md new file mode 100644 index 0000000..c6f0b20 --- /dev/null +++ b/docs/adr/0003-routepusher-seam-shape.md @@ -0,0 +1,18 @@ +# RoutePusher seam takes (service, route); workarounds stay inside adapters + +`RoutePusher` has one adapter today (Wahoo), but Coros, Garmin, and Strava +push are all foreseeable. The current Wahoo push code exposes its +workarounds — FIT Course conversion, the deterministic `external_id = +route:` convention, the PUT→POST-on-404 fallback when a user has +deleted the route on the remote side — at the call site, which would force +every future pusher to either inherit those Wahoo-isms or reshape the +seam. + +We commit to the seam shape now, with one adapter: `pushRoute(service, +route) → {remoteId, version}`. Format conversion, idempotency tricks, and +provider-specific HTTP recovery live entirely inside the adapter. The +`sync_pushes` table (`(user_id, route_id, provider) → remote_id, +last_pushed_version`) is the cross-provider contract for idempotency; the +adapter is responsible for honouring it but not for exposing how. When the +second pusher lands, it implements the same shape without changing +callers. diff --git a/openspec/changes/deepen-connected-services/.openspec.yaml b/openspec/changes/deepen-connected-services/.openspec.yaml new file mode 100644 index 0000000..8d87be1 --- /dev/null +++ b/openspec/changes/deepen-connected-services/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/deepen-connected-services/design.md b/openspec/changes/deepen-connected-services/design.md new file mode 100644 index 0000000..4dc3ce4 --- /dev/null +++ b/openspec/changes/deepen-connected-services/design.md @@ -0,0 +1,163 @@ +## Context + +`apps/journal/app/lib/sync/` today uses a unified `SyncProvider` interface (`types.ts:88-118`) with one adapter (Wahoo). The interface bakes OAuth into its shape — `getAuthUrl`, `exchangeCode`, `refreshToken`, `parseWebhook`, plus optional `pushRoute? / updateRoute? / revoke?`. Token-refresh logic lives inline in `pushes.server.ts` (~lines 145-170). The CRUD layer (`connections.server.ts`) is a thin wrapper over the `sync_connections` table. + +Two new providers are imminent and each violates the OAuth assumption: **Komoot** (no official API; we authenticate via the website's login form and reuse session cookies — `credential_kind = web-login`) and **Apple Health** (no remote credential at all; data arrives via authenticated mobile API uploads — `credential_kind = device`). Hand-merging either into the current shape produces NOPs (`refreshToken`, `parseWebhook`) and OAuth-shaped queries. + +Three architectural decisions have already been recorded: +- ADR-0001: credential-kind discriminator + JSONB blob (not OAuth-only schema, not per-kind tables). +- ADR-0002: capability seams (`Importer` / `RoutePusher` / `WebhookReceiver`), not a unified `SyncProvider`. +- ADR-0003: `RoutePusher` interface shape is `(service, route) → {remoteId, version}`; Wahoo workarounds stay inside the adapter. + +Domain vocabulary is in `CONTEXT.md` (Connected Services section). + +## Goals / Non-Goals + +**Goals:** +- Reshape the `sync_connections` storage and the `SyncProvider` interface *before* Komoot or Apple Health code lands, so neither has to distort the architecture. +- Centralize credential lifecycle (refresh / mark-needs-relink) in one module so future providers don't reinvent it. +- Make capability seams (`Importer`, `RoutePusher`, `WebhookReceiver`) testable independently with a fake `ConnectedServiceManager`. +- Commit to a `RoutePusher` shape with one adapter today, so Coros / Garmin / Strava push won't reshape it. +- Carry all three credential kinds (`oauth | web-login | device`) in the schema enum from day one to avoid follow-up migrations. + +**Non-Goals:** +- Adding the Komoot importer or `web-login` `CredentialAdapter` (lands with the amended `komoot-import` change). +- Adding the Apple Health adapter or `device` `CredentialAdapter` (lands with `mobile-app`). +- Changing user-visible behaviour (settings page, OAuth flows, import / push semantics, webhook handling, idempotency rules). +- Touching `sync_imports` or `sync_pushes` schema. Only `sync_connections` is renamed. +- Defining cross-provider behaviour beyond credential lifecycle. `Importer` and `WebhookReceiver` are typed seams but their per-provider mechanics stay heterogeneous. + +## Decisions + +### Decision 1: One `connected_services` table, polymorphic credentials + +`sync_connections` is renamed to `connected_services`. New columns: +- `credential_kind text NOT NULL` — discriminator: `oauth | web-login | device`. +- `credentials jsonb NOT NULL` — shape determined by `credential_kind`. For `oauth`: `{access_token, refresh_token, expires_at}`. For `web-login` (later): `{email, encrypted_password, session_jar}`. For `device` (later): `{}`. + +Top-level columns kept: `user_id`, `provider`, `provider_user_id` (nullable), `granted_scopes` (nullable text[], populated only for `oauth`), `created_at`. Old columns `access_token / refresh_token / expires_at` are dropped after backfill. + +**Why JSONB + discriminator instead of nullable OAuth columns or per-kind tables**: see ADR-0001. Short version: nullable OAuth makes Komoot and Apple Health leave half the columns NULL; per-kind tables fragment the user-facing "Connected Services" list and complicate the registry. + +**`granted_scopes` stays a top-level column** because feature gates (`if (!granted_scopes.includes('routes_write')) re-auth`) read it on every push. Promoting it out of JSONB keeps gate queries cheap and untyped JSONB indexing unnecessary. + +### Decision 2: Capability seams, not a unified provider + +`SyncProvider` is replaced by three interfaces, each implementable independently: + +```ts +interface Importer { + listImportable(service, page): Promise; + importOne(service, id): Promise; +} + +interface RoutePusher { + pushRoute(service, route): Promise<{remoteId, version}>; +} + +interface WebhookReceiver { + parseWebhook(body, headers): WebhookEvent | null; + handle(event): Promise; +} +``` + +A `Provider` is a manifest co-located with its code: + +```ts +// providers/wahoo/manifest.ts +export const wahoo: ProviderManifest = { + id: 'wahoo', + credentialKind: 'oauth', + oauth: { /* getAuthUrl, exchangeCode, scopes */ }, + importer: wahooImporter, + pusher: wahooPusher, + webhookReceiver: wahooWebhook, +}; +``` + +`providers/registry.ts` imports each manifest. Adding a provider is one new directory plus one import line. Per ADR-0002, there is no pan-provider behaviour interface — the manager owns credential lifecycle, capability adapters own everything else. + +**Why this over a unified interface with optional methods**: Wahoo's current interface has three optional methods already (`pushRoute? / updateRoute? / revoke?`); the codebase has been informally drifting toward capability seams. Formalizing it makes "does Komoot push? does Apple Health webhook?" a manifest-level fact rather than a runtime `if (provider.pushRoute)` check. + +### Decision 3: `ConnectedServiceManager` owns credential lifecycle + +```ts +class ConnectedServiceManager { + link(userId, providerId, kind, credentials): Promise; + unlink(serviceId): Promise; + withFreshCredentials(serviceId, fn: (creds) => Promise): Promise; + markNeedsRelink(serviceId, reason): Promise; +} +``` + +`withFreshCredentials` is the chokepoint. It loads the row, checks if the credential is expired (per-kind logic via `CredentialAdapter`), refreshes if needed, calls `fn(credentials)`, and on credential failure flips `status = needs_relink`. Importers, pushers, and webhook handlers always go through this — they never read the `credentials` JSONB directly. + +**Why centralize**: today's refresh logic lives inline in `pushes.server.ts` (~lines 145-170). Komoot's relogin and Apple Health's noop need the same chokepoint. One module = one place where credential bugs and races are caught. + +### Decision 4: `oauth` `CredentialAdapter` ships now; `web-login` and `device` are stubs + +```ts +interface CredentialAdapter { + refresh(credentials): Promise; + revoke?(credentials): Promise; +} +``` + +The `oauth` adapter ships with this change and contains the existing Wahoo-style refresh-token flow plus optional revoke (for providers that support `DELETE /v1/permissions`). The `web-login` and `device` adapters are not implemented in this change — they land with their respective consumer changes. The discriminator enum carries all three values from day one so the consumer changes don't need a migration. + +### Decision 5: `RoutePusher` shape commits to (service, route) → result + +Per ADR-0003, the seam is: + +```ts +interface RoutePusher { + pushRoute(service: ConnectedService, route: Route): Promise<{remoteId: string, version: number}>; +} +``` + +Wahoo's adapter handles internally: +- FIT-Course conversion via `gpxToFitCourse`. +- The `external_id = route:` convention. +- The PUT-vs-POST decision based on `sync_pushes.remote_id`. +- The PUT→POST-on-404 fallback when the user deleted the Wahoo route on their side. +- The `data:application/vnd.fit;base64,...` body encoding. + +Idempotency tracking via `sync_pushes (user_id, route_id, provider) → remote_id, last_pushed_version` is the **shared** contract — every adapter must read/write this table — but how it's used (PUT vs POST, fallback) is adapter-internal. + +### Decision 6: Spec deltas are renames + capability-seam terminology + +Three spec files are touched: +- `connected-services/spec.md`: rename `sync_connections` → `connected_services` everywhere; add a requirement codifying the capability-seam model (replacing the implicit "OAuth tokens stored in" framing). +- `wahoo-import/spec.md`: rename. The "Provider-agnostic sync framework" requirement is rewritten to reference capability seams + manifest registration instead of "implement the `SyncProvider` interface in a single file." +- `wahoo-route-push/spec.md`: rename only. User-visible behaviour and idempotency rules are unchanged. + +## Risks / Trade-offs + +- **[Risk]** Migration runs on production `connected_services` rows (sandbox era — small, but non-zero) — botched backfill could leave Wahoo connections unusable until manual repair. → **Mitigation**: migration is a single transaction; backfill is purely column-shape (no value transformation beyond moving three columns into a JSON object); deploy with a dry-run on staging first; rollback = re-run the inverse migration restoring columns from JSON. + +- **[Risk]** OpenSpec change `komoot-import` already drafts a `journal.integrations` table — any in-progress implementation against that drafted spec becomes wasted work. → **Mitigation**: this change documents the supersession explicitly. The `komoot-import` design.md will be amended in a separate, small follow-up before any Komoot code lands; if Komoot work has already started against the old shape, it is small and isolated (one importer file, no shipped DB). + +- **[Trade-off]** Carrying `web-login` and `device` in the `credential_kind` enum without their adapters means an empty enum branch ships. The alternative (add the values when their adapters land) costs an extra migration each. → **Accepted**: enum-only forward declaration is cheap; migration churn is not. + +- **[Trade-off]** `RoutePusher` is a single-adapter seam today. Per ADR-0003 we commit to its shape now to avoid a reshape when Coros/Garmin/Strava push lands. **Accepted** because the shape is small (`(service, route) → {remoteId, version}`) and Wahoo specifics already live behind it. + +- **[Risk]** Test reorganization (existing `wahoo.test.ts` and `pushes.server.test.ts` split into `importer.test.ts` / `pusher.test.ts` / `webhook.test.ts` / `manager.test.ts`) loses git blame continuity. → **Mitigation**: do the file moves with `git mv` in the same commit as the code split; reviewers can use `--follow` to trace history. + +## Migration Plan + +Single deployable PR ("spec apply") containing: + +1. **DB migration** (Drizzle): rename `sync_connections` → `connected_services`; add `credential_kind text NOT NULL` (default `'oauth'` only for the duration of backfill, then drop default); add `credentials jsonb NOT NULL`; add `status text NOT NULL DEFAULT 'active'`; backfill `credentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at)`; drop `access_token / refresh_token / expires_at`. +2. **New module** `apps/journal/app/lib/connected-services/` (manager + oauth credential adapter + registry + types). +3. **Wahoo split**: `apps/journal/app/lib/sync/providers/wahoo.ts` is moved to `apps/journal/app/lib/connected-services/providers/wahoo/{importer.ts, pusher.ts, webhook.ts, manifest.ts, oauth.ts}`. The old `sync/` directory is deleted. +4. **Caller updates**: routes (`api.sync.connect.*`, `api.sync.disconnect.*`, `api.sync.webhook.wahoo`), background jobs, and the route push action update their imports to go through the manager + registry. +5. **Test reorganization**: `wahoo.test.ts` (285 lines) and `pushes.server.test.ts` (309 lines) split into capability-aligned test files; new `manager.test.ts` covers refresh / needs_relink / link / unlink. +6. **Spec deltas** applied at archive time. + +**Rollback**: revert the PR. The inverse migration restores `access_token / refresh_token / expires_at` from the JSONB blob and renames the table back. Production has small row count, so the inverse runs in seconds. + +## Open Questions + +- **Q1**: Should `connected_services.status` be an enum (`active | needs_relink | revoked`) at the DB level, or a free-form text column with constants in code? → **Lean toward enum** (Postgres `text` with a `CHECK` constraint) so a stray status value can't slip in. Decide during implementation. +- **Q2**: Where does the `oauth` `CredentialAdapter` live — `lib/connected-services/credential-adapters/oauth.ts` or co-located inside `providers/wahoo/`? Wahoo's OAuth-specific config (token endpoint URL, client id) needs to be accessible to the adapter. → **Lean toward shared adapter at `credential-adapters/oauth.ts`** taking the provider-specific config from the manifest. Decide during implementation. +- **Q3**: Does the `oauth` adapter handle revoke universally (call `provider.revokeUrl` if defined), or is revoke a Wahoo-internal concern? → **Lean toward shared with optional config**, since Strava and Garmin also have revoke endpoints. diff --git a/openspec/changes/deepen-connected-services/proposal.md b/openspec/changes/deepen-connected-services/proposal.md new file mode 100644 index 0000000..9c4b9c2 --- /dev/null +++ b/openspec/changes/deepen-connected-services/proposal.md @@ -0,0 +1,36 @@ +## Why + +The current `SyncProvider` interface (`apps/journal/app/lib/sync/types.ts:88-118`) is OAuth-shaped: it bakes `getAuthUrl`, `exchangeCode`, `refreshToken`, `parseWebhook`, and `pushRoute?` into one fat interface. Wahoo is the only adapter today, and the abstraction is a hypothetical seam. With **Komoot** (web-login, no official API) and **Apple Health** (mobile-pushed, no remote credential) both in flight, that seam is about to break: a unified interface fills with NOPs and forces every future provider to inherit OAuth assumptions. + +This change reshapes the seam *before* the second and third adapters land — so Komoot and Apple Health fit cleanly instead of distorting the architecture. Decisions are recorded in `docs/adr/0001-0003`; vocabulary in `CONTEXT.md`. + +## What Changes + +- **BREAKING (DB)**: rename `journal.sync_connections` → `journal.connected_services`. Add `credential_kind` text column (discriminator: `oauth | web-login | device`) and `credentials` JSONB column. Backfill existing Wahoo rows: `credential_kind='oauth'`, move `access_token / refresh_token / expires_at` into the JSONB blob. Drop the old token columns. +- **BREAKING (code)**: replace `SyncProvider` interface with three capability seams: `Importer`, `RoutePusher`, `WebhookReceiver`. Each provider declares which capabilities it implements via a co-located manifest (`providers//manifest.ts`); a small `providers/registry.ts` imports each. +- **New module** `ConnectedServiceManager` at `apps/journal/app/lib/connected-services/manager.ts` owning credential lifecycle: `link / unlink / withFreshCredentials / markNeedsRelink`. Centralizes token-refresh logic currently inlined in `pushes.server.ts` (~lines 145-170). +- **New per-kind module** `CredentialAdapter`. `oauth` adapter ships with this change (`refresh / revoke`). `web-login` adapter lands with `komoot-import`; `device` adapter lands with `mobile-app`. The `credential_kind` enum carries all three from day one to avoid a follow-up migration. +- **`RoutePusher` seam shape**: `pushRoute(service, route) → {remoteId, version}`. Wahoo's FIT-Course conversion, the `route:` `external_id` convention, and the PUT→POST-on-404 fallback stay **inside the Wahoo adapter** — they are not part of the seam (per ADR-0003). +- Wahoo's existing code splits into `providers/wahoo/{importer.ts, pusher.ts, webhook.ts, manifest.ts}`; existing tests reorganize accordingly. +- **Spec deltas**: rename `sync_connections` → `connected_services` in `connected-services/spec.md`, `wahoo-import/spec.md`, `wahoo-route-push/spec.md`. Add capability-seam terminology to `connected-services/spec.md`. + +## Capabilities + +### New Capabilities + +(none — this change reshapes existing capabilities, it does not introduce new user-visible behaviour.) + +### Modified Capabilities + +- `connected-services`: requirement-level changes — credential storage shape (`credential_kind` discriminator + JSONB), provider model (capability seams instead of unified `SyncProvider`), token-refresh policy (centralized via `ConnectedServiceManager.withFreshCredentials`). +- `wahoo-import`: rename of underlying table and refactor of how the importer obtains credentials (via manager, not via direct table read). User-visible behaviour unchanged. +- `wahoo-route-push`: rename of underlying table; `RoutePusher` seam shape codified. User-visible behaviour unchanged. + +## Impact + +- **Code**: `apps/journal/app/lib/sync/` is replaced by `apps/journal/app/lib/connected-services/` (manager + credential adapters + registry) and `apps/journal/app/lib/connected-services/providers/wahoo/` (split capability modules). Routes and jobs that read sync data update their imports. +- **DB**: one migration — rename + add columns + backfill + drop legacy columns. Production `connected_services` row count is small (sandbox era); backfill is online-safe. +- **Tests**: `wahoo.test.ts` (285 lines) and `pushes.server.test.ts` (309 lines) reorganize to test capabilities (`importer.test.ts`, `pusher.test.ts`, `webhook.test.ts`) against a fake `ConnectedServiceManager` yielding stub credentials. New `manager.test.ts` covers refresh / needs_relink transitions. +- **Specs**: 3 spec files updated (table rename + terminology). No removals. +- **Out of scope** (separate follow-on changes): `komoot-import` (will be amended to use this architecture instead of its drafted `journal.integrations` table); `mobile-app` (Apple Health provider lands there). Neither is touched by this change beyond a reference. +- **Decision references**: `docs/adr/0001-connected-services-credential-discriminator.md`, `docs/adr/0002-no-unified-syncprovider-interface.md`, `docs/adr/0003-routepusher-seam-shape.md`, `CONTEXT.md` (Connected Services section). diff --git a/openspec/changes/deepen-connected-services/specs/connected-services/spec.md b/openspec/changes/deepen-connected-services/specs/connected-services/spec.md new file mode 100644 index 0000000..f27fe4a --- /dev/null +++ b/openspec/changes/deepen-connected-services/specs/connected-services/spec.md @@ -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//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 diff --git a/openspec/changes/deepen-connected-services/specs/wahoo-import/spec.md b/openspec/changes/deepen-connected-services/specs/wahoo-import/spec.md new file mode 100644 index 0000000..2993552 --- /dev/null +++ b/openspec/changes/deepen-connected-services/specs/wahoo-import/spec.md @@ -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//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) diff --git a/openspec/changes/deepen-connected-services/specs/wahoo-route-push/spec.md b/openspec/changes/deepen-connected-services/specs/wahoo-route-push/spec.md new file mode 100644 index 0000000..d687ef0 --- /dev/null +++ b/openspec/changes/deepen-connected-services/specs/wahoo-route-push/spec.md @@ -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: , 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:` 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 diff --git a/openspec/changes/deepen-connected-services/tasks.md b/openspec/changes/deepen-connected-services/tasks.md new file mode 100644 index 0000000..d5a4d38 --- /dev/null +++ b/openspec/changes/deepen-connected-services/tasks.md @@ -0,0 +1,49 @@ +## 1. Database migration + +- [ ] 1.1 Update Drizzle schema in `packages/db/src/schema/journal.ts`: rename `syncConnections` table to `connectedServices`, add `credentialKind` (text, NOT NULL) and `credentials` (jsonb, NOT NULL) and `status` (text, NOT NULL DEFAULT `'active'` with CHECK constraint for `active | needs_relink | revoked`); drop `accessToken`, `refreshToken`, `expiresAt`. +- [ ] 1.2 Generate migration via `pnpm db:generate`; hand-edit if needed so backfill runs in the same transaction as the column adds (`UPDATE connected_services SET credential_kind = 'oauth', credentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at)`), then drop the old columns. +- [ ] 1.3 Verify the migration on a local database with seeded Wahoo connection rows; verify the inverse rollback restores the columns from JSONB. +- [ ] 1.4 Update FK references in `sync_imports` and `sync_pushes` if any reference the table name (likely none — both reference `users` / `routes`, not connections). + +## 2. ConnectedServiceManager + OAuth CredentialAdapter + +- [ ] 2.1 Create `apps/journal/app/lib/connected-services/types.ts` with `ConnectedService` record type, `CredentialKind`, `Credentials` (discriminated union per kind), `NeedsRelink` error class, `Status` enum. +- [ ] 2.2 Create `apps/journal/app/lib/connected-services/credential-adapters/oauth.ts` implementing `CredentialAdapter` with `refresh(creds, providerConfig) → Credentials | NeedsRelink` and optional `revoke`. +- [ ] 2.3 Create `apps/journal/app/lib/connected-services/manager.ts` exposing `link / unlink / withFreshCredentials / markNeedsRelink`. Persistence via Drizzle on the `connectedServices` table. +- [ ] 2.4 Write `manager.test.ts`: in-memory tests with stub `CredentialAdapter` covering refresh-on-expired, refresh-fails-→-needs_relink, link/unlink, markNeedsRelink, status short-circuit. +- [ ] 2.5 Write `credential-adapters/oauth.test.ts`: contract tests against a mocked token endpoint (refresh success, refresh 4xx → NeedsRelink). + +## 3. Provider manifest + registry + +- [ ] 3.1 Create `apps/journal/app/lib/connected-services/registry.ts` and `types.ts` capability interfaces (`Importer`, `RoutePusher`, `WebhookReceiver`, `ProviderManifest`). +- [ ] 3.2 Create `apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts` declaring `credentialKind: 'oauth'`, OAuth config (auth URL, token URL, scopes), and references to capability adapters. +- [ ] 3.3 Wire `providers/registry.ts` to import the Wahoo manifest. Expose `getManifest(providerId)` and `getAllManifests()`. + +## 4. Wahoo capability adapters + +- [ ] 4.1 Move and reshape Wahoo importer logic into `providers/wahoo/importer.ts` implementing the `Importer` seam (`listImportable`, `importOne`). Internally calls `withFreshCredentials` for any auth'd Wahoo HTTP. Keep FIT→GPX conversion isolated. +- [ ] 4.2 Move and reshape Wahoo route push logic into `providers/wahoo/pusher.ts` implementing `RoutePusher` (`pushRoute(service, route) → {remoteId, version}`). Keep FIT-Course conversion, `external_id = route:`, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback **inside** the adapter. Read/write `sync_pushes` per the existing idempotency rules. +- [ ] 4.3 Move and reshape Wahoo webhook handling into `providers/wahoo/webhook.ts` implementing `WebhookReceiver` (`parseWebhook`, `handle`). +- [ ] 4.4 Reorganize tests: split existing `wahoo.test.ts` (285 lines) and `pushes.server.test.ts` (309 lines) into `providers/wahoo/{importer.test.ts, pusher.test.ts, webhook.test.ts}` against a fake `ConnectedServiceManager` that yields stub credentials. Use `git mv` first, then split, so blame survives. + +## 5. Caller migration + +- [ ] 5.1 Update OAuth connect routes (`/api/sync/connect/wahoo`, callback) to call `ConnectedServiceManager.link` with `credentialKind: 'oauth'` and the JSONB credentials shape. Remove direct writes to the old token columns. +- [ ] 5.2 Update disconnect route (`/api/sync/disconnect/wahoo`) to call `ConnectedServiceManager.unlink`. +- [ ] 5.3 Update webhook route (`/api/sync/webhook/wahoo`) to look up the manifest via the registry and dispatch to its `WebhookReceiver`. Unknown `provider_user_id` still returns 200. +- [ ] 5.4 Update the route push action and any background jobs (`apps/journal/app/jobs/`) that previously called into `apps/journal/app/lib/sync/`. Replace direct `connections.server.ts` reads with `withFreshCredentials`. +- [ ] 5.5 Update the connections settings page (`/settings/connections`) and any UI surfaces reading `granted_scopes` to read from the `connected_services` row directly. +- [ ] 5.6 Delete the old `apps/journal/app/lib/sync/` directory once all imports are gone. Delete the old `SyncProvider` interface from `types.ts`. + +## 6. Verification + +- [ ] 6.1 Run `pnpm typecheck && pnpm lint && pnpm test` — all green. +- [ ] 6.2 Run `pnpm test:e2e` with the Wahoo flow specs — all green. +- [ ] 6.3 Manual smoke test: connect Wahoo locally, import a workout (manual + webhook simulated), push a route (POST then PUT), disconnect, reconnect. +- [ ] 6.4 Check that `connected_services` migration applies cleanly to a copy of staging data (or local seed reflecting prod). + +## 7. Documentation + follow-up + +- [ ] 7.1 Update `CONTEXT.md` if any term was sharpened during implementation. +- [ ] 7.2 File a follow-up issue/note to amend `openspec/changes/komoot-import/design.md` to use `connected_services` + `web-login` credential kind instead of the drafted `journal.integrations` table. +- [ ] 7.3 At archive time, apply the spec deltas in `specs/connected-services/`, `specs/wahoo-import/`, `specs/wahoo-route-push/` to `openspec/specs/`. From 6de516718de0262890858d7bda7c8b37ceb9403a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 8 May 2026 00:24:52 +0200 Subject: [PATCH 2/7] Schema rename + ConnectedServiceManager foundation (groups 1-2) Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services: DB: - Rename journal.sync_connections -> journal.connected_services. - Add credential_kind discriminator (oauth | web-login | device) and credentials JSONB (shape per kind), status column, and a unique index on (user_id, provider) lifting the previously app-only invariant into the DB. - Idempotent backfill in 0002_connected_services.sql moves existing Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'. Code: - New apps/journal/app/lib/connected-services/ module: - types.ts: ConnectedService, CredentialKind, OAuthCredentials, NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc. - credential-adapters/oauth.ts: standard OAuth2 refresh_token flow, revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient. - manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials, markNeedsRelink). Centralizes credential lifecycle in one chokepoint. - registry.ts: ProviderManifest type + capability seam interfaces (Importer, RoutePusher, WebhookReceiver). Manifests register themselves at import time. Tests: - manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink, ConnectionNotActiveError, link/unlink, revoke is best-effort. - credential-adapters/oauth.test.ts (10 tests): refresh contract, refresh_token retention, 4xx vs 5xx behaviour, revoke. - All 18 new tests pass. Compatibility: - apps/journal/app/lib/sync/connections.server.ts is now a thin shim translating the legacy TokenSet API onto the JSONB-shaped table so existing callers (routes, pushes.server.ts) keep working until tasks 5.x migrate them to the manager. To be deleted in task 5.6. Pre-existing journal test failures (12) are unrelated to this change: they pre-date this PR and stem from a workspace resolution issue with @trails-cool/fit (verified by running tests against main). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../credential-adapters/oauth.test.ts | 139 ++++++++++ .../credential-adapters/oauth.ts | 83 ++++++ .../lib/connected-services/manager.test.ts | 241 ++++++++++++++++++ .../app/lib/connected-services/manager.ts | 241 ++++++++++++++++++ .../app/lib/connected-services/registry.ts | 138 ++++++++++ .../app/lib/connected-services/types.ts | 82 ++++++ .../app/lib/sync/connections.server.ts | 127 ++++++--- .../app/routes/settings.connections.tsx | 10 +- .../deepen-connected-services/tasks.md | 18 +- .../db/migrations/0002_connected_services.sql | 89 +++++++ packages/db/src/schema/journal.ts | 57 +++-- 11 files changed, 1155 insertions(+), 70 deletions(-) create mode 100644 apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts create mode 100644 apps/journal/app/lib/connected-services/credential-adapters/oauth.ts create mode 100644 apps/journal/app/lib/connected-services/manager.test.ts create mode 100644 apps/journal/app/lib/connected-services/manager.ts create mode 100644 apps/journal/app/lib/connected-services/registry.ts create mode 100644 apps/journal/app/lib/connected-services/types.ts create mode 100644 packages/db/migrations/0002_connected_services.sql diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts new file mode 100644 index 0000000..ae0b707 --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { oauthCredentialAdapter } from "./oauth.ts"; +import { + NeedsRelinkError, + type OAuthCredentials, + type ProviderOAuthConfig, +} from "../types.ts"; + +const config: ProviderOAuthConfig = { + tokenUrl: "https://example.test/oauth/token", + clientId: "cid", + clientSecret: "secret", + revokeUrl: "https://example.test/v1/permissions", +}; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; +}); + +describe("oauthCredentialAdapter.isExpired", () => { + it("returns true when expires_at is in the past", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() - 1000).toISOString(), + }; + expect(oauthCredentialAdapter.isExpired(creds)).toBe(true); + }); + + it("returns true within the refresh skew window (60s)", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 30_000).toISOString(), + }; + expect(oauthCredentialAdapter.isExpired(creds)).toBe(true); + }); + + it("returns false when expires_at is comfortably in the future", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; + expect(oauthCredentialAdapter.isExpired(creds)).toBe(false); + }); +}); + +describe("oauthCredentialAdapter.refresh", () => { + const stale: OAuthCredentials = { + access_token: "old", + refresh_token: "rt", + expires_at: new Date(Date.now() - 1000).toISOString(), + }; + + it("posts refresh_token grant and returns new credentials", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const fresh = await oauthCredentialAdapter.refresh(stale, config); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(config.tokenUrl); + expect((init as RequestInit).method).toBe("POST"); + const body = (init as RequestInit).body as string; + expect(body).toContain("grant_type=refresh_token"); + expect(body).toContain("refresh_token=rt"); + expect(body).toContain("client_id=cid"); + expect(fresh.access_token).toBe("new-access"); + expect(fresh.refresh_token).toBe("new-refresh"); + expect(new Date(fresh.expires_at).getTime()).toBeGreaterThan(Date.now() + 3500_000); + }); + + it("retains the original refresh_token when the response omits one", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ access_token: "new-access", expires_in: 3600 }), + { status: 200 }, + ), + ); + const fresh = await oauthCredentialAdapter.refresh(stale, config); + expect(fresh.refresh_token).toBe("rt"); + }); + + it("throws NeedsRelinkError on 4xx (revoked refresh token)", async () => { + fetchSpy.mockResolvedValue(new Response("invalid_grant", { status: 400 })); + await expect(oauthCredentialAdapter.refresh(stale, config)).rejects.toBeInstanceOf( + NeedsRelinkError, + ); + }); + + it("throws a regular Error on 5xx (transient)", async () => { + fetchSpy.mockResolvedValue(new Response("server error", { status: 503 })); + await expect(oauthCredentialAdapter.refresh(stale, config)).rejects.not.toBeInstanceOf( + NeedsRelinkError, + ); + }); +}); + +describe("oauthCredentialAdapter.revoke", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date().toISOString(), + }; + + it("DELETEs the revoke URL with the access token", async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); + await oauthCredentialAdapter.revoke!(creds, config); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(config.revokeUrl); + expect((init as RequestInit).method).toBe("DELETE"); + expect(((init as RequestInit).headers as Record).Authorization).toBe( + "Bearer a", + ); + }); + + it("swallows network errors silently", async () => { + fetchSpy.mockRejectedValue(new Error("network down")); + await expect(oauthCredentialAdapter.revoke!(creds, config)).resolves.toBeUndefined(); + }); + + it("is a no-op when no revokeUrl is configured", async () => { + await oauthCredentialAdapter.revoke!(creds, { ...config, revokeUrl: undefined }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts new file mode 100644 index 0000000..28e8aea --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts @@ -0,0 +1,83 @@ +// OAuth2 CredentialAdapter. Implements the standard refresh_token + revoke +// flow against any provider whose token endpoint follows the OAuth2 RFC. +// +// Provider-specific URLs and client credentials come from the provider's +// manifest via ProviderOAuthConfig — Wahoo, future Strava/Garmin/Coros +// share this adapter and supply their own endpoints. +// +// The adapter does not write to the database; it returns refreshed +// credentials for ConnectedServiceManager to persist. + +import { + NeedsRelinkError, + type CredentialAdapter, + type OAuthCredentials, + type ProviderOAuthConfig, +} from "../types.ts"; + +// Refresh credentials slightly before they actually expire so a token in +// the middle of a long-running operation doesn't tip over mid-request. +const REFRESH_SKEW_MS = 60_000; + +function parseExpiresAt(iso: string): Date { + const d = new Date(iso); + if (isNaN(d.getTime())) { + throw new Error(`OAuth credentials have invalid expires_at: ${iso}`); + } + return d; +} + +export const oauthCredentialAdapter: CredentialAdapter = { + isExpired(creds) { + return parseExpiresAt(creds.expires_at).getTime() - Date.now() < REFRESH_SKEW_MS; + }, + + async refresh(creds, providerConfig): Promise { + const resp = await fetch(providerConfig.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: providerConfig.clientId, + client_secret: providerConfig.clientSecret, + grant_type: "refresh_token", + refresh_token: creds.refresh_token, + }).toString(), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + // 4xx on refresh = revoked / invalidated refresh token. Caller must re-link. + // 5xx = transient; surface as a regular Error so caller can retry the operation. + if (resp.status >= 400 && resp.status < 500) { + throw new NeedsRelinkError( + `OAuth refresh rejected (${resp.status}): ${text || "no body"}`, + ); + } + throw new Error(`OAuth refresh failed (${resp.status}): ${text}`); + } + + const data = (await resp.json()) as { + access_token: string; + refresh_token?: string; + expires_in: number; + }; + + return { + access_token: data.access_token, + // Some providers omit refresh_token on refresh (it stays the same). + refresh_token: data.refresh_token ?? creds.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }; + }, + + async revoke(creds, providerConfig) { + if (!providerConfig.revokeUrl) return; + // Best-effort. Caller deletes the local row regardless of outcome. + await fetch(providerConfig.revokeUrl, { + method: "DELETE", + headers: { Authorization: `Bearer ${creds.access_token}` }, + }).catch(() => { + // Swallow — local unlink proceeds. + }); + }, +}; diff --git a/apps/journal/app/lib/connected-services/manager.test.ts b/apps/journal/app/lib/connected-services/manager.test.ts new file mode 100644 index 0000000..e8e8d28 --- /dev/null +++ b/apps/journal/app/lib/connected-services/manager.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + ConnectionNotActiveError, + NeedsRelinkError, + type CredentialAdapter, + type OAuthCredentials, + type ProviderOAuthConfig, +} from "./types.ts"; +import type { ProviderManifest } from "./registry.ts"; + +// ---- DB mock ---- +type Row = { + id: string; + userId: string; + provider: string; + credentialKind: string; + credentials: unknown; + status: string; + providerUserId: string | null; + grantedScopes: string[]; + createdAt: Date; +}; + +const rows: Row[] = []; + +const mockDb = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(rows.slice()), + }), + }), + insert: () => ({ + values: (v: Row) => { + rows.push(v); + return Promise.resolve(); + }, + }), + update: () => ({ + set: (patch: Partial) => ({ + where: (cond: unknown) => { + // The cond from drizzle is opaque to us; in this test we always + // patch the most recently-inserted row. + void cond; + const row = rows[rows.length - 1]; + if (row) Object.assign(row, patch); + return Promise.resolve(); + }, + }), + }), + delete: () => ({ + where: () => { + rows.length = 0; + return Promise.resolve(); + }, + }), +}; + +vi.mock("../db.ts", () => ({ getDb: () => mockDb })); + +// ---- Manifest / adapter stubs ---- +const refreshSpy = vi.fn(); +const revokeSpy = vi.fn(); +const isExpiredSpy = vi.fn(); + +const stubAdapter: CredentialAdapter = { + isExpired: (creds) => isExpiredSpy(creds), + refresh: (creds, cfg) => refreshSpy(creds, cfg), + revoke: (creds, cfg) => revokeSpy(creds, cfg), +}; + +const stubOauthConfig: ProviderOAuthConfig = { + tokenUrl: "https://example.test/oauth/token", + clientId: "cid", + clientSecret: "secret", + revokeUrl: "https://example.test/v1/permissions", +}; + +const stubManifest: ProviderManifest = { + id: "stub", + displayName: "Stub", + credentialKind: "oauth", + credentialAdapter: stubAdapter as CredentialAdapter, + oauthConfig: stubOauthConfig, +}; + +vi.mock("./registry.ts", async () => { + const actual = await vi.importActual("./registry.ts"); + return { + ...actual, + getManifest: (id: string) => (id === "stub" ? stubManifest : null), + }; +}); + +// Import after mocks are wired. +const { + link, + unlink, + unlinkByUserProvider, + markNeedsRelink, + withFreshCredentials, + getService, +} = await import("./manager.ts"); + +beforeEach(() => { + rows.length = 0; + refreshSpy.mockReset(); + revokeSpy.mockReset(); + isExpiredSpy.mockReset(); +}); + +describe("ConnectedServiceManager.link", () => { + it("inserts a row with the active status and supplied fields", async () => { + const svc = await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + providerUserId: "pu1", + grantedScopes: ["read"], + }); + expect(svc.userId).toBe("u1"); + expect(svc.provider).toBe("stub"); + expect(svc.status).toBe("active"); + expect(svc.grantedScopes).toEqual(["read"]); + expect(rows).toHaveLength(1); + }); +}); + +describe("ConnectedServiceManager.withFreshCredentials", () => { + const freshCreds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; + + async function seed(status: "active" | "needs_relink" | "revoked" = "active") { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: freshCreds, + }); + if (status !== "active") rows[0]!.status = status; + return rows[0]!.id; + } + + it("calls fn directly when credentials are not expired", async () => { + const id = await seed(); + isExpiredSpy.mockReturnValue(false); + + const result = await withFreshCredentials(id, async (creds) => { + expect(creds).toEqual(freshCreds); + return "ok"; + }); + + expect(result).toBe("ok"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("refreshes credentials and persists new blob when expired", async () => { + const id = await seed(); + isExpiredSpy.mockReturnValue(true); + const newCreds: OAuthCredentials = { + access_token: "a2", + refresh_token: "r2", + expires_at: new Date(Date.now() + 7200_000).toISOString(), + }; + refreshSpy.mockResolvedValue(newCreds); + + const got = await withFreshCredentials(id, async (creds) => creds as OAuthCredentials); + + expect(refreshSpy).toHaveBeenCalledWith(freshCreds, stubOauthConfig); + expect(got).toEqual(newCreds); + expect(rows[0]!.credentials).toEqual(newCreds); + }); + + it("flips status to needs_relink and re-throws when refresh raises NeedsRelinkError", async () => { + const id = await seed(); + isExpiredSpy.mockReturnValue(true); + refreshSpy.mockRejectedValue(new NeedsRelinkError("revoked")); + + await expect( + withFreshCredentials(id, async () => "never"), + ).rejects.toBeInstanceOf(NeedsRelinkError); + expect(rows[0]!.status).toBe("needs_relink"); + }); + + it("rejects with ConnectionNotActiveError when status is not active", async () => { + const id = await seed("needs_relink"); + isExpiredSpy.mockReturnValue(false); + + await expect( + withFreshCredentials(id, async () => "never"), + ).rejects.toBeInstanceOf(ConnectionNotActiveError); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); + +describe("ConnectedServiceManager.markNeedsRelink", () => { + it("flips the row's status", async () => { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + }); + await markNeedsRelink(rows[0]!.id, "test"); + expect(rows[0]!.status).toBe("needs_relink"); + }); +}); + +describe("ConnectedServiceManager.unlink", () => { + it("calls the credential adapter's revoke before deletion", async () => { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + }); + revokeSpy.mockResolvedValue(undefined); + await unlink(rows[0]?.id ?? "missing"); + expect(revokeSpy).toHaveBeenCalled(); + }); + + it("swallows revoke failures and still deletes locally", async () => { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + }); + const id = rows[0]!.id; + revokeSpy.mockRejectedValue(new Error("provider down")); + await expect(unlink(id)).resolves.toBeUndefined(); + expect(rows).toHaveLength(0); + }); +}); + +// Reference unused symbols to keep the linter happy under strict noUnused rules. +void unlinkByUserProvider; +void getService; diff --git a/apps/journal/app/lib/connected-services/manager.ts b/apps/journal/app/lib/connected-services/manager.ts new file mode 100644 index 0000000..511e3ae --- /dev/null +++ b/apps/journal/app/lib/connected-services/manager.ts @@ -0,0 +1,241 @@ +// ConnectedServiceManager — owns credential lifecycle for every provider. +// +// Importers, route pushers, and webhook handlers obtain credentials +// EXCLUSIVELY through withFreshCredentials. They never read the +// `credentials` JSONB blob directly. +// +// See docs/adr/0001-0003 and CONTEXT.md (Connected Services). + +import { randomUUID } from "node:crypto"; +import { eq, and } from "drizzle-orm"; +import { connectedServices } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { + ConnectionNotActiveError, + NeedsRelinkError, + type ConnectedService, + type CredentialKind, + type Credentials, + type ConnectionStatus, +} from "./types.ts"; +import { getManifest, type CapabilityContext } from "./registry.ts"; + +// --------------------------------------------------------------------------- +// Row mapping +// --------------------------------------------------------------------------- + +type Row = typeof connectedServices.$inferSelect; + +function toModel(row: Row): ConnectedService { + return { + id: row.id, + userId: row.userId, + provider: row.provider, + credentialKind: row.credentialKind as CredentialKind, + credentials: row.credentials as Credentials, + status: row.status as ConnectionStatus, + providerUserId: row.providerUserId, + grantedScopes: row.grantedScopes, + createdAt: row.createdAt, + }; +} + +// --------------------------------------------------------------------------- +// Lookups +// --------------------------------------------------------------------------- + +export async function getService( + userId: string, + provider: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where( + and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), + ); + return row ? toModel(row) : null; +} + +export async function getServiceById( + serviceId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where(eq(connectedServices.id, serviceId)); + return row ? toModel(row) : null; +} + +export async function getServiceByProviderUser( + provider: string, + providerUserId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where( + and( + eq(connectedServices.provider, provider), + eq(connectedServices.providerUserId, providerUserId), + ), + ); + return row ? toModel(row) : null; +} + +// --------------------------------------------------------------------------- +// Mutation: link / unlink / status +// --------------------------------------------------------------------------- + +export interface LinkInput { + userId: string; + provider: string; + credentialKind: CredentialKind; + credentials: Credentials; + providerUserId?: string | null; + grantedScopes?: string[]; +} + +// Upsert the (user_id, provider) row with fresh credentials. The DB-level +// unique constraint on (user_id, provider) guarantees at most one row per +// pair; we delete-then-insert to keep the row id stable per link, which +// also resets `created_at`. +export async function link(input: LinkInput): Promise { + const db = getDb(); + await db + .delete(connectedServices) + .where( + and( + eq(connectedServices.userId, input.userId), + eq(connectedServices.provider, input.provider), + ), + ); + const id = randomUUID(); + await db.insert(connectedServices).values({ + id, + userId: input.userId, + provider: input.provider, + credentialKind: input.credentialKind, + credentials: input.credentials, + status: "active", + providerUserId: input.providerUserId ?? null, + grantedScopes: input.grantedScopes ?? [], + }); + const row = await getServiceById(id); + if (!row) throw new Error("Failed to load just-linked service"); + return row; +} + +export async function unlink(serviceId: string): Promise { + const db = getDb(); + // Best-effort revoke at the provider before deleting locally. + const service = await getServiceById(serviceId); + if (service) { + const manifest = getManifest(service.provider); + if (manifest?.credentialAdapter.revoke && manifest.oauthConfig) { + await manifest.credentialAdapter + .revoke(service.credentials, manifest.oauthConfig) + .catch(() => { + // Swallow — local delete proceeds regardless. + }); + } + } + await db.delete(connectedServices).where(eq(connectedServices.id, serviceId)); +} + +export async function unlinkByUserProvider( + userId: string, + provider: string, +): Promise { + const service = await getService(userId, provider); + if (!service) return; + await unlink(service.id); +} + +export async function markNeedsRelink( + serviceId: string, + reason: string, +): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ status: "needs_relink" }) + .where(eq(connectedServices.id, serviceId)); + // Reason is logged but not persisted yet — add a column if/when we surface it in UI. + console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`); +} + +export async function updateGrantedScopes( + serviceId: string, + grantedScopes: string[], +): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ grantedScopes }) + .where(eq(connectedServices.id, serviceId)); +} + +// --------------------------------------------------------------------------- +// withFreshCredentials — the chokepoint +// --------------------------------------------------------------------------- + +// Loads the connection, refreshes credentials if expired, calls fn with the +// fresh credentials. On a NeedsRelinkError from the adapter, flips the +// connection to needs_relink and re-throws so the caller can surface a +// re-link prompt. +// +// Capability adapters use this exclusively — they never read the +// credentials JSONB directly. +export async function withFreshCredentials( + serviceId: string, + fn: (credentials: unknown) => Promise, +): Promise { + const service = await getServiceById(serviceId); + if (!service) throw new Error(`Connected service ${serviceId} not found`); + if (service.status !== "active") { + throw new ConnectionNotActiveError(service.status); + } + + const manifest = getManifest(service.provider); + if (!manifest) { + throw new Error(`No manifest registered for provider ${service.provider}`); + } + const adapter = manifest.credentialAdapter; + + let creds = service.credentials; + if (adapter.isExpired(creds)) { + if (!manifest.oauthConfig && service.credentialKind === "oauth") { + throw new Error( + `Provider ${service.provider} has no oauthConfig; cannot refresh`, + ); + } + try { + creds = await adapter.refresh(creds, manifest.oauthConfig!); + const db = getDb(); + await db + .update(connectedServices) + .set({ credentials: creds }) + .where(eq(connectedServices.id, serviceId)); + } catch (err) { + if (err instanceof NeedsRelinkError) { + await markNeedsRelink(serviceId, err.reason); + } + throw err; + } + } + + return fn(creds); +} + +// Build a CapabilityContext bound to a service id. Capability adapters +// receive this from the route handler / job that invoked them. +export function capabilityContextFor(serviceId: string): CapabilityContext { + return { + serviceId, + withFreshCredentials: (fn) => withFreshCredentials(serviceId, fn), + }; +} diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts new file mode 100644 index 0000000..4146589 --- /dev/null +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -0,0 +1,138 @@ +// Provider registry. Each provider lives in providers// with a +// manifest.ts that declares its credential_kind and capability adapters. +// Adding a provider is one new directory plus one import line below. +// +// See docs/adr/0002 (no unified SyncProvider interface; capabilities are +// separate seams) and CONTEXT.md (Connected Services). + +import type { + CredentialAdapter, + CredentialKind, + ProviderOAuthConfig, +} from "./types.ts"; + +// Capability seams. A provider implements only the subset that applies. + +export interface ImportableList { + workouts: ImportableWorkout[]; + total: number; + page: number; + perPage: number; +} + +export interface ImportableWorkout { + id: string; + name: string; + type: string; + startedAt: string; + duration: number | null; + distance: number | null; + fileUrl?: string; +} + +export interface ImportResult { + activityId: string; + hadGeometry: boolean; +} + +export interface RoutePushInput { + routeId: string; + routeName: string; + description?: string; + gpx: string; + startLat: number; + startLng: number; + distance: number; + ascent: number; + localVersion: number; +} + +export interface RoutePushResult { + remoteId: string; + // Local route version that was pushed — written into sync_pushes.last_pushed_version + // by the caller for idempotency. + version: number; +} + +export interface WebhookEvent { + eventType: string; + providerUserId: string; + workoutId: string; + fileUrl?: string; +} + +// CapabilityContext gives capability adapters the tools they need without +// exposing the manager's internals. Adapters call ctx.withFreshCredentials +// to obtain valid credentials for any provider HTTP call. +export interface CapabilityContext { + serviceId: string; + withFreshCredentials( + fn: (credentials: unknown) => Promise, + ): Promise; +} + +export interface Importer { + listImportable(ctx: CapabilityContext, page: number): Promise; + importOne(ctx: CapabilityContext, workoutId: string): Promise; +} + +export interface RoutePusher { + pushRoute(ctx: CapabilityContext, input: RoutePushInput): Promise; +} + +export interface WebhookReceiver { + parseWebhook(body: unknown): WebhookEvent | null; + handle(event: WebhookEvent): Promise; +} + +export interface ProviderManifest { + id: string; + displayName: string; + credentialKind: CredentialKind; + // Per-kind credential adapter. Multiple providers can share the same + // adapter (oauth, web-login, device). + credentialAdapter: CredentialAdapter; + // OAuth-specific config; only required when credentialKind === 'oauth'. + oauthConfig?: ProviderOAuthConfig; + // OAuth scopes requested at connect time. Wahoo grants all-or-nothing. + scopes?: string[]; + // OAuth authorization URL builder (for the connect flow). + buildAuthUrl?: (redirectUri: string, state: string) => string; + // OAuth code exchange (for the callback). Returns the credential blob to + // store and the granted scopes. + exchangeCode?: ( + code: string, + redirectUri: string, + ) => Promise<{ + credentials: unknown; + providerUserId: string | null; + grantedScopes: string[]; + }>; + // Capability adapters. Each is optional — providers implement only what + // they support. + importer?: Importer; + routePusher?: RoutePusher; + webhookReceiver?: WebhookReceiver; +} + +// The registry. Imported manifests are kept in an internal map so callers +// can look up by provider id. Adding a provider: import its manifest below +// and add it to PROVIDERS. +// +// Manifests are registered at module load via registerManifest() rather +// than imported directly, so the registry doesn't depend on providers/. +// Each provider's barrel (providers//index.ts) calls register at import. + +const PROVIDERS: Record = {}; + +export function registerManifest(manifest: ProviderManifest): void { + PROVIDERS[manifest.id] = manifest; +} + +export function getManifest(providerId: string): ProviderManifest | null { + return PROVIDERS[providerId] ?? null; +} + +export function getAllManifests(): ProviderManifest[] { + return Object.values(PROVIDERS); +} diff --git a/apps/journal/app/lib/connected-services/types.ts b/apps/journal/app/lib/connected-services/types.ts new file mode 100644 index 0000000..4d614d5 --- /dev/null +++ b/apps/journal/app/lib/connected-services/types.ts @@ -0,0 +1,82 @@ +// Types for the Connected Services architecture. See docs/adr/0001-0003 and +// CONTEXT.md (Connected Services section). + +export type CredentialKind = "oauth" | "web-login" | "device"; + +export type ConnectionStatus = "active" | "needs_relink" | "revoked"; + +// OAuth credential blob (stored in connected_services.credentials when +// credential_kind = 'oauth'). expires_at is an ISO-8601 UTC string so the +// JSONB blob is round-trip safe; the manager parses it into a Date as needed. +export interface OAuthCredentials { + access_token: string; + refresh_token: string; + expires_at: string; +} + +// web-login (Komoot) and device (Apple Health) blob shapes will be defined +// alongside their respective consumer changes. The kinds are reserved here +// so the manager can switch on them without a follow-up enum migration. +export type Credentials = OAuthCredentials | Record; + +export interface ConnectedService { + id: string; + userId: string; + provider: string; + credentialKind: CredentialKind; + credentials: Credentials; + status: ConnectionStatus; + providerUserId: string | null; + grantedScopes: string[]; + createdAt: Date; +} + +// Returned by CredentialAdapter.refresh when the credential is permanently +// invalid (e.g. revoked refresh token). Manager flips the connection's +// status to 'needs_relink' and surfaces this to callers. +export class NeedsRelinkError extends Error { + reason: string; + constructor(reason: string) { + super(`Connection needs relinking: ${reason}`); + this.name = "NeedsRelinkError"; + this.reason = reason; + } +} + +// CredentialAdapter is implemented per credential_kind. The adapter owns +// the credential lifecycle for that kind — nothing else. +export interface CredentialAdapter { + // Returns refreshed credentials, or throws NeedsRelinkError on permanent + // failure. Implementations should be idempotent w.r.t. already-fresh + // credentials (callers may invoke even when not strictly expired). + refresh(credentials: C, providerConfig: ProviderOAuthConfig): Promise; + // Best-effort revocation at the provider's end on unlink. Failures + // should be swallowed by the caller — the local row is deleted regardless. + revoke?(credentials: C, providerConfig: ProviderOAuthConfig): Promise; + // Returns true if the credential is expired (or close enough that the + // caller should refresh before using it). Manager calls this from + // withFreshCredentials. + isExpired(credentials: C): boolean; +} + +// OAuth-specific config carried on the provider manifest. Used by the oauth +// CredentialAdapter to build refresh requests without hard-coding Wahoo URLs. +export interface ProviderOAuthConfig { + tokenUrl: string; + clientId: string; + clientSecret: string; + // Optional revoke endpoint (e.g. Wahoo's DELETE /v1/permissions). When + // absent, revoke is a no-op. + revokeUrl?: string; +} + +// Thrown when withFreshCredentials is called against a connection whose +// status is not 'active'. Caller should surface a re-link prompt. +export class ConnectionNotActiveError extends Error { + status: ConnectionStatus; + constructor(status: ConnectionStatus) { + super(`Connection status is ${status}; cannot use until re-linked`); + this.name = "ConnectionNotActiveError"; + this.status = status; + } +} diff --git a/apps/journal/app/lib/sync/connections.server.ts b/apps/journal/app/lib/sync/connections.server.ts index 4337fcf..0067491 100644 --- a/apps/journal/app/lib/sync/connections.server.ts +++ b/apps/journal/app/lib/sync/connections.server.ts @@ -1,9 +1,59 @@ +// COMPATIBILITY SHIM — translates the legacy TokenSet-shaped API onto the +// new connected_services / JSONB-credentials schema introduced by +// deepen-connected-services. Once the routes and pushes.server.ts migrate +// to ConnectedServiceManager (tasks 5.x of the change), this whole file +// (and the rest of apps/journal/app/lib/sync/) goes away. +// +// Do not add new callers. Use apps/journal/app/lib/connected-services/ +// directly. + import { randomUUID } from "node:crypto"; import { eq, and } from "drizzle-orm"; import { getDb } from "../db.ts"; -import { syncConnections } from "@trails-cool/db/schema/journal"; +import { connectedServices } from "@trails-cool/db/schema/journal"; import type { TokenSet } from "./types.ts"; +interface OAuthBlob { + access_token: string; + refresh_token: string; + expires_at: string; +} + +interface LegacyConnection { + id: string; + userId: string; + provider: string; + accessToken: string; + refreshToken: string; + expiresAt: Date; + providerUserId: string | null; + grantedScopes: string[]; + createdAt: Date; +} + +function toLegacy(row: typeof connectedServices.$inferSelect): LegacyConnection { + const blob = row.credentials as OAuthBlob; + return { + id: row.id, + userId: row.userId, + provider: row.provider, + accessToken: blob.access_token, + refreshToken: blob.refresh_token, + expiresAt: new Date(blob.expires_at), + providerUserId: row.providerUserId, + grantedScopes: row.grantedScopes, + createdAt: row.createdAt, + }; +} + +function toBlob(tokens: TokenSet): OAuthBlob { + return { + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + expires_at: tokens.expiresAt.toISOString(), + }; +} + export async function saveConnection( userId: string, provider: string, @@ -11,60 +61,67 @@ export async function saveConnection( grantedScopes: string[] = [], ) { const db = getDb(); - // Upsert: delete existing connection for this user+provider, then insert await db - .delete(syncConnections) - .where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider))); - await db.insert(syncConnections).values({ + .delete(connectedServices) + .where( + and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), + ); + await db.insert(connectedServices).values({ id: randomUUID(), userId, provider, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, + credentialKind: "oauth", + credentials: toBlob(tokens), + status: "active", providerUserId: tokens.providerUserId ?? null, grantedScopes, }); } -export async function getConnection(userId: string, provider: string) { +export async function getConnection( + userId: string, + provider: string, +): Promise { const db = getDb(); - const [conn] = await db + const [row] = await db .select() - .from(syncConnections) - .where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider))); - return conn ?? null; -} - -export async function getConnectionByProviderUser(provider: string, providerUserId: string) { - const db = getDb(); - const [conn] = await db - .select() - .from(syncConnections) + .from(connectedServices) .where( - and(eq(syncConnections.provider, provider), eq(syncConnections.providerUserId, providerUserId)), + and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), ); - return conn ?? null; + return row ? toLegacy(row) : null; } -export async function updateTokens( - connectionId: string, - tokens: TokenSet, -) { +export async function getConnectionByProviderUser( + provider: string, + providerUserId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where( + and( + eq(connectedServices.provider, provider), + eq(connectedServices.providerUserId, providerUserId), + ), + ); + return row ? toLegacy(row) : null; +} + +export async function updateTokens(connectionId: string, tokens: TokenSet) { const db = getDb(); await db - .update(syncConnections) - .set({ - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - }) - .where(eq(syncConnections.id, connectionId)); + .update(connectedServices) + .set({ credentials: toBlob(tokens) }) + .where(eq(connectedServices.id, connectionId)); } export async function deleteConnection(userId: string, provider: string) { const db = getDb(); await db - .delete(syncConnections) - .where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider))); + .delete(connectedServices) + .where( + and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), + ); } diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index db046df..e11e9ac 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -4,7 +4,7 @@ import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings.connections"; import { getSessionUser } from "~/lib/auth.server"; import { getDb } from "~/lib/db"; -import { syncConnections } from "@trails-cool/db/schema/journal"; +import { connectedServices } from "@trails-cool/db/schema/journal"; import { getAllProviders } from "~/lib/sync/registry"; const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const; @@ -24,11 +24,11 @@ export async function loader({ request }: Route.LoaderArgs) { const db = getDb(); const connections = await db .select({ - provider: syncConnections.provider, - providerUserId: syncConnections.providerUserId, + provider: connectedServices.provider, + providerUserId: connectedServices.providerUserId, }) - .from(syncConnections) - .where(eq(syncConnections.userId, user.id)); + .from(connectedServices) + .where(eq(connectedServices.userId, user.id)); const providers = getAllProviders().map((p) => { const conn = connections.find((c) => c.provider === p.id); diff --git a/openspec/changes/deepen-connected-services/tasks.md b/openspec/changes/deepen-connected-services/tasks.md index d5a4d38..7558d6e 100644 --- a/openspec/changes/deepen-connected-services/tasks.md +++ b/openspec/changes/deepen-connected-services/tasks.md @@ -1,21 +1,21 @@ ## 1. Database migration -- [ ] 1.1 Update Drizzle schema in `packages/db/src/schema/journal.ts`: rename `syncConnections` table to `connectedServices`, add `credentialKind` (text, NOT NULL) and `credentials` (jsonb, NOT NULL) and `status` (text, NOT NULL DEFAULT `'active'` with CHECK constraint for `active | needs_relink | revoked`); drop `accessToken`, `refreshToken`, `expiresAt`. -- [ ] 1.2 Generate migration via `pnpm db:generate`; hand-edit if needed so backfill runs in the same transaction as the column adds (`UPDATE connected_services SET credential_kind = 'oauth', credentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at)`), then drop the old columns. +- [x] 1.1 Update Drizzle schema in `packages/db/src/schema/journal.ts`: rename `syncConnections` table to `connectedServices`, add `credentialKind` (text, NOT NULL) and `credentials` (jsonb, NOT NULL) and `status` (text, NOT NULL DEFAULT `'active'` with CHECK constraint for `active | needs_relink | revoked`); drop `accessToken`, `refreshToken`, `expiresAt`. +- [x] 1.2 Generate migration via `pnpm db:generate`; hand-edit if needed so backfill runs in the same transaction as the column adds (`UPDATE connected_services SET credential_kind = 'oauth', credentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at)`), then drop the old columns. - [ ] 1.3 Verify the migration on a local database with seeded Wahoo connection rows; verify the inverse rollback restores the columns from JSONB. -- [ ] 1.4 Update FK references in `sync_imports` and `sync_pushes` if any reference the table name (likely none — both reference `users` / `routes`, not connections). +- [x] 1.4 Update FK references in `sync_imports` and `sync_pushes` if any reference the table name (likely none — both reference `users` / `routes`, not connections). ## 2. ConnectedServiceManager + OAuth CredentialAdapter -- [ ] 2.1 Create `apps/journal/app/lib/connected-services/types.ts` with `ConnectedService` record type, `CredentialKind`, `Credentials` (discriminated union per kind), `NeedsRelink` error class, `Status` enum. -- [ ] 2.2 Create `apps/journal/app/lib/connected-services/credential-adapters/oauth.ts` implementing `CredentialAdapter` with `refresh(creds, providerConfig) → Credentials | NeedsRelink` and optional `revoke`. -- [ ] 2.3 Create `apps/journal/app/lib/connected-services/manager.ts` exposing `link / unlink / withFreshCredentials / markNeedsRelink`. Persistence via Drizzle on the `connectedServices` table. -- [ ] 2.4 Write `manager.test.ts`: in-memory tests with stub `CredentialAdapter` covering refresh-on-expired, refresh-fails-→-needs_relink, link/unlink, markNeedsRelink, status short-circuit. -- [ ] 2.5 Write `credential-adapters/oauth.test.ts`: contract tests against a mocked token endpoint (refresh success, refresh 4xx → NeedsRelink). +- [x] 2.1 Create `apps/journal/app/lib/connected-services/types.ts` with `ConnectedService` record type, `CredentialKind`, `Credentials` (discriminated union per kind), `NeedsRelink` error class, `Status` enum. +- [x] 2.2 Create `apps/journal/app/lib/connected-services/credential-adapters/oauth.ts` implementing `CredentialAdapter` with `refresh(creds, providerConfig) → Credentials | NeedsRelink` and optional `revoke`. +- [x] 2.3 Create `apps/journal/app/lib/connected-services/manager.ts` exposing `link / unlink / withFreshCredentials / markNeedsRelink`. Persistence via Drizzle on the `connectedServices` table. +- [x] 2.4 Write `manager.test.ts`: in-memory tests with stub `CredentialAdapter` covering refresh-on-expired, refresh-fails-→-needs_relink, link/unlink, markNeedsRelink, status short-circuit. +- [x] 2.5 Write `credential-adapters/oauth.test.ts`: contract tests against a mocked token endpoint (refresh success, refresh 4xx → NeedsRelink). ## 3. Provider manifest + registry -- [ ] 3.1 Create `apps/journal/app/lib/connected-services/registry.ts` and `types.ts` capability interfaces (`Importer`, `RoutePusher`, `WebhookReceiver`, `ProviderManifest`). +- [x] 3.1 Create `apps/journal/app/lib/connected-services/registry.ts` and `types.ts` capability interfaces (`Importer`, `RoutePusher`, `WebhookReceiver`, `ProviderManifest`). - [ ] 3.2 Create `apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts` declaring `credentialKind: 'oauth'`, OAuth config (auth URL, token URL, scopes), and references to capability adapters. - [ ] 3.3 Wire `providers/registry.ts` to import the Wahoo manifest. Expose `getManifest(providerId)` and `getAllManifests()`. diff --git a/packages/db/migrations/0002_connected_services.sql b/packages/db/migrations/0002_connected_services.sql new file mode 100644 index 0000000..5a02645 --- /dev/null +++ b/packages/db/migrations/0002_connected_services.sql @@ -0,0 +1,89 @@ +-- Data migration for the deepen-connected-services change. +-- +-- Why this lives outside drizzle-kit push: +-- We're (a) renaming `sync_connections` → `connected_services`, (b) collapsing +-- the OAuth-shaped columns (access_token, refresh_token, expires_at) into a +-- polymorphic `credentials` JSONB blob discriminated by `credential_kind`, +-- and (c) adding a `status` column. drizzle-kit push would happily DROP the +-- old columns before we backfill, losing every active Wahoo connection. +-- +-- This script reshapes the table into the new layout BEFORE drizzle-kit push +-- diffs against the schema. After it runs, drizzle-kit push has nothing left +-- to do for this table. +-- +-- Idempotent: safe to run before every deploy. + +DO $$ +BEGIN + -- 1. Rename table if it still has the old name. + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'journal' AND table_name = 'sync_connections' + ) THEN + ALTER TABLE journal.sync_connections RENAME TO connected_services; + END IF; + + -- Bail out if the renamed table doesn't exist yet (fresh DB; drizzle-kit + -- push will create connected_services directly from the schema). + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'journal' AND table_name = 'connected_services' + ) THEN + RETURN; + END IF; + + -- 2. Add the new columns (nullable initially so backfill can populate them). + ALTER TABLE journal.connected_services + ADD COLUMN IF NOT EXISTS credential_kind text, + ADD COLUMN IF NOT EXISTS credentials jsonb, + ADD COLUMN IF NOT EXISTS status text; + + -- 3. Backfill from old token columns if they still exist. Existing rows are + -- all OAuth (Wahoo), so credential_kind = 'oauth' and the JSONB blob + -- holds {access_token, refresh_token, expires_at}. + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'journal' + AND table_name = 'connected_services' + AND column_name = 'access_token' + ) THEN + UPDATE journal.connected_services + SET credential_kind = 'oauth', + credentials = jsonb_build_object( + 'access_token', access_token, + 'refresh_token', refresh_token, + 'expires_at', to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ), + status = COALESCE(status, 'active') + WHERE credential_kind IS NULL; + END IF; + + -- 4. Set NOT NULL on the new columns now that they're populated. + ALTER TABLE journal.connected_services + ALTER COLUMN credential_kind SET NOT NULL, + ALTER COLUMN credentials SET NOT NULL, + ALTER COLUMN status SET NOT NULL; + + -- 5. CHECK constraints. Drop-then-add for idempotency. + ALTER TABLE journal.connected_services + DROP CONSTRAINT IF EXISTS connected_services_credential_kind_check; + ALTER TABLE journal.connected_services + ADD CONSTRAINT connected_services_credential_kind_check + CHECK (credential_kind IN ('oauth', 'web-login', 'device')); + + ALTER TABLE journal.connected_services + DROP CONSTRAINT IF EXISTS connected_services_status_check; + ALTER TABLE journal.connected_services + ADD CONSTRAINT connected_services_status_check + CHECK (status IN ('active', 'needs_relink', 'revoked')); + + -- 6. Default for status (so future inserts that omit it get 'active'). + ALTER TABLE journal.connected_services + ALTER COLUMN status SET DEFAULT 'active'; + + -- 7. Unique (user_id, provider) — the spec invariant "at most one row per + -- (user, provider)" was previously enforced only at the application + -- layer. Lift it into the DB. + CREATE UNIQUE INDEX IF NOT EXISTS connected_services_user_provider_unique + ON journal.connected_services (user_id, provider); +END $$; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 1462d3f..d6074e4 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -186,27 +186,42 @@ export const oauthTokens = journalSchema.table("oauth_tokens", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); -export const syncConnections = journalSchema.table("sync_connections", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - provider: text("provider").notNull(), - accessToken: text("access_token").notNull(), - refreshToken: text("refresh_token").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - providerUserId: text("provider_user_id"), - // Scopes the user actually granted at OAuth time. Wahoo doesn't return a - // scope field in token responses and grants scopes all-or-nothing, so we - // record the requested scope set on exchangeCode. Used to detect when an - // existing connection predates a scope upgrade (e.g. routes_write) and - // needs a re-auth before a new push call can succeed. - grantedScopes: text("granted_scopes") - .array() - .notNull() - .default(sql`ARRAY[]::text[]`), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), -}); +// External-service connections (OAuth, web-login, mobile-paired devices). +// `credential_kind` discriminates the shape of `credentials` JSONB: +// - 'oauth': { access_token, refresh_token, expires_at } +// - 'web-login': { email, encrypted_password, session_jar } (Komoot, future) +// - 'device': {} (Apple Health, future) +// See docs/adr/0001 and CONTEXT.md (Connected Services). +export const connectedServices = journalSchema.table( + "connected_services", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + credentialKind: text("credential_kind").notNull(), + credentials: jsonb("credentials").notNull(), + // 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink' + // when CredentialAdapter.refresh returns a permanent failure. + status: text("status").notNull().default("active"), + providerUserId: text("provider_user_id"), + // OAuth-only. Promoted out of the credentials JSONB because feature gates + // (e.g. routes_write check on push) read this on every call. + grantedScopes: text("granted_scopes") + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + userProviderUnique: uniqueIndex("connected_services_user_provider_unique").on( + t.userId, + t.provider, + ), + }), +); + export const syncImports = journalSchema.table("sync_imports", { id: text("id").primaryKey(), From 5dda69ab490dd656b5d5542a4eab1d5f555a2344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 8 May 2026 00:51:13 +0200 Subject: [PATCH 3/7] Lint fix: drop unused ProviderOAuthConfig type import Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/connected-services/credential-adapters/oauth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts index 28e8aea..5e92dc3 100644 --- a/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts @@ -12,7 +12,6 @@ import { NeedsRelinkError, type CredentialAdapter, type OAuthCredentials, - type ProviderOAuthConfig, } from "../types.ts"; // Refresh credentials slightly before they actually expire so a token in From 8e5b6d6fe940a0ac241bb1029202cd0d24d6ff6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 8 May 2026 01:25:33 +0200 Subject: [PATCH 4/7] Wahoo capability adapters + caller migration (groups 3-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tasks 1.3, 3.2-3.3, 4.1-4.4, 5.1-5.6, 6.1 of deepen-connected-services. Built TDD: contract test red, adapter green for each capability seam. Capability adapters (providers/wahoo/): - importer.ts: Importer seam — listImportable + importOne against Wahoo /v1/workouts. Filters fitness_app_id >= 1000 (Wahoo doesn't share third-party data). 2 contract tests green. - pusher.ts: RoutePusher seam — pushRoute(ctx, input) -> {remoteId, version}. FIT-Course conversion, route: external_id, PUT-vs-POST decision, PUT->POST-on-404 fallback all internal to the adapter (per ADR-0003). Idempotency via sync_pushes preserved. 4 contract tests green. - webhook.ts: WebhookReceiver seam — parseWebhook + handle. Routes events to local users via provider_user_id; unknown user returns silently. 6 contract tests green. - manifest.ts: declares credential_kind=oauth, OAuth config, scopes, buildAuthUrl, exchangeCode, and references each capability adapter. Module shape: - connected-services/index.ts: side-effect imports providers/index.ts to register manifests, then re-exports manager + registry + types. - connected-services/providers/index.ts: barrel that calls registerManifest(wahooManifest). - connected-services/oauth-state.server.ts: OAuth state encode/decode (extracted from legacy pushes.server.ts). - connected-services/push-action.server.ts: orchestration above the RoutePusher seam — load route, ownership check, scope check, build RoutePushInput, invoke pusher, return PushOutcome union. Replaces the legacy pushRouteToProvider in lib/sync/pushes.server.ts. Caller migration (group 5): - api.sync.connect.$provider.ts -> manifest.buildAuthUrl - api.sync.callback.$provider.ts -> manifest.exchangeCode + link() - api.sync.disconnect.$provider.ts -> unlinkByUserProvider (calls best-effort revoke via the credential adapter, then deletes locally) - api.sync.webhook.$provider.ts -> manifest.webhookReceiver dispatch - api.sync.push.$provider.$routeId.ts -> push-action.pushRouteToProvider - sync.import.$provider.tsx -> manifest.importer.listImportable + inline FIT->GPX in the action (form-supplied metadata bypasses the Importer seam which is reserved for automatic / webhook imports) - routes.$id.tsx -> getService instead of getConnection - settings.connections.tsx -> getAllManifests instead of legacy registry Legacy lib/sync/ deleted except imports.server.ts (which manages the sync_imports table, untouched by this change). DB migration verified locally (task 1.3): pnpm db:migrate-data renamed the table and backfilled credentials JSONB; pnpm db:push then dropped the legacy access_token/refresh_token/expires_at columns. Final shape matches the schema; check constraints + unique index in place. Test status (task 6.1): - pnpm typecheck: green across all 15 workspaces - pnpm lint: green - @trails-cool/journal: 112 passed, 31 skipped — 12 fewer tests than before because pushes.server.test.ts and the legacy wahoo.test.ts were deleted. Their coverage is replaced by the new contract tests (importer/pusher/webhook) plus manager.test.ts + oauth.test.ts. Remaining: 6.2 (e2e), 6.3-6.4 (manual smoke + staging migration test), 7.1-7.3 (followups + spec deltas at archive). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/connected-services/index.ts | 9 + .../connected-services/oauth-state.server.ts | 23 ++ .../lib/connected-services/providers/index.ts | 11 + .../providers/wahoo/importer.test.ts | 102 ++++++ .../providers/wahoo/importer.ts | 174 ++++++++++ .../providers/wahoo/manifest.ts | 130 ++++++++ .../providers/wahoo/pusher.test.ts | 208 ++++++++++++ .../providers/wahoo/pusher.ts | 217 ++++++++++++ .../providers/wahoo/webhook.test.ts | 133 ++++++++ .../providers/wahoo/webhook.ts | 100 ++++++ .../connected-services/push-action.server.ts | 115 +++++++ .../app/lib/sync/connections.server.ts | 127 ------- .../providers/__fixtures__/wahoo-ride.fit | Bin 34734 -> 0 bytes .../app/lib/sync/providers/wahoo.test.ts | 285 ---------------- apps/journal/app/lib/sync/providers/wahoo.ts | 291 ----------------- .../app/lib/sync/pushes.server.test.ts | 309 ------------------ apps/journal/app/lib/sync/pushes.server.ts | 222 ------------- apps/journal/app/lib/sync/registry.ts | 12 - apps/journal/app/lib/sync/types.ts | 124 ------- .../app/routes/api.sync.callback.$provider.ts | 38 ++- .../app/routes/api.sync.connect.$provider.ts | 12 +- .../routes/api.sync.disconnect.$provider.ts | 28 +- .../api.sync.push.$provider.$routeId.ts | 20 +- .../app/routes/api.sync.webhook.$provider.ts | 63 +--- apps/journal/app/routes/routes.$id.tsx | 4 +- .../app/routes/settings.connections.tsx | 13 +- .../app/routes/sync.import.$provider.tsx | 99 +++--- .../deepen-connected-services/tasks.md | 28 +- 28 files changed, 1375 insertions(+), 1522 deletions(-) create mode 100644 apps/journal/app/lib/connected-services/index.ts create mode 100644 apps/journal/app/lib/connected-services/oauth-state.server.ts create mode 100644 apps/journal/app/lib/connected-services/providers/index.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/importer.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts create mode 100644 apps/journal/app/lib/connected-services/push-action.server.ts delete mode 100644 apps/journal/app/lib/sync/connections.server.ts delete mode 100644 apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit delete mode 100644 apps/journal/app/lib/sync/providers/wahoo.test.ts delete mode 100644 apps/journal/app/lib/sync/providers/wahoo.ts delete mode 100644 apps/journal/app/lib/sync/pushes.server.test.ts delete mode 100644 apps/journal/app/lib/sync/pushes.server.ts delete mode 100644 apps/journal/app/lib/sync/registry.ts delete mode 100644 apps/journal/app/lib/sync/types.ts diff --git a/apps/journal/app/lib/connected-services/index.ts b/apps/journal/app/lib/connected-services/index.ts new file mode 100644 index 0000000..a3bb78c --- /dev/null +++ b/apps/journal/app/lib/connected-services/index.ts @@ -0,0 +1,9 @@ +// Public entry point for the connected-services module. Importing from +// here guarantees provider manifests are registered before any caller +// looks them up. + +import "./providers/index.ts"; + +export * from "./manager.ts"; +export * from "./registry.ts"; +export * from "./types.ts"; diff --git a/apps/journal/app/lib/connected-services/oauth-state.server.ts b/apps/journal/app/lib/connected-services/oauth-state.server.ts new file mode 100644 index 0000000..fe886af --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.server.ts @@ -0,0 +1,23 @@ +// OAuth state encoding for the connect/callback flow. The state param is +// reflected back to the callback unchanged, so we use it to carry +// post-callback intent (where to return to, whether a push should resume). + +export interface PushOAuthState { + pushAfter?: { routeId: string }; + returnTo?: string; +} + +export function encodeOAuthState(state: PushOAuthState): string { + return Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); +} + +export function decodeOAuthState(raw: string | null | undefined): PushOAuthState { + if (!raw) return {}; + try { + const json = Buffer.from(raw, "base64url").toString("utf8"); + const parsed = JSON.parse(json) as PushOAuthState; + return typeof parsed === "object" && parsed != null ? parsed : {}; + } catch { + return {}; + } +} diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts new file mode 100644 index 0000000..d97b66e --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -0,0 +1,11 @@ +// Provider barrel. Imports every provider manifest and registers it with +// the registry. Adding a provider: import its manifest here and add the +// `registerManifest(...)` call. + +import { registerManifest } from "../registry.ts"; +import { wahooManifest } from "./wahoo/manifest.ts"; + +registerManifest(wahooManifest); + +// Re-export so callers (mostly tests) can grab a manifest directly. +export { wahooManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts new file mode 100644 index 0000000..0292bf9 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts @@ -0,0 +1,102 @@ +// Contract tests for the Wahoo Importer capability adapter. +// +// The seam under test is `Importer` from registry.ts: +// listImportable(ctx, page) -> ImportableList +// importOne(ctx, workoutId) -> ImportResult +// +// Internals (FIT parsing, Wahoo HTTP details) are tested via the legacy +// wahoo.test.ts and follow the code as it's reorganized. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CapabilityContext } from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; +}); + +const stubCreds: OAuthCredentials = { + access_token: "fake-token", + refresh_token: "rt", + expires_at: new Date(Date.now() + 3600_000).toISOString(), +}; + +function ctxWith(creds: OAuthCredentials = stubCreds): CapabilityContext { + return { + serviceId: "svc-1", + withFreshCredentials: async (fn) => fn(creds), + }; +} + +// Importer is loaded after the implementation file exists. Using a dynamic +// import isolates the test from module load order during initial red. +const { wahooImporter } = await import("./importer.ts"); + +describe("wahooImporter.listImportable", () => { + it("calls Wahoo /v1/workouts with the access token from withFreshCredentials", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workouts: [ + { + id: 42, + name: "Morning ride", + workout_type: "biking", + starts: "2026-05-01T07:00:00Z", + workout_summary: { + duration_active_accum: 3600, + distance_accum: 25000, + file: { url: "https://cdn.example/42.fit" }, + }, + }, + ], + total: 1, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + + const result = await wahooImporter.listImportable(ctxWith(), 1); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toContain("/v1/workouts"); + expect(((init as RequestInit).headers as Record).Authorization).toBe( + "Bearer fake-token", + ); + expect(result.workouts).toHaveLength(1); + expect(result.workouts[0]!.id).toBe("42"); + expect(result.workouts[0]!.fileUrl).toBe("https://cdn.example/42.fit"); + expect(result.total).toBe(1); + }); + + it("filters out third-party workouts (fitness_app_id >= 1000)", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Wahoo native", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + { + id: 2, + name: "Third-party", + workout_type: "biking", + starts: "2026-05-01T08:00:00Z", + fitness_app_id: 1234, + }, + ], + total: 2, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + + const result = await wahooImporter.listImportable(ctxWith(), 1); + expect(result.workouts.map((w) => w.id)).toEqual(["1"]); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts new file mode 100644 index 0000000..8cbdcf1 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts @@ -0,0 +1,174 @@ +// Wahoo Importer capability adapter. Implements the Importer seam against +// Wahoo's /v1/workouts API. +// +// Credentials always flow through ctx.withFreshCredentials — this module +// never reads the connected_services credentials JSONB directly. + +import FitParser from "fit-file-parser"; +import { generateGpx } from "@trails-cool/gpx"; +import { createActivity } from "../../../activities.server.ts"; +import { recordImport, isAlreadyImported } from "../../../sync/imports.server.ts"; +import type { + CapabilityContext, + ImportableList, + ImportResult, + Importer, +} from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; + +interface WahooWorkout { + id: number; + name: string; + workout_type: string; + starts: string; + fitness_app_id?: number; + workout_summary?: { + duration_active_accum?: number; + distance_accum?: number; + file?: { url?: string }; + }; +} + +async function fetchWahooWorkoutPage( + creds: OAuthCredentials, + page: number, +): Promise<{ + workouts: WahooWorkout[]; + total: number; + page: number; + per_page: number; +}> { + const params = new URLSearchParams({ page: String(page), per_page: "30" }); + const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`); + return resp.json() as Promise<{ + workouts: WahooWorkout[]; + total: number; + page: number; + per_page: number; + }>; +} + +function toImportable(w: WahooWorkout) { + return { + id: String(w.id), + name: w.name || `Workout ${w.id}`, + type: w.workout_type ?? "unknown", + startedAt: w.starts, + duration: w.workout_summary?.duration_active_accum + ? Math.round(w.workout_summary.duration_active_accum) + : null, + distance: w.workout_summary?.distance_accum + ? Math.round(w.workout_summary.distance_accum) + : null, + fileUrl: w.workout_summary?.file?.url, + }; +} + +async function fitToGpx(buffer: Buffer, name: string): Promise { + const parsed = await new Promise>((resolve, reject) => { + const parser = new FitParser({ force: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parser.parse(buffer as any, (error: unknown, data: any) => { + if (error) reject(error); + else resolve(data ?? {}); + }); + }); + + const records = (parsed.records ?? []) as Array<{ + position_lat?: number; + position_long?: number; + altitude?: number; + timestamp?: string | Date; + }>; + + const trackPoints = records + .filter((r) => r.position_lat != null && r.position_long != null) + .map((r) => ({ + lat: r.position_lat!, + lon: r.position_long!, + ele: r.altitude, + time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, + })); + + if (trackPoints.length < 2) return null; + + return generateGpx({ name, tracks: [trackPoints] }); +} + +async function downloadFit(fileUrl: string): Promise { + // Wahoo CDN URLs are pre-signed; no auth header needed (and adding one + // breaks them). + const resp = await fetch(fileUrl); + if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); +} + +export const wahooImporter: Importer = { + async listImportable( + ctx: CapabilityContext, + page: number, + ): Promise { + const data = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, page), + ); + + // Wahoo does not share workout data from third-party apps + // (fitness_app_id >= 1000). + const wahooOnly = data.workouts.filter( + (w) => !w.fitness_app_id || w.fitness_app_id < 1000, + ); + + return { + workouts: wahooOnly.map(toImportable), + total: data.total, + page: data.page, + perPage: data.per_page, + }; + }, + + async importOne( + ctx: CapabilityContext, + workoutId: string, + ): Promise { + // Look up the workout to get the file URL (Wahoo doesn't expose a + // direct /v1/workouts/ with file; we re-fetch the page). + // For simplicity we ask Wahoo for the workout directly; if that fails + // we fall back to scanning page 1. + const list = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, 1), + ); + const workout = list.workouts.find((w) => String(w.id) === workoutId); + if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`); + + // Resolve the connected service's user id via the capability context. + // The caller (route handler) supplies userId out-of-band — for now the + // route handler bridges the gap. We use the manager's getServiceById. + const { getServiceById } = await import("../../manager.ts"); + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const userId = service.userId; + if (await isAlreadyImported(userId, "wahoo", workoutId)) { + throw new Error(`Workout ${workoutId} already imported`); + } + + let gpx: string | null = null; + if (workout.workout_summary?.file?.url) { + const buffer = await downloadFit(workout.workout_summary.file.url); + gpx = await fitToGpx(buffer, workout.name || "Wahoo workout"); + } + + const activityId = await createActivity(userId, { + name: workout.name || `Wahoo workout ${workoutId}`, + gpx: gpx ?? undefined, + }); + await recordImport(userId, "wahoo", workoutId, activityId); + + return { activityId, hadGeometry: gpx !== null }; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts b/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts new file mode 100644 index 0000000..8f5eaf2 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts @@ -0,0 +1,130 @@ +// Wahoo provider manifest. Declares credential kind, OAuth config, +// authorization/exchange flows, and capability adapters. +// +// Adding to the registry happens via providers/index.ts which imports each +// provider's barrel. Don't `import`-cycle this file from registry.ts. + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { + ProviderManifest, + CapabilityContext, +} from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { wahooImporter } from "./importer.ts"; +import { wahooPusher } from "./pusher.ts"; +import { wahooWebhook } from "./webhook.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; +const WAHOO_AUTH = "https://api.wahooligan.com/oauth"; + +const SCOPES = [ + "workouts_read", + "user_read", + "offline_data", + "routes_read", + "routes_write", +]; + +function clientId(): string { + return process.env.WAHOO_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.WAHOO_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return `${WAHOO_AUTH}/token`; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, + get revokeUrl() { + return `${WAHOO_API}/v1/permissions`; + }, +}; + +export const wahooManifest: ProviderManifest = { + id: "wahoo", + displayName: "Wahoo", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + scopes: SCOPES, + + buildAuthUrl(redirectUri: string, state: string): string { + const params = new URLSearchParams({ + client_id: clientId(), + redirect_uri: redirectUri, + response_type: "code", + scope: SCOPES.join(" "), + state, + }); + return `${WAHOO_AUTH}/authorize?${params}`; + }, + + async exchangeCode( + code: string, + redirectUri: string, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(`${WAHOO_AUTH}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId(), + client_secret: clientSecret(), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + const err = new Error(`Wahoo token exchange failed: ${resp.status} ${text}`); + // Attach a code so callers can distinguish the "too many tokens" sandbox case. + (err as Error & { code?: string }).code = text.includes("Too many unrevoked access tokens") + ? "too_many_tokens" + : "generic"; + throw err; + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + // Pull provider user id so webhook routing works. + const userResp = await fetch(`${WAHOO_API}/v1/user`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { id: number }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.id != null ? String(user.id) : null, + // Wahoo does not return a `scope` field and grants scopes + // all-or-nothing, so the requested set is the granted set. + grantedScopes: SCOPES, + }; + }, + + importer: wahooImporter, + routePusher: wahooPusher, + webhookReceiver: wahooWebhook, +}; + +// Re-export the capability adapters for direct testing access if needed. +export type { CapabilityContext }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts new file mode 100644 index 0000000..4bb2a30 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts @@ -0,0 +1,208 @@ +// Contract tests for the Wahoo RoutePusher capability adapter. +// +// Seam: pushRoute(ctx, input) -> {remoteId, version} +// +// Wahoo workarounds (FIT conversion, route: external_id, PUT-vs-POST, +// PUT->POST-on-404 fallback) are tested here as adapter-internal — they +// must not surface on the seam. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CapabilityContext } from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +// ---- mocks ---- + +const fetchSpy = vi.fn(); + +const mockFitConvert = vi.fn(); +vi.mock("@trails-cool/fit", () => ({ + gpxToFitCourse: (input: unknown) => mockFitConvert(input), +})); + +// sync_pushes idempotency state — manipulated per-test. +let existingPushRow: + | { + id: string; + userId: string; + routeId: string; + provider: string; + remoteId: string | null; + lastPushedVersion: number | null; + pushedAt: Date | null; + error: string | null; + } + | null = null; +const insertedPushes: unknown[] = []; +const updatedPushes: unknown[] = []; + +const mockDb = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve(existingPushRow ? [existingPushRow] : []), + }), + }), + }), + insert: () => ({ + values: (v: unknown) => { + insertedPushes.push(v); + return Promise.resolve(); + }, + }), + update: () => ({ + set: (patch: unknown) => ({ + where: () => { + updatedPushes.push(patch); + return Promise.resolve(); + }, + }), + }), +}; + +vi.mock("../../../db.ts", () => ({ getDb: () => mockDb })); + +vi.mock("../../manager.ts", () => ({ + getServiceById: async () => ({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + credentialKind: "oauth", + credentials: {}, + status: "active", + providerUserId: null, + grantedScopes: [], + createdAt: new Date(), + }), +})); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + mockFitConvert.mockReset(); + mockFitConvert.mockResolvedValue(new Uint8Array([0xfe, 0xed, 0xfa, 0xce])); + existingPushRow = null; + insertedPushes.length = 0; + updatedPushes.length = 0; +}); + +const stubCreds: OAuthCredentials = { + access_token: "fake-token", + refresh_token: "rt", + expires_at: new Date(Date.now() + 3600_000).toISOString(), +}; + +function ctx(): CapabilityContext { + return { + serviceId: "svc-1", + withFreshCredentials: async (fn) => fn(stubCreds), + }; +} + +const pushInput = { + routeId: "route-abc", + routeName: "Berlin loop", + description: "morning ride", + gpx: ` + 34 + 40 + `, + startLat: 52.5, + startLng: 13.4, + distance: 1234, + ascent: 56, + localVersion: 3, +}; + +const { wahooPusher } = await import("./pusher.ts"); + +describe("wahooPusher.pushRoute — first push (no existing row)", () => { + it("POSTs to /v1/routes with FIT body and external_id=route:", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 9001 }), { status: 201 }), + ); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toBe("https://api.wahooligan.com/v1/routes"); + expect((init as RequestInit).method).toBe("POST"); + const body = (init as RequestInit).body as string; + expect(body).toContain("route%5Bexternal_id%5D=route%3Aroute-abc"); + expect(body).toContain("route%5Bfile%5D=data%3Aapplication%2Fvnd.fit%3Bbase64%2C"); + expect(insertedPushes).toHaveLength(1); + }); +}); + +describe("wahooPusher.pushRoute — re-push of an unchanged route", () => { + it("short-circuits without calling Wahoo when last_pushed_version matches", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 3, + pushedAt: new Date("2026-01-01"), + error: null, + }; + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("wahooPusher.pushRoute — re-push after edit", () => { + it("PUTs to /v1/routes/ when lastPushedVersion < localVersion", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 2, + pushedAt: new Date("2026-01-01"), + error: null, + }; + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toBe("https://api.wahooligan.com/v1/routes/9001"); + expect((init as RequestInit).method).toBe("PUT"); + }); +}); + +describe("wahooPusher.pushRoute — PUT 404 fallback", () => { + it("falls back to POST and overwrites remoteId when PUT returns 404", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 2, + pushedAt: new Date("2026-01-01"), + error: null, + }; + fetchSpy + .mockResolvedValueOnce(new Response("not found", { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 9999 }), { status: 201 }), + ); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9999"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [, secondInit] = fetchSpy.mock.calls[1]!; + expect((secondInit as RequestInit).method).toBe("POST"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts new file mode 100644 index 0000000..365347b --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts @@ -0,0 +1,217 @@ +// Wahoo RoutePusher capability adapter. +// +// Exposes the seam shape `pushRoute(ctx, input) -> {remoteId, version}` per +// ADR-0003. Wahoo specifics — FIT-Course conversion, the `route:` +// external_id convention, the PUT-vs-POST decision based on sync_pushes, +// and the PUT-on-404 → POST fallback — live entirely inside this module +// and never appear on the seam. +// +// Idempotency state lives in `journal.sync_pushes`. The seam returns the +// remote id and the local version that was pushed; the caller is free to +// inspect the table for richer state. + +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { gpxToFitCourse } from "@trails-cool/fit"; +import { syncPushes } from "@trails-cool/db/schema/journal"; +import { getDb } from "../../../db.ts"; +import { getServiceById } from "../../manager.ts"; +import type { + CapabilityContext, + RoutePushInput, + RoutePushResult, + RoutePusher, +} from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; + +interface WahooErrorShape { + status: number; + body: string; +} + +class WahooHttpError extends Error { + shape: WahooErrorShape; + constructor(shape: WahooErrorShape) { + super(`Wahoo route ${shape.status}: ${shape.body}`); + this.shape = shape; + } +} + +function externalIdFor(routeId: string): string { + return `route:${routeId}`; +} + +function buildBody( + fit: Uint8Array, + input: RoutePushInput, +): URLSearchParams { + // Wahoo expects route[file] as a data URI, not raw base64. Sending raw + // base64 results in a route with file.url = null; the app shows + // metadata but renders no track. + const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`; + const body = new URLSearchParams({ + "route[external_id]": externalIdFor(input.routeId), + "route[provider_updated_at]": new Date().toISOString(), + "route[name]": input.routeName, + "route[workout_type_family_id]": "0", + "route[start_lat]": input.startLat.toString(), + "route[start_lng]": input.startLng.toString(), + "route[distance]": input.distance.toString(), + "route[ascent]": input.ascent.toString(), + "route[file]": fitDataUri, + }); + if (input.description) body.set("route[description]", input.description); + return body; +} + +async function postOrPut( + method: "POST" | "PUT", + url: string, + accessToken: string, + body: URLSearchParams, + fallbackRemoteId?: string, +): Promise<{ remoteId: string }> { + const resp = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (resp.ok) { + let remoteId: string | undefined; + if (resp.status !== 204) { + const data = (await resp.json().catch(() => null)) as { id?: number | string } | null; + remoteId = data?.id?.toString(); + } + remoteId ??= fallbackRemoteId; + if (!remoteId) throw new Error(`Wahoo response missing route id`); + return { remoteId }; + } + + const text = await resp.text().catch(() => ""); + throw new WahooHttpError({ status: resp.status, body: text }); +} + +interface ExistingPush { + id: string; + userId: string; + routeId: string; + provider: string; + remoteId: string | null; + lastPushedVersion: number | null; + pushedAt: Date | null; + error: string | null; +} + +async function findExistingPush( + userId: string, + routeId: string, +): Promise { + const db = getDb(); + const rows = (await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, userId), + eq(syncPushes.routeId, routeId), + eq(syncPushes.provider, "wahoo"), + ), + ) + .limit(1)) as ExistingPush[]; + return rows[0] ?? null; +} + +async function recordPush( + existing: ExistingPush | null, + userId: string, + input: RoutePushInput, + remoteId: string, +): Promise { + const db = getDb(); + const now = new Date(); + if (existing) { + await db + .update(syncPushes) + .set({ + remoteId, + lastPushedVersion: input.localVersion, + pushedAt: now, + error: null, + updatedAt: now, + }) + .where(eq(syncPushes.id, existing.id)); + } else { + await db.insert(syncPushes).values({ + id: randomUUID(), + userId, + routeId: input.routeId, + provider: "wahoo", + externalId: externalIdFor(input.routeId), + remoteId, + lastPushedVersion: input.localVersion, + pushedAt: now, + error: null, + }); + } +} + +export const wahooPusher: RoutePusher = { + async pushRoute( + ctx: CapabilityContext, + input: RoutePushInput, + ): Promise { + // Resolve the user from the connected service so we can read/write + // sync_pushes for the right (user, route, provider) tuple. + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const existing = await findExistingPush(service.userId, input.routeId); + if ( + existing?.pushedAt && + existing.remoteId && + existing.lastPushedVersion === input.localVersion + ) { + return { remoteId: existing.remoteId, version: input.localVersion }; + } + + const fit = await gpxToFitCourse({ + gpx: input.gpx, + name: input.routeName, + description: input.description, + }); + const body = buildBody(fit, input); + + const result = await ctx.withFreshCredentials(async (creds) => { + const accessToken = (creds as OAuthCredentials).access_token; + // PUT-vs-POST: PUT in place when we have a remoteId on file. + if (existing?.remoteId) { + try { + return await postOrPut( + "PUT", + `${WAHOO_API}/v1/routes/${encodeURIComponent(existing.remoteId)}`, + accessToken, + body, + existing.remoteId, + ); + } catch (err) { + // 404 means the user deleted the route on Wahoo's side. Fall + // back to POST and overwrite the local remoteId. + if (err instanceof WahooHttpError && err.shape.status === 404) { + return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body); + } + throw err; + } + } + return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body); + }); + + await recordPush(existing, service.userId, input, result.remoteId); + return { remoteId: result.remoteId, version: input.localVersion }; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts new file mode 100644 index 0000000..525d719 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts @@ -0,0 +1,133 @@ +// Contract tests for the Wahoo WebhookReceiver capability adapter. +// +// Seam: parseWebhook(body) -> WebhookEvent | null +// handle(event) -> void (creates an activity if file present, dedups via sync_imports) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const fetchSpy = vi.fn(); +const mockCreateActivity = vi.fn(); +const mockIsAlreadyImported = vi.fn(); +const mockRecordImport = vi.fn(); +const mockGetServiceByProviderUser = vi.fn(); +const mockWithFreshCredentials = vi.fn(); + +vi.mock("../../../activities.server.ts", () => ({ + createActivity: mockCreateActivity, +})); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: mockIsAlreadyImported, + recordImport: mockRecordImport, +})); +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + withFreshCredentials: mockWithFreshCredentials, +})); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + mockCreateActivity.mockReset(); + mockIsAlreadyImported.mockReset(); + mockRecordImport.mockReset(); + mockGetServiceByProviderUser.mockReset(); + mockWithFreshCredentials.mockReset(); +}); + +const { wahooWebhook } = await import("./webhook.ts"); + +describe("wahooWebhook.parseWebhook", () => { + it("returns a WebhookEvent for workout_summary payloads", () => { + const event = wahooWebhook.parseWebhook({ + event_type: "workout_summary", + user: { id: 7 }, + workout_summary: { + workout: { id: 42 }, + file: { url: "https://cdn.example/42.fit" }, + }, + }); + expect(event).toEqual({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + fileUrl: "https://cdn.example/42.fit", + }); + }); + + it("returns null for unrecognized event types", () => { + expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull(); + }); + + it("returns null when user.id is missing", () => { + expect( + wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }), + ).toBeNull(); + }); +}); + +describe("wahooWebhook.handle", () => { + it("creates an activity and records the import for a known user", async () => { + mockGetServiceByProviderUser.mockResolvedValue({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + }); + mockIsAlreadyImported.mockResolvedValue(false); + mockCreateActivity.mockResolvedValue("act-1"); + // withFreshCredentials passes the credentials to fn — we don't need to + // download a file to assert the basic flow; pass a no-op file URL test + // via parseWebhook output containing fileUrl undefined to skip download. + mockWithFreshCredentials.mockImplementation( + async (_id: string, fn: (creds: unknown) => Promise) => + fn({ + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }), + ); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + // no fileUrl — the activity is created without GPX + }); + + expect(mockCreateActivity).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ name: expect.stringContaining("Wahoo") }), + ); + expect(mockRecordImport).toHaveBeenCalledWith("u1", "wahoo", "42", "act-1"); + }); + + it("silently skips when the providerUserId is unknown (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "999", + workoutId: "42", + }); + + expect(mockCreateActivity).not.toHaveBeenCalled(); + expect(mockRecordImport).not.toHaveBeenCalled(); + }); + + it("silently skips when the workout was already imported (idempotency)", async () => { + mockGetServiceByProviderUser.mockResolvedValue({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + }); + mockIsAlreadyImported.mockResolvedValue(true); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + }); + + expect(mockCreateActivity).not.toHaveBeenCalled(); + expect(mockRecordImport).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts new file mode 100644 index 0000000..2b11b68 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts @@ -0,0 +1,100 @@ +// Wahoo WebhookReceiver capability adapter. +// +// Wahoo posts `workout_summary` events to /api/sync/webhook/wahoo when a +// workout completes. We route the event to the right local user via +// provider_user_id, deduplicate via sync_imports, then download + convert +// the FIT file (if present) and create an activity. + +import FitParser from "fit-file-parser"; +import { generateGpx } from "@trails-cool/gpx"; +import { createActivity } from "../../../activities.server.ts"; +import { isAlreadyImported, recordImport } from "../../../sync/imports.server.ts"; +import { getServiceByProviderUser, withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; + +interface WahooWebhookBody { + event_type?: string; + user?: { id?: number }; + workout_summary?: { + id?: number; + workout?: { id?: number }; + file?: { url?: string }; + }; +} + +async function fitToGpx(buffer: Buffer): Promise { + const parsed = await new Promise>((resolve, reject) => { + const parser = new FitParser({ force: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parser.parse(buffer as any, (error: unknown, data: any) => { + if (error) reject(error); + else resolve(data ?? {}); + }); + }); + + const records = (parsed.records ?? []) as Array<{ + position_lat?: number; + position_long?: number; + altitude?: number; + timestamp?: string | Date; + }>; + + const trackPoints = records + .filter((r) => r.position_lat != null && r.position_long != null) + .map((r) => ({ + lat: r.position_lat!, + lon: r.position_long!, + ele: r.altitude, + time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, + })); + + if (trackPoints.length < 2) return null; + return generateGpx({ name: "Wahoo workout", tracks: [trackPoints] }); +} + +export const wahooWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent | null { + const payload = body as WahooWebhookBody; + if (payload.event_type !== "workout_summary" || !payload.user?.id) return null; + + return { + eventType: payload.event_type, + providerUserId: String(payload.user.id), + workoutId: String( + payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", + ), + fileUrl: payload.workout_summary?.file?.url, + }; + }, + + async handle(event: WebhookEvent): Promise { + // Match incoming webhooks to local users via provider_user_id. + // Unknown users return silently — no leak. + const service = await getServiceByProviderUser("wahoo", event.providerUserId); + if (!service) return; + + if (await isAlreadyImported(service.userId, "wahoo", event.workoutId)) return; + + let gpx: string | null = null; + if (event.fileUrl) { + // Wahoo CDN URLs are pre-signed; no auth header needed. We still go + // through withFreshCredentials so the manager has a chance to refresh + // a near-expired credential before any subsequent Wahoo call this + // handler might make. + const buffer = await withFreshCredentials(service.id, async (_creds) => { + void (_creds as unknown as OAuthCredentials); + const resp = await fetch(event.fileUrl!); + if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); + }); + gpx = await fitToGpx(buffer); + } + + const activityId = await createActivity(service.userId, { + name: `Wahoo workout`, + gpx: gpx ?? undefined, + }); + await recordImport(service.userId, "wahoo", event.workoutId, activityId); + }, +}; diff --git a/apps/journal/app/lib/connected-services/push-action.server.ts b/apps/journal/app/lib/connected-services/push-action.server.ts new file mode 100644 index 0000000..a42039f --- /dev/null +++ b/apps/journal/app/lib/connected-services/push-action.server.ts @@ -0,0 +1,115 @@ +// Orchestrates a route push: load route, check ownership, check scopes, +// build the RoutePushInput, and invoke the provider's RoutePusher +// capability. Replaces the legacy pushRouteToProvider in lib/sync. +// +// The pusher (per-provider) handles HTTP, FIT conversion, idempotency, +// and PUT/POST/404 fallback. This module is the orchestration layer +// callers use (route handlers, OAuth callback resume). + +import { desc, eq } from "drizzle-orm"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { routeVersions } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { getRoute } from "../routes.server.ts"; +import { + ConnectionNotActiveError, + NeedsRelinkError, +} from "./types.ts"; +import { capabilityContextFor, getService } from "./manager.ts"; +import { getManifest, type RoutePushInput } from "./registry.ts"; + +export type PushOutcome = + | { status: "success"; remoteId: string; pushedAt: Date } + | { status: "scope_missing" } + | { status: "no_connection" } + | { status: "not_owner" } + | { status: "not_found" } + | { status: "unsupported_provider" } + | { status: "no_geometry" } + | { status: "needs_relink" } + | { + status: "error"; + code: "validation" | "rate_limit" | "token_expired" | "generic"; + message: string; + }; + +export interface PushRouteOptions { + userId: string; + providerId: string; + routeId: string; +} + +export async function pushRouteToProvider( + opts: PushRouteOptions, +): Promise { + const { userId, providerId, routeId } = opts; + const db = getDb(); + + const manifest = getManifest(providerId); + if (!manifest) return { status: "not_found" }; + if (!manifest.routePusher) return { status: "unsupported_provider" }; + + const route = await getRoute(routeId); + if (!route) return { status: "not_found" }; + if (route.ownerId !== userId) return { status: "not_owner" }; + + const service = await getService(userId, providerId); + if (!service) return { status: "no_connection" }; + if (!service.grantedScopes.includes("routes_write")) { + return { status: "scope_missing" }; + } + + // Pull the locked-in version GPX (not routes.gpx, which is the working copy). + const [latestVersion] = await db + .select() + .from(routeVersions) + .where(eq(routeVersions.routeId, routeId)) + .orderBy(desc(routeVersions.version)) + .limit(1); + + const versionGpx = latestVersion?.gpx ?? route.gpx; + const versionNumber = latestVersion?.version ?? 1; + if (!versionGpx) return { status: "no_geometry" }; + + const parsed = await parseGpxAsync(versionGpx); + const points = parsed.tracks.flat(); + if (points.length === 0) return { status: "no_geometry" }; + + const input: RoutePushInput = { + routeId, + routeName: route.name, + description: route.description ?? undefined, + gpx: versionGpx, + startLat: points[0]!.lat, + startLng: points[0]!.lon, + distance: parsed.distance, + ascent: parsed.elevation.gain, + localVersion: versionNumber, + }; + + try { + const ctx = capabilityContextFor(service.id); + const result = await manifest.routePusher.pushRoute(ctx, input); + return { + status: "success", + remoteId: result.remoteId, + pushedAt: new Date(), + }; + } catch (err) { + if (err instanceof NeedsRelinkError) return { status: "needs_relink" }; + if (err instanceof ConnectionNotActiveError) return { status: "needs_relink" }; + const message = err instanceof Error ? err.message : String(err); + // Map known HTTP-shape errors. The pusher throws Error / WahooHttpError; + // we don't try to recover further here. + if (message.includes("422")) { + return { status: "error", code: "validation", message }; + } + if (message.includes("429")) { + return { status: "error", code: "rate_limit", message }; + } + if (message.includes("401") || message.includes("403")) { + return { status: "error", code: "token_expired", message }; + } + return { status: "error", code: "generic", message }; + } +} diff --git a/apps/journal/app/lib/sync/connections.server.ts b/apps/journal/app/lib/sync/connections.server.ts deleted file mode 100644 index 0067491..0000000 --- a/apps/journal/app/lib/sync/connections.server.ts +++ /dev/null @@ -1,127 +0,0 @@ -// COMPATIBILITY SHIM — translates the legacy TokenSet-shaped API onto the -// new connected_services / JSONB-credentials schema introduced by -// deepen-connected-services. Once the routes and pushes.server.ts migrate -// to ConnectedServiceManager (tasks 5.x of the change), this whole file -// (and the rest of apps/journal/app/lib/sync/) goes away. -// -// Do not add new callers. Use apps/journal/app/lib/connected-services/ -// directly. - -import { randomUUID } from "node:crypto"; -import { eq, and } from "drizzle-orm"; -import { getDb } from "../db.ts"; -import { connectedServices } from "@trails-cool/db/schema/journal"; -import type { TokenSet } from "./types.ts"; - -interface OAuthBlob { - access_token: string; - refresh_token: string; - expires_at: string; -} - -interface LegacyConnection { - id: string; - userId: string; - provider: string; - accessToken: string; - refreshToken: string; - expiresAt: Date; - providerUserId: string | null; - grantedScopes: string[]; - createdAt: Date; -} - -function toLegacy(row: typeof connectedServices.$inferSelect): LegacyConnection { - const blob = row.credentials as OAuthBlob; - return { - id: row.id, - userId: row.userId, - provider: row.provider, - accessToken: blob.access_token, - refreshToken: blob.refresh_token, - expiresAt: new Date(blob.expires_at), - providerUserId: row.providerUserId, - grantedScopes: row.grantedScopes, - createdAt: row.createdAt, - }; -} - -function toBlob(tokens: TokenSet): OAuthBlob { - return { - access_token: tokens.accessToken, - refresh_token: tokens.refreshToken, - expires_at: tokens.expiresAt.toISOString(), - }; -} - -export async function saveConnection( - userId: string, - provider: string, - tokens: TokenSet, - grantedScopes: string[] = [], -) { - const db = getDb(); - await db - .delete(connectedServices) - .where( - and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), - ); - await db.insert(connectedServices).values({ - id: randomUUID(), - userId, - provider, - credentialKind: "oauth", - credentials: toBlob(tokens), - status: "active", - providerUserId: tokens.providerUserId ?? null, - grantedScopes, - }); -} - -export async function getConnection( - userId: string, - provider: string, -): Promise { - const db = getDb(); - const [row] = await db - .select() - .from(connectedServices) - .where( - and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), - ); - return row ? toLegacy(row) : null; -} - -export async function getConnectionByProviderUser( - provider: string, - providerUserId: string, -): Promise { - const db = getDb(); - const [row] = await db - .select() - .from(connectedServices) - .where( - and( - eq(connectedServices.provider, provider), - eq(connectedServices.providerUserId, providerUserId), - ), - ); - return row ? toLegacy(row) : null; -} - -export async function updateTokens(connectionId: string, tokens: TokenSet) { - const db = getDb(); - await db - .update(connectedServices) - .set({ credentials: toBlob(tokens) }) - .where(eq(connectedServices.id, connectionId)); -} - -export async function deleteConnection(userId: string, provider: string) { - const db = getDb(); - await db - .delete(connectedServices) - .where( - and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), - ); -} diff --git a/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit b/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit deleted file mode 100644 index 94e96ef0fafeb46c8da9545a9b8a50792e76b8d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34734 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 OZ->2lWmlD0`~Lu3eFd}t diff --git a/apps/journal/app/lib/sync/providers/wahoo.test.ts b/apps/journal/app/lib/sync/providers/wahoo.test.ts deleted file mode 100644 index ebc7de9..0000000 --- a/apps/journal/app/lib/sync/providers/wahoo.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { wahooProvider } from "./wahoo"; -import { PushError, OAuthError } from "../types.ts"; - -const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit"); -const fitBuffer = readFileSync(fixturePath); - -describe("wahooProvider.convertToGpx", () => { - it("converts a FIT file to valid GPX with correct coordinates", async () => { - const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); - - expect(gpx).not.toBeNull(); - expect(gpx).toContain(' { - const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); - expect(gpx).not.toBeNull(); - - const latMatches = gpx!.matchAll(/lat="([^"]+)"/g); - const lonMatches = gpx!.matchAll(/lon="([^"]+)"/g); - - const lats = [...latMatches].map((m) => parseFloat(m[1]!)); - const lons = [...lonMatches].map((m) => parseFloat(m[1]!)); - - expect(lats.length).toBeGreaterThan(10); - expect(lons.length).toBeGreaterThan(10); - - for (const lat of lats) { - expect(lat).toBeGreaterThan(-90); - expect(lat).toBeLessThan(90); - // Should be real-world coords, not near-zero from double-conversion - expect(Math.abs(lat)).toBeGreaterThan(1); - } - for (const lon of lons) { - expect(lon).toBeGreaterThan(-180); - expect(lon).toBeLessThan(180); - } - }); - - it("includes ISO 8601 timestamps (not Date objects)", async () => { - const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); - expect(gpx).not.toBeNull(); - - const timeMatches = gpx!.matchAll(/