Add deepen-connected-services architecture artifacts

Reshape the sync-providers seam before Komoot (web-login) and Apple
Health (device) adapters land. Captures the decisions in three ADRs,
seeds CONTEXT.md with Connected Services vocabulary, and proposes the
OpenSpec change covering schema rename + ConnectedServiceManager +
capability seams (Importer / RoutePusher / WebhookReceiver).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-08 00:10:49 +02:00
parent ede480e652
commit cfba3146e2
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 595 additions and 0 deletions

View file

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

View 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.

View 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.