Extract the post-verify orchestration shared across passkey register-finish, passkey login-finish, magic-link verify-code, and magic-link click-through into a single completeAuth function. Two ADRs record the decision: - ADR-0004: centralize web auth completion (record terms + create session + redirect) in apps/journal/app/lib/auth/completion.ts. - ADR-0005: explicitly no AuthMethod polymorphism. Passkey + magic- link is the entire identity surface; OAuth2/PKCE is session transport, not a peer method. Recorded as a negative decision so future architecture passes don't re-suggest extracting the interface. CONTEXT.md gains an Authentication section covering completeAuth, the two methods, the OAuth2-as-transport distinction, and where the Terms gate enforcement lives (root loader for web, requireApiUser for API per the just-merged mobile-terms-gate). OpenSpec change unify-auth-completion captures the proposal, design (5 decisions including the negative-scope choices), spec delta on authentication-methods, and 14 tasks. Implementation follows on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
7.8 KiB
Markdown
180 lines
7.8 KiB
Markdown
# 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`.
|
|
|
|
---
|
|
|
|
## Authentication
|
|
|
|
User identity in the Journal. Two **authentication methods** are supported
|
|
and are intentionally the entire surface (see ADR-0005): **passkey**
|
|
(WebAuthn) as the preferred method and **magic-link + 6-digit code**
|
|
(email) as a fallback for users without passkey support or for cross-device
|
|
sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys
|
|
already deliver the one-tap UX, and adding centralized identity providers
|
|
would conflict with the privacy-first ethos and ActivityPub federation.
|
|
|
|
OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication
|
|
method. It is a **session transport** for native clients: users still
|
|
authenticate via passkey or magic-link in a WebView, then the mobile app
|
|
exchanges the resulting authorization code for long-lived bearer tokens.
|
|
The peer of OAuth2 transport is the cookie session, not passkey or
|
|
magic-link.
|
|
|
|
### completeAuth
|
|
The single chokepoint for the post-verify orchestration of every web
|
|
auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see
|
|
ADR-0004). Called by every route handler that has just verified a
|
|
user's identity (passkey login finish, magic-link code verify, magic
|
|
link consumer). Does three things in order:
|
|
|
|
1. If `isNewRegistration`, records the accepted Terms version
|
|
(`recordTermsAcceptance`).
|
|
2. Creates the session cookie via `createSession`.
|
|
3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie`
|
|
header attached.
|
|
|
|
Identity-method-specific work (WebAuthn ceremony verification, magic
|
|
token consumption) stays in the per-method functions and runs *before*
|
|
`completeAuth`. The chokepoint deliberately knows nothing about how
|
|
identity was proved.
|
|
|
|
### Terms gate
|
|
Cross-cutting middleware enforcing that `users.terms_version` matches
|
|
the current `TERMS_VERSION` constant before any non-allow-listed
|
|
authenticated request succeeds. Two enforcement points:
|
|
|
|
- **Web (cookie sessions)**: the root loader redirects stale-terms users
|
|
to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`,
|
|
`/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the
|
|
allow-list, so OAuth code issuance is gated by this same redirect
|
|
before mobile sees an authorization code.
|
|
- **API (bearer tokens)**: `requireApiUser` returns
|
|
`403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms
|
|
bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.)
|
|
|
|
`completeAuth` only **records** terms on registration; it does not
|
|
enforce them. Enforcement remains middleware's job.
|