diff --git a/CONTEXT.md b/CONTEXT.md index e41990c..007ea13 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,3 +124,57 @@ webhooks to the right user via `provider_user_id`. Unknown `(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. diff --git a/docs/adr/0004-centralize-auth-completion.md b/docs/adr/0004-centralize-auth-completion.md new file mode 100644 index 0000000..533e53e --- /dev/null +++ b/docs/adr/0004-centralize-auth-completion.md @@ -0,0 +1,26 @@ +# Centralize web auth completion in `completeAuth` + +Every successful web authentication today (passkey login, passkey +register, magic-link code verify, magic-link click-through verify) +inlines the same three-step orchestration in its route handler: +record the accepted Terms version on registration, mint the session +cookie, and redirect to `returnTo` (or `/`). With four call sites +re-implementing the same sequence, a bug in any of them — a missed +terms write, a wrong cookie option, an open-redirect-prone returnTo +— must be fixed in four places, and a future fifth flow (e.g. an +admin-impersonation login, a one-time recovery flow) re-derives the +same boilerplate. + +We extract the orchestration into a single function +`completeAuth({ userId, isNewRegistration, termsVersion?, request, +returnTo? })` at `apps/journal/app/lib/auth/completion.ts`. Per-method +identity verification (WebAuthn ceremony, magic-token consumption) +stays in its own function and runs *before* `completeAuth` — +the chokepoint deliberately knows nothing about how identity was +proved. Terms enforcement (the gate) remains middleware (root loader +for web, `requireApiUser` for API); `completeAuth` only *records* +terms on registration. The OAuth-code-issuance flow at +`/oauth/authorize` is **not** routed through `completeAuth` — it +operates on an already-authenticated user and shares only the +trailing redirect, not the full sequence (see ADR-0005 for the +broader scope decision). diff --git a/docs/adr/0005-no-authmethod-polymorphism.md b/docs/adr/0005-no-authmethod-polymorphism.md new file mode 100644 index 0000000..cbab089 --- /dev/null +++ b/docs/adr/0005-no-authmethod-polymorphism.md @@ -0,0 +1,40 @@ +# No `AuthMethod` polymorphism — passkey + magic-link is the entire surface + +A natural temptation when reshaping auth is to extract an +`AuthMethod` interface (analogous to the connected-services +`Importer` / `RoutePusher` / `WebhookReceiver` capability seams) so +passkey and magic-link become two adapters of a common shape, with +future Google/Apple/SAML/etc. as additional adapters. We are +explicitly **not** doing that, and recording the negative decision +here so future architecture passes don't re-suggest it. + +Two reasons. First, the *number of adapters is fixed*: passkey is +trails.cool's preferred identity method and magic-link is its +fallback for browsers without WebAuthn support and cross-device sign- +in. There are no plans for additional methods. Social sign-in (Google, +Apple) would conflict with the privacy-first ethos in `CLAUDE.md` +(third-party identity providers see every login), would fight the +ActivityPub federation model (centralized OIDC vs. +`user@domain` identity), and would add account-linking edge cases +without a UX win — passkeys already deliver the one-tap convenience +that justifies social sign-in elsewhere. With two adapters and no +forecast third, an `AuthMethod` interface is theoretical +polymorphism with no future second customer; the deletion test fails. + +Second, the *interface shape would be shallow*. WebAuthn is a +multi-step ceremony (start → finish, with challenge persistence +between calls); magic-link is a request-then-verify flow with a +15-minute token in the database. Forcing both into a unified +`startAuth(input) → finishAuth(response)` interface produces an +adapter where each implementation barely shares a type signature — +the two flows have genuinely different shapes, and the seam would +fill with optional methods and discriminated unions. The real +repetition lives in the *post-verify orchestration* (record terms, +mint session, redirect), which ADR-0004 addresses with a single +`completeAuth` function — no polymorphism required. + +OAuth2/PKCE in the `mobile-app` change is sometimes mistaken for a +third auth method. It isn't: mobile users still authenticate via +passkey or magic-link in a WebView, and OAuth2 only exchanges the +resulting code for long-lived bearer tokens. OAuth2 is **session +transport** (peer of: HTTP-only cookie), not identity proof. diff --git a/openspec/changes/unify-auth-completion/.openspec.yaml b/openspec/changes/unify-auth-completion/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/unify-auth-completion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/unify-auth-completion/design.md b/openspec/changes/unify-auth-completion/design.md new file mode 100644 index 0000000..a55e0ce --- /dev/null +++ b/openspec/changes/unify-auth-completion/design.md @@ -0,0 +1,103 @@ +## Context + +`apps/journal/app/lib/auth.server.ts` is 489 lines covering passkey ceremonies, magic-link token lifecycle, session cookie storage, terms recording, and email-change tokens. Four route handlers (`api.auth.register.ts`, `api.auth.login.ts` × 2 branches, `auth.verify.tsx`) each repeat the same trailing sequence after their per-method identity verification: + +```ts +if (isNewRegistration) await recordTermsAcceptance(userId, termsVersion); +const headers = await createSession(userId, request); +return redirect(safeReturnTo(returnTo) ?? "/", { headers }); +``` + +The repetition is the *entire* asymmetry between methods at the post-verify layer. WebAuthn vs. magic-link diverge before this point (challenge persistence, token consumption, etc.) but converge here — every flow does the same thing. + +ADRs already recorded: +- ADR-0004: centralize this orchestration in a single `completeAuth` function. +- ADR-0005: do **not** extract an `AuthMethod` interface; passkey + magic-link is the entire surface and OAuth2 is transport, not a peer. + +## Goals / Non-Goals + +**Goals:** +- One function (`completeAuth`) is the only place where a successful web auth records terms + creates session + redirects. Bug fixes apply once. +- File reorganization: `apps/journal/app/lib/auth/` directory containing `completion.ts` and `session.ts`. The existing `auth.server.ts` shrinks but stays for the per-method functions (passkey ceremony, magic-token lifecycle). +- `returnTo` sanitization centralized inside `completeAuth` so no caller can ship an open-redirect. +- Refactor is behaviour-preserving: existing e2e auth tests pass without modification. + +**Non-Goals:** +- No `AuthMethod` interface (per ADR-0005). +- The OAuth-code issuance flow at `/oauth/authorize` is NOT migrated. It operates on already-authenticated users; only the trailing redirect overlaps, not enough leverage to fold in. +- The add-passkey post-login ceremony does NOT need `completeAuth` (no session creation — user is already logged in). +- The magic-link request-token step does NOT need `completeAuth` (only emits a token; verification happens later). +- Session transport (cookie vs. bearer) — orthogonal; not touched. +- Connected-devices UX — separate spec. +- Any change to the Terms gate enforcement points (root loader for web, `requireApiUser` for API). + +## Decisions + +### Decision 1: Single function, no mode flag +`completeAuth` covers exactly the four web post-verify call sites listed in the proposal. There is no `redirectMode: 'cookie' | 'oauth-code'` flag because OAuth-code issuance is genuinely a different flow (already-authenticated user, no session creation, only a code + redirect). Folding both into one function would force a discriminated union and obscure that they don't share much. The OAuth-code path stays in `oauth.authorize.tsx` unchanged. + +### Decision 2: Function signature +```ts +async function completeAuth(input: { + userId: string; + isNewRegistration: boolean; + termsVersion?: string; // required if isNewRegistration; ignored otherwise + request: Request; + returnTo?: string | null; +}): Promise +``` + +Returns a `Response` (a `redirect` from React Router with the session `Set-Cookie` attached). Callers do `return await completeAuth(...)` from their action / loader. No headers-vs-redirect split: the chokepoint owns both halves. + +`termsVersion` is optional at the type level but required-when-`isNewRegistration` at runtime. Asserting it inside the function (throw on `isNewRegistration && !termsVersion`) keeps the call sites tidy. Alternative: a discriminated union forcing the compiler to require `termsVersion` when `isNewRegistration: true`. Lean toward the runtime assertion — three call sites, two of which set `isNewRegistration: false` — the type ergonomics aren't worth the union complexity. + +### Decision 3: File layout +New directory `apps/journal/app/lib/auth/` containing: +- `completion.ts` — `completeAuth` + `safeReturnTo` helper (open-redirect rejection). +- `session.ts` — `sessionStorage`, `createSession`, `getSessionUser`, `destroySession`. Moved from `auth.server.ts`. + +`auth.server.ts` keeps the per-method functions (passkey ceremony, magic-token lifecycle, terms recording, email-change). It re-exports the moved session helpers from their new home so existing imports keep working without a touch: +```ts +// auth.server.ts +export { sessionStorage, createSession, getSessionUser, destroySession } from "./auth/session.ts"; +``` + +That keeps the migration small. A future change can rip the re-exports and update callers across the app — out of scope here. + +### Decision 4: `returnTo` sanitization centralizes inside `completeAuth` +Today, several callers do their own `returnTo` checks of varying rigor (some check `startsWith("/") && !startsWith("//")`, others don't). Move the check into `safeReturnTo` inside `completion.ts`; every caller passes `returnTo` raw and gets the safe version applied uniformly. Reject anything that isn't a same-origin path → fall back to `/`. + +### Decision 5: Tests +Unit tests for `completeAuth` in `completion.test.ts`: +- new-registration writes `users.terms_version` + `users.terms_accepted_at` (mock the DB or use an in-memory test DB — match existing test patterns). +- non-registration skips the terms write. +- response includes a `Set-Cookie` header from `sessionStorage.commitSession`. +- `returnTo` of `/foo` redirects to `/foo`; `returnTo` of `//evil.com/x` redirects to `/`; `returnTo` of `https://evil.com` redirects to `/`; missing `returnTo` redirects to `/`. + +Existing `e2e/auth.test.ts` (passkey register + login + logout) MUST pass without modification — that's the behaviour-preservation proof. + +## Risks / Trade-offs + +- **[Risk]** Re-exporting from `auth.server.ts` to maintain backwards compatibility means there are temporarily two paths to the same symbols. → **Mitigation:** comment marks the re-exports as transitional. A follow-up PR (small) can update import paths app-wide and remove the re-exports. + +- **[Risk]** The runtime assertion (`isNewRegistration && !termsVersion → throw`) is a programmer error, not a user-visible failure. If a future caller forgets to pass `termsVersion`, tests should catch it; if they don't, registration breaks loudly. → **Accepted:** the type-level alternative (discriminated union) costs more than it pays back at three internal call sites. + +- **[Trade-off]** Centralizing `returnTo` sanitization changes behaviour at any call sites that *don't* currently sanitize. The change is strictly safer (rejecting more inputs as `/`) but worth confirming there are no integration tests that pass a deliberately-unsafe `returnTo` and expect it to work. + +## Migration Plan + +Single PR ("spec apply") containing: + +1. Create `apps/journal/app/lib/auth/session.ts` with the moved helpers. +2. Add re-exports in `auth.server.ts`. +3. Create `apps/journal/app/lib/auth/completion.ts` with `completeAuth` + `safeReturnTo`. +4. Write `completion.test.ts` (unit tests). +5. Migrate the 4 callers to `completeAuth`. +6. Verify `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. + +**Rollback**: revert the PR. No DB or schema changes; the file moves are pure code-organization, the call-site changes are mechanical. + +## Open Questions + +- **Q1**: Does `sessionStorage` get its `secret` from `process.env.SESSION_SECRET` directly, or from a config helper? Move-as-is and revisit only if it complicates the `session.ts` file. → Decide during implementation. +- **Q2**: Should the `auth.server.ts` re-exports be marked `@deprecated` JSDoc-style so editors warn on use? Light touch — yes, with a comment pointing at `./auth/session.ts`. Decide during implementation. diff --git a/openspec/changes/unify-auth-completion/proposal.md b/openspec/changes/unify-auth-completion/proposal.md new file mode 100644 index 0000000..1c557cb --- /dev/null +++ b/openspec/changes/unify-auth-completion/proposal.md @@ -0,0 +1,32 @@ +## Why + +Every successful web authentication today inlines the same three-step orchestration in its route handler: record the accepted Terms version on registration (`recordTermsAcceptance`), mint the session cookie (`createSession`), and `redirect(returnTo ?? "/", { headers })`. Four call sites — passkey register-finish, passkey login-finish, magic-link verify-code, and the magic-link click-through consumer — re-implement the same sequence. A bug in any one of them must be fixed in four places, and a future fifth flow (admin impersonation, recovery, etc.) re-derives the same boilerplate. + +This change extracts the orchestration into a single function `completeAuth` and folds in the small file-organization win that comes with it (a new `apps/journal/app/lib/auth/` directory). It does **not** introduce an `AuthMethod` interface — see ADR-0005 for why polymorphism here is theoretical with no future adapter. + +## What Changes + +- **New module** `apps/journal/app/lib/auth/completion.ts` exporting `completeAuth({ userId, isNewRegistration, termsVersion?, request, returnTo? }) → Promise`. Records terms on registration (only), creates the session, returns a `redirect(...)` Response with the `Set-Cookie` header attached. +- **New module** `apps/journal/app/lib/auth/session.ts` carrying the cookie session storage (`sessionStorage`, `createSession`, `getSessionUser`, `destroySession`) extracted from `auth.server.ts`. The existing path keeps re-exports for backwards compatibility — caller migration is a follow-up if anything. +- **Caller migration**: 4 route handlers (`api.auth.register.ts` passkey-finish branch, `api.auth.login.ts` passkey-finish-passkey branch, `api.auth.login.ts` magic-link verify-code branch, `auth.verify.tsx` magic-link click-through) replace their inlined orchestration with a single `completeAuth(...)` call. +- **`returnTo` sanitization** centralizes inside `completeAuth` (was already same-origin checked in callers; now there's one place). +- **Tests**: new `completion.test.ts` covering register-records-terms, non-register-skips-terms, returnTo default, header attachment, returnTo open-redirect rejection. Existing `e2e/auth.test.ts` continues to pass without modification — that's the proof the refactor is behaviour-preserving. +- **Negative scope (recorded in ADR-0005)**: no `AuthMethod` interface, no adapter pattern. Passkey + magic-link is the entire identity surface; OAuth2/PKCE is session transport, not a third method. + +## Capabilities + +### New Capabilities + +(none — this change reshapes existing capabilities, no new user-visible behaviour.) + +### Modified Capabilities + +- `authentication-methods`: requirement-level addition codifying that every post-verify web flow goes through the single completion chokepoint that records terms (on register), creates the session, and redirects. User-visible behaviour unchanged. + +## Impact + +- **Code**: 1 new directory (`apps/journal/app/lib/auth/`), 2 new files (`completion.ts`, `session.ts`), `auth.server.ts` slims down by ~80 lines (the moved session helpers), 4 route handlers shrink. No DB changes. No schema changes. +- **Tests**: 1 new unit test file (`completion.test.ts`). Existing e2e auth test should pass unmodified. +- **Specs**: 1 spec gets a small ADDED requirement (`authentication-methods/spec.md`). +- **Decision references**: `docs/adr/0004-centralize-auth-completion.md`, `docs/adr/0005-no-authmethod-polymorphism.md`, `CONTEXT.md` (Authentication section). +- **Out of scope** (separate concerns): the OAuth-code issuance flow at `/oauth/authorize` (operates on already-authenticated users; only shares the trailing redirect), the magic-link request-token step (emits a token, no session created), the add-passkey post-login ceremony (no session creation). Connected-devices UX is its own spec/change. Session-transport changes (cookie ↔ bearer) are orthogonal and not touched here. diff --git a/openspec/changes/unify-auth-completion/specs/authentication-methods/spec.md b/openspec/changes/unify-auth-completion/specs/authentication-methods/spec.md new file mode 100644 index 0000000..d7035c9 --- /dev/null +++ b/openspec/changes/unify-auth-completion/specs/authentication-methods/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Single web auth completion chokepoint +Every successful web authentication flow — passkey register-finish, passkey login-finish, magic-link 6-digit-code verify, magic-link click-through verify — SHALL complete by calling a single `completeAuth` function at `apps/journal/app/lib/auth/completion.ts`. The function SHALL be the sole place where a successful web authentication records the accepted Terms version (only on registration), mints the cookie session, and constructs the redirect to `returnTo` (or `/` when absent or rejected). + +Per-method identity verification (WebAuthn ceremony, magic-token consumption, 6-digit-code consumption) SHALL run in its own function and produce a `userId` *before* `completeAuth` is invoked. `completeAuth` SHALL NOT know how identity was proved. + +The Terms gate (root-loader redirect for cookie sessions; `requireApiUser` 403 for bearer-token API requests) SHALL remain the enforcement point for stale `terms_version`. `completeAuth` only **records** terms on new registrations; it does not enforce them. + +OAuth-code issuance at `/oauth/authorize` SHALL NOT be routed through `completeAuth` — that flow operates on an already-authenticated user and shares only the trailing redirect, not the full sequence. + +#### Scenario: Passkey register-finish completes through the chokepoint +- **WHEN** a visitor submits a successful WebAuthn `step: "finish"` registration response +- **THEN** the route handler verifies the credential, creates the user row, and calls `completeAuth({ userId, isNewRegistration: true, termsVersion, request, returnTo })` +- **AND** `completeAuth` writes `users.terms_version` and `users.terms_accepted_at`, mints the session cookie, and returns a `Response` redirecting to `returnTo` (or `/`) + +#### Scenario: Passkey login-finish completes through the chokepoint +- **WHEN** a visitor submits a successful WebAuthn `step: "finish-passkey"` login response +- **THEN** the route handler verifies the credential and calls `completeAuth({ userId, isNewRegistration: false, request, returnTo })` +- **AND** `completeAuth` skips the terms write (only registrations record terms), mints the session cookie, and returns a `Response` redirecting to `returnTo` (or `/`) + +#### Scenario: Magic-link 6-digit-code verify completes through the chokepoint +- **WHEN** a visitor submits a valid 6-digit code via `step: "verify-code"` +- **THEN** the route handler consumes the magic token (marks `used_at`) and calls `completeAuth({ userId, isNewRegistration: false, request, returnTo })` + +#### Scenario: Magic-link click-through verify completes through the chokepoint +- **WHEN** a visitor opens `/auth/verify?token=` with a valid, unused, unexpired token +- **THEN** the route handler consumes the magic token and calls `completeAuth({ userId, isNewRegistration: false, request, returnTo })` + +#### Scenario: returnTo is sanitized inside completeAuth +- **WHEN** `completeAuth` is called with a `returnTo` value that is not a same-origin absolute path (e.g. starts with `//`, an absolute URL, or is malformed) +- **THEN** the redirect target falls back to `/` rather than honoring the unsafe value +- **AND** every caller benefits from the same check rather than reimplementing it diff --git a/openspec/changes/unify-auth-completion/tasks.md b/openspec/changes/unify-auth-completion/tasks.md new file mode 100644 index 0000000..ca50181 --- /dev/null +++ b/openspec/changes/unify-auth-completion/tasks.md @@ -0,0 +1,30 @@ +## 1. New auth module structure + +- [ ] 1.1 Create `apps/journal/app/lib/auth/` directory. +- [ ] 1.2 Create `apps/journal/app/lib/auth/session.ts` and move `sessionStorage`, `createSession`, `getSessionUser`, `destroySession` from `auth.server.ts` (preserve behaviour, including `process.env.SESSION_SECRET` source). +- [ ] 1.3 In `auth.server.ts`, re-export the moved helpers from `./auth/session.ts` so existing imports keep working unchanged. Add a JSDoc `@deprecated`-style comment pointing at the new path. + +## 2. completeAuth chokepoint + +- [ ] 2.1 Write `apps/journal/app/lib/auth/completion.test.ts` (TDD red): scenarios for new-registration writes terms, non-registration skips terms, returnTo defaults to `/`, returnTo `//evil.com` rejected, returnTo `https://evil.com` rejected, response includes Set-Cookie. +- [ ] 2.2 Create `apps/journal/app/lib/auth/completion.ts` exporting `completeAuth({ userId, isNewRegistration, termsVersion?, request, returnTo? }) → Promise` and a private `safeReturnTo(value)` helper. Implementation: assert `termsVersion` when `isNewRegistration`; if `isNewRegistration`, `recordTermsAcceptance(userId, termsVersion)`; `createSession(userId, request)`; `redirect(safeReturnTo(returnTo) ?? "/", { headers })`. +- [ ] 2.3 Run completion tests green. + +## 3. Caller migration + +- [ ] 3.1 `apps/journal/app/routes/api.auth.register.ts` passkey-finish branch — replace inlined session+redirect with `return completeAuth({ userId, isNewRegistration: true, termsVersion, request, returnTo })`. Drop now-unused imports. +- [ ] 3.2 `apps/journal/app/routes/api.auth.login.ts` passkey `step: "finish-passkey"` branch — replace with `return completeAuth({ userId, isNewRegistration: false, request, returnTo })`. +- [ ] 3.3 `apps/journal/app/routes/api.auth.login.ts` magic-link `step: "verify-code"` branch — replace with `return completeAuth(...)`. +- [ ] 3.4 `apps/journal/app/routes/auth.verify.tsx` magic-link click-through consumer — replace with `return completeAuth(...)`. +- [ ] 3.5 Confirm no other callers of `createSession` remain inside auth route handlers (they should all flow through `completeAuth`). `getSessionUser` and `destroySession` continue to be called directly from non-completion sites — that's expected. + +## 4. Verification + +- [ ] 4.1 `pnpm typecheck && pnpm lint && pnpm test` green. +- [ ] 4.2 `pnpm test:e2e` (auth flows) green without modification — proves behaviour-preserving refactor. +- [ ] 4.3 Manual sanity: register with passkey locally, login with passkey, log out, re-login via magic-link 6-digit code, click-through magic link from `auth.verify.tsx`. Confirm session cookie set + correct redirect each time. + +## 5. Documentation + follow-up + +- [ ] 5.1 At archive time, apply the spec delta in `specs/authentication-methods/` to `openspec/specs/`. +- [ ] 5.2 (Optional follow-up — not part of this change) update import paths app-wide from `auth.server.ts` to `./auth/session.ts` and drop the re-exports. Track separately.