From b873b50b59e8ef89a5c350861531b9f88b74403a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 23:21:51 -0700 Subject: [PATCH 1/8] Serve Pocket and account-scoped Relay routes from relay.dormouse.sh Postgres relay state (hashed bearer secrets, single-use consumption, per-account caps), the Pocket-facing setup/sign-in/reauth/burrows routes with the self-host Relay's shapes and error strings, the ADMIN_EMAIL entitlement on every Burrow-authenticated request, Pocket at the relay root under its own policy, and Pocket using the account id its Relay answers instead of 'owner'. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 31 +- docs/specs/hosted.md | 48 +- docs/specs/hosted.rationale.md | 9 + docs/specs/one-time.md | 22 +- docs/specs/pocket-app.md | 32 +- docs/specs/relay.md | 26 +- docs/specs/remote-network.md | 2 +- docs/specs/remote-security-model.md | 10 +- docs/specs/security-hosted.md | 24 +- docs/specs/security-remote.md | 2 +- hosted/README.md | 30 +- hosted/package.json | 4 +- hosted/scripts/changed.mjs | 7 +- hosted/scripts/changed.test.mjs | 5 +- hosted/scripts/preview.mjs | 2 +- hosted/scripts/preview.test.mjs | 4 +- hosted/scripts/production.test.mjs | 12 +- hosted/scripts/stage-one-time.mjs | 46 -- hosted/scripts/stage-one-time.test.mjs | 46 -- hosted/scripts/stage-relay.mjs | 62 ++ hosted/scripts/stage-relay.test.mjs | 91 +++ hosted/server/bindings.ts | 11 +- .../server/dormouse-migrations/002_relay.sql | 88 +++ hosted/server/headers.ts | 88 ++- hosted/server/pocket.ts | 30 + hosted/server/relay-api.ts | 643 ++++++++++++++++++ hosted/server/relay-worker.ts | 22 +- hosted/server/tests/boundary.test.ts | 115 +++- hosted/server/tests/one-time.test.ts | 18 +- hosted/server/tests/pocket.test.ts | 167 +++++ hosted/server/tests/relay.test.ts | 613 +++++++++++++++++ hosted/server/worker-app.ts | 10 +- hosted/wrangler.relay.jsonc | 14 + lib/src/host/remote/service.test.ts | 2 +- lib/src/host/remote/service.ts | 4 +- lib/src/remote/client/pocket-client.test.ts | 27 +- lib/src/remote/client/pocket-client.ts | 27 +- lib/src/remote/client/test-e2e-harness.ts | 21 +- relay/src/app.ts | 160 +---- relay/src/setup-token.ts | 13 +- remote-lib-common/src/index.ts | 1 + remote-lib-common/src/remote/relay-common.ts | 161 +++++ remote-lib-common/test/relay-common.test.mjs | 54 ++ scripts/spec-word-budgets.json | 4 +- 44 files changed, 2385 insertions(+), 423 deletions(-) delete mode 100644 hosted/scripts/stage-one-time.mjs delete mode 100644 hosted/scripts/stage-one-time.test.mjs create mode 100644 hosted/scripts/stage-relay.mjs create mode 100644 hosted/scripts/stage-relay.test.mjs create mode 100644 hosted/server/dormouse-migrations/002_relay.sql create mode 100644 hosted/server/pocket.ts create mode 100644 hosted/server/relay-api.ts create mode 100644 hosted/server/tests/pocket.test.ts create mode 100644 hosted/server/tests/relay.test.ts create mode 100644 remote-lib-common/src/remote/relay-common.ts create mode 100644 remote-lib-common/test/relay-common.test.mjs diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 34203430d..07d6a4825 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -7,18 +7,23 @@ **Output file:** `audit-hosted.md` This is a code-and-specs audit of Hosted's three Workers — the account -application (`hosted.dormouse.sh`), the relay that serves the one-time -rendezvous (`relay.dormouse.sh`), and managed voice (`voice.dormouse.sh`). You need no +application (`hosted.dormouse.sh`), the relay that serves the Hosted Relay's +account-scoped routes, Pocket, and the one-time rendezvous +(`relay.dormouse.sh`), and managed voice (`voice.dormouse.sh`). You need no PAT — do not use one. The two pgstencil provenance checks below do read the GitHub API, but only a public repository, which the workflow's default `GITHUB_TOKEN` and the operator's own `gh` login both reach; if that API is unreachable, report those two checks as `UNVERIFIABLE`. Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", -"Hosted rendezvous", and "Phone page"), `hosted/server/`, `hosted/src/`, +"Hosted rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", +"Setup tokens and the pairing QR", and "WebAuthn without a WebAuthn library", +whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, `hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`, -`remote-lib-common/src/remote/one-time-wire.ts`, the phone page the relay serves — +`remote-lib-common/src/remote/one-time-wire.ts`, +`remote-lib-common/src/remote/relay-common.ts`, the Pocket build the relay +serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and `.github/workflows/hosted-preview.yml` and @@ -91,14 +96,24 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: reaches browser JSON or storage. - **Does a secret reach a Worker that has no use for it?** Read each mapper in `hosted/server/bindings.ts` and each Wrangler config: the relay and voice - Workers must hold and pass no auth secret and never import Better Auth, the - relay no Hyperdrive, the account no ElevenLabs key. + Workers must hold and pass no auth secret and never import Better Auth, and + the account and relay no ElevenLabs key. The relay's Hyperdrive reaches only + its own tables and the entitlement's user row. +- **Can one account reach another's Relay rows?** Trace every query in + `hosted/server/relay-api.ts`: a session or Burrow token of account B must not + list, prove with, spend, or mint against account A's Burrows, passkeys, + nonces, setup tokens, or setup challenges; a single-use row must be spent in + the statement that reads it; a bearer secret must be at rest only as its + hash; a de-entitled or revoked Burrow must act on nothing; and every table a + caller grows must stay bounded against that caller. - **Can a Hosted login become terminal access, or an account become someone else's?** `authPolicy` must keep explicit linking and independent logins; a callback whose initiating login was revoked must fail; an unused or unknown provider credential must enable nothing. No Hosted endpoint may mint a Burrow - ACL grant or stand in for the encrypted pairing and presence proof, and the - rendezvous authorizes nothing. + ACL grant or stand in for the encrypted pairing and presence proof, the + Relay must read no cookie, and the rendezvous authorizes nothing. Pocket and + `/connect/` share the relay origin; check what each page's policy lets it + reach of the other. - **Can the rendezvous become more than a handshake pipe?** Trace a frame through `OneTimeRoom`: nothing may read, keep, or log it, and the length, type, and count bounds must close both ends before a byte past them is diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index ef6b512d8..2686869bf 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -1,7 +1,7 @@ # Dormouse Hosted accounts > See `docs/specs/glossary.md` for Burrow, Client, Relay, and Session vocabulary. -> Owns the Hosted account application and the deployment of Hosted's three Workers. The one-time rendezvous the relay Worker serves belongs to `docs/specs/one-time.md` -> "Hosted rendezvous"; remote authorization to `docs/specs/remote-security-model.md`; the multi-tenant Relay remains in `docs/specs/relay.md` -> Future. +> Owns the Hosted account application, the Hosted Relay's account-scoped routes, and the deployment of Hosted's three Workers. The one-time rendezvous the relay Worker serves belongs to `docs/specs/one-time.md` -> "Hosted rendezvous"; the Relay's shared route semantics to `docs/specs/relay.md` -> "HTTP API"; remote authorization to `docs/specs/remote-security-model.md`. ## Application boundary @@ -10,10 +10,10 @@ | Worker | Origin | Serves | Holds | |---|---|---|---| | `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, `/api/ready`, voice tokens | the login cookie, auth secrets, Hyperdrive | -| `dormouse-relay` | `https://relay.dormouse.sh` | the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | `OneTimeRoom`, the one-time rate limits | +| `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay and Pocket ("Relay"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, the one-time and sign-in rate limits | | `dormouse-voice` | `https://voice.dormouse.sh` | speak and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | -Every Worker answers `/api/health`, 404s any other `/api/*`, `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. Only the account falls back to SPA assets. The 421 origin gate, each Worker's bindings mapper, and the cookie routes' exact-`Origin` check are `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). +Every Worker answers `/api/health`, 404s any other `/api/*`, `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. The account falls back to its SPA assets, the relay to Pocket's. The 421 origin gate, each Worker's bindings mapper, and the cookie routes' exact-`Origin` check are `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). Marketing is a separate bundle and deployment. `remote-lib-common` is compiled in from source through `hosted/tsconfig.json` `paths`, which every esbuild bundle and Wrangler honor. @@ -33,7 +33,7 @@ Source of truth: `workerApp` in `hosted/server/worker-app.ts`; `accountApp` in ` **May create provider-only accounts without verified email.** Public email is null; pgstencil's internal placeholder is never a delivery address. Email-code login remains an access path to an account's canonical verified mailbox. No merge, email adoption, unlink, or account-recovery interface exists. -**Must identify accounts by immutable user ID, never email.** Provider-only accounts keep their identity when a provider subsequently supplies email. Exception: "Managed voice". +**Must identify accounts by immutable user ID, never email.** Provider-only accounts keep their identity when a provider subsequently supplies email. Exception: the `ADMIN_EMAIL` gate of "Managed voice" and "Relay". **Must enable providers explicitly in `OAUTH_PROVIDERS`.** The allowed set is GitHub, Google, Microsoft, and Apple. Missing paired credentials or unknown names fail closed; unused credentials enable nothing. Email uses Postmark in production and local capture in development. @@ -66,7 +66,7 @@ An admin-only test slice: Dormouse desktop exchanges a pasted voice token for El Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 for any account but the admin. -**Must admit only `ADMIN_EMAIL` while it is the account's verified email, rechecked on every request.** This is the only exception to "never email" ("Identity and login"); nothing else may key on an address, and it ends with the entitlement in Future item 3. Cookie routes ask the Better Auth handler's `get-session` for the login; speak reads the token owner's user row. +**Must admit only `ADMIN_EMAIL` while it is the account's verified email, rechecked on every request.** This and the Relay's entitlement are the only exceptions to "never email" ("Identity and login"); nothing else may key on an address, and both end with the entitlement in Future item 3. Cookie routes ask the Better Auth handler's `get-session` for the login; speak reads the token owner's user row. **Must store only a token's SHA-256.** A token is `dmv_` plus base64url of 32 random bytes, returned only by the mint response. Revocation is permanent; speak stamps `lastUsedAt`. @@ -90,11 +90,41 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceTokenRoutes` / `speakRoute` / `elevenLabs` / `sweepOnCron` / `sweepAfterSpeech` in `hosted/server/voice.ts`; `scheduled` in `hosted/server/voice-app.ts` and `hosted/server/worker-app.ts`; `triggers` in `hosted/wrangler.voice.jsonc`; `hosted/server/dormouse-migrations/001_voice_tokens.sql`; `preflight` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/production.test.mjs`, and `hosted/scripts/preview.test.mjs`. +## Relay + +The relay Worker serves the self-host Relay's HTTP API to many accounts: the paths, shapes, statuses, and error strings of `docs/specs/relay.md` -> "HTTP API", "Setup tokens and the pairing QR", and "WebAuthn without a WebAuthn library", so a Burrow and Pocket cannot tell the two apart. Only the differences: + +| Route | On Hosted | +|---|---| +| `POST /api/setup/begin`, `/finish` | The passkey joins the account owning the token's Burrow: `accountId` is its user ID, `existingCredentialIds` its passkeys; 409 at `MAX_PASSKEYS_PER_ACCOUNT` | +| `POST /api/setup/retire` | Spends only a token one of the session's account's Burrows minted | +| `POST /api/signin/begin` | 429 with `Retry-After` past `RELAY_SIGNIN_LIMIT` per address | +| `POST /api/signin/finish` | The account owning the asserted credential; `accountId` is its user ID | +| `POST /api/reauth/begin`, `/finish` | Only the session's account's credentials and nonces | +| `GET /api/burrows` | The session's account's unrevoked Burrows, each `online: false`: no relay socket reaches Hosted yet | +| `POST /api/burrow/setup-token` | 401 for a revoked Burrow, 403 `NOT_ENTITLED_ERROR` for an owner not entitled | +| `POST /api/burrow/enroll` | Always 401 `UNAUTHORIZED_ERROR`: Hosted has no setup password, and no route enrolls a Burrow yet (Future) | +| `GET /api/push/config` | `{ applicationServerKey: null }`: push is off; every other push route and `/ws/*` is 404 | +| `GET /*` | Pocket (below) | + +- **Must keep the Relay's state in Postgres, every bearer secret only as its SHA-256**: session, Burrow, and setup tokens, and enrollment device codes. An account's rows cascade with it. +- **Must scope every query reading a Burrow, passkey, nonce, or setup token to the caller's account**, the session's or the Burrow token's owner. A setup challenge redeems only with a token of the Burrow that began it. +- **Must spend a setup token, challenge, or presence nonce in one statement.** A refused `finish` restores its token on the original expiry within the Burrow's cap. +- **Must admit a Burrow only while its owner is entitled, rechecked on every Burrow-authenticated request and every setup redemption.** Until billing exists the entitlement is "Managed voice"'s `isAdmin`. +- **Must cap every table a caller grows, keyed by whoever grows it**, as `docs/specs/relay.md` -> "Guardrails" does: setup tokens and setup challenges per Burrow (`MAX_TOKENS_PER_BURROW`), presence nonces per session (`MAX_PENDING_REAUTH_NONCES_PER_SESSION`), and sessions per account (`MAX_SESSIONS_PER_ACCOUNT`), each evicting its key's own oldest; passkeys per account are refused at the cap (rationale). Sign-in challenges, minted unauthenticated, take the per-address limit. Each write path prunes its table's expired rows. +- **Never read a cookie or ask auth**: the Relay reads a user row only for the entitlement. + +The bounds are the self-host Relay's, shared through `remote-lib-common`; assertions demand presence, not verification. + +**Pocket at the root.** `build` stages `lib/dist-pocket` at the root of the relay's assets and the one-time page beside it, checking both shells. **Must serve Pocket under `docs/specs/pocket-app.md` -> "Serving the built bundle"**: a file as itself, `…/index.html` from its directory (the diagnostics harness), a missing `/assets/` file as a 404, and every other miss as the shell. Every path outside `/connect`, `/api/`, and `/ws/` carries Pocket's policy, `camera=(self)` for its scanner, and `no-cache` unless a hashed asset. + +Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `hosted/server/dormouse-migrations/002_relay.sql`; `pocketRoutes` in `hosted/server/pocket.ts`; `relayPolicy` / `relayPermissions` / `isPocketPath` in `hosted/server/headers.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `remote-lib-common/src/remote/relay-common.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `hosted/scripts/stage-relay.test.mjs`. + ## Development and release -**Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and the voice token routes but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback, without a database (`docs/specs/one-time.md` -> "Dev loop"). +**Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and the voice token routes but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback without a database, so its Relay routes answer 503 (`docs/specs/one-time.md` -> "Dev loop"). -**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free Miniflare suites (the rendezvous, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. +**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free Miniflare suites (the rendezvous, Pocket's serving, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts` and `relay.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must keep production, test, and preview databases and credentials separate.** The development and preview entries are email-only. Production configuration and operator steps live in `hosted/README.md`. @@ -104,7 +134,7 @@ Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `host **Must deploy only verified same-repository PR merge revisions touching Hosted or its shared build inputs.** Drafts qualify; forks receive no deployment credentials. Changed paths include rename sources and all API pages. Deployment runs serialize per PR without cancellation; close/merge cleanup ignores path filtering and tolerates absent resources. -**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive the account and voice share, and a Neon branch from an empty dedicated preview project**, all reused until close. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespace and preview-only rate-limit namespaces**, and delete every preview Worker with `force`. The smoke checks what production's does ("Production releases"), with the captured-mail login in place of providers, retrying each part on its own. +**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until close. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespace and preview-only rate-limit namespaces**, and delete every preview Worker with `force`. The smoke checks what production's does ("Production releases"), with the captured-mail login in place of providers, retrying each part on its own. **Must run cleanup from the base branch's checkout, never the closed PR's.** @@ -129,4 +159,4 @@ Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / 1. Deploy the configured providers and pass real production acceptance. pgstencil includes the Microsoft fix; personal and work/school callbacks need acceptance. 2. Add per-browser login listing/revocation, sign-out-everywhere, and account recovery before broad paid use. Revisit the fixed 24-hour login lifetime for daily voice use. 3. Managed voice beyond the admin slice: a real entitlement or licence replacing `ADMIN_EMAIL`, credentials scoped for non-admin accounts, per-account quotas, usage accounting, and spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure. -4. Hosted Relay: **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. +4. Hosted Relay beyond "Relay": Burrow enrollment, relay sockets and `online`, and push — **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 40872bc0e..13529742b 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -25,3 +25,12 @@ Three origins (decided 2026-09-30): - Deploy order (2026-09-30): the account's `v2` deletes the `OneTimeRoom` namespace the relay's `v1` replaces, and a deployed migration is a rollback floor. Deploying the account first would make the deletion permanent before the replacement is known to deploy; with the relay first, a failed relay deploy stops the release before the account changes. - Relay smoke before the account (2026-10-01): a relay deploy can succeed while its custom domain or rendezvous does not serve, and the full smoke runs only after the account deployed, so by then `v2` had already deleted the old room. Smoking the relay right after its deploy keeps the deletion behind a proven replacement. - Smoke attempts (2026-09-30): the first release after the split attaches `relay.dormouse.sh` and `voice.dormouse.sh` as new custom domains, and a new certificate can take longer to issue than the health GET's six five-second retries. Repeating a relay or voice smoke sends only GETs and WebSockets on a fresh room, so it replays no POST; the account smoke's POSTs keep it at one attempt. + +## Relay + +Caps (2026-09-30): + +- Passkeys per account (`MAX_PASSKEYS_PER_ACCOUNT`, 32): registration needs a setup token the account's own Burrow minted, so only the account grows its rows. A person registers one per phone or unsynced browser profile, and every `setup/begin` returns the whole list as `excludeCredentials`. A full account is refused rather than evicted, since evicting a passkey would sign a device out without saying so. +- Sessions per account (`MAX_SESSIONS_PER_ACCOUNT`, 32): each costs an assertion by one of the account's own passkeys, so only the account can spend it, and 32 is far above the browsers a person signs in from within the 12-hour lifetime. Evicting the oldest costs at worst one re-sign-in. +- Setup challenges are keyed by the Burrow whose token began them, which also makes a challenge unredeemable with another Burrow's token. The self-host Relay's single flat issuer was the accepted exception for one tenant; across accounts, a flat map would let one account evict another's live registration. +- Sign-in challenges stay flat: `signin/begin` has no caller to key on. The per-address limit plus two-minute expiry bounds them, rather than a global cap whose flood would evict every account's live sign-in. diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index b066548c1..43a392050 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -316,8 +316,8 @@ The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): - **Never open a socket before the Connect tap**: the tap builds the client and runs `connectOnce`, so a link-preview crawler spends nothing. - Its ICE servers: `docs/specs/remote-network.md` -> "Anywhere". -- **Never persist anything** on the relay origin: no storage, IndexedDB, - worker, push, or cookie. `applyPocketTheme` applies Pocket's +- **Never persist anything** from the page: no storage, IndexedDB, worker, + push, or cookie, though Pocket keeps its own on the same origin. `applyPocketTheme` applies Pocket's default theme without reading or writing a stored pick, for the page and its `PocketWall`. `scripts/e2e-lint.mjs` holds the page to the client's store rule. - **Mounts the wall only on `ok`**, through `mountRemoteWall`. End, Cancel, a @@ -334,15 +334,17 @@ The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): no module-preload polyfill) into `lib/dist-one-time`, then runs `assertPocketShell --one-time`: scripts under `/connect/assets/`, links under `/connect/`, nothing inline. Hosted's `build` runs it first, and -`hosted/scripts/stage-one-time.mjs` empties the relay's assets directory, -`hosted/dist/relay/`, copies it to `hosted/dist/relay/connect/`, and checks the copy. +`hosted/scripts/stage-relay.mjs` empties the relay's assets directory, +`hosted/dist/relay/`, copies it to `hosted/dist/relay/connect/` beside Pocket at +the root, and checks the copy. **Serving.** The relay Worker answers `/connect`, `/connect/`, and -`/connect/assets/*` from its assets, which have no SPA fallback; an HTML answer -under `/connect/assets/` and any other path, under `/connect/` or not, is a 404. +`/connect/assets/*` from its assets, with no SPA fallback; an HTML answer +under `/connect/assets/` and any other path under `/connect/` is a 404. A hashed file there is cached immutably. Everything under `/connect` carries this policy, from the relay's `APP_ORIGIN` (``; `` with `http` -replaced by `ws`); every other relay response carries `RUNS_NOTHING_POLICY`: +replaced by `ws`); every other relay response carries Pocket's policy or +`RUNS_NOTHING_POLICY` (`docs/specs/hosted.md` -> "Relay"): ``` default-src 'none'; script-src /connect/assets/ 'wasm-unsafe-eval'; @@ -363,13 +365,13 @@ Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `takeOneTimeLinkUrl` / `reloadOnNewLink` in `lib/src/remote/one-time-app/take-link.ts`; `applyPocketTheme` in `lib/src/remote/pocket-app/pocket-theme.ts`; `assertPocketShell` in -`lib/scripts/assert-pocket-worker.mjs`; `stageOneTime` in -`hosted/scripts/stage-one-time.mjs`; `oneTimePageRoutes` in +`lib/scripts/assert-pocket-worker.mjs`; `stageRelay` in +`hosted/scripts/stage-relay.mjs`; `oneTimePageRoutes` in `hosted/server/one-time.ts`; `relayPolicy` in `hosted/server/headers.ts`. Pinned by `lib/src/remote/one-time-app/OneTimeApp.test.tsx`, `lib/src/remote/pocket-app/assert-pocket-worker.test.ts`, -`hosted/scripts/stage-one-time.test.mjs`, and +`hosted/scripts/stage-relay.test.mjs`, and `hosted/server/tests/one-time.test.ts`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs` checks a deployment's page and script. diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 0268b2d67..841f938f3 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -476,9 +476,12 @@ previous build's hashed assets (rationale). Two rules make it hold: response's cache policy describes the response, and the shell is never a useful answer to a subresource miss. (rationale) -Source of truth: `registerPocketServing` in `relay/src/app.ts`. Both built HTML -shells are checked by `assertPocketShell` in -`lib/scripts/assert-pocket-worker.mjs`. +The Hosted Relay serves the same bundle by the same rules +(`docs/specs/hosted.md` -> "Relay"). + +Source of truth: `registerPocketServing` in `relay/src/app.ts`; `pocketRoutes` in +`hosted/server/pocket.ts`. Both built HTML shells are checked by +`assertPocketShell` in `lib/scripts/assert-pocket-worker.mjs`. ### The capability harness @@ -608,7 +611,7 @@ passkeys to the serving origin, and Chrome's Private Network Access rules block public-site → private-network fetches. Pocket holds itself to it by construction — an empty API base, a `wsBase` from `location.origin` — and the Relay enforces it: a registration or assertion whose `clientDataJSON.origin` is -not the configured `DORMOUSE_ORIGIN` is rejected ([relay.md](./relay.md); +not the configured origin is rejected ([relay.md](./relay.md); rationale); the Relay emits no cross-origin grant ([security-remote.md](./security-remote.md#cross-origin-access)). **The bundle mounts at the origin root, never under a path prefix**: the manifest's @@ -626,8 +629,8 @@ Every source is the app's own origin * **`style-src 'unsafe-inline'`**, because the shell carries a pre-paint `