Add deepen-connected-services architecture artifacts
Reshape the sync-providers seam before Komoot (web-login) and Apple Health (device) adapters land. Captures the decisions in three ADRs, seeds CONTEXT.md with Connected Services vocabulary, and proposes the OpenSpec change covering schema rename + ConnectedServiceManager + capability seams (Importer / RoutePusher / WebhookReceiver). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ede480e652
commit
cfba3146e2
11 changed files with 595 additions and 0 deletions
126
CONTEXT.md
Normal file
126
CONTEXT.md
Normal file
|
|
@ -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:<id>`
|
||||
`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`.
|
||||
19
docs/adr/0001-connected-services-credential-discriminator.md
Normal file
19
docs/adr/0001-connected-services-credential-discriminator.md
Normal file
|
|
@ -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.
|
||||
19
docs/adr/0002-no-unified-syncprovider-interface.md
Normal file
19
docs/adr/0002-no-unified-syncprovider-interface.md
Normal file
|
|
@ -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/<name>/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.
|
||||
18
docs/adr/0003-routepusher-seam-shape.md
Normal file
18
docs/adr/0003-routepusher-seam-shape.md
Normal file
|
|
@ -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:<id>` 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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-07
|
||||
163
openspec/changes/deepen-connected-services/design.md
Normal file
163
openspec/changes/deepen-connected-services/design.md
Normal file
|
|
@ -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<ImportableList>;
|
||||
importOne(service, id): Promise<ImportResult>;
|
||||
}
|
||||
|
||||
interface RoutePusher {
|
||||
pushRoute(service, route): Promise<{remoteId, version}>;
|
||||
}
|
||||
|
||||
interface WebhookReceiver {
|
||||
parseWebhook(body, headers): WebhookEvent | null;
|
||||
handle(event): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
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<ConnectedService>;
|
||||
unlink(serviceId): Promise<void>;
|
||||
withFreshCredentials<T>(serviceId, fn: (creds) => Promise<T>): Promise<T>;
|
||||
markNeedsRelink(serviceId, reason): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`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<Credentials | NeedsRelink>;
|
||||
revoke?(credentials): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
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:<id>` 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.
|
||||
36
openspec/changes/deepen-connected-services/proposal.md
Normal file
36
openspec/changes/deepen-connected-services/proposal.md
Normal file
|
|
@ -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/<name>/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:<id>` `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).
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: OAuth token storage in `connected_services`
|
||||
External-service credentials SHALL be stored in the `journal.connected_services` table (renamed from `sync_connections`) keyed by `(user_id, provider)`. Each row SHALL persist a `credential_kind` discriminator (`oauth | web-login | device`), a `credentials` JSONB blob whose schema is determined by the kind, the provider-side user id (`provider_user_id`, nullable for kinds without one), and OAuth-specific `granted_scopes` as a top-level column (populated only when `credential_kind = 'oauth'`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
|
||||
|
||||
For `credential_kind = 'oauth'`, the `credentials` blob SHALL contain `access_token`, `refresh_token`, and `expires_at`. Other kinds (web-login, device) carry their own blob shapes and are introduced by their respective consumer changes; the enum value is reserved from day one so adding a kind does not require a migration.
|
||||
|
||||
#### Scenario: Wahoo connect persists tokens
|
||||
- **WHEN** a user completes the Wahoo OAuth flow
|
||||
- **THEN** a `connected_services` row is upserted with `provider = 'wahoo'`, `credential_kind = 'oauth'`, the `credentials` JSONB containing access/refresh tokens and `expires_at`, the provider user id, and the granted scopes
|
||||
|
||||
#### Scenario: Disconnect removes the row but keeps imports
|
||||
- **WHEN** a user clicks "Disconnect" on a Wahoo connection
|
||||
- **THEN** the matching `connected_services` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
|
||||
|
||||
#### Scenario: Each user has at most one row per provider
|
||||
- **WHEN** a user reconnects an already-connected provider
|
||||
- **THEN** the existing `connected_services` row is updated in place with the fresh credentials; no duplicate row is created
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Capability seams for providers
|
||||
The system SHALL model each external provider as a manifest declaring its `credential_kind` and which capabilities it implements. Capabilities are modelled as separate interfaces — `Importer`, `RoutePusher`, `WebhookReceiver` — and a provider implements only the subset that applies to it. There SHALL NOT be a unified provider interface that lists every capability with optional methods.
|
||||
|
||||
#### Scenario: Add a new provider
|
||||
- **WHEN** a developer adds a new provider (e.g. Garmin)
|
||||
- **THEN** they create `providers/<name>/manifest.ts` declaring the provider's `credential_kind` and the capability adapters it implements
|
||||
- **AND** they implement only the relevant capability interfaces (e.g. an OAuth-pull-only provider implements `Importer` only; not `RoutePusher` or `WebhookReceiver`)
|
||||
- **AND** they register the manifest by importing it in `providers/registry.ts`
|
||||
- **AND** existing settings UI, webhook routing, and credential lifecycle work without further changes
|
||||
|
||||
### Requirement: Centralized credential lifecycle via `ConnectedServiceManager`
|
||||
The system SHALL expose a `ConnectedServiceManager` that owns credential lifecycle for all providers. Importers, pushers, and webhook handlers SHALL obtain credentials exclusively via `withFreshCredentials(serviceId, fn)`, which loads the row, refreshes via the kind-appropriate `CredentialAdapter` if expired, calls `fn(credentials)`, and flips `status = needs_relink` if refresh fails. Capability adapters SHALL NOT read the `credentials` JSONB directly.
|
||||
|
||||
#### Scenario: Expired OAuth token is refreshed transparently
|
||||
- **WHEN** a capability adapter calls `withFreshCredentials` for a `connected_services` row whose `expires_at` has passed
|
||||
- **THEN** the manager invokes the OAuth `CredentialAdapter`'s refresh flow
|
||||
- **AND** updates the `credentials` JSONB with the new tokens and `expires_at`
|
||||
- **AND** invokes `fn` with the fresh credentials
|
||||
- **AND** the capability adapter never sees the expired token
|
||||
|
||||
#### Scenario: Refresh failure flips status to needs_relink
|
||||
- **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token)
|
||||
- **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row
|
||||
- **AND** raises an error that the caller surfaces as a re-connect prompt
|
||||
- **AND** subsequent calls for the same service short-circuit until the user re-links
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Provider-agnostic sync framework
|
||||
The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at `providers/<name>/manifest.ts` listing its `credential_kind` (`oauth | web-login | device`) and the capability adapters it implements (`Importer`, `RoutePusher`, `WebhookReceiver`). There SHALL NOT be a unified `SyncProvider` interface containing every capability with optional methods.
|
||||
|
||||
#### Scenario: Add new provider
|
||||
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
|
||||
- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities
|
||||
- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider)
|
||||
- **AND** they register the manifest in `providers/registry.ts`
|
||||
- **AND** OAuth flows, webhook routing, and settings UI work without further changes
|
||||
|
||||
### Requirement: Connect Wahoo account
|
||||
Users SHALL be able to connect their Wahoo account via OAuth2.
|
||||
|
||||
#### Scenario: Connect Wahoo
|
||||
- **WHEN** a user clicks "Connect Wahoo" in journal settings
|
||||
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write`
|
||||
- **AND** after granting permission, redirected back to the journal
|
||||
- **AND** access and refresh tokens are stored in `connected_services` (in the `credentials` JSONB blob, with `credential_kind = 'oauth'`)
|
||||
- **AND** the granted scopes are recorded in `connected_services.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
|
||||
|
||||
#### Scenario: Disconnect Wahoo
|
||||
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
|
||||
- **THEN** the stored credentials are deleted from `connected_services`
|
||||
|
||||
#### Scenario: Token refresh
|
||||
- **WHEN** a Wahoo API call fails with an expired token
|
||||
- **THEN** the OAuth `CredentialAdapter` is invoked via `ConnectedServiceManager.withFreshCredentials` to obtain a new access token automatically
|
||||
- **AND** the new tokens are written back to the `credentials` JSONB blob
|
||||
|
||||
#### Scenario: Existing connection without routes_write
|
||||
- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route)
|
||||
- **THEN** the system detects the missing scope from `connected_services.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
|
||||
- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work
|
||||
|
||||
### Requirement: Webhook-based automatic sync
|
||||
New Wahoo workouts SHALL be automatically imported when they complete.
|
||||
|
||||
#### Scenario: Webhook receives new workout
|
||||
- **WHEN** Wahoo sends a `workout_summary` webhook to `/api/sync/webhook/wahoo`
|
||||
- **THEN** the system identifies the user via `provider_user_id`
|
||||
- **AND** downloads the FIT file from Wahoo's CDN (without auth headers, as CDN URLs are pre-signed)
|
||||
- **AND** converts it to GPX
|
||||
- **AND** creates a journal activity with the GPX, stats, and PostGIS geometry
|
||||
- **AND** records the import in `sync_imports` to prevent duplicates
|
||||
|
||||
#### Scenario: Webhook for workout without file
|
||||
- **WHEN** a webhook arrives for a workout with no FIT file URL
|
||||
- **THEN** the activity is created without GPX or geometry
|
||||
|
||||
#### Scenario: Duplicate webhook
|
||||
- **WHEN** a webhook arrives for a workout already imported
|
||||
- **THEN** the import is skipped silently (idempotent)
|
||||
|
||||
#### Scenario: Unknown user webhook
|
||||
- **WHEN** a webhook arrives with a `provider_user_id` not matching any connection
|
||||
- **THEN** the request is ignored with a 200 response (don't reveal user existence)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Send to Wahoo action on route detail page
|
||||
The Journal SHALL show a "Send to Wahoo" button on the route detail page when all of the following hold: the viewer owns the route, the viewer has a connected Wahoo account, and the route has stored geometry (`routes.geom` is non-null and `routes.gpx` is non-empty). The button SHALL trigger a server action that pushes the current route version to Wahoo.
|
||||
|
||||
#### Scenario: Owner with connected Wahoo and a route with geometry
|
||||
- **WHEN** the route owner loads a route detail page for a route that has geometry
|
||||
- **AND** the owner has a `connected_services` row with `provider = 'wahoo'`
|
||||
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
|
||||
|
||||
#### Scenario: Owner without a connected Wahoo account
|
||||
- **WHEN** the route owner loads a route detail page
|
||||
- **AND** the owner has no Wahoo `connected_services` row
|
||||
- **THEN** the "Send to Wahoo" button is not rendered
|
||||
|
||||
#### Scenario: Non-owner viewing the route
|
||||
- **WHEN** any visitor who is not the route owner loads the route detail page
|
||||
- **THEN** the "Send to Wahoo" button is not rendered, regardless of the viewer's own Wahoo connection
|
||||
|
||||
#### Scenario: Route without geometry
|
||||
- **WHEN** the route owner loads a route detail page for a route whose `geom` is null or whose `gpx` is empty
|
||||
- **THEN** the "Send to Wahoo" button is not rendered
|
||||
|
||||
### Requirement: Re-auth flow when `routes_write` scope is missing
|
||||
The Journal SHALL detect when a connected Wahoo account lacks the `routes_write` scope before calling Wahoo, redirect the user through OAuth to grant it, and resume the push automatically after the user returns.
|
||||
|
||||
#### Scenario: Existing connection lacks routes_write
|
||||
- **WHEN** the route owner clicks "Send to Wahoo"
|
||||
- **AND** the user's `connected_services.granted_scopes` does not include `routes_write`
|
||||
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }`
|
||||
- **AND** no Wahoo `/v1/routes` call is attempted
|
||||
|
||||
#### Scenario: Push completes after re-auth
|
||||
- **WHEN** the user returns from Wahoo's OAuth callback with `push_after = true` in the state
|
||||
- **THEN** the connection is updated with the new scopes
|
||||
- **AND** the push action runs automatically against the route encoded in `return_to`
|
||||
- **AND** the user lands back on the route detail page with the "Sent to Wahoo" confirmation visible
|
||||
|
||||
#### Scenario: User declines the new scope
|
||||
- **WHEN** the user reaches Wahoo's authorization page and clicks "Deny"
|
||||
- **THEN** the user is redirected back to the route detail page with an inline notice "Sending to Wahoo needs route permission — please reconnect your account in Settings"
|
||||
- **AND** no `sync_pushes` row is created
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `RoutePusher` capability seam
|
||||
The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface.
|
||||
|
||||
#### Scenario: Wahoo pusher implements the seam without leaking workarounds
|
||||
- **WHEN** the route push action invokes the Wahoo `RoutePusher`
|
||||
- **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}`
|
||||
- **AND** the FIT-Course conversion, the `external_id = route:<route_id>` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter
|
||||
- **AND** callers do not pass FIT data or Wahoo-specific fields across the seam
|
||||
|
||||
#### Scenario: Future pusher reuses the same seam
|
||||
- **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin)
|
||||
- **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape
|
||||
- **AND** the route push action invokes it identically to the Wahoo pusher
|
||||
- **AND** their format conversion and HTTP recovery live inside the adapter
|
||||
49
openspec/changes/deepen-connected-services/tasks.md
Normal file
49
openspec/changes/deepen-connected-services/tasks.md
Normal file
|
|
@ -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:<id>`, 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/`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue