Eliminates the silent-failure pattern where a route/activity row could be committed with gpx IS NOT NULL but geom IS NULL if the PostGIS write failed after the row insert. - New gpx-save.server.ts owns all GPX validation (GpxValidationError, validateGpx) and PostGIS geometry writes (writeGeom, tx-aware) - createRoute, updateRoute, createActivity, createRouteFromActivity all wrapped in db.transaction() covering row + geom + version snapshot - demo-bot uses createRoute/createActivity instead of raw inserts; errors propagate loudly - Callback endpoint returns 400 for GpxValidationError instead of 401 - ADR-0006 documents the invariant for future explorers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
9.3 KiB
Markdown
198 lines
9.3 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).
|
||
|
||
---
|
||
|
||
## GPX Save
|
||
|
||
The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction.
|
||
|
||
### gpx-save module
|
||
`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly.
|
||
|
||
### GpxValidationError
|
||
Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat −90..90, lon −180..180). Callers catch this to return a user-facing 400.
|
||
|
||
### validateGpx
|
||
`(gpx: string) → Promise<ParsedGpx>`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write.
|
||
|
||
### atomic GPX save
|
||
The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path.
|
||
|
||
---
|
||
|
||
## 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.
|