Skip to content

Add Hosted accounts, isolated PR previews, and verified releases - #631

Draft
nedtwigg wants to merge 3 commits into
mainfrom
hosted-auth
Draft

Add Hosted accounts, isolated PR previews, and verified releases#631
nedtwigg wants to merge 3 commits into
mainfrom
hosted-auth

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 11, 2026

Copy link
Copy Markdown
Member

Dormouse Hosted now has an isolated account app at hosted.dormouse.sh, using packed pgstencil Better Auth with Postgres through Hyperdrive. It supports email codes and individually enabled GitHub, Google, Microsoft, and Apple providers, independent simultaneous logins, and explicit provider connections. Marketing remains a separate app; production exposes no email inbox or test clock.

PRs touching Hosted or its shared build inputs now verify and build before deploying a stable per-PR Worker, uncached Hyperdrive, and isolated Neon branch. The preview captures test email in a persistent public inbox, disables OAuth/external mail, and deletes its resources on close. Forks verify without deployment credentials; credentialed jobs require approval in the dedicated Hosted environments.

Production release is manually dispatched from main. It checks accepted package provenance, database identity, required Worker secrets and uncached Hyperdrive, verifies an encrypted database backup through restore, applies migrations, deploys, and smoke-checks the live revision before creating an immutable hosted/YYYY-MM-DD deployment tag with numeric daily revisions. Tagging retries are idempotent. Existing admin-only merge/tag protections remain intact.

Validation: 29 Hosted tests pass, including real workerd/Postgres auth and deployed-preview smoke coverage; Hosted TypeScript/build/Wrangler dry-run, actionlint, spec/public-doc, loopback, installer/deploy and remote-security lints pass. The current push also runs repository CI.

The three protected GitHub environments have been created. Cloudflare/Neon resource IDs and credentials, Postmark and OAuth registrations, and real-provider acceptance remain pending. hosted/DEPLOYMENT.md contains the resource inventory and exact GitHub secret/variable commands; hosted/README.md contains provider callbacks and acceptance steps. Production rejects the currently dirty pgstencil snapshot until refreshed from an accepted clean revision. Microsoft's real callback issue remains a separate upstream investigation. Hosted voice and the managed Relay remain staged.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: fd5181b
Status: ✅  Deploy successful!
Preview URL: https://4823703d.mouseterm.pages.dev
Branch Preview URL: https://hosted-auth.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.

The Worker's boundary work holds up: the origin check, the 404s on /dev/* and /__test/*, the fail-closed providerBindings, and the workerd+Postgres suite that pins all of it are the parts a security spec most needs pinned, and they are pinned. Five things I'd change before this deploys.

hosted/ has an undeclared dependency on lib/. hosted/src/main.tsx imports ../../lib/src/lib/themes/apply and .../store by relative path, but hosted/package.json declares no dormouse-lib edge — website/package.json and standalone/package.json both declare "dormouse-lib": "workspace:*". It resolves today only because apply.ts and store.ts reach nothing outside lib/src; the first third-party import added to that subtree breaks the Hosted build with no dependency edge to explain why. Adding "dormouse-lib": "workspace:*" needs a pnpm install in the same commit, which is why I haven't suggested it inline.

restoreTheme asserts two theme IDs that nothing pins. getBundledThemes().find((theme) => theme.id === id)! in hosted/src/main.tsx runs at module scope, before createRoot(...).render(<App />). If either vscode.theme-kimbie-dark.kimbie-dark or vscode.theme-defaults.light-visual-studio is renamed in lib/src/lib/themes/bundled.json, applyTheme(undefined) throws there and the sign-in page renders blank — no error UI, because React never mounts. lib/src/lib/themes/apply.test.ts names the Kimbie ID for its own assertions, so a rename would go red in lib first, but nothing ties that to Hosted. Either fall back to a bundled theme rather than asserting, or pin both IDs from a Hosted test.

/api/ready is an unauthenticated Postgres probe. It sits outside /api/auth/*, so the adapter's database-backed limiter (rateLimit: { enabled: true, storage: 'database' } in the packed better-auth module) doesn't cover it, and each call opens a Hyperdrive connection to run its LIMIT 0 query. A flood costs the operator real connections on the same pool the auth path needs. The spec's own Source of truth: list doesn't claim a limiter here, so this looks unintentional rather than accepted.

A typo in OAUTH_PROVIDERS reads as a total outage. providerBindings(...) is evaluated as an argument to app.fetch, so its throw is caught by the outer handler in hosted/server/worker.ts before routing — /api/health returns the same 503 as every other route. With "observability": { "enabled": false } in wrangler.jsonc and both the onError handler and the outer catch discarding the error, one wrong character in a var is indistinguishable from a database outage, with nothing recorded anywhere. Fail-closed is right; failing closed silently and identically to a real outage is what I'd change. Validating the bindings once outside the request path, or letting /api/health answer before the bindings are built, would separate the two.

secureHeaders's development parameter is dead. hosted/server/worker.ts:24 is the only call site and passes one argument; hosted/server/dev.ts never calls it at all. So the if (!development) branch that strips HSTS and CSP is unreachable, and reads as if a development path exists that skips them. Drop the parameter and unconditionally set both.

Two smaller ones: refresh() in hosted/src/App.tsx awaits getSession() and getProviders() together, so a failure of the providers call alone means setSession never runs and an authenticated user is shown the sign-in form under an error banner — worth settling the session independently. And vendor/build.json records "dirty": true, which hosted/server/tests/artifacts.test.ts never reads and no FAIL IF in docs/specs/security-hosted.md covers; the PR body and hosted/README.md both say a dirty snapshot must be replaced before activation, but per AGENTS.md that makes it a claim rather than a check.

Comment thread hosted/src/api.ts
Comment on lines +1 to +2
export const providers = ["github", "google", "microsoft", "apple"] as const;
export type Provider = (typeof providers)[number];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provider allowlist has two owners: this list and providerIds in hosted/server/policy.ts. getProviders then filters the server's answer through the frontend copy, so a provider enabled in OAUTH_PROVIDERS but missing here never renders a button and reports nothing — the server says it is on, the UI silently drops it.

Importing the server's list instead also makes providerNames below a compile-time exhaustiveness check: adding a fifth provider server-side fails the typecheck until its display name exists. policy.ts's only import is import type, so nothing extra reaches the browser bundle.

Suggested change
export const providers = ["github", "google", "microsoft", "apple"] as const;
export type Provider = (typeof providers)[number];
import { providerIds, type ProviderId } from "../server/policy";
export const providers = providerIds;
export type Provider = ProviderId;

If importing server/ from src/ is a layering line you'd rather not cross, a hosted/shared/providers.ts that both sides import does the same job.

@nedtwigg nedtwigg changed the title Add isolated Hosted accounts with pgstencil Better Auth Add Hosted accounts, isolated PR previews, and verified releases Sep 11, 2026

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.

Notes on the new preview/release delta only; the points from the previous review still stand where the delta didn't touch them.

Preview teardown is gated behind the same human approval as deployment. The cleanup job declares environment: hosted-preview, and setup-github.mjs configures all three environments with two required reviewers and can_admins_bypass: false. A required reviewer blocks every job that names the environment, so closing a PR deletes nothing until someone approves a deployment on a thread that has already closed — and GitHub auto-rejects an unapproved deployment after 30 days, failing the job. The Worker, Hyperdrive and Neon branch then outlive the PR with no signal anyone is watching for. DEPLOYMENT.md prepares the operator for the failure they'd see ("Rerun failed cleanup; already-absent resources are tolerated") but not for a teardown that never starts. Gating the deploy is the environment's whole purpose; gating the teardown reads as a consequence of reusing it rather than a decision. Separating cleanup into its own reviewer-free environment holding only the delete-scoped credentials would keep the approval where it buys something.

Production smoke has no propagation retry, and the preview path has one. preview-smoke.mjs's CLI wraps smoke in six attempts at ten-second intervals, with the comment that a just-uploaded Worker takes time to become reachable everywhere. production.mjs's smoke action calls it once. If that transient lands, the deploy has already applied migrations and uploaded the Worker, so production is live while the job goes red and tag is skipped — leaving a live revision with no deployment tag. Recovery is rerunning the failed deploy job, which repeats the dump, the restore verification and the migration apply to get back to a state the first run already reached. The same retry the preview path uses would cost nothing here.

The container that reads the decrypted production dump is pinned to a mutable tag. production-backup.mjs sets image = "postgres:17.11-alpine", and that container receives restored.dump — the plaintext production database, users and sessions included — over docker cp. Every GitHub Action in both new workflows is pinned to a 40-hex SHA, so the one component here that sees production data in the clear has the weakest pin in the pipeline. A digest (postgres:17.11-alpine@sha256:…) would match the convention the rest of the file already follows.

@@ -0,0 +1,147 @@
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copyFile is pulled in with a dynamic await import("node:fs/promises") at the bottom of the script, from the module already imported statically here. Add it to this line and drop the dynamic import below.

Suggested change
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
import { mkdtemp, writeFile, mkdir, rm, copyFile } from "node:fs/promises";

Comment on lines +133 to +134
const { copyFile } = await import("node:fs/promises");
await copyFile(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { copyFile } = await import("node:fs/promises");
await copyFile(
await copyFile(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants