From 1c5060353ea771309a6782a178cde0115bfe9abd Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 14:00:57 +0100 Subject: [PATCH 01/10] docs(architecture): record why auth-service is on a subdomain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subdomain split (AUTH_HOSTNAME as a subdomain of PDS_HOSTNAME) was inherited whole from upstream magic-pds at the initial commit, and its rationale lived only in better-auth-migration-plan.md. Surface it in the main architecture doc as a Key Design Decision: cookie isolation, security-header isolation, and independent deployability — distinct from the authorization_endpoint metadata override, which is the mechanism, not the motivation. Note the accepted costs (same-site rewrite, cross-subdomain cookie plumbing) and link the open re-evaluation (#200). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 6e319a74..12aeb70b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,3 +74,52 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic - **HMAC-signed callback**: the redirect from Auth Service to PDS Core's `/oauth/epds-callback` is signed with `EPDS_CALLBACK_SECRET` so PDS Core can verify it was produced by a legitimate auth flow. + +- **Auth Service on a subdomain, not a single shared domain**: `AUTH_HOSTNAME` + must be a subdomain of `PDS_HOSTNAME` (e.g. `auth.pds.example` / + `pds.example`). A single-domain design — one origin, routing the auth paths + by path prefix the way [pds-gatekeeper](https://tangled.org/baileytownsend.dev/pds-gatekeeper) + does — was **considered and rejected**. The reasons: + + - **Mechanism (how the takeover happens)**: PDS Core overrides the + Authorization Server metadata so `authorization_endpoint` points at the + Auth Service. OAuth clients redirect the browser straight there, so the + stock `@atproto/pds` sign-in UI is never shown. This alone does not + _require_ a separate origin — PDS Core already wraps upstream heavily and + could shadow `/oauth/authorize` in place — so it is the mechanism, not the + motivation. + + - **Cookie isolation (the motivation)**: the Auth Service's session cookies + (Better Auth's `session`, historically `magic_account_session`) are a + _separate authentication domain_ from AT Protocol access/refresh tokens. + Giving the Auth Service its own origin keeps the two cookie namespaces from + colliding by construction. (Note the tension: cross-subdomain cookie + _sharing_ for the device-session reuse path is a _cost_ of this split — see + [pds-white-boxing.md](design/pds-white-boxing.md) items 14–15 — whereas + isolation between the two auth systems is the _benefit_.) + + - **Security-header isolation**: the Auth Service is a full web app (HTML + forms, static assets, inline scripts/styles) that needs its own CSP and + security headers, independent of PDS Core's stricter policy. Separate + origins let each set headers without one breaking the other. + + - **Independent deployability**: separate service and process, deployable and + restartable on its own. + + The contrast with pds-gatekeeper is the crux: path-based single-domain + routing is fine for a _thin interception layer_, but the Auth Service is a + _full web app with its own cookie domain_, so it was given its own origin. + + Costs of the subdomain choice (accepted, and only partly documented at the + time): the `same-site` → `same-origin` `sec-fetch-site` rewrite and the + cross-subdomain cookie-domain plumbing, both in + [pds-white-boxing.md](design/pds-white-boxing.md) (items 5, 14, 15). Whether + these accumulated costs still outweigh the isolation benefits — given that + PDS Core already wraps upstream and could host the auth UI in-process — has + not been formally re-evaluated (tracked in + [#200](https://github.com/hypercerts-org/ePDS/issues/200)). + + Origin rationale: `better-auth-migration-plan.md`, section "Considered and + rejected: single-domain architecture" (introduced in commit `e6d6a08`; the + design itself was inherited whole from the upstream `magic-pds` project at + the initial commit `de3876e`). From 19fab35cda7dda65d5a298c1b5d4d1d605cde5cc Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 15:46:32 +0100 Subject: [PATCH 02/10] docs(architecture): re-examine subdomain split, add migration design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the three benefits (cookie isolation, security-header isolation, independent deployability) is a non-negotiable — all are reproducible on a single origin, and every documented cost (sec-fetch-site rewrite, cross-subdomain cookie plumbing) is an artifact of being cross-origin-but-same-site. Strengthen the architecture.md decision with this analysis and add docs/design/single-domain-migration.md laying out the collapse to a path-prefixed single origin, the real blockers (published authorization_endpoint, email links, AUTH_HOSTNAME config), and a phased rollout. Tracked in #200. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 38 ++++++- docs/design/single-domain-migration.md | 135 +++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 docs/design/single-domain-migration.md diff --git a/docs/architecture.md b/docs/architecture.md index 12aeb70b..a627f067 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,11 +113,39 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic Costs of the subdomain choice (accepted, and only partly documented at the time): the `same-site` → `same-origin` `sec-fetch-site` rewrite and the cross-subdomain cookie-domain plumbing, both in - [pds-white-boxing.md](design/pds-white-boxing.md) (items 5, 14, 15). Whether - these accumulated costs still outweigh the isolation benefits — given that - PDS Core already wraps upstream and could host the auth UI in-process — has - not been formally re-evaluated (tracked in - [#200](https://github.com/hypercerts-org/ePDS/issues/200)). + [pds-white-boxing.md](design/pds-white-boxing.md) (items 5, 14, 15). + + **On re-examination, none of the three benefits is a non-negotiable, and all + the costs are artifacts of being cross-origin-but-same-site:** + + - _Cookie isolation_ is already done by explicit cookie **naming** + (`epds_csrf`, `magic_account_session`, the `DEVICE_COOKIE_NAMES` set), not + by origin — so it survives a collapse to one origin, optionally hardened + with a `__Host-`/path scope. The split does not just fail to be _required_ + for isolation; it actively _creates_ a cookie problem — the + `Domain=` sharing in items 14–15 — that a single origin deletes + outright. + - _Security-header isolation_ is already per-route in both packages + (`auth-service/src/lib/security-headers.ts` sets an auth-specific CSP per + request; `pds-core` already rewrites the upstream CSP per response in + `chooser-enrichment.ts` / `client-css-injection.ts`). Per-route CSP does + not need per-origin hosting. + - _Independent deployability_ is a soft benefit already weakened by a shared + repo, build, and data volume. + - The `sec-fetch-site` rewrite (item 5) exists **only** because the redirect + chain is `same-site`; a single origin makes it `same-origin`, which + upstream already allows — so the one upstream validation that looks like a + constraint actually _favors_ single-origin. + + The remaining real risk is not any of the three benefits but external + addressability of `auth.`: `authorization_endpoint` is published in AS + metadata and cached by clients, and `EPDS_LINK_BASE_URL` appears in live + email links — so a migration is a published-URL change needing a transition, + not a flip. (There is _no_ AT Protocol identity coupling: DIDs reference the + PDS host, and OAuth `redirect_uri` is always the client's.) This + re-evaluation, and a concrete single-domain migration design, are tracked in + [#200](https://github.com/hypercerts-org/ePDS/issues/200); see + [design/single-domain-migration.md](design/single-domain-migration.md). Origin rationale: `better-auth-migration-plan.md`, section "Considered and rejected: single-domain architecture" (introduced in commit `e6d6a08`; the diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md new file mode 100644 index 00000000..d3b2232b --- /dev/null +++ b/docs/design/single-domain-migration.md @@ -0,0 +1,135 @@ +# Single-Domain Migration: Collapse `auth.` into the PDS origin + +**Status:** Proposal (tracked in [#200](https://github.com/hypercerts-org/ePDS/issues/200)) + +This document designs the migration from the current two-origin architecture +(`auth.pds.example` + `pds.example`) to a single origin, where the Auth Service +is served under a path prefix (e.g. `pds.example/auth/*`) on the PDS host. + +For _why_ this is worth doing, see the "Auth Service on a subdomain" decision +in [../architecture.md](../architecture.md) and the cost inventory in +[pds-white-boxing.md](pds-white-boxing.md) items 5, 14, 15. This doc assumes +that case is accepted and focuses on _how_. + +## Summary of the change + +| Concern | Today (two origins) | After (one origin) | +| --- | --- | --- | +| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` (path-prefixed) | +| `authorization_endpoint` (AS metadata) | `https://auth./oauth/authorize` | `https:///auth/oauth/authorize` | +| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** | +| Device-session cookies | `Domain=` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** | +| Auth session cookie | `magic_account_session` / better-auth `session` on `auth.` | same names, path-scoped `/auth` on `` | +| CSP | per-route in each service (unchanged approach) | per-route, unchanged | +| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later | + +The routing model is exactly pds-gatekeeper's: **Caddy routes by path**, sending +`/auth/*` (and the auth-owned `/oauth/authorize`, `/account/*`) to the +auth-service process and everything else to the PDS. The auth-service stays a +separate process; only its _origin_ changes. + +## What gets deleted + +The whole point. These exist solely because of cross-origin-same-site: + +1. **`packages/pds-core/src/lib/sec-fetch-site-rewrite.ts`** — the `same-site` → + `same-origin` rewrite (white-boxing item 5). On one origin the header is + `same-origin`, which upstream `@atproto/oauth-provider` already accepts. + Delete the middleware and its `_router.stack` injection. +2. **Cross-subdomain device-cookie plumbing** (white-boxing items 14–15) — the + `Domain=` broadening in `pds-core` and the sibling-reading logic in + `auth-service/src/lib/session-reuse.ts`. On one origin the device session is + directly readable; cookies stay host-only. +3. **`authHostname.endsWith('.' + pdsHostname)` special-casing** — e.g. + `session-reuse.ts:195-200`, the `parentCookieDomain` derivation in + `pds-core/src/index.ts:930`, and `authOrigin` derivation in + `chooser-enrichment.ts`. The two hostnames become one. + +## What stays + +- The `authorization_endpoint` **metadata override** (`pds-core/src/index.ts:643`). + Still needed — it just points at a path on the same host instead of a + subdomain. The upstream sign-in UI is still never shown. +- The **HMAC-signed `/oauth/epds-callback`** bridge. The trust boundary between + auth-service and pds-core is unchanged; they still communicate over + authenticated HTTP, not shared memory. +- **Per-route CSP** in both packages. No change. +- The **handle picker, consent, OTP** flows. No change to their logic. + +## Migration blockers (external addressability of `auth.`) + +None of the three stated benefits is a blocker; these published-URL references +are the real work. + +### 1. `authorization_endpoint` is cached by OAuth clients + +`pds-core/src/index.ts:643` publishes `https://auth./oauth/authorize` in +the AS metadata document. Clients fetch this and may cache it. A hard switch +would strand in-flight and cached clients. + +**Mitigation:** keep `auth.` resolving during a transition window. Serve a +301/302 (or continue proxying) from `auth./oauth/authorize` → +`/auth/oauth/authorize` until metadata TTLs expire and clients re-fetch. +Only retire the subdomain DNS/cert after the window. + +### 2. `EPDS_LINK_BASE_URL` appears in live email links + +`auth-service/.env.example:94` — `EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify`. +Verification/recovery links already delivered to inboxes point at the subdomain. + +**Mitigation:** same transition redirect covers these. Update the env var to the +new path-based URL for _new_ emails; keep the subdomain redirecting for the +lifetime of the longest-lived link (OTP/verification TTLs are short — 600s per +`auth-flow.ts` — so this window is small). + +### 3. `AUTH_HOSTNAME` is load-bearing config + +Referenced in `pds-core/src/index.ts:138`, `auth-service/src/index.ts:138`, +`session-reuse.ts`, `chooser-enrichment.ts` (`authOrigin`), +`sec-fetch-site-rewrite.ts` allowlist, `demo/.env.example` (`AUTH_ENDPOINT`). + +**Approach:** introduce an `AUTH_PATH_PREFIX` (default `/auth`) and derive the +auth origin as `PDS_HOSTNAME + AUTH_PATH_PREFIX`. Keep `AUTH_HOSTNAME` as an +optional legacy override so existing subdomain deployments keep working during +transition (config-gated, not a hard cutover). + +### Non-blockers (confirmed) + +- **No AT Protocol identity coupling.** DIDs are `did:web:` (the PDS + host), never `auth.`. Grep for `did:web` shows only PDS-host and test + fixtures. +- **OAuth `redirect_uri` is always the client's**, never the auth service's. + Nothing to migrate there. +- **CORS.** The only `Access-Control-Allow-Origin: *` headers are on the demo's + public JSON documents (`client-metadata.json`, `jwks.json`) and a pds-core + metadata response — none depend on the auth origin being distinct. + +## Phased rollout + +1. **Config plumbing.** Add `AUTH_PATH_PREFIX`; derive auth origin from it; + keep `AUTH_HOSTNAME` as a legacy override. No behaviour change yet. +2. **Caddy path routing.** Add a single-origin Caddyfile variant that routes + `/auth/*`, `/oauth/authorize`, `/account/*` to auth-service and the rest to + the PDS. Stand it up in a test/preview env. +3. **Delete the cross-origin workarounds** (items 5, 14, 15 above) _behind the + single-origin config path_ so subdomain deployments are unaffected until they + flip. +4. **Transition redirects.** Serve `auth./*` → `/auth/*` for the + metadata-TTL + email-link-TTL window. +5. **Cut over** `authorization_endpoint` and `EPDS_LINK_BASE_URL` to the new + path-based URLs. +6. **Retire** the `auth.` DNS record, TLS cert, and the legacy + `AUTH_HOSTNAME` code paths after the transition window. + +## Open questions + +- Do we keep two processes (Caddy path-routes) indefinitely, or eventually merge + auth-service into the pds-core process? The process split is cheap to keep and + preserves the HMAC trust boundary; recommend keeping it and only collapsing + the _origin_. +- Preview/e2e environments assume the subdomain in several fixtures + (`preview.ts`, `preview-emails.ts`, `demo/.env.example`). These need updating + in lockstep with step 1; enumerate them before starting. +- Does any downstream trusted client hardcode `auth.` anywhere other than + by fetching AS metadata? Needs a check with the known trusted-client operators + before retiring the subdomain (step 6). From 29644498b059a3eb17fae47e5a3c9f4954a9abfd Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 15:50:46 +0100 Subject: [PATCH 03/10] docs(architecture): add merge benefits and npm-version risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover two further considerations: (1) merging removes privileged cross-service endpoints (HMAC callback, /_internal lookups) and yields operational simplicity, and (2) npm peer-dependency clashes are a risk only if the two *processes* merge, not if they stay separate behind path routing — bounded anyway by the pnpm non-flat workspace. Distinguish origin-merge (recommended, no version risk) from process-merge throughout. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 26 +++++++++++++++ docs/design/single-domain-migration.md | 45 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index a627f067..ec1db964 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -137,6 +137,32 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic upstream already allows — so the one upstream validation that looks like a constraint actually _favors_ single-origin. + Beyond dissolving those costs, collapsing to one origin has **positive** + benefits the split forecloses: + + - **Fewer privileged cross-service endpoints.** The split forces the two + services to talk over authenticated HTTP: the HMAC-signed + `/oauth/epds-callback` and the `/_internal/*` / `/_magic/*` lookup + endpoints (e.g. `account-by-email`, `check-email`), each an attack surface + that has to be signed, gated, and kept in sync. Co-located on one origin, + much of this can become in-process calls with no wire boundary to secure. + (Whether to also merge the _processes_ — versus keep two behind path + routing — is a separate decision; see the migration doc.) + + - **Operational simplicity.** One origin means one TLS cert, one DNS record, + one CSP surface, and one set of cross-origin edge cases to reason about, + instead of a parent/subdomain pair whose relationship is itself + load-bearing config. + + A **countervailing risk** applies only if the two _processes_ are merged + (not if they stay separate behind path routing): sharing one Node runtime + could surface npm peer-dependency conflicts between `auth-service`'s + `better-auth` and `pds-core`'s `@atproto/*` stack. In practice the repo is a + pnpm workspace with a non-flat `node_modules`, so each package already + resolves its own versions and the overlap today is minimal (`express`, + aligned). The risk is real but bounded, and avoided entirely by the + keep-two-processes option. + The remaining real risk is not any of the three benefits but external addressability of `auth.`: `authorization_endpoint` is published in AS metadata and cached by clients, and `EPDS_LINK_BASE_URL` appears in live diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md index d3b2232b..4c579f61 100644 --- a/docs/design/single-domain-migration.md +++ b/docs/design/single-domain-migration.md @@ -28,6 +28,47 @@ The routing model is exactly pds-gatekeeper's: **Caddy routes by path**, sending auth-service process and everything else to the PDS. The auth-service stays a separate process; only its _origin_ changes. +## Two levels of "merge" — keep them distinct + +This matters for both benefits and risks, so fix the terms up front: + +- **Merge the _origin_ (recommended baseline).** One hostname, Caddy path-routes + to two still-separate processes. Dissolves every cross-origin cost; keeps the + HMAC trust boundary; carries **no** npm-version risk. +- **Merge the _processes_ (optional, later).** Fold auth-service into the + pds-core Node process. Adds the "fewer privileged endpoints" benefit in full + (calls become in-process) but is the only variant that can surface + dependency conflicts. Treat as a separate, later decision. + +## Positive benefits (beyond deleting costs) + +- **Fewer privileged cross-service endpoints.** The split _requires_ the two + services to communicate over authenticated HTTP: the HMAC-signed + `/oauth/epds-callback` plus the internal lookup endpoints (`/_internal/*`, + `/_magic/check-email`, `account-by-email`). Each is an attack surface that + must be signed, gated, and version-matched across the boundary. Merging the + origin lets some of these relax; merging the processes lets most become + in-process calls with no wire boundary at all. +- **Operational simplicity.** One TLS cert, one DNS record, one CSP surface, one + origin's worth of cross-origin edge cases — instead of a parent/subdomain pair + whose `endsWith('.'+pdsHostname)` relationship is itself load-bearing config + threaded through several modules. + +## Risk: npm version clashes (process-merge only) + +Applies **only** to the process-merge variant, not the origin-merge baseline. + +- Today the repo is a **pnpm workspace** (`pnpm --recursive`) with pnpm's + default non-flat `node_modules`, so `auth-service` and `pds-core` already + resolve their dependencies independently. +- Overlap is small: `pds-core` owns the heavy `@atproto/*` stack, `auth-service` + owns `better-auth`; the only shared runtime dep is `express ^4.18.2`, already + aligned. +- A single Node process would force one resolution of any shared/peer dep. The + realistic conflict surface is a future `express` (or a transitive peer both + pull) diverging. Bounded, but a real reason to prefer keeping two processes + unless the in-process-call benefit is specifically wanted. + ## What gets deleted The whole point. These exist solely because of cross-origin-same-site: @@ -44,6 +85,10 @@ The whole point. These exist solely because of cross-origin-same-site: `session-reuse.ts:195-200`, the `parentCookieDomain` derivation in `pds-core/src/index.ts:930`, and `authOrigin` derivation in `chooser-enrichment.ts`. The two hostnames become one. +4. **(Process-merge only) some privileged cross-service endpoints** — the + `/_internal/*` / `/_magic/*` HTTP lookups become in-process calls. The + HMAC-signed `/oauth/epds-callback` can also collapse to an in-process call + if the processes merge. See "Two levels of merge" above. ## What stays From cf7a3c36788e839cd9700b869e797d579f31348d Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 15:59:33 +0100 Subject: [PATCH 04/10] docs(design): spec full merge to one origin and one process Add single-process-merge.md covering the maximal collapse: mount the auth-service Express app onto pds.app under /auth, delete the HMAC-signed /oauth/epds-callback and /_internal/* HTTP hops in favour of in-process calls, and retire the second process. Documents the real integration hazards (better-auth body-parser ordering, /static and favicon collisions, trust-proxy/CSRF/error-handler scoping), the now-live npm peer-dependency risk, and a reversible phased rollout gated on a peer audit and a callback-core extraction refactor. Cross-linked from single-domain-migration.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/single-domain-migration.md | 3 +- docs/design/single-process-merge.md | 188 +++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 docs/design/single-process-merge.md diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md index 4c579f61..bb313094 100644 --- a/docs/design/single-domain-migration.md +++ b/docs/design/single-domain-migration.md @@ -38,7 +38,8 @@ This matters for both benefits and risks, so fix the terms up front: - **Merge the _processes_ (optional, later).** Fold auth-service into the pds-core Node process. Adds the "fewer privileged endpoints" benefit in full (calls become in-process) but is the only variant that can surface - dependency conflicts. Treat as a separate, later decision. + dependency conflicts. Treat as a separate, later decision — specced in full + in [single-process-merge.md](single-process-merge.md). ## Positive benefits (beyond deleting costs) diff --git a/docs/design/single-process-merge.md b/docs/design/single-process-merge.md new file mode 100644 index 00000000..8b8159e4 --- /dev/null +++ b/docs/design/single-process-merge.md @@ -0,0 +1,188 @@ +# Full Merge: One Origin, One Process + +**Status:** Proposal (tracked in [#200](https://github.com/hypercerts-org/ePDS/issues/200)) + +This document specifies the **maximal** collapse of the two-service +architecture: not just a single origin (covered in +[single-domain-migration.md](single-domain-migration.md)) but a **single Node +process** hosting both the PDS and the auth UI on one Express app. + +Read [single-domain-migration.md](single-domain-migration.md) first — this doc +assumes the single-origin work is done or done concurrently, and only adds the +process-collapse layer on top. The "Two levels of merge" section there defines +the origin-merge vs. process-merge distinction; this is the process-merge half, +specced in full. + +## Why go all the way to one process + +Single-origin already dissolves every cross-origin cost. Merging the _processes_ +adds a distinct set of wins that single-origin alone cannot deliver: + +- **The HMAC callback boundary disappears.** `/oauth/epds-callback` exists so + auth-service can prove to pds-core that a redirect came from a legitimate auth + flow, over the wire, signed with `EPDS_CALLBACK_SECRET`. In one process the + auth flow can call the code-issuance path **directly** as a function — no + HMAC, no secret to rotate, no signature-verification code, no replay window. +- **The internal HTTP lookups disappear.** `/_internal/account-by-email`, + `/_magic/check-email`, and the `/_internal/ping-request` keepalive become + in-process function calls against the same objects (`pds.ctx.accountManager`, + `provider.requestManager`) that pds-core already holds. No auth, no JSON + round-trip, no drift between caller and callee. +- **One deploy unit, one health check, one log stream, one restart.** True + operational singularity, not just one origin. +- **Shared in-memory state becomes trivial.** Today the PAR-keepalive + (`ping-request`) and session-reuse logic reason about state across an HTTP + boundary; in-process they share the live objects. + +The cost of going this far — and the reason single-origin is the safer default — +is the integration hazards in the next section and the npm-version risk below. + +## Target architecture + +Both packages already make this feasible: + +- `packages/auth-service/src/index.ts` exposes `createAuthService(config)` which + **returns a mountable `express.Express`** plus its context — it does not + hardwire a server. The `app.listen()` is a thin wrapper below the factory. +- `packages/pds-core/src/index.ts` mounts all its middleware onto **`pds.app`**, + the upstream PDS's own Express instance. + +So the merge is: build the auth app (or a router equivalent), **mount it onto +`pds.app` under `/auth`**, and delete auth-service's own `listen()`. One process, +one Express tree, one port. + +``` +pds.app (single Express instance, single listen) +├── (upstream PDS routes: xrpc, /oauth/authorize, /oauth/par, …) +├── asMetadataOverride → authorization_endpoint = /auth/oauth/authorize +├── /auth/oauth/authorize → auth UI (was auth-service /oauth/authorize) +├── /auth/api/auth/* → better-auth handler +├── /auth/account/* → account settings +├── /auth/complete, /auth/choose-handle, … +└── (code issuance called in-process, no /oauth/epds-callback HTTP hop) +``` + +## Integration hazards (the real work) + +These are concrete Express-level collisions between the two apps. Each must be +resolved deliberately; they are why this is a spec and not a one-line mount. + +### 1. Body-parser ordering — the sharp edge + +auth-service mounts better-auth **before** `express.json()`: + +```ts +// auth-service/src/index.ts +app.all('/api/auth/*', toNodeHandler(betterAuthInstance)) // BEFORE json() +app.use(express.urlencoded({ extended: true })) +app.use(express.json()) +``` + +better-auth parses its own request bodies and breaks if a JSON parser consumes +the stream first. pds-core's app already has its own body-parsing. **On the +merged app, `/auth/api/auth/*` must be registered ahead of any global +`express.json()`**, or scoped so the global parser skips that path. This is the +single most likely source of a silent runtime break. Mount order is +load-bearing. + +### 2. Duplicate `/static` and favicon + +Both apps do `app.use('/static', express.static(publicDir))` and serve a +favicon. Merged, these collide. Namespace the auth assets under `/auth/static` +(and `/auth/favicon`) so the two static roots don't shadow each other. + +### 3. `trust proxy`, CSRF, rate-limit — set once, not twice + +Both set `app.set('trust proxy', 1)` and install their own CSRF + rate-limit +middleware. On one app: set `trust proxy` once; scope the auth CSRF/rate-limit +middleware to the `/auth` mount (`app.use('/auth', csrfProtection(...))`) so +they don't wrap PDS routes that have their own protections. + +### 4. Error / not-found handlers + +auth-service ends with `notFoundHandler` + `errorHandler` as terminal +middleware. Terminal handlers mounted globally would swallow PDS routes. Scope +them to the `/auth` router, not the merged app root. + +### 5. CSP middleware convergence + +Both set CSP per-route (auth via `security-headers.ts`, pds-core by rewriting +upstream's CSP). These already coexist per-response, so no origin change is +needed — but confirm the auth CSP middleware only fires on `/auth/*` once +co-mounted, and does not leak `unsafe-inline` onto PDS routes. + +### 6. Shared context / DB handles + +auth-service builds `AuthServiceContext` (its own `db`, `emailSender`); pds-core +holds `pds.ctx`. Merged, decide whether they share one SQLite handle or keep +separate connections to the same files. The migration plan already notes +`account.sqlite` is the single source of truth for email→DID; in-process, the +direct-lookup replacements for `/_internal/account-by-email` read it through +`pds.ctx.accountManager` rather than a second connection. + +## Replacing the HMAC callback in-process + +Today: auth flow → HMAC-signed 303 → `pds-core` `/oauth/epds-callback` → +`provider.requestManager.setAuthorized(...)` issues the code. + +Merged: the auth-complete handler calls the same `setAuthorized` path +**directly**. Concretely: + +- Extract the body of the `/oauth/epds-callback` handler (the part that calls + `requestManager.setAuthorized` / `createAccount`) into a plain function on a + shared module, taking typed args instead of a signed request. +- The auth `/auth/complete` handler calls that function directly. +- `EPDS_CALLBACK_SECRET` and the signature-verification middleware are deleted. +- **Keep the HTTP `/oauth/epds-callback` route only if** a transition period + needs old auth-service instances to still call it; otherwise remove it. + +This is the highest-value deletion in the whole merge and also the most +security-sensitive change — the HMAC existed to stop an attacker forging a +callback. In-process there is no forgeable wire boundary, but the review must +confirm no other caller (including a partially-migrated deployment) can still +reach the code-issuance path unauthenticated. + +## npm version clash — now in play + +Unlike single-origin, one process forces **one resolution** of every shared or +peer dependency across `better-auth` and the `@atproto/*` stack. + +- The repo is a pnpm workspace with non-flat `node_modules`, so today the two + packages resolve independently. Merging their runtime removes that isolation. +- Current overlap is small: the only shared runtime dep is `express ^4.18.2` + (aligned). The realistic future conflict is `express` majors, or a transitive + peer both pull (e.g. a differing `zod`/`@types/node` peer requirement between + better-auth and an @atproto package). +- **Mitigation:** before merging, run a dedup/peer audit (`pnpm why express`, + `pnpm dedupe --check`) and pin the shared set. Treat any unresolvable peer + conflict as a blocker that keeps the two processes separate (fall back to the + origin-merge-only outcome). + +## Phased rollout (extends the single-domain phases) + +The single-origin phases (see [single-domain-migration.md](single-domain-migration.md)) +come first. This process-merge adds: + +1. **Peer-dependency audit.** `pnpm why` / `pnpm dedupe --check` across the + merged dependency set; pin shared deps. Gate: no unresolvable peer conflict. +2. **Extract the callback core.** Refactor `/oauth/epds-callback`'s issuance + logic into a directly-callable function; leave the HTTP route delegating to + it (no behaviour change yet). Ship and test this alone. +3. **Mount auth onto `pds.app`.** Convert `createAuthService` into a router + mounted at `/auth` on the PDS app; resolve hazards 1–6 above. Keep + auth-service's standalone `listen()` behind a flag for rollback. +4. **Switch `/auth/complete` to the in-process call.** Route code issuance + through the extracted function; stop signing/sending the HMAC callback. +5. **Delete** `EPDS_CALLBACK_SECRET`, the callback signature middleware, the + `/_internal/*` + `/_magic/*` HTTP endpoints, and auth-service's standalone + server. Retire the second process. + +Each step is independently shippable and reversible until step 5. + +## Recommendation + +Do the single-origin merge first and independently — it captures most of the +value at a fraction of the risk. Treat the process merge as a **follow-on**, +gated on the peer-dependency audit (step 1) and the callback-extraction refactor +(step 2), both of which are worth doing on their own merits regardless of whether +the final process collapse happens. From 93edeea80a0f6e43bf16721e09a556f01fd41fa0 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 16:23:29 +0100 Subject: [PATCH 05/10] docs: apply prettier formatting Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 3 --- docs/design/single-domain-migration.md | 18 +++++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ec1db964..f7942db6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,6 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic `pds.example`). A single-domain design — one origin, routing the auth paths by path prefix the way [pds-gatekeeper](https://tangled.org/baileytownsend.dev/pds-gatekeeper) does — was **considered and rejected**. The reasons: - - **Mechanism (how the takeover happens)**: PDS Core overrides the Authorization Server metadata so `authorization_endpoint` points at the Auth Service. OAuth clients redirect the browser straight there, so the @@ -117,7 +116,6 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic **On re-examination, none of the three benefits is a non-negotiable, and all the costs are artifacts of being cross-origin-but-same-site:** - - _Cookie isolation_ is already done by explicit cookie **naming** (`epds_csrf`, `magic_account_session`, the `DEVICE_COOKIE_NAMES` set), not by origin — so it survives a collapse to one origin, optionally hardened @@ -139,7 +137,6 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic Beyond dissolving those costs, collapsing to one origin has **positive** benefits the split forecloses: - - **Fewer privileged cross-service endpoints.** The split forces the two services to talk over authenticated HTTP: the HMAC-signed `/oauth/epds-callback` and the `/_internal/*` / `/_magic/*` lookup diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md index bb313094..2cb97451 100644 --- a/docs/design/single-domain-migration.md +++ b/docs/design/single-domain-migration.md @@ -13,15 +13,15 @@ that case is accepted and focuses on _how_. ## Summary of the change -| Concern | Today (two origins) | After (one origin) | -| --- | --- | --- | -| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` (path-prefixed) | -| `authorization_endpoint` (AS metadata) | `https://auth./oauth/authorize` | `https:///auth/oauth/authorize` | -| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** | -| Device-session cookies | `Domain=` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** | -| Auth session cookie | `magic_account_session` / better-auth `session` on `auth.` | same names, path-scoped `/auth` on `` | -| CSP | per-route in each service (unchanged approach) | per-route, unchanged | -| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later | +| Concern | Today (two origins) | After (one origin) | +| -------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` (path-prefixed) | +| `authorization_endpoint` (AS metadata) | `https://auth./oauth/authorize` | `https:///auth/oauth/authorize` | +| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** | +| Device-session cookies | `Domain=` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** | +| Auth session cookie | `magic_account_session` / better-auth `session` on `auth.` | same names, path-scoped `/auth` on `` | +| CSP | per-route in each service (unchanged approach) | per-route, unchanged | +| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later | The routing model is exactly pds-gatekeeper's: **Caddy routes by path**, sending `/auth/*` (and the auth-owned `/oauth/authorize`, `/account/*`) to the From 9fe538e533c09746e5b7eebf08411c530c8179a3 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 15:29:12 +0100 Subject: [PATCH 06/10] docs: address CodeRabbit and Copilot review feedback - Reflect current code: magic_account_session no longer exists (historical note only), /_magic/check-email replaced by /_internal/account-by-email, 600s OTP TTL sourced to better-auth.ts not auth-flow.ts. - AUTH_HOSTNAME subdomain is a recommended production relationship, not a hard runtime requirement (unrelated hostnames supported with null cookie domain). - Scope the "in-process calls / fewer privileged endpoints" benefit to process-merge; origin-merge alone keeps the HTTP boundary. - Add path-shadowing note (auth surface must move under /auth so it doesn't shadow pds-core's /oauth/* and /account*) and an explicit Set-Cookie contract (unique names, no Domain, __Host- vs /auth path-scope exclusivity). - Require transition redirects to preserve full path + query string. - Distinguish origin/base-URL/hostname config values (AUTH_PATH_PREFIX / AUTH_BASE_URL) instead of concatenating a path onto a hostname. - Single-process merge: preserve one-time issuance gating in the direct path, and retain HMAC + signed callback route until legacy callers are drained (compatibility gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 47 +++++++++----- docs/design/single-domain-migration.md | 88 +++++++++++++++++++------- docs/design/single-process-merge.md | 39 +++++++++--- 3 files changed, 125 insertions(+), 49 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f7942db6..b005d39f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,9 +75,16 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic `/oauth/epds-callback` is signed with `EPDS_CALLBACK_SECRET` so PDS Core can verify it was produced by a legitimate auth flow. -- **Auth Service on a subdomain, not a single shared domain**: `AUTH_HOSTNAME` - must be a subdomain of `PDS_HOSTNAME` (e.g. `auth.pds.example` / - `pds.example`). A single-domain design — one origin, routing the auth paths +- **Auth Service on a subdomain, not a single shared domain**: the + recommended production relationship is `AUTH_HOSTNAME` as a subdomain of + `PDS_HOSTNAME` (e.g. `auth.pds.example` / `pds.example`). This is not a hard + runtime requirement — the code also supports unrelated hostnames (e.g. + Railway preview envs where both services sit under `up.railway.app`), in + which case the shared cookie domain is `null` and device cookies stay + host-only (`deriveCookieDomain` in `pds-core/src/cookie-domain.ts`). The + cross-subdomain **device-session reuse** path only works when the subdomain + relationship holds, which is why it is the recommended arrangement. A + single-domain design — one origin, routing the auth paths by path prefix the way [pds-gatekeeper](https://tangled.org/baileytownsend.dev/pds-gatekeeper) does — was **considered and rejected**. The reasons: - **Mechanism (how the takeover happens)**: PDS Core overrides the @@ -89,7 +96,8 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic motivation. - **Cookie isolation (the motivation)**: the Auth Service's session cookies - (Better Auth's `session`, historically `magic_account_session`) are a + (the Better Auth session cookie; historically `magic_account_session`, + no longer present in code) are a _separate authentication domain_ from AT Protocol access/refresh tokens. Giving the Auth Service its own origin keeps the two cookie namespaces from colliding by construction. (Note the tension: cross-subdomain cookie @@ -117,12 +125,17 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic **On re-examination, none of the three benefits is a non-negotiable, and all the costs are artifacts of being cross-origin-but-same-site:** - _Cookie isolation_ is already done by explicit cookie **naming** - (`epds_csrf`, `magic_account_session`, the `DEVICE_COOKIE_NAMES` set), not - by origin — so it survives a collapse to one origin, optionally hardened - with a `__Host-`/path scope. The split does not just fail to be _required_ - for isolation; it actively _creates_ a cookie problem — the - `Domain=` sharing in items 14–15 — that a single origin deletes - outright. + (`epds_csrf`, `epds_auth_flow`, the Better Auth session cookie, and the + `DEVICE_COOKIE_NAMES` device-session set), not by origin — so it survives a + collapse to one origin. Concretely, on a shared origin the contract is: + guaranteed-unique names, and **no parent `Domain` attribute** (cookies stay + host-only). This unique-name isolation is distinct from — and does not + require — `__Host-` prefixing; note `__Host-` mandates `Path=/` and so + cannot be combined with a `/auth` path scope, whereas plain path scoping + (`Path=/auth`) is an available but separate hardening option. The split + does not just fail to be _required_ for isolation; it actively _creates_ a + cookie problem — the `Domain=` sharing in items 14–15 — that a + single origin deletes outright. - _Security-header isolation_ is already per-route in both packages (`auth-service/src/lib/security-headers.ts` sets an auth-specific CSP per request; `pds-core` already rewrites the upstream CSP per response in @@ -139,12 +152,14 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic benefits the split forecloses: - **Fewer privileged cross-service endpoints.** The split forces the two services to talk over authenticated HTTP: the HMAC-signed - `/oauth/epds-callback` and the `/_internal/*` / `/_magic/*` lookup - endpoints (e.g. `account-by-email`, `check-email`), each an attack surface - that has to be signed, gated, and kept in sync. Co-located on one origin, - much of this can become in-process calls with no wire boundary to secure. - (Whether to also merge the _processes_ — versus keep two behind path - routing — is a separate decision; see the migration doc.) + `/oauth/epds-callback` and the `/_internal/*` lookup endpoints (e.g. + `/_internal/account-by-email`, which replaced the old unauthenticated + `/_magic/check-email`), each an attack surface that has to be signed, + gated, and kept in sync. **Note this benefit requires merging the + _processes_, not just the origin**: a single origin with two processes + still has an HTTP boundary (merely same-origin now). Only a process merge + turns these into in-process calls with no wire boundary to secure — a + separate decision from origin-merging; see the migration doc. - **Operational simplicity.** One origin means one TLS cert, one DNS record, one CSP surface, and one set of cross-origin edge cases to reason about, diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md index 2cb97451..997bbbe4 100644 --- a/docs/design/single-domain-migration.md +++ b/docs/design/single-domain-migration.md @@ -13,20 +13,46 @@ that case is accepted and focuses on _how_. ## Summary of the change -| Concern | Today (two origins) | After (one origin) | -| -------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------- | -| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` (path-prefixed) | -| `authorization_endpoint` (AS metadata) | `https://auth./oauth/authorize` | `https:///auth/oauth/authorize` | -| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** | -| Device-session cookies | `Domain=` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** | -| Auth session cookie | `magic_account_session` / better-auth `session` on `auth.` | same names, path-scoped `/auth` on `` | -| CSP | per-route in each service (unchanged approach) | per-route, unchanged | -| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later | +| Concern | Today (two origins) | After (one origin) | +| -------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` — including `/auth/oauth/authorize` and `/auth/account/*` (see path-shadowing note below) | +| `authorization_endpoint` (AS metadata) | `https://auth./oauth/authorize` | `https:///auth/oauth/authorize` | +| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** | +| Device-session cookies | `Domain=` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** (see Set-Cookie contract below) | +| Auth session cookie | Better Auth session cookie (historically `magic_account_session`) on `auth.` | same cookie, host-only on `` (see Set-Cookie contract below) | +| CSP | per-route in each service (unchanged approach) | per-route, unchanged | +| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later | The routing model is exactly pds-gatekeeper's: **Caddy routes by path**, sending -`/auth/*` (and the auth-owned `/oauth/authorize`, `/account/*`) to the -auth-service process and everything else to the PDS. The auth-service stays a -separate process; only its _origin_ changes. +the auth surface to the auth-service process and everything else to the PDS. The +auth-service stays a separate process; only its _origin_ changes. + +### Path shadowing — the auth surface must move under `/auth` + +On a single origin the auth service **cannot** keep serving `/oauth/authorize` +and `/account/*` at the root: upstream `@atproto/oauth-provider` already renders +`/oauth/*` and `/account*` on pds-core, so routing those roots to auth-service +would shadow the PDS's own endpoints. The migration therefore exposes the entire +auth surface under a non-conflicting prefix — `/auth/oauth/authorize`, +`/auth/account/*`, `/auth/*` — optionally with a Caddy path-strip rewrite so the +auth-service process can keep its current internal route paths unchanged. + +### Set-Cookie contract on the merged origin + +With one origin, the cookie isolation that the subdomain previously provided by +separation must instead be provided by the cookie attributes: + +- **Auth session cookie** (Better Auth) and **CSRF / auth-flow cookies** + (`epds_csrf`, `epds_auth_flow`): guaranteed-**unique names**, **no `Domain` + attribute** (host-only), `Secure`, `HttpOnly` where applicable, + `SameSite=Lax`. Optionally scope non-`__Host-` cookies with `Path=/auth`. +- **Device-session cookies** (`DEVICE_COOKIE_NAMES`): host-only — emit with **no + `Domain` attribute** at all, deleting the `Domain=` broadening (items + 14–15). +- `__Host-` prefixing is a possible hardening but is **mutually exclusive with a + `/auth` path scope**: `__Host-` requires `Path=/`. Pick unique-name + + host-only as the baseline; add either `__Host-` (at `Path=/`) or `Path=/auth` + scoping, not both. ## Two levels of "merge" — keep them distinct @@ -46,7 +72,8 @@ This matters for both benefits and risks, so fix the terms up front: - **Fewer privileged cross-service endpoints.** The split _requires_ the two services to communicate over authenticated HTTP: the HMAC-signed `/oauth/epds-callback` plus the internal lookup endpoints (`/_internal/*`, - `/_magic/check-email`, `account-by-email`). Each is an attack surface that + e.g. `/_internal/account-by-email`, which replaced the old unauthenticated + `/_magic/check-email`). Each is an attack surface that must be signed, gated, and version-matched across the boundary. Merging the origin lets some of these relax; merging the processes lets most become in-process calls with no wire boundary at all. @@ -87,7 +114,7 @@ The whole point. These exist solely because of cross-origin-same-site: `pds-core/src/index.ts:930`, and `authOrigin` derivation in `chooser-enrichment.ts`. The two hostnames become one. 4. **(Process-merge only) some privileged cross-service endpoints** — the - `/_internal/*` / `/_magic/*` HTTP lookups become in-process calls. The + `/_internal/*` HTTP lookups become in-process calls. The HMAC-signed `/oauth/epds-callback` can also collapse to an in-process call if the processes merge. See "Two levels of merge" above. @@ -116,17 +143,22 @@ would strand in-flight and cached clients. **Mitigation:** keep `auth.` resolving during a transition window. Serve a 301/302 (or continue proxying) from `auth./oauth/authorize` → `/auth/oauth/authorize` until metadata TTLs expire and clients re-fetch. -Only retire the subdomain DNS/cert after the window. +The redirect/proxy **must preserve the complete request target** — full path and +query string — because the authorization request carries `client_id`, +`redirect_uri`, `code_challenge`, `state`, etc.; dropping the query breaks +in-flight logins. Only retire the subdomain DNS/cert after the window. ### 2. `EPDS_LINK_BASE_URL` appears in live email links `auth-service/.env.example:94` — `EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify`. Verification/recovery links already delivered to inboxes point at the subdomain. -**Mitigation:** same transition redirect covers these. Update the env var to the -new path-based URL for _new_ emails; keep the subdomain redirecting for the -lifetime of the longest-lived link (OTP/verification TTLs are short — 600s per -`auth-flow.ts` — so this window is small). +**Mitigation:** same transition redirect covers these — and, as above, it must +**preserve the full path and query string** so the verification token in the +link survives the hop. Update the env var to the new path-based URL for _new_ +emails; keep the subdomain redirecting for the lifetime of the longest-lived +link (OTP/verification TTLs are short — `expiresIn: 600` in +`auth-service/src/better-auth.ts` — so this window is small). ### 3. `AUTH_HOSTNAME` is load-bearing config @@ -134,10 +166,20 @@ Referenced in `pds-core/src/index.ts:138`, `auth-service/src/index.ts:138`, `session-reuse.ts`, `chooser-enrichment.ts` (`authOrigin`), `sec-fetch-site-rewrite.ts` allowlist, `demo/.env.example` (`AUTH_ENDPOINT`). -**Approach:** introduce an `AUTH_PATH_PREFIX` (default `/auth`) and derive the -auth origin as `PDS_HOSTNAME + AUTH_PATH_PREFIX`. Keep `AUTH_HOSTNAME` as an -optional legacy override so existing subdomain deployments keep working during -transition (config-gated, not a hard cutover). +**Approach:** keep these as distinct values rather than conflating them — +`PDS_HOSTNAME + AUTH_PATH_PREFIX` yields `pds.example/auth`, which is a **base +URL/path, not an origin**, and today's `AuthServiceConfig.hostname` expects a +bare hostname while public-URL config carries the scheme. So: + +- keep `AuthServiceConfig.hostname` a hostname; +- introduce `AUTH_PATH_PREFIX` (default `/auth`) as the path the auth surface + mounts under; +- derive the full mount point from an explicit **origin** (`https://`) + joined with the prefix — or introduce an explicit `AUTH_BASE_URL` — and update + URL-joining so paths and origins are never concatenated as bare strings. + +Keep `AUTH_HOSTNAME` as an optional legacy override so existing subdomain +deployments keep working during transition (config-gated, not a hard cutover). ### Non-blockers (confirmed) diff --git a/docs/design/single-process-merge.md b/docs/design/single-process-merge.md index 8b8159e4..ad09a95f 100644 --- a/docs/design/single-process-merge.md +++ b/docs/design/single-process-merge.md @@ -22,9 +22,13 @@ adds a distinct set of wins that single-origin alone cannot deliver: auth-service can prove to pds-core that a redirect came from a legitimate auth flow, over the wire, signed with `EPDS_CALLBACK_SECRET`. In one process the auth flow can call the code-issuance path **directly** as a function — no - HMAC, no secret to rotate, no signature-verification code, no replay window. -- **The internal HTTP lookups disappear.** `/_internal/account-by-email`, - `/_magic/check-email`, and the `/_internal/ping-request` keepalive become + HMAC, no secret to rotate, no signature-verification code. Note this removes + only the cross-service _authenticity_ check; the one-time, session-bound + issuance gating must be preserved in the direct path (see "Preserving + one-time issuance" below). +- **The internal HTTP lookups disappear.** `/_internal/account-by-email` (which + replaced the old unauthenticated `/_magic/check-email`) and the + `/_internal/ping-request` keepalive become in-process function calls against the same objects (`pds.ctx.accountManager`, `provider.requestManager`) that pds-core already holds. No auth, no JSON round-trip, no drift between caller and callee. @@ -132,9 +136,21 @@ Merged: the auth-complete handler calls the same `setAuthorized` path `requestManager.setAuthorized` / `createAccount`) into a plain function on a shared module, taking typed args instead of a signed request. - The auth `/auth/complete` handler calls that function directly. -- `EPDS_CALLBACK_SECRET` and the signature-verification middleware are deleted. -- **Keep the HTTP `/oauth/epds-callback` route only if** a transition period - needs old auth-service instances to still call it; otherwise remove it. +- **Preserve the one-time issuance gating in the extracted function.** Dropping + HMAC removes only the cross-service _authenticity_ check — it does **not** + remove the need for one-time, session-bound, idempotent guards. The direct + path must still ensure a repeated or replayed `/auth/complete` cannot mint a + second authorization code (the auth-flow row is consumed exactly once, keyed + to the session). Carry these guards into the shared function; they are + independent of the HMAC and must not be deleted alongside it. +- **Retain `EPDS_CALLBACK_SECRET`, the signature-verification middleware, and + the HTTP `/oauth/epds-callback` route while any legacy Auth instance can + still call it.** During a rolling deploy an old auth-service may still POST + the signed callback; deleting the verifier early would either break it or + (worse) expose an unauthenticated issuance route. Remove the secret, the + verifier, and the route **only** in a later compatibility gate, after all old + callers are drained and the direct-call path is fully deployed (see rollout + steps 4–5). This is the highest-value deletion in the whole merge and also the most security-sensitive change — the HMAC existed to stop an attacker forging a @@ -172,10 +188,13 @@ come first. This process-merge adds: mounted at `/auth` on the PDS app; resolve hazards 1–6 above. Keep auth-service's standalone `listen()` behind a flag for rollback. 4. **Switch `/auth/complete` to the in-process call.** Route code issuance - through the extracted function; stop signing/sending the HMAC callback. -5. **Delete** `EPDS_CALLBACK_SECRET`, the callback signature middleware, the - `/_internal/*` + `/_magic/*` HTTP endpoints, and auth-service's standalone - server. Retire the second process. + through the extracted function; stop signing/sending the HMAC callback. The + signed HTTP route stays live and verified here — it is not yet removed. +5. **Compatibility gate — delete only once legacy callers are drained.** After + confirming no old auth-service instance still POSTs the callback, delete + `EPDS_CALLBACK_SECRET`, the callback signature middleware, the HTTP + `/oauth/epds-callback` route, the `/_internal/*` HTTP endpoints, and + auth-service's standalone server. Retire the second process. Each step is independently shippable and reversible until step 5. From 18c64937a315813f0f2b95c2a9efca3497ecf745 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 14:27:27 +0100 Subject: [PATCH 07/10] docs(design): record the recurring cost of auth facts across the boundary The merge analysis framed the HMAC callback purely as an authenticity check. HYPER-219 (PR #234) showed the boundary also forces every authentication *fact* to be re-serialised: the code that knows how the user authenticated lives in auth-service, the API that records it lives in pds-core, and nothing but an explicit signed field can carry the fact between them. The natural implementation instead inferred it -- a valid callback had only ever followed an OTP, so arrival was treated as proof of control of the address. That is correct for today's single sign-in flow and silently wrong for the next one: a passkey flow would send a signed callback carrying the email merely to locate the account, and the address would be marked confirmed with nothing proved. Add the bullet to the merge benefits, a worked example section, and a note to the recommendation. The phasing is unchanged -- the integration hazards still argue for single-origin first -- but the boundary's cost is now known to recur per authentication mechanism rather than being a fixed one-off, which raises the standing cost of not merging. Also captures two details worth reusing at any such boundary: make carried auth facts required rather than sentinel-defaulted, so an omitting caller fails loudly instead of claiming a false negative; and reset the fact when the subject is rebound, as the recovery path does when it swaps a verified backup address for the primary. --- docs/design/single-process-merge.md | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/design/single-process-merge.md b/docs/design/single-process-merge.md index ad09a95f..a797fbbf 100644 --- a/docs/design/single-process-merge.md +++ b/docs/design/single-process-merge.md @@ -26,6 +26,16 @@ adds a distinct set of wins that single-origin alone cannot deliver: only the cross-service _authenticity_ check; the one-time, session-bound issuance gating must be preserved in the direct path (see "Preserving one-time issuance" below). +- **Authentication facts stop being re-serialised.** The HMAC boundary carries + _authenticity_ (this redirect is genuine) but not _content_ — anything + pds-core needs to know about **how** the user authenticated has to be encoded + into the signed payload as an explicit field, or else inferred. Inference is + the dangerous default: it is invisible, it looks correct for as long as only + one sign-in flow exists, and it silently becomes wrong the day a second one + is added. In one process there is nothing to serialise — the code that + verified the user calls the code that records the result, and the fact cannot + drift from its use. See "Auth facts across the boundary" below for the worked + example that motivated this bullet. - **The internal HTTP lookups disappear.** `/_internal/account-by-email` (which replaced the old unauthenticated `/_magic/check-email`) and the `/_internal/ping-request` keepalive become @@ -158,6 +168,73 @@ callback. In-process there is no forgeable wire boundary, but the review must confirm no other caller (including a partially-migrated deployment) can still reach the code-issuance path unauthenticated. +## Auth facts across the boundary + +A worked example of the "authentication facts stop being re-serialised" bullet, +from HYPER-219 (PR #234). It is the clearest evidence so far of a cost that is +_structural_, not incidental — no amount of care in either service removes it. + +**The task.** Every ePDS account is created after the user verifies an emailed +one-time code, so the address genuinely is verified. But the PDS `account` +table's `emailConfirmedAt` stayed null, so `email_verified` was false in every +OIDC claim and upstream's email-change verification gate never engaged. + +**In one process** this is a single call at the point of proof: where +better-auth confirms the code, call `accountManager.confirmEmail(...)`. The +invariant — _record confirmation only where control of the address was +proved_ — is enforced by control flow. There is no way to express the bug. + +**Across two processes** the code that knows the fact (auth-service, holding +the better-auth session) cannot reach the API that records it (pds-core, sole +owner of `account.sqlite`; auth-service does not even depend on `@atproto/pds`). +So the fact has to travel. The first implementation instead let pds-core +_infer_ it: a valid signed callback had only ever followed an OTP, so arrival +was treated as proof. That inference is correct today and silently wrong the +moment a second sign-in flow exists — a passkey flow would legitimately send a +signed callback carrying `email` merely to locate the account, having proved +nothing about that address, and pds-core would mark it confirmed. The failure +is in the worst direction: asserting `email_verified: true` to relying parties +on no evidence, and arming the email-change gate on an unproven address. + +**What the boundary cost to make safe:** + +- a new required `email_verified` field on the shared `CallbackParams` type; +- a change to the positional HMAC payload in both `signCallback` and + `verifyCallback`, so the claim is tamper-proof rather than a query param + anyone holding the URL could flip from `0` to `1`; +- wiring it through **both** callback producers in auth-service + (`/auth/complete` and `/auth/choose-handle`); +- a consumer check in pds-core that confirms only on an explicit `'1'`; +- tests pinning tamper-resistance and fail-closed behaviour. + +Two details are worth carrying into any future boundary work: + +- **Make such fields required, not sentinel-defaulted.** `handle` and + `client_id` use an empty-string sentinel so absent means "not set". Applying + that to an auth fact would mean a future flow that forgets it gets + "unverified" silently. Because the payload is positional, a **required** + field means an omitting caller signs a different payload and is rejected at + the trust boundary — the failure is loud, and cannot be a false claim of + verification. +- **Rebinding the subject invalidates the fact.** The recovery path swaps + `email` from the verified backup address to the account's primary address. + The session's "verified" flag refers to the address the user actually proved, + so it must be reset to `false` when the subject changes. In-process this + hazard is far easier to see, because the fact and its use sit together. + +**The general shape.** Each authentication fact pds-core needs is a field that +must be added to the payload, signed, produced by every producer, consumed +correctly, and tested — and the cost recurs per fact. `email_verified` answers +only "was control of this address proved?". A future `auth_method` +(`otp` / `passkey` / …), or step-up/assurance data, is another full round of +the same work. In one process these are ordinary function arguments, or simply +not needed because the caller already holds the session. + +This does not by itself justify the merge — the integration hazards above are +real and the rollout is not free. It does mean the boundary's cost is not +one-off: it is a recurring tax on every future authentication mechanism, paid +in exactly the area where silent errors are most damaging. + ## npm version clash — now in play Unlike single-origin, one process forces **one resolution** of every shared or @@ -205,3 +282,15 @@ value at a fraction of the risk. Treat the process merge as a **follow-on**, gated on the peer-dependency audit (step 1) and the callback-extraction refactor (step 2), both of which are worth doing on their own merits regardless of whether the final process collapse happens. + +One update since this was first written: the boundary's cost is now known to +_recur_, not just to sit as a fixed overhead. "Auth facts across the boundary" +records a live example (HYPER-219 / PR #234) where a fact known to auth-service +had to be re-serialised into the signed payload before pds-core could act on it +safely, and where the natural implementation inferred the fact instead — correct +for today's single sign-in flow, silently wrong for the next one. Every future +authentication mechanism (passkeys, step-up, assurance levels) pays that tax +again. This does not change the phasing recommended above, but it does raise the +standing cost of _not_ merging, and it is a reason to keep the +callback-extraction refactor (step 2) moving even if the final collapse stays +deferred. From 3d132daca42746e505447d017002182ff361a49b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 21 Jul 2026 17:25:41 +0100 Subject: [PATCH 08/10] docs(architecture): note takeover-mechanism cost is origin-independent The authorization_endpoint takeover forces PDS Core to police upstream's two unreachable auth UIs, producing brittle guards (auth-ui-guard.ts, white-boxing item 18; HYPER-367). Clarify this cost is orthogonal to the subdomain split: the opaque boundary is PDS Core <-> upstream atproto, so neither an origin merge nor a process merge removes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index b005d39f..e38c1a64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,19 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic stock `@atproto/pds` sign-in UI is never shown. This alone does not _require_ a separate origin — PDS Core already wraps upstream heavily and could shadow `/oauth/authorize` in place — so it is the mechanism, not the - motivation. + motivation. The takeover does, however, carry its own recurring cost + _independent of origin or process topology_: because clients are pointed + away from the stock UI, PDS Core must police every path on which upstream + `@atproto/oauth-provider` might still render one of its two unreachable + auth UIs (welcome page, password sign-in-view). The render decision lives + in upstream-private logic PDS Core cannot cleanly wrap, so the guard + (`auth-ui-guard.ts`; [pds-white-boxing.md](design/pds-white-boxing.md) + item 18) either mirrors that decision pre-route or detects it in the + rendered HTML — both brittle by construction (see + [HYPER-367](https://linear.app/hypercerts/issue/HYPER-367)). Merging the + origin does not remove this; merging the two ePDS _processes_ does not + either, because the opaque boundary here is PDS Core ↔ upstream atproto, + not PDS Core ↔ Auth Service. - **Cookie isolation (the motivation)**: the Auth Service's session cookies (the Better Auth session cookie; historically `magic_account_session`, From 446c5246dadcb0600f870447f74f2a2a769e32b6 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 14:46:43 +0100 Subject: [PATCH 09/10] docs(design): close migration gaps found in review Six review findings, all valid on inspection. single-domain-migration.md: - Step 2 routed /oauth/authorize and /account/* to auth-service, which the "Path shadowing" section two pages earlier says cannot be done -- those roots are served by upstream on pds-core. Route only /auth/*, and require an explicit choice between stripping the prefix and registering /auth-prefixed routes, since that choice determines the redirect targets in step 4. - Step 4's blanket auth./* -> /auth/* redirect double- prefixes existing links: EPDS_LINK_BASE_URL is already https://auth.pds.example/auth/verify, so it would resolve to /auth/auth/verify and break every verification email in flight. Replace with a per-path mapping table. - The Set-Cookie contract covered steady state but not cutover. These cookies are host-only, so nothing set at auth. is sent to : a redirect carries the URL but not epds_csrf, epds_auth_flow or the session. Document the controlled drain (short OTP TTL makes it practical) versus a server-side handoff, and require the no-cookies arrival to degrade to "start sign-in again". single-process-merge.md: - CSP said the two policies "already coexist". They coexist because the processes are separate; on one Express app both write the same header and setHeader overwrites, so ordering silently decides the policy. Require path-scoped mounting, single-writer-per-request, and tests asserting the header on a real request rather than the middleware in isolation. - Merging processes does not merge transaction boundaries: the auth DB and account.sqlite stay separate, so a crash between the two writes can consume flow state with no account, or leave a replayable flow. Specify commit order, idempotency keys, and failure-injection recovery tests before replacing the callback. - Step 5 deleted /_internal/* alongside the callback. Callback drain says nothing about getDidByEmail, pingParRequest or handle lookups, which have their own callers; gate each endpoint on its own drain. Raised by CodeRabbit on PR #201. --- docs/design/single-domain-migration.md | 51 ++++++++++++++++++-- docs/design/single-process-merge.md | 67 ++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md index 997bbbe4..898feff8 100644 --- a/docs/design/single-domain-migration.md +++ b/docs/design/single-domain-migration.md @@ -54,6 +54,30 @@ separation must instead be provided by the cookie attributes: host-only as the baseline; add either `__Host-` (at `Path=/`) or `Path=/auth` scoping, not both. +**In-flight sessions do not survive the host change.** These cookies are +host-only by design, so a cookie set at `auth.` is never sent to +`` — RFC 6265bis host-only semantics. The transition redirects in step 4 +carry the path and query, and therefore the request parameters, but they carry +**no cookies**: `epds_csrf`, `epds_auth_flow`, and the Better Auth session all +vanish across the hop. A user mid-sign-in at cutover lands on the new origin +with the right URL and no state. + +Pick one of these explicitly before cutting over — the migration is not +complete without it: + +- **Controlled drain (recommended).** Stop issuing new flows on the subdomain, + wait out the auth-flow TTL (OTP `expiresIn: 600`, so ~10 minutes), then flip. + In-flight users restart sign-in; nobody sees a broken state because there are + no live flows left. Cheapest, and the TTL is short enough to make it + practical. +- **Server-side handoff.** Redirect through a one-time, short-TTL token that the + new origin exchanges for the session server-side. Preserves in-flight flows at + the cost of a new exchange endpoint — which is itself a credential-bearing + redirect and needs the same scrutiny as the HMAC callback. + +Whichever is chosen, the restart path must be graceful: a user arriving without +the expected cookies must get the "start sign-in again" flow, not an error. + ## Two levels of "merge" — keep them distinct This matters for both benefits and risks, so fix the terms up front: @@ -197,13 +221,32 @@ deployments keep working during transition (config-gated, not a hard cutover). 1. **Config plumbing.** Add `AUTH_PATH_PREFIX`; derive auth origin from it; keep `AUTH_HOSTNAME` as a legacy override. No behaviour change yet. 2. **Caddy path routing.** Add a single-origin Caddyfile variant that routes - `/auth/*`, `/oauth/authorize`, `/account/*` to auth-service and the rest to - the PDS. Stand it up in a test/preview env. + **only `/auth/*`** to auth-service and everything else to the PDS. Routing + `/oauth/authorize` or `/account/*` at the public root would shadow the PDS's + own endpoints — see "Path shadowing" above; the auth equivalents are reached + as `/auth/oauth/authorize` and `/auth/account/*`. Either strip the `/auth` + prefix before forwarding (so auth-service keeps its current internal route + paths) or register `/auth`-prefixed routes in auth-service; pick one + explicitly, because the two choices are not interchangeable at the + redirect-target level (step 4). Stand it up in a test/preview env. 3. **Delete the cross-origin workarounds** (items 5, 14, 15 above) _behind the single-origin config path_ so subdomain deployments are unaffected until they flip. -4. **Transition redirects.** Serve `auth./*` → `/auth/*` for the - metadata-TTL + email-link-TTL window. +4. **Transition redirects.** Serve path-specific redirects for the + metadata-TTL + email-link-TTL window, preserving the full path and query + string on each: + + | Legacy | Target | + | ----------------------------- | ----------------------------- | + | `auth./oauth/authorize` | `/auth/oauth/authorize` | + | `auth./account/*` | `/auth/account/*` | + | `auth./auth/*` | `/auth/*` | + + A blanket `auth./* → /auth/*` rule is wrong: existing links + already carry an `/auth` segment (`EPDS_LINK_BASE_URL` is + `https://auth.pds.example/auth/verify`), so it would produce + `/auth/auth/verify` and break every verification email in flight. + 5. **Cut over** `authorization_endpoint` and `EPDS_LINK_BASE_URL` to the new path-based URLs. 6. **Retire** the `auth.` DNS record, TLS cert, and the legacy diff --git a/docs/design/single-process-merge.md b/docs/design/single-process-merge.md index a797fbbf..e4347eb1 100644 --- a/docs/design/single-process-merge.md +++ b/docs/design/single-process-merge.md @@ -121,18 +121,53 @@ them to the `/auth` router, not the merged app root. ### 5. CSP middleware convergence Both set CSP per-route (auth via `security-headers.ts`, pds-core by rewriting -upstream's CSP). These already coexist per-response, so no origin change is -needed — but confirm the auth CSP middleware only fires on `/auth/*` once -co-mounted, and does not leak `unsafe-inline` onto PDS routes. +upstream's CSP). They coexist today only because they run in separate +processes; on one Express app they write the **same** response header, and +`setHeader` overwrites rather than merges — so the last middleware to run +silently wins. + +"Confirm they coexist" is therefore not enough. Assign ownership explicitly: + +- Mount the auth CSP middleware **path-scoped** to `/auth/*`, not app-wide, so + ordering cannot decide the policy for PDS routes. +- Treat CSP as **single-writer per request**: exactly one middleware sets the + header on any given path. If both must contribute, compose the value in one + place instead of letting two `setHeader` calls race. +- **Assert the final header, not the intent.** Add tests that issue a real + request to a representative `/auth/*` path and a representative PDS path and + assert on the exact `Content-Security-Policy` received, including that + `unsafe-inline` does not appear on PDS routes. A test that only checks the + middleware in isolation would pass while the mounted app serves the wrong + policy. ### 6. Shared context / DB handles auth-service builds `AuthServiceContext` (its own `db`, `emailSender`); pds-core holds `pds.ctx`. Merged, decide whether they share one SQLite handle or keep -separate connections to the same files. The migration plan already notes -`account.sqlite` is the single source of truth for email→DID; in-process, the -direct-lookup replacements for `/_internal/account-by-email` read it through -`pds.ctx.accountManager` rather than a second connection. +separate connections to the same files. + +**Separate handles stay separate transaction boundaries.** One process does not +make a write across the two stores atomic: the auth DB and `account.sqlite` +remain distinct databases, so a crash between them can consume auth-flow state +without the PDS account being created or confirmed, or leave a flow row intact +after the account exists and invite an unsafe retry. Today the HTTP callback +masks this — a failed request is visibly a failed request. In-process the two +writes look like one function call and the boundary becomes easy to forget. +Before replacing the callback, pin down: + +- **Commit order**, chosen so the survivable failure is the one that happens: + create/confirm the PDS account first, consume the auth-flow row last, so a + crash leaves a replayable flow rather than a consumed one with no account. +- **Idempotency keys**, so a replay of the same flow converges on the same + account instead of minting a second one — this is the same one-time, + session-bound guarantee the HMAC path enforces today, and it must survive the + move. +- **Recovery tests** that inject a failure between the two writes and assert the + system converges: no orphaned flow rows, no duplicate accounts, no + half-confirmed state. The migration plan already notes + `account.sqlite` is the single source of truth for email→DID; in-process, the + direct-lookup replacements for `/_internal/account-by-email` read it through + `pds.ctx.accountManager` rather than a second connection. ## Replacing the HMAC callback in-process @@ -269,9 +304,21 @@ come first. This process-merge adds: signed HTTP route stays live and verified here — it is not yet removed. 5. **Compatibility gate — delete only once legacy callers are drained.** After confirming no old auth-service instance still POSTs the callback, delete - `EPDS_CALLBACK_SECRET`, the callback signature middleware, the HTTP - `/oauth/epds-callback` route, the `/_internal/*` HTTP endpoints, and - auth-service's standalone server. Retire the second process. + `EPDS_CALLBACK_SECRET`, the callback signature middleware, and the HTTP + `/oauth/epds-callback` route. + + **Gate `/_internal/*` removal separately, per endpoint.** Draining callback + callers says nothing about the other internal consumers: `getDidByEmail` + (`/_internal/account-by-email`), `pingParRequest` (`/_internal/ping-request`) + and handle-availability lookups each have their own callers and their own + drain window. Inventory every endpoint, migrate each caller to the + in-process function, confirm no traffic remains on that specific route, and + only then delete it. Removing them wholesale alongside the callback would + break any caller that had not yet been migrated — and because these are + internal routes, the failure surfaces as a runtime error in a sign-in, not + at build time. + + Retire the second process once every route above is drained and deleted. Each step is independently shippable and reversible until step 5. From ee1f9f2c6a5e9ac51d1dcbabdfe5b3930971e25e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 14:49:20 +0100 Subject: [PATCH 10/10] docs(design): name the real cookie-domain symbol The migration doc cited a `parentCookieDomain` derivation at pds-core/src/index.ts:930. No such symbol exists: the value is `cookieDomain`, derived by `deriveCookieDomain(authHostname, handleDomain)` from pds-core/src/cookie-domain.ts. Drop the line number too, since it had already drifted. Raised by Copilot on PR #201 (suppressed comment). --- docs/design/single-domain-migration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md index 898feff8..bf8d29db 100644 --- a/docs/design/single-domain-migration.md +++ b/docs/design/single-domain-migration.md @@ -134,8 +134,9 @@ The whole point. These exist solely because of cross-origin-same-site: `auth-service/src/lib/session-reuse.ts`. On one origin the device session is directly readable; cookies stay host-only. 3. **`authHostname.endsWith('.' + pdsHostname)` special-casing** — e.g. - `session-reuse.ts:195-200`, the `parentCookieDomain` derivation in - `pds-core/src/index.ts:930`, and `authOrigin` derivation in + `session-reuse.ts:195-200`, the `cookieDomain` derivation in + `pds-core/src/index.ts` (via `deriveCookieDomain(authHostname, handleDomain)` + in `pds-core/src/cookie-domain.ts`), and `authOrigin` derivation in `chooser-enrichment.ts`. The two hostnames become one. 4. **(Process-merge only) some privileged cross-service endpoints** — the `/_internal/*` HTTP lookups become in-process calls. The