diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index 49ab208b8..d4a7b01cd 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -4,6 +4,7 @@ - `docs/specs/security-local.md` - `docs/specs/security-remote.md` +- `docs/specs/security-hosted.md` **Output file:** `audit-application.md` @@ -11,6 +12,11 @@ This is a code-and-specs audit of the product's own boundaries — the remote control stack, and the local application. You need no GitHub API access and no PAT — do not use one. +For Hosted accounts, read `docs/specs/hosted.md`, `hosted/server/`, the packed +core/auth modules in `vendor/`, and `hosted/src/`. Verify the archive hashes +against `vendor/build.json`. Distinguish tested code from pending production +configuration; do not treat local provider simulations as live OAuth acceptance. + Read, at minimum: `docs/specs/remote-security-model.md` **and its paired `docs/specs/remote-security-model.rationale.md`**, `docs/specs/relay.md`, `docs/specs/remote-api.md`, `docs/specs/pocket-app.md`, `SELF_HOST.md`, and then diff --git a/.github/workflows/hosted-preview.yml b/.github/workflows/hosted-preview.yml new file mode 100644 index 000000000..a438050c8 --- /dev/null +++ b/.github/workflows/hosted-preview.yml @@ -0,0 +1,170 @@ +name: Hosted PR preview + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + contents: read + +# Let provisioning finish before cleanup/redeploy; canceling halfway leaks resources. +concurrency: + group: hosted-pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + changes: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + hosted: ${{ steps.paths.outputs.hosted }} + steps: + # Build the PR merge revision, including Hosted files added to its base. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Detect changes across the full PR + id: paths + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + run: | + # GitHub caps this API at 3,000 files. Verify conservatively above the cap. + if [ "$CHANGED_FILES" -gt 3000 ]; then + echo "hosted=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[] | .filename, (.previous_filename // empty)' > "$RUNNER_TEMP/hosted-files" + node hosted/scripts/changed.mjs "$RUNNER_TEMP/hosted-files" >> "$GITHUB_OUTPUT" + + verify: + needs: changes + if: needs.changes.outputs.hosted == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # Build the PR merge revision, including Hosted files added to its base. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:hosted + - run: pnpm build:hosted + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: preview-assets + path: hosted/dist + retention-days: 3 + - if: vars.HOSTED_PREVIEWS_ENABLED != 'true' + run: echo '::notice::Cloud previews are not configured yet. Follow hosted/DEPLOYMENT.md, set HOSTED_PREVIEWS_ENABLED=true, then rerun this workflow.' + + deploy: + needs: verify + # Never expose deployment credentials to fork code or pull_request_target. + if: >- + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + vars.HOSTED_PREVIEWS_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: hosted-preview + url: ${{ steps.deploy.outputs.url }} + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + BUILD_SHA: ${{ github.sha }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_WORKERS_SUBDOMAIN: ${{ vars.CLOUDFLARE_WORKERS_SUBDOMAIN }} + steps: + # Build the PR merge revision, including Hosted files added to its base. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: preview-assets + path: hosted/dist + - name: Check preview settings before creating resources + run: | + node --input-type=module <<'JS' + import { required } from './hosted/scripts/preview.mjs'; + for (const name of ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_WORKERS_SUBDOMAIN', + 'NEON_PROJECT_ID', 'NEON_PREVIEW_PARENT_BRANCH', + 'CLOUDFLARE_API_TOKEN', 'NEON_API_KEY', 'PREVIEW_AUTH_SECRET']) + required(process.env, name); + JS + env: + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} + NEON_PREVIEW_PARENT_BRANCH: ${{ vars.NEON_PREVIEW_PARENT_BRANCH }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + PREVIEW_AUTH_SECRET: ${{ secrets.PREVIEW_AUTH_SECRET }} + - name: Create or reuse PR database branch + id: database + uses: neondatabase/create-branch-action@fb620d43d4c565abaf088b848a4e28e5c4ea4d9c # v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + parent_branch: ${{ vars.NEON_PREVIEW_PARENT_BRANCH }} + branch_name: dormouse-hosted-pr-${{ github.event.pull_request.number }} + api_key: ${{ secrets.NEON_API_KEY }} + # Use the plan default (5 minutes); Free rejects custom timeouts. + suspend_timeout: 0 + - name: Apply and validate SQL migrations on this PR's database + run: pnpm --filter dormouse-hosted db:migrate --preview && pnpm --filter dormouse-hosted db:validate --preview + env: + DATABASE_URL: ${{ steps.database.outputs.db_url }} + - name: Deploy Worker and Hyperdrive + id: deploy + run: pnpm --filter dormouse-hosted preview:deploy + env: + DATABASE_URL: ${{ steps.database.outputs.db_url }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PREVIEW_AUTH_SECRET: ${{ secrets.PREVIEW_AUTH_SECRET }} + - name: Check deployed revision, database, cookies and routes + run: pnpm --filter dormouse-hosted preview:smoke "$PREVIEW_ORIGIN" "$BUILD_SHA" + env: + PREVIEW_ORIGIN: ${{ steps.deploy.outputs.url }} + + cleanup: + if: >- + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + vars.HOSTED_PREVIEWS_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: hosted-preview + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + - name: Remove this PR's Worker, Hyperdrive and Neon branch + run: node hosted/scripts/preview.mjs cleanup + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml new file mode 100644 index 000000000..477f1b671 --- /dev/null +++ b/.github/workflows/hosted-production.yml @@ -0,0 +1,120 @@ +name: Hosted production release +on: + workflow_dispatch: + inputs: + promote: + description: Deploy to hosted.dormouse.sh after verification + type: boolean + default: false +permissions: + contents: read +concurrency: + group: hosted-production + cancel-in-progress: false +jobs: + verify: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:hosted + - run: pnpm build:hosted + - name: Require accepted package provenance + run: node --input-type=module -e 'import { verifyPackages } from "./hosted/scripts/production.mjs"; await verifyPackages();' + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hosted-production-assets + path: hosted/dist + if-no-files-found: error + retention-days: 3 + deploy: + needs: verify + if: inputs.promote + runs-on: ubuntu-latest + timeout-minutes: 25 + environment: + name: hosted-production + url: https://hosted.dormouse.sh + outputs: + verified-at: ${{ steps.live.outputs.verified-at }} + deployment-id: ${{ steps.live.outputs.deployment-id }} + env: + BUILD_SHA: ${{ github.sha }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + HYPERDRIVE_ID: ${{ vars.HYPERDRIVE_ID }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: hosted-production-assets + path: hosted/dist + - name: Validate production identity, uncached Hyperdrive and Worker secrets + run: node hosted/scripts/production.mjs preflight + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + - name: Install backup encryption tool + run: sudo apt-get update -qq && sudo apt-get install -y age + - name: Back up and verify decryption and database restore + run: node hosted/scripts/production-backup.mjs + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_AGE_IDENTITY: ${{ secrets.BACKUP_AGE_IDENTITY }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hosted-backup-${{ github.run_id }}-${{ github.run_attempt }} + path: hosted/.wrangler/production-backup/*.age + if-no-files-found: error + retention-days: 30 + - name: Apply and validate production migrations + run: pnpm --filter dormouse-hosted db:migrate && pnpm --filter dormouse-hosted db:validate + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + - name: Deploy verified build + run: node hosted/scripts/production.mjs deploy + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + - name: Verify live production revision and auth boundary + id: live + run: | + node hosted/scripts/production.mjs smoke + echo "verified-at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + echo "deployment-id=$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + tag: + needs: deploy + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: hosted-release-tag + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + - name: Record the verified Hosted deployment + run: node hosted/scripts/production-tag.mjs + env: + # GITHUB_TOKEN cannot bypass the repository's admin-only tag ruleset. + GH_TOKEN: ${{ secrets.HOSTED_TAG_TOKEN }} + BUILD_SHA: ${{ github.sha }} + DEPLOYMENT_VERIFIED_AT: ${{ needs.deploy.outputs.verified-at }} + DEPLOYMENT_ID: ${{ needs.deploy.outputs.deployment-id }} diff --git a/.gitignore b/.gitignore index fbc49a524..c18263b27 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,11 @@ website/public/guide/ # Environment .env.local +hosted/.env +hosted/.dev.vars* +hosted/.wrangler/ +hosted/.pgstencil/ +hosted/dist-worker/ # Storybook / Chromatic storybook-static/ diff --git a/AGENTS.md b/AGENTS.md index 61edb5cf1..aaae68016 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ marking a PR ready for review is what spends them. - **`vscode-ext/`** — VS Code extension wrapping the lib in a webview (esbuild; node-pty via forked child process) - **`website/`** — Marketing site (Vite) bundling part of the lib as an interactive demo on `FakePtyAdapter` - **`relay/`** — Selfhost coordinating Relay for remote control (Hono): accounts + passkey auth in local JSON files (no database), WebSocket routing between Clients and Burrows, serves the built Pocket app +- **`hosted/`** — Separate Hosted account frontend and Hono Worker; packed pgstencil Better Auth, Postgres, and provider configuration. - **`dor/`** — The `dor` CLI (stricli) staged onto the `PATH` of every Dormouse-launched terminal; talks to its host over a private control socket - **`remote-lib-common/`** — Security primitives + remote wire contract shared by `relay`, the Burrow module in `lib`, and the Pocket app (bare ES2022 — no DOM or Node types) - **`dor-lib-common/`** — Cross-platform external-process spawning (`spawnAndCapture`) shared by `dor` and the `lib` host. Despite the parallel names, the two `*-lib-common` packages are unrelated: `remote-lib-common` is remote security/wire, `dor-lib-common` is spawn plumbing. @@ -72,6 +73,8 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/remote-security-model.md`** — Remote-control trust model: one Noise channel per ceremony, passkeys proving presence inside it, per-Burrow Client statics, the Burrow (not the Relay) authorizing the pair. Read first for anything remote. - **`docs/specs/remote-api.md`** — What an authorized Client speaks: the shipped terminal-only **protocol-v1** and the staged remainder. - **`docs/specs/relay.md`** — The selfhost coordinating Relay and shared Burrow-service runtime: env config, JSON-file state, WebAuthn without a library, HTTP API, relay flow, enrollment, running it end to end. +- **`docs/specs/hosted.md`** — Hosted accounts: application boundary, login/linking policy, local development, and staged paid services. +- **`docs/specs/security-hosted.md`** — Hosted account origin, identity, and deployment security checks. - **`SELF_HOST.md`** (repo root) — Self-host deployment: the assistant-run install runbook plus the Installer contract that `docs/specs/security-remote.md`'s `FAIL IF` lines and `scripts/deploy-lint.mjs` audit. - **`docs/specs/pocket-app.md`** — Pocket: the remote session is a `PlatformAdapter` (`RemotePtyAdapter`), so Pocket is auth screens plus the mobile composition; owns the same-origin deployment rule. - **`docs/specs/deploy.md`** — Release process: artifact matrix, release checklist, two-stage sign-and-release pipeline, updater manifest, changelog flow. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index cc73fe3be..f01792fb1 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -174,6 +174,10 @@ Source of truth: `create_release` in `scripts/sign-and-deploy.sh`; `website/scri `docs/specs/security-ci.md` -> "Desktop Releases" owns the argv-exposure rules for the three prompted secrets. +## Hosted account releases + +See `docs/specs/hosted.md` -> "Production releases" for the Hosted pipeline and `hosted/DEPLOYMENT.md` for provisioning and operator commands. + ## Future **Analytics-backed download URLs.** The `/latest/download/` hotlinks could move to `dormouse.sh/download/...` behind Cloudflare R2. Changing website links and manifest bundle URLs needs no app update while the manifest endpoint remains stable. diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md new file mode 100644 index 000000000..52f45fd05 --- /dev/null +++ b/docs/specs/hosted.md @@ -0,0 +1,75 @@ +# Dormouse Hosted accounts + +> See `docs/specs/glossary.md` for Burrow, Client, Relay, and Session vocabulary. +> Owns the Hosted account application. Remote authorization belongs to `docs/specs/remote-security-model.md`; the multi-tenant Relay remains in `docs/specs/relay.md` -> Future. + +## Application boundary + +**Must serve the account frontend and its API from `https://hosted.dormouse.sh`.** The Hono Worker serves Vite assets and pgstencil's request-scoped Better Auth adapter. Requests addressed to another origin receive 421. Marketing remains a separate bundle and deployment; no marketing component is imported. + +**Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding. The runtime creates and closes its database pool within each request. + +**Must pin locally packed core/auth packages through root pnpm overrides and commit archives, provenance, and lockfile together.** `vendor/build.json` records the source commit, dirty state, and archive hashes. No runtime import depends on a sibling checkout. The auth migrations remain owned by the package. + +Source of truth: `auth` in `hosted/server/worker.ts`; `workerApp` in `hosted/server/worker-app.ts`; `migrations` in `hosted/server/migrations.ts`; `scripts/sync-pgstencil.mjs`. + +## Identity and login + +**Must retain independent simultaneous browser logins.** Login lifetime is 24 hours without refresh or cookie caching; logout revokes only the current login. These authentication records are not terminal Sessions. + +**Must require explicit provider connection from a login less than ten minutes old.** The callback must retain that same live login. Matching email alone never connects an unbound OAuth identity. Different verified provider emails are allowed; an identity already attached to another account cannot be claimed. + +**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. There is no merge, email adoption, unlink, or account-recovery interface. + +**Must identify accounts by immutable user ID, never email.** Provider-only accounts keep their identity when a provider subsequently supplies email. + +**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. + +**Must discard provider tokens after identity verification and omit login tokens from browser JSON.** Cookies and upstream identity verification follow the packed adapter; `hosted/server/tests/workers.test.ts` pins the consumer's browser contract in workerd with real Postgres and simulated providers. + +Source of truth: `authPolicy` / `providerBindings` in `hosted/server/policy.ts`; `App` in `hosted/src/App.tsx`. + +## Interface + +**Must show configured sign-in methods only.** Email has send, existing-code, verify, resend, and change-address paths. The account screen lists connected methods and explains recent-login requirements and provider-only recovery limits. Failed callbacks display a recoverable error and remove query parameters from browser history. + +**Must check the account on return to the page and serialize submitted actions.** Authenticated data remains in memory; login tokens never enter local storage. Only public identity fields are rendered, without provider images or external assets. + +**Must inherit Dormouse product theme tokens before mounting React.** The OS light/dark preference selects bundled Light Visual Studio or Kimbie Dark. Hosted uses a narrow single-column form, 44px controls, 16px inputs, and 13px body copy; its page heading is 18px. It loads no marketing styles, fonts, or analytics. + +Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/main.tsx`; `hosted/src/style.css`. + +## Development and release + +**Must run local development with `dor ensure -- pnpm dev:hosted` inside Dormouse.** A single loopback origin serves Vite and Node auth, with a disposable development database. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; the production entry imports no inbox or test-control handler. + +**Must verify the production Worker bundle and run the consumer's integration suite before release.** The test entry alone injects the actual 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` and `hosted/DEPLOYMENT.md`. + +Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `hosted/server/tests/workers.test.ts`; `hosted/wrangler.jsonc`. + +## PR previews + +**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 a persistent Worker, uncached Hyperdrive, and Neon branch from an empty dedicated preview project.** Reuse `dormouse-hosted-pr-N` until close. No production database is copied. The preview config excludes production routes and credentials; runtime bindings cannot enable OAuth or Postmark. + +**Must capture preview mail in Postgres and expose escaped text only.** The public inbox shows the newest 100 messages from the last 24 hours, prunes expired rows on capture, and accepts only the preview's configured origin. No test clock is deployed. Preview data is disposable; it is not access-controlled. + +Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfig` / `cleanup` in `hosted/scripts/preview.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. + +## Production releases + +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** Preflight checks archive hashes, uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and required Worker secret names. Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Production has no public candidate URL. + +**Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification.** Dates use America/Los_Angeles; later deployments use numeric `--r2`, `--r3` suffixes. Tags identify the deployed commit and verification run/attempt. Tag retries are idempotent; redeployments get new tags. Code rollback never reverses migrations. + +Source of truth: `.github/workflows/hosted-production.yml`; `verifyPackages` / `preflight` in `hosted/scripts/production.mjs`; `hosted/scripts/production-backup.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs` and `hosted/scripts/production-tag.test.mjs`. + +## Future + +1. Complete separate Dormouse OAuth registrations, Postmark sender, Postgres/Hyperdrive provisioning, and real production acceptance. Microsoft callback diagnosis and shared logging are coordinated in pgstencil separately. +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. Hosted ElevenLabs: desktop authorization, scoped revocable credentials, quotas, usage accounting, spending bounds, and explicit text/redaction disclosure. +4. Hosted Relay: follow the **saas-multitenant** scope in `docs/specs/relay.md`; account login never replaces Burrow pairing and authorization. Paid security claims retain the independent-review precondition. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 1c4d9df7b..99426b4db 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -1070,9 +1070,10 @@ single-owner selfhost Relay and a multi-tenant SaaS on `*.dormouse.sh`, including the Bring-Your-Own-Tailnet (BYOT) posture that puts the relay inside a customer's own tailnet without a custom client build. The wire API and security model are unchanged from selfhost ([remote-api.md](./remote-api.md), Transport); -everything here is deployment and relay plumbing beneath them. The SaaS account -model (email + passkey self-serve signup) is this scope's own — **Accounts** -below. Front-door work staged elsewhere and not restated: CloudFlare routing + +everything here is deployment and relay plumbing beneath them. Hosted account +identity lives in [hosted.md](./hosted.md); this scope adds Relay tenant ownership +and passkey enrollment — **Accounts** below. Front-door work staged elsewhere +and not restated: CloudFlare routing + Pocket static serving in [pocket-app.md](./pocket-app.md) `## Future`. Framing invariant: Tailscale is network-layer defense-in-depth *under* the @@ -1090,8 +1091,8 @@ Selfhost (everything above the fold) stays as-is; SaaS is a parallel deployment that lifts each single-tenant simplification, every one chosen to be liftable: * **Accounts.** One `accountId: "owner"` behind a shared setup password becomes - many accounts, each created by email + - passkey. The two hand-edited JSON files (`account.json`, `burrows.json`) become + many Hosted account IDs with enrolled passkeys. The two hand-edited JSON files + (`account.json`, `burrows.json`) become a real per-tenant store with per-tenant revocation, and Burrow enrollment moves from the global setup password to the authenticated account. * **Relay tenant-scoping (an invariant, not a check).** The relay binds one Burrow diff --git a/docs/specs/security-audit.md b/docs/specs/security-audit.md index 290ca4f8d..7563a360d 100644 --- a/docs/specs/security-audit.md +++ b/docs/specs/security-audit.md @@ -23,7 +23,7 @@ |---|---| | `supply-chain` | `docs/specs/security-supply-chain.md` | | `ci-and-secrets` | `docs/specs/security-ci.md`, `docs/specs/security-audit.md`, `docs/specs/security.md` | -| `application-security` | `docs/specs/security-local.md`, `docs/specs/security-remote.md` | +| `application-security` | `docs/specs/security-local.md`, `docs/specs/security-remote.md`, `docs/specs/security-hosted.md` | **The separation is one of context, not of credential.** `AUDIT_PAT` is a step-level `env:` on the one job, so every subagent inherits it, and only the prompt tells `application-security` not to use it. **Known gap:** a prompt is not a control; a real separation needs a second job outside the `security-audit` environment, passing fragments as artifacts — worth doing, not done. Three contexts each *read* less; none *holds* less. @@ -40,7 +40,7 @@ - `application-security` — **everything else**, worked out from `ls -A` rather than from a list, including `.impeccable/`. **Dotfile directories are named explicitly wherever they land**, here and in the prompt files. **The subtraction is recursive**: where another domain claims a subdirectory rather than a whole tree — as both do inside `website/` — the remainder of that tree belongs here. - **FAIL IF** a `docs/specs/security*.md` spec is in no domain's scope, or in two, or a scope names a file that does not exist (rationale). -- **FAIL IF** the audit stops fanning out to a dedicated `application-security` subagent scoped to `docs/specs/security-local.md` and `docs/specs/security-remote.md`, or that scope is merged back into a context that also carries the supply-chain or CI domains (rationale). +- **FAIL IF** the audit stops fanning out to a dedicated `application-security` subagent scoped to the application specs in the Domains table, or that scope is merged back into a context that also carries the supply-chain or CI domains (rationale). - **FAIL IF** `application-security` does not run on a stronger model than the mechanical domains, in **both** `.github/workflows/security-audit.yaml`'s `--agents` and `scripts/security-audit-local.sh` (rationale). - **FAIL IF** `.github/audit/` is missing a prompt file the workflow names, or `scripts/security-audit-local.sh` stops running the audit from those same files (rationale). - **FAIL IF** the union of the subagents' qualitative scopes does not cover every top-level path in the repository (rationale). diff --git a/docs/specs/security-ci.md b/docs/specs/security-ci.md index cabcaf8df..9b822e9e4 100644 --- a/docs/specs/security-ci.md +++ b/docs/specs/security-ci.md @@ -62,7 +62,7 @@ This repository runs the [tend](https://github.com/max-sixty/tend) agent harness - **FAIL IF** `.github/workflows/workflow-audit.yaml` starts deriving its lower bound from anything the pusher controls; it must stay the previous successful run's server-set `created_at` (rationale). The `--since` filter is the known evasion above. - **FAIL IF** either admin-gating ruleset is missing or weakened. `Merge access` must target `~DEFAULT_BRANCH`, block nothing beyond `update`, and carry admin (`RepositoryRole` actor `5`) as its sole bypass actor; `Tag operations` must target `~ALL` tags, block both `creation` and `update`, and carry the same admin-only bypass. - **FAIL IF** `dormouse-bot` holds `maintain` or `admin` on this repository. `GET /collaborators/dormouse-bot/permission` spells `push` as `write` in both `permission` and `role_name`, so check that neither of those two roles appears rather than string-comparing against `push`. -- **FAIL IF** any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the `Tag operations` or `Merge access` rulesets. Today: `vscode-extension-publish` and `release-attest` (`v*` tag, admin-only via `Tag operations`); `security-audit` (`main` admin-only via `Merge access`, plus `v*` tag); `tend` (`main` only, admin-only via `Merge access`). +- **FAIL IF** any GitHub environment except `hosted-preview` admits a ref that is not admin-gated by the `Tag operations` or `Merge access` rulesets. Hosted environments follow "Hosted Deployments" below. Today: `vscode-extension-publish` and `release-attest` (`v*` tag, admin-only via `Tag operations`); `security-audit` (`main` admin-only via `Merge access`, plus `v*` tag); `tend` (`main` only, admin-only via `Merge access`). - **FAIL IF** the secret inventory departs from this placement (rationale). One pass over `actions/secrets`, `actions/organization-secrets`, and each environment's secret listing answers every line: - `AUDIT_PAT` — in `security-audit`, absent at repo level. - `TEND_BOT_TOKEN` — in `tend`, absent at repo level. @@ -80,6 +80,17 @@ This repository runs the [tend](https://github.com/max-sixty/tend) agent harness Source of truth: `WINDOW` and `is_tend_regen` in `.github/workflows/workflow-audit.yaml`. +## Hosted Deployments + +**Must keep Hosted credentials in dedicated environments.** `hosted-production` and `hosted-release-tag` admit only `main`; `hosted-preview` admits only `main` and `refs/pull/*/merge`. All require Ned or Edgar's review with administrator bypass disabled; self-review is allowed. Preview approval authorizes the PR code to receive test-resource credentials only. + +- **FAIL IF** a Hosted environment lacks those branch restrictions, required reviewers, or disabled administrator bypass; inspect all three environments and their deployment policies. +- **FAIL IF** Hosted credentials appear at repository/org scope, production credentials appear in `hosted-preview`, or preview credentials can reach production/TTR/marketing resources. Inspect GitHub secret placement and Cloudflare/Neon token scope; names alone do not isolate resources. +- **FAIL IF** `HOSTED_TAG_TOKEN` appears outside `hosted-release-tag`, or that environment is used by a job other than `tag` in `.github/workflows/hosted-production.yml`. Its admin identity's repository-scoped Contents-write PAT can write code and bypass tag protection; it must never enter a deployment job or PR execution. +- **FAIL IF** a Hosted preview deploy accepts a fork or a failing verification, or a Hosted production tag can run before live verification succeeds; inspect the workflow dependency/condition graph. + +Source of truth: `hosted/scripts/setup-github.mjs`; `.github/workflows/hosted-preview.yml`; `.github/workflows/hosted-production.yml`. + ## VS Code Extension Releases The extension is published by GitHub Actions, and the publishing secrets `VSCE_PAT` and `OVSX_PAT` live only in a protected GitHub environment. **Must require human approval from an account other than the triggering account**, with administrator bypass disabled. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md new file mode 100644 index 000000000..debe93f05 --- /dev/null +++ b/docs/specs/security-hosted.md @@ -0,0 +1,36 @@ +# Hosted account security + +> See `docs/specs/glossary.md` for Burrow, Client, and Relay vocabulary. +> Owns the account application's security checks. Defers identity behavior to `docs/specs/hosted.md` and terminal access to `docs/specs/remote-security-model.md`. +> Read `docs/specs/security.md` first. Provisioning and real-provider acceptance remain pending. + +## Origin boundary + +- **FAIL IF** Hosted accepts a request URL outside configured `APP_ORIGIN`, grants marketing-origin credentialed CORS, or permits a state-changing auth request without exact Origin and CSRF checks; inspect `hosted/server/worker-app.ts` and the packed adapter. +- **FAIL IF** authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. +- **FAIL IF** the production HTML permits third-party scripts, framing, inline script execution, or caching account API responses; inspect `secureHeaders` in `hosted/server/headers.ts` and Worker asset routing in `hosted/wrangler.jsonc`. +- **FAIL IF** marketing scripts, analytics, provider avatars, or remote fonts enter the Hosted frontend; inspect the frontend import graph and deployed response when available. Cloudflare script injection must be excluded for the Hosted hostname at provisioning. + +Pinned by `hosted/server/tests/workers.test.ts`. + +## Account boundary + +- **FAIL IF** the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter. +- **FAIL IF** an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`. +- **FAIL IF** Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. No Hosted endpoint currently implements terminal access. + +Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.test.ts`. + +## Deployment boundary + +- **FAIL IF** a production Worker exposes the captured-email inbox or deterministic clock controls, or imports the testing injection module; inspect `hosted/server/worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`. +- **FAIL IF** an archive's SHA-256 differs from `vendor/build.json`, the core/auth pnpm overrides cease resolving to those archives, or a runtime import depends on a sibling source checkout. +- **FAIL IF** the local email inbox accepts a foreign Host or Origin or cross-site Fetch Metadata; inspect `allowedDevRequest` in `hosted/server/dev-host-guard.ts`, including the upgrade guard in `hosted/server/dev.ts`. + +- **FAIL IF** preview mail or OAuth calls reach external providers, preview configuration copies production routes/bindings, or a preview exposes deterministic time controls; inspect `hosted/server/preview-worker.ts`, `hosted/scripts/preview.mjs`, and `hosted/server/tests/workers.test.ts`. + +Production activation must verify uncached Hyperdrive, separate credentials, and excluded marketing injection using `hosted/README.md`; checked-in placeholders do not prove those external controls. + +## Future + +Public hosted voice and Relay require their own abuse, authorization, data-disclosure, and recovery checks before activation; `docs/specs/hosted.md` owns the staged work. diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index 30cb0bb1a..97d87d46d 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -26,10 +26,11 @@ The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. **Must list `dormouse-lib` as a root independently of workspace edges**; `remote-lib-common` and `dor-lib-common` are workspace edges from those roots. **Must use package names for roots and exclusions**; for example, `vscode-ext/` declares itself `dormouse` and `website/` declares itself `dormouse-website`. -**Two workspace packages are deliberately not roots:** +**Must exclude workspaces that install no artifact:** - `canopy` — a Storybook-only rendering lab no shipped build imports. - `website` — runs in a visitor's browser rather than being installed anywhere, which is what makes "puts on a user's machine" the operative test (rationale). +- `dormouse-hosted` — runs on Workers and in the browser; no installed desktop or selfhost artifact imports it. **External binaries are outside this graph by construction** — the user's shell, and the `agent-browser` CLI `dor ab` forwards to (`npm i -g agent-browser`, a dependency of nothing here, resolved off `PATH`). **Dormouse instead ships nothing that pulls them in silently** (rationale). diff --git a/docs/specs/security.md b/docs/specs/security.md index 6b107af8f..f28f4629f 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -3,7 +3,7 @@ > See `docs/specs/glossary.md` for Session, Pane, Surface, and remote-role vocabulary. > Owns the guarantees Dormouse makes, what it does not defend, the gaps it > knows about, and how all of it is checked. Defers every mechanism to the spec -> that owns it, and every audited check to the five specs under +> that owns it, and every audited check to the specs under > [How the guarantees are checked](#how-the-guarantees-are-checked). Published > at `https://dormouse.sh/docs/security`, whole but for the three blocks split > by audience; `docs/specs/website-docs.md` owns the page. @@ -13,7 +13,9 @@ Dormouse holds shells, source trees, credentials, and local files. Its **remote control** admits an authorized phone as a person at the keyboard; **loopback listeners** receive requests from pages in the user's browser. -**Only the self-hosted deployment ships.** The relay runs on hardware the user +**Only the self-hosted remote-control deployment ships.** Hosted account code is +implemented with production provisioning pending ([Hosted accounts](./hosted.md)); +it grants no terminal access. The relay runs on hardware the user owns and is private to their tailnet by default, but its application boundary assumes the HTTPS origin is public ([SELF_HOST.md](../../SELF_HOST.md)). **Nothing about remote control applies to a Burrow (a Standalone or VS Code @@ -152,7 +154,7 @@ ones are the record of what tripped and what changed. | Domain | Specs | Covers | | --- | --- | --- | -| `application-security` | [security-local.md](./security-local.md), [security-remote.md](./security-remote.md) | the local application's boundaries, remote control, and every path no other domain claims | +| `application-security` | [security-local.md](./security-local.md), [security-remote.md](./security-remote.md), [security-hosted.md](./security-hosted.md) | local boundaries, remote control, Hosted accounts, and every path no other domain claims | | `supply-chain` | [security-supply-chain.md](./security-supply-chain.md) | the dependency graph, the lockfile, the disclosure and its generator | | `ci-and-secrets` | [security-ci.md](./security-ci.md), [security-audit.md](./security-audit.md), this spec | GitHub Actions, the bot, releases, secrets, and the audit itself | diff --git a/hosted/.impeccable/design.json b/hosted/.impeccable/design.json new file mode 100644 index 000000000..02bb47c26 --- /dev/null +++ b/hosted/.impeccable/design.json @@ -0,0 +1,75 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-09-11T22:45:00Z", + "title": "Design System: Dormouse Hosted", + "extensions": { + "breakpoints": [{ "name": "compact", "value": "540px" }] + }, + "components": [ + { + "name": "Primary button", + "kind": "button", + "refersTo": "button-primary", + "description": "Full-width email form action.", + "html": "", + "css": ".ds-primary{width:100%;min-height:44px;padding:9px 14px;border:0;border-radius:4px;background:var(--vscode-list-activeSelectionBackground);color:var(--vscode-list-activeSelectionForeground);font:13px/1.6 var(--vscode-editor-font-family);cursor:pointer}.ds-primary:hover:not(:disabled){filter:brightness(1.15)}.ds-primary:focus-visible{outline:2px solid var(--vscode-focusBorder);outline-offset:3px}.ds-primary:disabled{opacity:.55;cursor:default}" + }, + { + "name": "Secondary button", + "kind": "button", + "refersTo": "button-secondary", + "description": "Provider and account actions.", + "html": "", + "css": ".ds-secondary{min-height:44px;padding:9px 14px;border:0;border-radius:4px;background:var(--vscode-list-inactiveSelectionBackground);color:var(--vscode-list-inactiveSelectionForeground);font:13px/1.6 var(--vscode-editor-font-family);cursor:pointer}.ds-secondary:hover:not(:disabled){filter:brightness(1.15)}.ds-secondary:focus-visible{outline:2px solid var(--vscode-focusBorder);outline-offset:3px}.ds-secondary:disabled{opacity:.55;cursor:default}" + }, + { + "name": "Text button", + "kind": "button", + "refersTo": "button-text", + "description": "Inline recovery and form options.", + "html": "", + "css": ".ds-text{min-height:44px;padding:6px 0;border:0;border-radius:4px;background:transparent;color:var(--vscode-textLink-foreground);font:13px/1.6 var(--vscode-editor-font-family);text-decoration:underline;text-underline-offset:3px;cursor:pointer}.ds-text:hover:not(:disabled){filter:brightness(1.15);text-decoration-thickness:2px}.ds-text:focus-visible{outline:2px solid var(--vscode-focusBorder);outline-offset:3px}.ds-text:disabled{opacity:.55;cursor:default}" + }, + { + "name": "Email input", + "kind": "input", + "refersTo": "input", + "description": "Labelled email field with full-opacity placeholder.", + "html": "", + "css": ".ds-label{display:block;margin:16px 0 6px;color:var(--vscode-sideBar-foreground);font:13px/1.6 var(--vscode-editor-font-family)}.ds-input{width:100%;min-height:44px;padding:10px 12px;border:0;border-radius:4px;background:var(--vscode-list-inactiveSelectionBackground);color:var(--vscode-list-inactiveSelectionForeground);font:16px/1.6 var(--vscode-editor-font-family)}.ds-input::placeholder{color:inherit;opacity:1}.ds-input[readonly]{opacity:1}.ds-input:focus-visible{outline:2px solid var(--vscode-focusBorder);outline-offset:3px}" + }, + { + "name": "Inline notice", + "kind": "custom", + "refersTo": "notice", + "description": "Status feedback within the task column.", + "html": "

Code sent. Check your inbox. It expires in 10 minutes.

", + "css": ".ds-notice{margin:0 0 16px;padding:12px;background:var(--vscode-list-inactiveSelectionBackground);color:var(--vscode-list-inactiveSelectionForeground);font:13px/1.6 var(--vscode-editor-font-family)}" + } + ], + "narrative": { + "northStar": "The Native Tenant", + "overview": "Hosted inherits [Dormouse's product world](../PRODUCT.md) and [parent design system](../DESIGN.md): focused, approachable, capable, with monospace text and quiet, flat controls. This app-boundary record captures the implemented account interface and its browser/mobile sizing exceptions; the parent owns shared product identity.", + "keyCharacteristics": [ + "Runtime theme colors, including matched foreground/background pairs.", + "A narrow task column with open spacing between sections.", + "Mobile-sized controls with compact monospace labels.", + "Inline feedback and explicit account connection states." + ], + "rules": [ + { "name": "The Matched Pair Rule", "body": "Keep every control foreground paired with its theme background. Inputs, placeholders, and readonly values retain full foreground opacity.", "section": "colors" }, + { "name": "The Runtime Palette Rule", "body": "Inherit colors through the existing theme variables; do not introduce fixed colors or synthetic tonal ramps.", "section": "colors" }, + { "name": "The Browser Form Rule", "body": "Preserve the input role's larger type and a minimum control height of 44px for buttons and inputs, including text buttons. These are Hosted account-form exceptions to the parent's terminal chrome sizing.", "section": "typography" } + ], + "dos": [ + "Do inherit the parent theme and monospace identity.", + "Do preserve full foreground opacity in editable, placeholder, and readonly input text.", + "Do keep connection state and progress visible in action labels." + ], + "donts": [ + "Don't import the marketing site's palette, imagery, or typography into account controls.", + "Don't add shadows or decorative card wrappers to this flat account surface.", + "Don't reduce form controls to the parent's terminal chrome dimensions." + ] + } +} diff --git a/hosted/DEPLOYMENT.md b/hosted/DEPLOYMENT.md new file mode 100644 index 000000000..c958d8c59 --- /dev/null +++ b/hosted/DEPLOYMENT.md @@ -0,0 +1,171 @@ +# Hosted deployments + +The account application is `hosted.dormouse.sh`. Marketing, desktop releases, +Hosted production, and PR previews have separate deployment credentials. +[README.md](README.md) owns provider registration and real-login acceptance. +No cloud resources or real-provider acceptance are implied by a passing local test. + +## Resource inventory + +| Boundary | Resources | +| --- | --- | +| Preview | Dedicated test Cloudflare account with a registered workers.dev subdomain; dedicated empty Neon project and parent branch; GitHub `hosted-preview` environment | +| Each PR | `dormouse-hosted-pr-N` Worker, uncached Hyperdrive, and Neon branch, all reused until close | +| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive, `dormouse-hosted` Worker and `hosted.dormouse.sh` custom domain; GitHub `hosted-production` environment | +| Email | Dedicated Postmark server, verified `signin@hosted.dormouse.sh`, SPF/DKIM/DMARC, Apple Private Email Relay registration | +| OAuth | Separate Dormouse GitHub, Google, Microsoft, and Apple registrations; exact callbacks in README | +| Release history | `hosted-release-tag` GitHub environment, an admin identity's repository-scoped Contents-write fine-grained PAT, immutable annotated `hosted/` tags | +| Recovery | Neon backups/PITR enabled, encrypted pre-migration dumps retained as GitHub artifacts for 30 days, age identity also retained independently in a password manager | + +Cloudflare Workers Scripts and Hyperdrive permissions are account-scoped. A +preview token must not reach production, TTR, or marketing resources. Use a +separate test account. Production deployment isolation also requires a boundary +marketing's existing credentials cannot reach; coordinate the hostname/zone +placement before choosing an account. Naming Workers differently does not limit +a token. Do not reuse TTR's Neon project, mail token, or OAuth registrations. + +## GitHub setup + +Run once from the repository root with the operator's existing `gh` login: + +```sh +node hosted/scripts/setup-github.mjs +``` + +The script creates/updates the three environments. Ned or Edgar must approve +credentialed jobs, including previews; administrator bypass is disabled. +Production and release tagging admit only `main`. Preview admits `main` and +`refs/pull/*/merge`; same-repository PRs alone can deploy. Self-approval is allowed +for an operator's own deployment. Repository branch/tag protections are unchanged. + +### Preview configuration + +In Cloudflare, create the dedicated preview account, register its workers.dev +subdomain, and create a token with **Workers Scripts: Edit** and **Hyperdrive: +Edit**, scoped only to that account. No zone/DNS access is needed. In Neon, +create a dedicated preview project with an empty parent branch, default `neondb` +database and `neondb_owner` role. Use a project-scoped API key where supported. +Record the project and parent branch IDs; never select a production parent. + +These commands prompt invisibly for tokens; the generated auth secret goes +directly to GitHub: + +```sh +gh secret set CLOUDFLARE_API_TOKEN --repo diffplug/dormouse --env hosted-preview +gh secret set NEON_API_KEY --repo diffplug/dormouse --env hosted-preview +openssl rand -hex 32 | gh secret set PREVIEW_AUTH_SECRET --repo diffplug/dormouse --env hosted-preview +``` + +Replace the public placeholders below. The subdomain is just its label, with +no dots, protocol, or `.workers.dev` suffix: + +```sh +gh variable set CLOUDFLARE_ACCOUNT_ID --repo diffplug/dormouse --env hosted-preview --body 'PREVIEW_ACCOUNT_ID' +gh variable set CLOUDFLARE_WORKERS_SUBDOMAIN --repo diffplug/dormouse --env hosted-preview --body 'SUBDOMAIN' +gh variable set NEON_PROJECT_ID --repo diffplug/dormouse --env hosted-preview --body 'PREVIEW_PROJECT_ID' +gh variable set NEON_PREVIEW_PARENT_BRANCH --repo diffplug/dormouse --env hosted-preview --body 'EMPTY_PARENT_BRANCH_ID' +# Enable last, at repository scope so job selection can read it before entering an environment. +gh variable set HOSTED_PREVIEWS_ENABLED --repo diffplug/dormouse --body true +``` + +A PR touching `hosted/`, the workflow, vendored packages, or shared build inputs +runs Hosted tests and builds the exact PR merge revision before provisioning. +Forks verify without credentials. +The changed-files check paginates the entire PR and includes renamed source paths. +The URL is in the deployment environment link and job summary; no PR comment bot +or write-scoped workflow token is needed. Draft PRs receive previews too. + +Open `https://dormouse-hosted-pr-N.SUBDOMAIN.workers.dev/`, request a code for a +disposable address, and read it at `/dev/emails`. This inbox is public to anyone +with the URL. No real email or OAuth provider is contacted. It shows at most 100 +messages from the last 24 hours and renders escaped text only. The database +stores messages across Worker restarts; old mail is pruned on capture. + +New commits retain the URL and test accounts. Migrations are append-only; to +change an already-applied migration, close the PR, wait for successful cleanup, +then reopen. Closing or merging deletes the Worker, Hyperdrive and Neon branch +even if the final diff no longer touches Hosted. Per-PR runs serialize without +canceling in-flight provisioning. Rerun failed cleanup; already-absent resources +are tolerated. Keep previews enabled until all live previews are removed. +Manual cleanup uses `node hosted/scripts/preview.mjs cleanup` with the preview +environment's credentials and `PR_NUMBER`. These credentials cannot be downloaded +back from GitHub; retain independent copies in your password manager. + +### Production configuration + +Follow README's production database, runtime-role, mail, domain, and OAuth +steps. Create a Hyperdrive with query caching disabled using the runtime role; +keep its connection host and database identical to the direct migration URL. +The runtime role needs auth-table/schema DML permissions and sequence access; +the migration role owns migrations and can dump the database. Grant defaults for +future migration-created tables/sequences as well. Never put a connection URL +in a command argument. Keep Neon backups and a suitable PITR window enabled. + +Store the public IDs and deployment/migration credentials: + +```sh +gh variable set CLOUDFLARE_ACCOUNT_ID --repo diffplug/dormouse --env hosted-production --body 'PRODUCTION_ACCOUNT_ID' +gh variable set HYPERDRIVE_ID --repo diffplug/dormouse --env hosted-production --body 'PRODUCTION_HYPERDRIVE_ID' +gh secret set CLOUDFLARE_API_TOKEN --repo diffplug/dormouse --env hosted-production +gh secret set DATABASE_URL --repo diffplug/dormouse --env hosted-production +gh secret set BACKUP_AGE_IDENTITY --repo diffplug/dormouse --env hosted-production +gh secret set HOSTED_TAG_TOKEN --repo diffplug/dormouse --env hosted-release-tag +``` + +`DATABASE_URL` is the direct migration-role Postgres URL. Generate the age +identity with `age-keygen` into private password-manager storage, then enter it +at the hidden prompt; preserve that independent copy and old keys on rotation. +Use a production Cloudflare token covering Workers deployment, Hyperdrive read, +and the custom-domain zone permissions required for that hostname. The tag PAT +belongs to a repository admin and selects only this repository with Contents +write. It can bypass the existing tag ruleset and can also write code, so it is +isolated from the deploy job in a separately approved main-only environment. +Record its expiry; do not grant tag bypass to the bot or Actions generally. + +Runtime auth/mail/OAuth secrets live in the Worker, not GitHub. In your own +terminal, from `hosted/`, authenticate Wrangler to the production account and +run README's hidden `wrangler secret put` commands. The first secret can create +the initial Worker stub; it does not activate account service. Set all required +secrets before release. Replace the checked-in Hyperdrive placeholder for local +operator deployment; CI overrides it with `HYPERDRIVE_ID`. Configure sender and +enabled providers in `wrangler.jsonc`, reviewed in a PR. Deployment preserves +existing Worker secrets and preflight checks required names before migrations. +Microsoft remains disabled until its upstream fix and real callback pass. + +## Release and recovery + +Merge reviewed code to `main`, then run **Hosted production release**. Default +`promote=false` verifies and builds only. `promote=true` enters the protected +production environment, checks package provenance, database identity, uncached +Hyperdrive and required secret names, creates an encrypted dump and verifies its +decryption/restore into disposable PostgreSQL, uploads the encrypted artifact, +applies/validates migrations, deploys, then checks the live revision, database, +CSRF/cookies, provider start URLs and absent preview/test routes. No real mail is +sent by these smoke checks. README's real-provider acceptance is still required. +Production uses no public candidate hostname or workers.dev alias. + +```sh +gh workflow run hosted-production.yml --repo diffplug/dormouse --ref main -f promote=true +``` + +Only successful live verification unlocks tagging. The exact deployed SHA gets +`hosted/YYYY-MM-DD` in America/Los_Angeles time, followed by `--r2`, `--r3`, etc. +Tags are annotated with verification time and workflow run/attempt, never moved +or overwritten. If only tagging fails, rerun failed jobs: it records the original +deployment without deploying again. Retrying a tag reuses it; redeploying the +same commit records a new deployment. These tags do not trigger desktop `v*` +release workflows and are code history, not database backups. + +A failed migration or deployment may already have changed production state; +workflow failure does not automatically reverse it. Use Worker deployment +history for code rollback and investigate database compatibility first. +Restore a backup only after an explicit operator decision, into a separate +database first. Migrations are never rolled back by the release workflow. +Use PostgreSQL 17 for the production database; the backup/restore tooling pins +PostgreSQL 17.11. Preview and auth integration tests exercise disposable databases. + +Current provisioning status is discoverable with `gh secret list --env NAME`, +`gh variable list --env NAME`, and each provider console. Configuration presence +alone is not acceptance. The initial vendored pgstencil manifest is dirty; +production preflight intentionally rejects it until refreshed from an accepted +clean revision with matching archive hashes. diff --git a/hosted/DESIGN.md b/hosted/DESIGN.md new file mode 100644 index 000000000..b9d17becc --- /dev/null +++ b/hosted/DESIGN.md @@ -0,0 +1,145 @@ +--- +name: Dormouse Hosted +description: Theme-adaptive account controls for Dormouse hosted services. +colors: + app-bg: "var(--vscode-sideBar-background)" + app-fg: "var(--vscode-sideBar-foreground)" + action-bg: "var(--vscode-list-activeSelectionBackground)" + action-fg: "var(--vscode-list-activeSelectionForeground)" + control-bg: "var(--vscode-list-inactiveSelectionBackground)" + control-fg: "var(--vscode-list-inactiveSelectionForeground)" + link: "var(--vscode-textLink-foreground)" + focus: "var(--vscode-focusBorder)" + error: "var(--vscode-errorForeground)" +typography: + title: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "18px" + fontWeight: 600 + lineHeight: 1.4 + body: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "13px" + fontWeight: 400 + lineHeight: 1.6 + input: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "16px" + fontWeight: 400 + lineHeight: 1.6 + detail: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "12px" + fontWeight: 400 + lineHeight: 1.6 +rounded: + control: "4px" +spacing: + small: "8px" + medium: "16px" + large: "24px" + section: "32px" +components: + button-primary: + backgroundColor: "{colors.action-bg}" + textColor: "{colors.action-fg}" + rounded: "{rounded.control}" + padding: "9px 14px" + typography: "{typography.body}" + width: "100%" + button-secondary: + backgroundColor: "{colors.control-bg}" + textColor: "{colors.control-fg}" + rounded: "{rounded.control}" + padding: "9px 14px" + typography: "{typography.body}" + button-text: + backgroundColor: "transparent" + textColor: "{colors.link}" + rounded: "{rounded.control}" + padding: "6px 0" + typography: "{typography.body}" + input: + backgroundColor: "{colors.control-bg}" + textColor: "{colors.control-fg}" + rounded: "{rounded.control}" + padding: "10px 12px" + typography: "{typography.input}" + width: "100%" + notice: + backgroundColor: "{colors.control-bg}" + textColor: "{colors.control-fg}" + padding: "12px" + typography: "{typography.body}" +--- + +# Design System: Dormouse Hosted + +## Overview + +**Creative North Star: "The Native Tenant"** + +Hosted inherits [Dormouse's product world](../PRODUCT.md) and [parent design system](../DESIGN.md): focused, approachable, capable, with monospace text and quiet, flat controls. This app-boundary record captures the implemented account interface and its browser/mobile sizing exceptions; the parent owns shared product identity. + +**Key Characteristics:** + +- Runtime theme colors, including matched foreground/background pairs. +- A narrow task column with open spacing between sections. +- Mobile-sized controls with compact monospace labels. +- Inline feedback and explicit account connection states. + +Implementation evidence: `src/style.css`, `src/App.tsx`, and `src/main.tsx`. The surface direction contract lives in `index.html`. + +## Colors + +The palette comes from bundled Dormouse themes through `applyTheme()`. System color preference selects Kimbie Dark or Light Visual Studio and updates when that preference changes. + +### Primary + +The active-selection pair identifies the email form's primary action. Links use the theme's link foreground; keyboard focus uses its focus border. + +### Neutral + +Sidebar colors paint the page and text. The inactive-selection pair paints secondary buttons, inputs, and notices. Secondary prose retains the page foreground at 0.95 opacity. Error text uses the theme's error foreground. + +**The Matched Pair Rule.** Keep every control foreground paired with its theme background. Inputs, placeholders, and readonly values retain full foreground opacity. + +**The Runtime Palette Rule.** Inherit colors through the existing theme variables; do not introduce fixed colors or synthetic tonal ramps. + +## Typography + +All roles use the parent system's editor font. Hosted deliberately extends the compact terminal type scale with the title, body, and input roles in the frontmatter. Section headings stay at body size, bold with a 1.5 line height; account identifiers and footer text use the detail role. There is no display tier. + +**The Browser Form Rule.** Preserve the input role's larger type and a minimum control height of 44px for buttons and inputs, including text buttons. These are Hosted account-form exceptions to the parent's terminal chrome sizing. + +## Layout + +The full-height shell places header and footer around a centered column capped at 480px, including 24px horizontal padding. Main content starts 64px below its container top. Section gaps use the section spacing token; provider buttons use a two-column grid with the small gap. Account method rows align the method name left and its action or connection state right, with a minimum height of 52px. + +At 540px and below, header padding becomes 16px, main top padding becomes 36px, and the footer stacks vertically. The page supports a minimum width of 280px. Long account identifiers, email addresses, and errors wrap rather than truncate. + +## Elevation & Depth + +The account interface has no shadows, gradients, overlays, or animated transitions. Background changes and spacing establish grouping; notices sit inline with the form or account content. + +## Shapes + +Buttons and inputs share gently rounded corners from the control radius token and have no resting border. Notice blocks are square. Keyboard focus uses a visible 2px outline with 3px offset. + +## Components + +- **Buttons:** primary actions fill the column; secondary buttons serve provider sign-in, explicit connections, and sign-out. Enabled button hover brightens the existing theme color. Disabled buttons use 0.55 opacity and a default cursor. Busy actions replace their label with progress text. +- **Text buttons and links:** use underlines with a 3px offset, thickening on hover. Text buttons retain the common minimum target height. +- **Inputs:** full-width fields with visible labels. Email and verification code share styling; readonly email keeps its normal appearance. The code field receives focus when revealed. +- **Feedback:** errors use `role="alert"` and an inline retry action. Notices and loading text use `role="status"`. +- **Account method rows:** show the provider name with either a Connect action or Connected text. State is communicated in words, not solely through color. +- **Navigation:** plain product and external links in the header and footer, with no tab bar or card wrapper. + +## Do's and Don'ts + +- **Do** inherit the parent theme and monospace identity. +- **Do** preserve full foreground opacity in editable, placeholder, and readonly input text. +- **Do** keep connection state and progress visible in action labels. +- **Don't** import the marketing site's palette, imagery, or typography into account controls. +- **Don't** add shadows or decorative card wrappers to this flat account surface. +- **Don't** reduce form controls to the parent's terminal chrome dimensions. diff --git a/hosted/README.md b/hosted/README.md new file mode 100644 index 000000000..0474d4c50 --- /dev/null +++ b/hosted/README.md @@ -0,0 +1,171 @@ +# Dormouse Hosted + +Account frontend and Hono/Cloudflare Worker for `https://hosted.dormouse.sh`. +The marketing website is a separate application. Hosted voice and the managed +Relay are not implemented. See [the spec](../docs/specs/hosted.md). + +## Run locally + +From the repository root, with Docker running: + +```sh +pnpm install +dor ensure -- pnpm dev:hosted +``` + +Outside Dormouse, use `pnpm dev:hosted`. Open `http://127.0.0.1:5188`. +Request a code for a test address and read it at `/api/dev/emails` on that +same origin. No real mail is sent. OAuth is disabled in this local entry; +the development database is isolated by the worktree path. Use another +`PORT` if 5188 is occupied. Do not share this local inbox publicly. + +```sh +pnpm test:hosted +pnpm build:hosted +``` + +Tests run the production composition in real workerd with disposable Postgres +clones and a local OAuth simulator. The build includes a Wrangler dry-run; it +does not deploy. The production entry contains neither the inbox nor test clock. + +## Refresh private packages + +```sh +node scripts/sync-pgstencil.mjs /path/to/pgstencil +``` + +This runs `pnpm packages:pack` in pgstencil, vendors core/auth, records source +commit/dirty state and SHA-256 hashes in `vendor/build.json`, and installs. +Commit archives, provenance, and lockfile together. The direct Node command +also works before the archives exist (pnpm may otherwise auto-install first). +Re-run integration tests after every refresh. A dirty-source snapshot must be +reviewed and replaced with an accepted revision before production activation. + +## Provision the production boundary + +Use dedicated Dormouse resources in the existing Cloudflare, Neon, and Postmark +accounts. Do not reuse TTR's database, mail server/token, or OAuth registrations. + +1. Create a dedicated Dormouse production Postgres database (Neon is the TTR + precedent). Keep TTR, development, and previews separate. Enable backups and + verify a restore into a separate database before accepting real accounts. +2. Create a Cloudflare Hyperdrive configuration for that database with **query + caching disabled**. Enter connection credentials directly in Cloudflare; + replace the zero Hyperdrive ID in `wrangler.jsonc` with the resulting public ID. +3. Give the runtime only the data permissions required by the shipped auth + tables. Keep a separate migration credential. Supply its `DATABASE_URL` + through a secret manager, then run `pnpm --filter dormouse-hosted db:migrate` + and `db:validate`. These commands never reset or drop an existing database. +4. Set up a dedicated Postmark server/token and verify the sending address + `signin@hosted.dormouse.sh` (or update `EMAIL_FROM`). Configure SPF/DKIM and + DMARC. Register the sender with Apple Private Email Relay for relay-address + delivery. +5. Use a deployment identity separate from marketing, with access limited to + the Hosted deployment resources. Do not give marketing CI the Hosted auth, + database, email, OAuth, or deployment secrets. If a Cloudflare account token + cannot express that isolation, use a separate account/deployment boundary. +6. Configure `hosted.dormouse.sh` as the Worker's custom domain. Exclude this + hostname from Cloudflare Web Analytics, Zaraz, and other script injection + or rewriting rules. Disable account API caching. Keep `workers_dev` and + public preview URLs disabled. + +PR previews and production releases use the workflows and isolated GitHub +environments in [DEPLOYMENT.md](DEPLOYMENT.md). The checked-in deployment has +a placeholder Hyperdrive ID and enables no OAuth providers until configured. + +Authenticate Wrangler to the intended Cloudflare account before provisioning. +Inside Dormouse, run `dor ensure -- pnpm exec wrangler login --browser=false --use-keyring` +from `hosted/`, then open the printed authorization link with `dor ab`. Review +the account and requested access before granting it. Secrets remain in the OS +keychain. Authenticate in your own terminal; account/provider sign-in is operator-owned. + +## Separate OAuth registrations + +Create Dormouse registrations; do not reuse TTR credentials or replace TTR's +callbacks. Register these exact URLs with no trailing slash: + +| Provider | Registration | Callback | +| --- | --- | --- | +| GitHub | Organization-owned OAuth App, identity/email scopes only | `https://hosted.dormouse.sh/api/auth/callback/github` | +| Google | Web application OAuth client under a Dormouse consent configuration | `https://hosted.dormouse.sh/api/auth/callback/google` | +| Microsoft | Entra application: personal and work/school accounts; Web platform | `https://hosted.dormouse.sh/api/auth/callback/microsoft` | +| Apple | Dormouse Services ID associated with a Sign in with Apple primary App ID | `https://hosted.dormouse.sh/api/auth/callback/apple` | + +Use `https://hosted.dormouse.sh` as the application origin/homepage where the +provider requests it. Configure consent branding, support contact, privacy +policy, and production/test-user settings before testing with ordinary accounts. +For Google, add the exact callback under authorized redirect URIs, and the +Hosted origin under authorized JavaScript origins if requested. + +For Microsoft, request optional ID-token claims `email` and `xms_edov`. Store +the client-secret **Value**, not its identifier, and record its expiry. +Microsoft's existing real callback failure belongs to the separate pgstencil +investigation. Prepare the Dormouse registration now, but enable Microsoft +only after consuming the accepted fix and passing a real Dormouse callback. + +For Apple, register domain `hosted.dormouse.sh` and the return URL above. The +client ID is the Services ID; the client secret is an ES256 JWT signed with +the Apple key (Team ID issuer, Services ID subject, Apple audience, key ID +header). Keep the signing key in your secret manager, generate the JWT locally, +and renew it before expiry (at most six months). The callback accepts Apple's +form POST and uses the package's browser-bound relay; do not replace it with a +generic JSON/CSRF handler. Schedule secret-expiry reminders before activation. + +## Enter secrets yourself + +From `hosted/`, use Wrangler's hidden prompt, never a command-line value: + +```sh +pnpm exec wrangler secret put AUTH_SECRET +pnpm exec wrangler secret put POSTMARK_SERVER_TOKEN +pnpm exec wrangler secret put GITHUB_CLIENT_SECRET +pnpm exec wrangler secret put GOOGLE_CLIENT_SECRET +pnpm exec wrangler secret put MICROSOFT_CLIENT_SECRET +pnpm exec wrangler secret put APPLE_CLIENT_SECRET +``` + +Generate a fresh cryptographically random `AUTH_SECRET` with at least 32 bytes +of entropy in your secret manager. Client IDs are public but may be stored +through the same prompts as `GITHUB_CLIENT_ID`, `GOOGLE_CLIENT_ID`, +`MICROSOFT_CLIENT_ID`, and `APPLE_CLIENT_ID`. Never paste secrets into chat, +source files, URLs, command arguments, or build logs. `.dev.vars*` and `.env` +under `hosted/` are ignored; use only isolated test credentials there. + +Enable each ready provider in `OAUTH_PROVIDERS` in `wrangler.jsonc`, comma +separated. Paired credentials alone do not enable it; unknown names and missing +credentials fail closed. Facebook is outside this milestone. + +## Release and acceptance + +1. Review the exact package provenance and code revision. Run `pnpm test:hosted`, + `pnpm build:hosted`, and the repository lints. Validate production migrations. +2. Run the production workflow in [DEPLOYMENT.md](DEPLOYMENT.md). Check `/api/health` and `/api/ready` on the + canonical hostname. Inspect the actual HTML response/CSP and browser network + requests for injected marketing scripts or unexpected third-party assets. +3. Request a real email, enter its code, reload, and log out. Enter a code in a + second browser to verify that mail access is not tied to the first browser. +4. For each enabled provider, test first login, consent cancellation, repeat + login, and logout. Include GitHub private email and Apple Hide My Email; + Microsoft needs personal and work/school accounts after the shared fix. +5. Start with email, attempt the same-address provider from another browser, + and confirm no automatic linking. Connect it from the signed-in account, + then confirm both methods return the same account ID. Connect Apple with + its different relay address. An identity belonging to another account must + never transfer. A connection started before logout must fail on return. +6. Log in on two devices and confirm both remain signed in. Log out on one; + the other must remain signed in. Provider-only accounts display no email + and repeat login preserves their account ID. +7. Confirm `/api/dev/emails`, `/dev/emails`, and `/__test/time` are absent. + Confirm marketing-origin auth POSTs fail, auth JSON is not cached, and + cookies are host-only, Secure, HttpOnly, and SameSite=Lax. + +Do not mark all five login methods complete until email and all four providers +pass in real browsers. Simulated Microsoft tests do not resolve its known +production issue. No real-provider result is claimed by the initial implementation. + +Use the Worker's deployment history for code rollback; migrations are append-only +and are not reversed by a code rollback. Database restoration requires an +explicit operator decision and a tested backup. Current limitations: fixed +24-hour logins, no device-revocation screen or sign-out-everywhere, no account +merge/recovery, no paid-service activation. PR preview provisioning is documented in +[DEPLOYMENT.md](DEPLOYMENT.md). diff --git a/hosted/index.html b/hosted/index.html new file mode 100644 index 000000000..d9e56bede --- /dev/null +++ b/hosted/index.html @@ -0,0 +1,20 @@ + + + + + + + Dormouse Hosted + + + +
+ + + + diff --git a/hosted/package.json b/hosted/package.json new file mode 100644 index 000000000..8d6870ee8 --- /dev/null +++ b/hosted/package.json @@ -0,0 +1,41 @@ +{ + "name": "dormouse-hosted", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx server/dev.ts", + "typecheck": "tsc --noEmit", + "build": "pnpm typecheck && vite build && wrangler deploy --dry-run --outdir dist-worker", + "test": "pnpm test:deploy && vitest run", + "db:migrate": "tsx server/db.ts migrate", + "db:validate": "tsx server/db.ts validate", + "db:status": "tsx server/db.ts status", + "deploy": "pnpm build && wrangler deploy", + "preview:worker": "wrangler dev --local --local-protocol https", + "test:deploy": "node --test scripts/*.test.mjs", + "preview:deploy": "node scripts/preview.mjs deploy", + "preview:cleanup": "node scripts/preview.mjs cleanup", + "preview:smoke": "node scripts/preview-smoke.mjs" + }, + "dependencies": { + "@pgstencil/auth": "file:../vendor/pgstencil-auth-0.1.0.tgz", + "pgstencil": "file:../vendor/pgstencil-0.1.0.tgz", + "hono": "4.13.7", + "@hono/node-server": "2.0.6", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/node": "24.13.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^6.0.3", + "vite": "^8.0.14", + "vitest": "^4.1.6", + "tsx": "4.23.13", + "esbuild": "0.28.2", + "wrangler": "4.130.0", + "miniflare": "5.20260908.0-alpha" + } +} diff --git a/hosted/scripts/changed.mjs b/hosted/scripts/changed.mjs new file mode 100644 index 000000000..ace7d765f --- /dev/null +++ b/hosted/scripts/changed.mjs @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Shared build inputs can change Hosted without editing its directory. +export function touchesHosted(paths) { + return paths.some( + (path) => + /^(hosted\/|vendor\/|lib\/src\/(theme|lib\/(themes\/|(?:local-json-store|is-record|css-color)\.ts$))|scripts\/sync-pgstencil\.mjs$|\.github\/workflows\/hosted-[^/]+\.yml$)/.test( + path, + ) || + ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"].includes(path), + ); +} +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + console.log( + `hosted=${touchesHosted(readFileSync(process.argv[2], "utf8").split("\n"))}`, + ); diff --git a/hosted/scripts/changed.test.mjs b/hosted/scripts/changed.test.mjs new file mode 100644 index 000000000..85cc5f9e6 --- /dev/null +++ b/hosted/scripts/changed.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { touchesHosted } from "./changed.mjs"; +test("Hosted and shared inputs trigger previews; unrelated application changes do not", () => { + for (const path of [ + "hosted/README.md", + "hosted/server/worker.ts", + "vendor/build.json", + "pnpm-lock.yaml", + ".github/workflows/hosted-preview.yml", + "lib/src/theme-colors.css", + "lib/src/lib/themes/bundled.json", + "lib/src/lib/css-color.ts", + ]) + assert.equal(touchesHosted([path]), true, path); + for (const path of [ + "website/src/App.tsx", + "standalone/src/main.tsx", + "docs/specs/layout.md", + ".github/workflows/ci.yml", + ]) + assert.equal(touchesHosted([path]), false, path); + assert.equal(touchesHosted([]), false); +}); diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs new file mode 100644 index 000000000..8df0ae49d --- /dev/null +++ b/hosted/scripts/preview-smoke.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** @param {{ text: string }} email */ +export const codeFrom = (email) => email.text.match(/\b\d{8}\b/)?.[0]; + +export async function smoke( + origin, + sha, + fetcher = fetch, + preview = false, + expectedProviders = [], + authOrigin = origin, +) { + assert.equal( + new URL(origin).origin, + origin, + "Supply an exact origin without a trailing slash", + ); + assert.equal(new URL(origin).protocol, "https:"); + const request = (path, options = {}) => + fetcher(origin + path, { + redirect: "manual", + signal: AbortSignal.timeout(30_000), + ...options, + }); + const health = await request("/api/health"); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { + ok: true, + revision: sha, + }); + assert.equal( + (await request("/api/ready")).status, + 200, + "Auth schema must be reachable through Hyperdrive", + ); + const start = await request("/api/auth/csrf"); + assert.equal(start.status, 200, "Auth must issue a CSRF challenge"); + assert.match(start.headers.get("cache-control"), /no-store/); + const state = await start.json(); + assert.equal(await (await request("/api/auth/get-session")).json(), null); + assert.deepEqual( + await (await request("/api/providers")).json(), + preview ? [] : expectedProviders, + "Unexpected enabled OAuth providers", + ); + assert.ok(state.csrf); + const cookies = start.headers.getSetCookie(); + assert.ok(cookies.length); + for (const cookie of cookies) { + assert.match(cookie, /^__Host-/); + assert.match(cookie, /; Secure(?:;|$)/); + assert.match(cookie, /; HttpOnly(?:;|$)/); + assert.match(cookie, /; SameSite=Lax(?:;|$)/); + assert.doesNotMatch(cookie, /; Domain=/i); + } + const cookieHeader = cookies.map((c) => c.split(";")[0]).join("; "); + const rejected = await request("/api/auth/email-otp/send-verification-otp", { + method: "POST", + headers: { + origin: "https://wrong-origin.invalid", + "content-type": "application/json", + "x-csrf-token": state.csrf, + cookie: cookieHeader, + }, + body: JSON.stringify({ + email: "must-not-send@example.invalid", + type: "sign-in", + }), + }); + assert.equal(rejected.status, 403); + for (const path of ["/dev/emails", "/api/dev/emails"]) + assert.equal((await request(path)).status, preview ? 200 : 404, path); + assert.equal((await request("/api/billing")).status, 404); + assert.equal((await request("/__test/time")).status, 404); + for (const provider of preview ? [] : expectedProviders) { + const started = await request("/api/auth/sign-in/social", { + method: "POST", + headers: { + origin: authOrigin, + "content-type": "application/json", + "x-csrf-token": state.csrf, + cookie: cookieHeader, + }, + body: JSON.stringify({ provider }), + }); + assert.equal(started.status, 200, `${provider} authorization must start`); + const authorization = new URL((await started.json()).url); + assert.equal( + authorization.origin, + { + github: "https://github.com", + google: "https://accounts.google.com", + apple: "https://appleid.apple.com", + facebook: "https://www.facebook.com", + microsoft: "https://login.microsoftonline.com", + }[provider], + ); + assert.equal( + authorization.searchParams.get("redirect_uri"), + `${authOrigin}/api/auth/callback/${provider}`, + ); + assert.ok(authorization.searchParams.get("client_id")); + assert.ok(authorization.searchParams.get("state")); + if (provider === "google" || provider === "microsoft") { + assert.equal(authorization.searchParams.get("prompt"), "select_account"); + assert.equal( + authorization.searchParams.get("code_challenge_method"), + "S256", + ); + assert.ok(authorization.searchParams.get("code_challenge")); + } + if ( + provider === "google" || + provider === "apple" || + provider === "microsoft" + ) + assert.ok(authorization.searchParams.get("nonce")); + if (provider === "apple") + assert.equal( + authorization.searchParams.get("response_mode"), + "form_post", + ); + } + if (preview) await emailLoginSmoke(request, origin); + let login = await request("/login"); + // Workers Assets canonicalizes prerendered index pages to a trailing slash. + if (login.status === 307 || login.status === 308) { + assert.equal( + new URL(login.headers.get("location"), origin).href, + `${origin}/login/`, + ); + login = await request("/login/"); + } + assert.equal(login.status, 200); + assert.match(login.headers.get("content-type"), /text\/html/); + assert.match(await login.text(), / { + const result = await request(path, { + ...options, + headers: { + ...options.headers, + cookie: [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + }, + }); + for (const value of result.headers.getSetCookie()) { + const pair = value.split(";", 1)[0]; + const separator = pair.indexOf("="); + cookies.set(pair.slice(0, separator), pair.slice(separator + 1)); + } + return result; + }; + const { csrf } = await (await browser("/api/auth/csrf")).json(); + const post = (path, body) => + browser(path, { + method: "POST", + headers: { + origin, + "x-csrf-token": csrf, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + const email = `preview-smoke-${crypto.randomUUID()}@example.invalid`; + assert.equal( + ( + await post("/api/auth/email-otp/send-verification-otp", { + email, + type: "sign-in", + }) + ).status, + 200, + "Capture sign-in email", + ); + const response = await request("/api/dev/emails"); + assert.equal(response.status, 200); + const message = (await response.json()).find((message) => + message.to.includes(email), + ); + assert.ok(message, "The requested email must appear in the database inbox"); + const otp = codeFrom(message); + assert.ok(otp, "Email must contain an eight-digit code"); + const detail = await request(`/dev/emails/${message.id}`); + assert.equal(detail.status, 200); + assert.ok((await detail.text()).includes(otp)); + assert.equal( + (await post("/api/auth/sign-in/email-otp", { email, otp })).status, + 200, + "Email code verification", + ); + const signedIn = await (await browser("/api/auth/get-session")).json(); + assert.equal(signedIn?.user.email, email); + assert.equal((await post("/api/auth/sign-out", {})).status, 200); + assert.equal( + await (await browser("/api/auth/get-session")).json(), + null, + "Logout must clear the session", + ); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const [origin, sha] = process.argv.slice(2); + assert.match(sha ?? "", /^[a-f0-9]{40}$/); + // A just-uploaded Worker may take a short time to become reachable everywhere. + for (let attempt = 1; ; attempt++) { + try { + await smoke(origin, sha, fetch, true); + console.log(`Preview smoke checks passed: ${origin}/login (${sha})`); + break; + } catch (error) { + if (attempt === 6) throw error; + console.log( + `Preview not ready (attempt ${attempt}/6); retrying in 10 seconds`, + ); + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + } +} diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs new file mode 100644 index 000000000..94b3c2a28 --- /dev/null +++ b/hosted/scripts/preview.mjs @@ -0,0 +1,239 @@ +import { createHmac } from "node:crypto"; +import { readFile, writeFile, mkdir, appendFile, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +export function required(env, name) { + if (!env[name]) throw new Error(`Missing ${name}; see hosted/DEPLOYMENT.md`); + return env[name]; +} + +export function previewName(pr) { + if (!/^[1-9]\d{0,8}$/.test(pr ?? "")) + throw new Error("PR_NUMBER must be a positive integer"); + return `dormouse-hosted-pr-${pr}`; +} + +export function previewConfig(base, env, hyperdriveId) { + const name = previewName(env.PR_NUMBER); + const subdomain = required(env, "CLOUDFLARE_WORKERS_SUBDOMAIN"); + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(subdomain)) + throw new Error( + "Use the workers.dev subdomain only, without dots or a URL", + ); + if (!/^[a-f0-9]{32}$/.test(hyperdriveId)) + throw new Error("Invalid Hyperdrive ID"); + if (!/^[a-f0-9]{40}$/.test(env.BUILD_SHA ?? "")) + throw new Error("BUILD_SHA must be a commit SHA"); + // Deliberately allowlist fields: no production routes, bindings, or OAuth secrets. + return { + name, + main: "../../server/preview-worker.ts", + compatibility_date: base.compatibility_date, + compatibility_flags: base.compatibility_flags, + workers_dev: true, + preview_urls: false, + assets: { ...base.assets, directory: "../../dist" }, + vars: { + APP_ORIGIN: `https://${name}.${subdomain}.workers.dev`, + BUILD_SHA: required(env, "BUILD_SHA"), + }, + hyperdrive: [{ binding: "HYPERDRIVE", id: hyperdriveId }], + }; +} + +export function hyperdriveOrigin(connectionString) { + const url = new URL(connectionString); + if ( + !["postgres:", "postgresql:"].includes(url.protocol) || + !url.password || + !url.username + ) + throw new Error("DATABASE_URL must be a direct Postgres connection URL"); + if (url.hostname.includes("-pooler.")) + throw new Error("Hyperdrive needs the direct Neon URL"); + return { + scheme: "postgres", + host: url.hostname, + port: Number(url.port || 5432), + database: decodeURIComponent(url.pathname.slice(1)), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + }; +} + +export function cloudflare(env, fetcher = fetch) { + const account = required(env, "CLOUDFLARE_ACCOUNT_ID"); + if (!/^[a-f0-9]{32}$/.test(account)) + throw new Error("Invalid Cloudflare account ID"); + const token = required(env, "CLOUDFLARE_API_TOKEN"); + if (/\s|["']/.test(token)) + throw new Error( + "CLOUDFLARE_API_TOKEN must contain only the token value, without whitespace, quotes, or a Bearer prefix", + ); + return async (path, method = "GET", body) => { + const response = await fetcher( + `https://api.cloudflare.com/client/v4/accounts/${account}/${path}`, + { + method, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(60_000), + }, + ); + if (method === "DELETE" && response.status === 404) return null; + const data = await response.json(); + // Provider messages can contain credentials. Expose only numeric error codes. + if (!response.ok || !data.success) { + const codes = []; + const collect = (errors) => { + if (!Array.isArray(errors)) return; + for (const error of errors) { + if (Number.isSafeInteger(error?.code)) codes.push(error.code); + collect(error?.error_chain); + } + }; + collect(data.errors); + throw new Error( + `Cloudflare ${method} ${path} failed (${response.status}; codes: ${codes.join(", ") || "none"})`, + ); + } + return data; + }; +} + +export async function findHyperdrives(api, name) { + const matches = []; + for (let page = 1; ; page++) { + const data = await api(`hyperdrive/configs?per_page=100&page=${page}`); + matches.push(...data.result.filter((item) => item.name === name)); + if (page >= (data.result_info?.total_pages ?? 1)) return matches; + } +} + +export async function prepare(env = process.env) { + const name = previewName(env.PR_NUMBER); + const base = JSON.parse( + await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), + ); + const config = previewConfig(base, env, "0".repeat(32)); + const secret = required(env, "PREVIEW_AUTH_SECRET"); + if (secret.length < 32) + throw new Error("PREVIEW_AUTH_SECRET needs at least 32 random characters"); + const secrets = { + AUTH_SECRET: createHmac("sha256", secret).update(name).digest("hex"), + }; + const origin = hyperdriveOrigin(required(env, "DATABASE_URL")); + const api = cloudflare(env); + const matches = await findHyperdrives(api, name); + if (matches.length > 1) + throw new Error(`Multiple Hyperdrives named ${name}; remove duplicates`); + const body = { + name, + origin, + caching: { disabled: true }, + origin_connection_limit: 5, + }; + const path = `hyperdrive/configs${matches[0] ? `/${matches[0].id}` : ""}`; + const { result } = await api(path, matches[0] ? "PUT" : "POST", body); + config.hyperdrive[0].id = result.id; + const directory = new URL("../.wrangler/preview/", import.meta.url); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await writeFile( + new URL("wrangler.json", directory), + JSON.stringify(config, null, 2) + "\n", + ); + await writeFile(new URL("secrets.json", directory), JSON.stringify(secrets), { + mode: 0o600, + }); + if (env.GITHUB_OUTPUT) + await appendFile(env.GITHUB_OUTPUT, `url=${config.vars.APP_ORIGIN}\n`); + if (env.GITHUB_STEP_SUMMARY) + await appendFile( + env.GITHUB_STEP_SUMMARY, + `Preview target: ${config.vars.APP_ORIGIN}/login\n\nRevision: ${env.BUILD_SHA}\n\nCaptured email inbox: ${config.vars.APP_ORIGIN}/dev/emails. No real email is sent. Deployment and smoke checks must succeed below.\n`, + ); + console.log(`Prepared ${config.vars.APP_ORIGIN}`); +} + +export async function cleanup(env = process.env) { + const name = previewName(env.PR_NUMBER); + // Validate both providers before deleting anything, so a missing Neon token is caught first. + required(env, "NEON_PROJECT_ID"); + required(env, "NEON_API_KEY"); + const api = cloudflare(env); + await api(`workers/scripts/${name}`, "DELETE"); + for (const item of await findHyperdrives(api, name)) + await api(`hyperdrive/configs/${item.id}`, "DELETE"); + // The official create action reuses branches; cleanup must also tolerate retries. + const project = encodeURIComponent(required(env, "NEON_PROJECT_ID")); + const neon = async (path, method = "GET") => { + const response = await fetch( + `https://console.neon.tech/api/v2/projects/${project}/${path}`, + { + method, + headers: { authorization: `Bearer ${required(env, "NEON_API_KEY")}` }, + signal: AbortSignal.timeout(60_000), + }, + ); + if (method === "DELETE" && response.status === 404) return; + if (!response.ok) + throw new Error(`Neon ${method} failed (${response.status})`); + return response.json(); + }; + let cursor; + do { + const data = await neon( + `branches?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ); + for (const branch of data.branches.filter((b) => b.name === name)) + await neon(`branches/${encodeURIComponent(branch.id)}`, "DELETE"); + cursor = data.pagination?.next; + } while (cursor); + console.log(`Removed preview resources for ${name}`); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + const action = process.argv[2]; + if (action === "prepare") await prepare(); + else if (action === "cleanup") await cleanup(); + else if (action === "deploy") { + await prepare(); + try { + const result = spawnSync( + "pnpm", + [ + "exec", + "wrangler", + "deploy", + "--config", + ".wrangler/preview/wrangler.json", + "--secrets-file", + ".wrangler/preview/secrets.json", + ], + { + cwd: fileURLToPath(new URL("../", import.meta.url)), + stdio: "inherit", + }, + ); + process.exitCode = result.status ?? 1; + } finally { + await rm( + new URL("../.wrangler/preview/secrets.json", import.meta.url), + { force: true }, + ); + } + } else throw new Error("Use prepare, deploy or cleanup"); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs new file mode 100644 index 000000000..4b0be3e6b --- /dev/null +++ b/hosted/scripts/preview.test.mjs @@ -0,0 +1,218 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + previewName, + previewConfig, + hyperdriveOrigin, + cloudflare, + findHyperdrives, + cleanup, +} from "./preview.mjs"; +import { smoke } from "./preview-smoke.mjs"; + +const env = { + PR_NUMBER: "42", + CLOUDFLARE_WORKERS_SUBDOMAIN: "hosted-tests", + CLOUDFLARE_ACCOUNT_ID: "a".repeat(32), + CLOUDFLARE_API_TOKEN: "dummy-token", + BUILD_SHA: "b".repeat(40), + NEON_PROJECT_ID: "test-project", + NEON_API_KEY: "dummy-neon-token", +}; +const result = (value, extra = {}) => + Response.json({ success: true, result: value, ...extra }); + +test("preview configuration isolates the origin and excludes production bindings", async () => { + const base = JSON.parse( + await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), + ); + const config = previewConfig( + { + ...base, + routes: ["production.example/*"], + vars: { GOOGLE_CLIENT_SECRET: "do-not-copy" }, + d1_databases: [{ production: true }], + }, + env, + "c".repeat(32), + ); + assert.equal(config.name, "dormouse-hosted-pr-42"); + assert.equal( + config.vars.APP_ORIGIN, + "https://dormouse-hosted-pr-42.hosted-tests.workers.dev", + ); + assert.equal(config.workers_dev, true); + assert.equal(config.main, "../../server/preview-worker.ts"); + assert.equal(config.vars.EMAIL_FROM, undefined); + assert.equal(config.routes, undefined); + assert.equal(config.d1_databases, undefined); + assert.equal(config.vars.GOOGLE_CLIENT_SECRET, undefined); + assert.equal(config.assets.run_worker_first, true); + for (const bad of ["0", "-1", "42/../../production", "main", "42\n"]) + assert.throws(() => previewName(bad)); + assert.throws(() => + previewConfig( + base, + { ...env, CLOUDFLARE_WORKERS_SUBDOMAIN: "example.com" }, + "c".repeat(32), + ), + ); +}); + +test("Hyperdrive uses a direct URL and decodes credentials without logging them", () => { + assert.deepEqual( + hyperdriveOrigin( + "postgresql://test:p%40ss%3Aword@ep-test.neon.tech/neondb?sslmode=require", + ), + { + scheme: "postgres", + host: "ep-test.neon.tech", + port: 5432, + database: "neondb", + user: "test", + password: "p@ss:word", + }, + ); + assert.throws( + () => + hyperdriveOrigin( + "postgres://test:password@ep-test-pooler.neon.tech/neondb", + ), + /direct/, + ); + assert.throws(() => hyperdriveOrigin("https://example.com")); +}); + +test("Cloudflare errors omit provider bodies, and missing deletions are idempotent", async () => { + const api = cloudflare(env, async () => + Response.json( + { success: false, errors: ["sensitive-password"] }, + { status: 403 }, + ), + ); + await assert.rejects(api("hyperdrive/configs"), (error) => { + assert.match(error.message, /403/); + assert.doesNotMatch(error.message, /sensitive-password/); + return true; + }); + const missing = cloudflare( + env, + async () => new Response("missing", { status: 404 }), + ); + assert.equal( + await missing("workers/scripts/dormouse-hosted-pr-42", "DELETE"), + null, + ); + await assert.rejects(missing("workers/scripts/dormouse-hosted-pr-42")); +}); + +test("resource lookup paginates and matches exact PR names", async () => { + const paths = []; + const found = await findHyperdrives(async (path) => { + paths.push(path); + return { + result_info: { total_pages: 2 }, + result: + paths.length === 1 + ? [{ id: "other", name: "dormouse-hosted-pr-420" }] + : [{ id: "ours", name: "dormouse-hosted-pr-42" }], + }; + }, "dormouse-hosted-pr-42"); + assert.deepEqual(found, [{ id: "ours", name: "dormouse-hosted-pr-42" }]); + assert.equal(paths.length, 2); +}); + +test("Cloudflare auth diagnostics expose numeric codes, never provider messages or malformed tokens", async () => { + for (const token of [ + "Bearer synthetic-token", + "synthetic-token\n", + '"synthetic-token"', + ]) + assert.throws( + () => cloudflare({ ...env, CLOUDFLARE_API_TOKEN: token }), + (error) => { + assert.match(error.message, /only the token value/); + assert.doesNotMatch(error.message, /synthetic-token/); + return true; + }, + ); + const api = cloudflare(env, async () => + Response.json( + { + success: false, + errors: [ + { + code: 6003, + message: "sensitive-password", + error_chain: [{ code: 6111, message: "sensitive-token" }], + }, + { code: "sensitive-non-numeric-code" }, + ], + }, + { status: 400 }, + ), + ); + await assert.rejects(api("hyperdrive/configs"), (error) => { + assert.match(error.message, /400; codes: 6003, 6111/); + assert.doesNotMatch(error.message, /sensitive/); + return true; + }); +}); + +test("cleanup only deletes this PR's resources and can run twice", async (t) => { + const removed = []; + let existing = true; + t.mock.method(globalThis, "fetch", async (url, options) => { + const path = new URL(url).pathname; + if (options.method === "DELETE") { + removed.push(path); + return existing ? result({}) : new Response("missing", { status: 404 }); + } + if (path.endsWith("hyperdrive/configs")) + return result( + existing + ? [ + { id: "ours", name: "dormouse-hosted-pr-42" }, + { id: "other", name: "dormouse-hosted-pr-420" }, + ] + : [], + ); + if (path.endsWith("branches")) + return Response.json({ + branches: existing + ? [ + { id: "br-ours", name: "dormouse-hosted-pr-42" }, + { id: "br-main", name: "main" }, + ] + : [], + }); + throw new Error(`Unexpected request ${path}`); + }); + await cleanup(env); + existing = false; + await cleanup(env); + assert.deepEqual( + removed.map((path) => path.split("/").pop()), + ["dormouse-hosted-pr-42", "ours", "br-ours", "dormouse-hosted-pr-42"], + ); +}); + +test("deployment smoke fails on a stale revision before making any auth requests", async () => { + let requests = 0; + await assert.rejects( + smoke( + "https://dormouse-hosted-pr-42.test.workers.dev", + env.BUILD_SHA, + async () => { + requests++; + return Response.json({ + ok: true, + development: false, + revision: "stale", + }); + }, + ), + ); + assert.equal(requests, 1); +}); diff --git a/hosted/scripts/production-backup.mjs b/hosted/scripts/production-backup.mjs new file mode 100644 index 000000000..fa307d888 --- /dev/null +++ b/hosted/scripts/production-backup.mjs @@ -0,0 +1,147 @@ +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { required, hyperdriveOrigin } from "./preview.mjs"; + +// Plaintext and the age identity exist only in private runner scratch space. +// Only the encrypted archive leaves the runner, after decrypt-and-restore succeeds. +const temporary = await mkdtemp(join(tmpdir(), "hosted-backup-")); +const container = `hosted-restore-${randomUUID()}`; +const image = "postgres:17.11-alpine"; +function run(step, command, args, env = process.env) { + const result = spawnSync(command, args, { + env, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + }); + // Third-party stderr can contain connection details or rows; report the failed command only. + if (result.status !== 0) + throw new Error(`Backup ${step} failed (exit ${result.status})`); + return result.stdout.trim(); +} +try { + const origin = hyperdriveOrigin(required(process.env, "DATABASE_URL")); + const pgEnv = { + ...process.env, + PGHOST: origin.host, + PGPORT: String(origin.port), + PGDATABASE: origin.database, + PGUSER: origin.user, + PGPASSWORD: origin.password, + PGSSLMODE: "verify-full", + PGSSLROOTCERT: "system", + PGCONNECT_TIMEOUT: "30", + }; + const identity = join(temporary, "identity.txt"); + await writeFile( + identity, + required(process.env, "BACKUP_AGE_IDENTITY") + "\n", + { mode: 0o600 }, + ); + const recipient = run("read encryption identity", "age-keygen", [ + "-y", + identity, + ]); + run( + "database dump", + "docker", + [ + "run", + "--rm", + "--user", + `${process.getuid()}:${process.getgid()}`, + ...[ + "PGHOST", + "PGPORT", + "PGDATABASE", + "PGUSER", + "PGPASSWORD", + "PGSSLMODE", + "PGSSLROOTCERT", + "PGCONNECT_TIMEOUT", + ].flatMap((key) => ["-e", key]), + "-v", + `${temporary}:/backup`, + image, + "pg_dump", + "--format=custom", + "--no-owner", + "--no-acl", + "--file=/backup/database.dump", + ], + pgEnv, + ); + const encrypted = join(temporary, "database.dump.age"); + run("encryption", "age", [ + "-r", + recipient, + "-o", + encrypted, + join(temporary, "database.dump"), + ]); + run("decryption", "age", [ + "-d", + "-i", + identity, + "-o", + join(temporary, "restored.dump"), + encrypted, + ]); + run("start restore container", "docker", [ + "run", + "-d", + "--rm", + "--name", + container, + "-e", + "POSTGRES_HOST_AUTH_METHOD=trust", + image, + ]); + for (let attempt = 0; ; attempt++) { + // The image's temporary initialization server accepts Unix sockets only. + // Wait for TCP so restore cannot race its shutdown and final server startup. + const ready = spawnSync( + "docker", + ["exec", container, "pg_isready", "-h", "127.0.0.1", "-U", "postgres"], + { stdio: "ignore" }, + ); + if (ready.status === 0) break; + if (attempt >= 30) throw new Error("Backup restore database did not start"); + await new Promise((done) => setTimeout(done, 1000)); + } + run("copy restore archive", "docker", [ + "cp", + join(temporary, "restored.dump"), + `${container}:/tmp/restored.dump`, + ]); + run("database restore", "docker", [ + "exec", + container, + "pg_restore", + "--host=127.0.0.1", + "--username=postgres", + "--dbname=postgres", + "--no-owner", + "--no-acl", + "--exit-on-error", + "/tmp/restored.dump", + ]); + const output = resolve("hosted/.wrangler/production-backup"); + await mkdir(output, { recursive: true, mode: 0o700 }); + const { copyFile } = await import("node:fs/promises"); + await copyFile( + encrypted, + join(output, `${new Date().toISOString().replaceAll(":", "-")}.dump.age`), + ); + console.log( + "Encrypted production backup created; decryption and PostgreSQL restore verified.", + ); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} finally { + spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" }); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/hosted/scripts/production-tag.mjs b/hosted/scripts/production-tag.mjs new file mode 100644 index 000000000..dbcdd4abd --- /dev/null +++ b/hosted/scripts/production-tag.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { appendFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function productionDay(timestamp) { + const date = new Date(timestamp); + assert.ok( + Number.isFinite(date.getTime()), + "Supply a valid deployment timestamp", + ); + return new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Los_Angeles", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); +} + +export function nextProductionTag(day, names) { + const base = `hosted/${day}`; + const pattern = new RegExp(`^${base}(?:--r([2-9]|[1-9][0-9]+))?$`); + let revision = 0; + for (const name of names) { + const match = name.match(pattern); + if (match) revision = Math.max(revision, Number(match[1] ?? 1)); + } + return revision === 0 ? base : `${base}--r${revision + 1}`; +} + +function github(path, body) { + const args = ["api", "--method", body ? "POST" : "GET", path]; + if (body) args.push("--input", "-"); + const result = spawnSync("gh", args, { + input: body ? JSON.stringify(body) : undefined, + encoding: "utf8", + }); + if (result.status !== 0) + throw new Error(`GitHub deployment tag request failed: ${path}`); + return JSON.parse(result.stdout); +} + +export async function recordDeployment( + { repository, sha, verifiedAt, deploymentId }, + api = github, +) { + assert.match(repository, /^[\w.-]+\/[\w.-]+$/); + assert.match(sha, /^[a-f0-9]{40}$/); + assert.match(deploymentId, /^[1-9]\d*\/[1-9]\d*$/); + const day = productionDay(verifiedAt); + const prefix = `repos/${repository}/git`; + const refs = await api(`${prefix}/matching-refs/tags/hosted/${day}`); + const marker = `Deployment: ${deploymentId}`; + // Retrying only the tag job must not invent another deployment. + for (const ref of refs) { + if (ref.object.type !== "tag") continue; + const tag = await api(`${prefix}/tags/${ref.object.sha}`); + if (tag.message.split("\n").includes(marker)) { + assert.equal(tag.object.type, "commit"); + assert.equal( + tag.object.sha, + sha, + "Existing deployment tag points to a different commit", + ); + return ref.ref.replace("refs/tags/", ""); + } + } + const name = nextProductionTag( + day, + refs.map((ref) => ref.ref.replace("refs/tags/", "")), + ); + const [runId, attempt] = deploymentId.split("/"); + const tag = await api(`${prefix}/tags`, { + tag: name, + object: sha, + type: "commit", + message: `Verified production deployment\n\n${marker}\nVerified at: ${new Date(verifiedAt).toISOString()}\nWorkflow: https://github.com/${repository}/actions/runs/${runId}/attempts/${attempt}\n`, + }); + // Create only: never move or overwrite a production tag. A conflict fails safely. + await api(`${prefix}/refs`, { ref: `refs/tags/${name}`, sha: tag.sha }); + return name; +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + const name = await recordDeployment({ + repository: process.env.GITHUB_REPOSITORY, + sha: process.env.BUILD_SHA, + verifiedAt: process.env.DEPLOYMENT_VERIFIED_AT, + deploymentId: process.env.DEPLOYMENT_ID, + }); + console.log(`Production deployment recorded: ${name}`); + if (process.env.GITHUB_STEP_SUMMARY) + await appendFile( + process.env.GITHUB_STEP_SUMMARY, + `Production tag: [${name}](https://github.com/${process.env.GITHUB_REPOSITORY}/tree/${name})\n`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/hosted/scripts/production-tag.test.mjs b/hosted/scripts/production-tag.test.mjs new file mode 100644 index 000000000..af2c19829 --- /dev/null +++ b/hosted/scripts/production-tag.test.mjs @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + productionDay, + nextProductionTag, + recordDeployment, +} from "./production-tag.mjs"; + +test("production dates follow Los Angeles across UTC midnight and DST", () => { + assert.equal(productionDay("2026-09-11T06:59:59Z"), "2026-09-10"); + assert.equal(productionDay("2026-09-11T07:00:00Z"), "2026-09-11"); + assert.equal(productionDay("2026-01-02T07:59:59Z"), "2026-01-01"); + assert.equal(productionDay("2026-01-02T08:00:00Z"), "2026-01-02"); + assert.throws(() => productionDay("invalid")); +}); + +test("daily revisions increase numerically without reusing gaps or unrelated tag names", () => { + const day = "2026-09-10"; + assert.equal(nextProductionTag(day, []), `hosted/${day}`); + assert.equal(nextProductionTag(day, [`hosted/${day}`]), `hosted/${day}--r2`); + assert.equal( + nextProductionTag(day, [`hosted/${day}--r2`, `hosted/${day}--r10`]), + `hosted/${day}--r11`, + ); + assert.equal( + nextProductionTag(day, [ + "hosted/2026-09-09--r20", + `hosted/${day}-unrelated`, + ]), + `hosted/${day}`, + ); +}); + +const deployment = { + repository: "test/repo", + sha: "a".repeat(40), + verifiedAt: "2026-09-10T22:49:39Z", + deploymentId: "123/2", +}; + +function fakeGithub() { + const refs = []; + const tags = new Map(); + const writes = []; + return { + refs, + tags, + writes, + api: async (path, body) => { + if (!body && path.includes("matching-refs/")) return refs; + if (!body) return tags.get(path.split("/").at(-1)); + writes.push({ path, body }); + if (path.endsWith("/tags")) { + const sha = String(tags.size + 1).padStart(40, "0"); + tags.set(sha, { + message: body.message, + object: { type: body.type, sha: body.object }, + }); + return { sha }; + } + assert.ok(path.endsWith("/refs")); + assert.equal( + refs.some((ref) => ref.ref === body.ref), + false, + "Must never overwrite a ref", + ); + refs.push({ ref: body.ref, object: { type: "tag", sha: body.sha } }); + return {}; + }, + }; +} + +test("annotated tags record the deployed SHA and run, retry idempotently, and distinguish redeployments", async () => { + const github = fakeGithub(); + assert.equal( + await recordDeployment(deployment, github.api), + "hosted/2026-09-10", + ); + assert.equal(github.writes[0].body.object, deployment.sha); + assert.match(github.writes[0].body.message, /runs\/123\/attempts\/2/); + assert.equal( + await recordDeployment(deployment, github.api), + "hosted/2026-09-10", + ); + assert.equal(github.writes.length, 2); + assert.equal( + await recordDeployment( + { ...deployment, deploymentId: "123/3" }, + github.api, + ), + "hosted/2026-09-10--r2", + ); + await assert.rejects( + recordDeployment({ ...deployment, sha: "b".repeat(40) }, github.api), + /different commit/, + ); + assert.equal(github.writes.length, 4); +}); + +test("a ref creation failure never falls back to updating an existing tag", async () => { + const calls = []; + await assert.rejects( + recordDeployment(deployment, async (path, body) => { + calls.push(path); + if (!body) return []; + if (path.endsWith("/tags")) return { sha: "c".repeat(40) }; + throw new Error("conflict"); + }), + /conflict/, + ); + assert.equal(calls.length, 3); +}); diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs new file mode 100644 index 000000000..b054afbf5 --- /dev/null +++ b/hosted/scripts/production.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { required, cloudflare, hyperdriveOrigin } from "./preview.mjs"; +import { smoke } from "./preview-smoke.mjs"; + +const root = new URL("../", import.meta.url); +export function productionConfig(base, env) { + assert.match(required(env, "BUILD_SHA"), /^[a-f0-9]{40}$/); + assert.match(required(env, "HYPERDRIVE_ID"), /^[a-f0-9]{32}$/); + assert.notEqual( + env.HYPERDRIVE_ID, + "0".repeat(32), + "Provision production Hyperdrive first", + ); + assert.match(required(env, "CLOUDFLARE_ACCOUNT_ID"), /^[a-f0-9]{32}$/); + assert.equal(base.name, "dormouse-hosted"); + assert.equal(base.vars.APP_ORIGIN, "https://hosted.dormouse.sh"); + assert.equal(base.workers_dev, false); + assert.equal(base.preview_urls, false); + return { + ...base, + main: "../../server/worker.ts", + assets: { ...base.assets, directory: "../../dist" }, + vars: { ...base.vars, BUILD_SHA: env.BUILD_SHA }, + hyperdrive: [{ binding: "HYPERDRIVE", id: env.HYPERDRIVE_ID }], + }; +} +export async function verifyPackages() { + const manifest = JSON.parse( + await readFile(new URL("../../vendor/build.json", import.meta.url), "utf8"), + ); + assert.equal( + manifest.dirty, + false, + "Production requires accepted, clean pgstencil provenance; refresh the vendored packages first", + ); + assert.match(manifest.commit, /^[a-f0-9]{40}$/); + assert.deepEqual( + manifest.files.map((entry) => entry.filename).sort(), + ["pgstencil-0.1.0.tgz", "pgstencil-auth-0.1.0.tgz"], + "Expected both pinned pgstencil archives", + ); + for (const entry of manifest.files) { + assert.match(entry.filename, /^pgstencil(?:-auth)?-[\w.-]+\.tgz$/); + const bytes = await readFile( + new URL(`../../vendor/${entry.filename}`, import.meta.url), + ); + assert.equal( + createHash("sha256").update(bytes).digest("hex"), + entry.sha256, + "Vendored archive checksum mismatch", + ); + } +} +export async function preflight(env, config, api = cloudflare(env)) { + const origin = hyperdriveOrigin(required(env, "DATABASE_URL")); + const { result } = await api(`hyperdrive/configs/${env.HYPERDRIVE_ID}`); + assert.equal( + result.caching?.disabled, + true, + "Production Hyperdrive must disable caching", + ); + assert.equal( + result.origin.host, + origin.host, + "Migration and runtime databases must use the same host", + ); + assert.equal( + result.origin.database, + origin.database, + "Migration and runtime databases must match", + ); + assert.notEqual( + result.origin.user, + origin.user, + "Use separate runtime and migration roles", + ); + const { result: bindings } = await api( + "workers/scripts/dormouse-hosted/secrets", + ); + const names = new Set(bindings.map((item) => item.name)); + const requiredSecrets = ["AUTH_SECRET", "POSTMARK_SERVER_TOKEN"]; + for (const provider of config.vars.OAUTH_PROVIDERS.split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + assert.ok( + ["github", "google", "microsoft", "apple"].includes(provider), + "Unknown OAuth provider", + ); + requiredSecrets.push( + `${provider.toUpperCase()}_CLIENT_ID`, + `${provider.toUpperCase()}_CLIENT_SECRET`, + ); + } + for (const name of requiredSecrets) + assert.ok(names.has(name), `Missing Worker secret: ${name}`); +} +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + const base = JSON.parse( + await readFile(new URL("wrangler.jsonc", root), "utf8"), + ); + const config = productionConfig(base, process.env); + const action = process.argv[2]; + if (action === "smoke") { + await smoke( + config.vars.APP_ORIGIN, + process.env.BUILD_SHA, + fetch, + false, + config.vars.OAUTH_PROVIDERS.split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + console.log("Hosted production revision and auth boundary verified."); + } else if (action === "preflight" || action === "deploy") { + await verifyPackages(); + await preflight(process.env, config); + if (action === "deploy") { + const directory = new URL(".wrangler/production/", root); + await mkdir(directory, { recursive: true }); + const path = new URL("wrangler.json", directory); + await writeFile(path, JSON.stringify(config, null, 2) + "\n"); + const run = spawnSync( + "pnpm", + ["exec", "wrangler", "deploy", "--config", fileURLToPath(path)], + { + cwd: fileURLToPath(root), + stdio: "inherit", + }, + ); + await rm(path, { force: true }); + assert.equal(run.status, 0, "Hosted deploy failed"); + } + } else throw new Error("Use preflight, deploy or smoke"); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs new file mode 100644 index 000000000..7a6463df6 --- /dev/null +++ b/hosted/scripts/production.test.mjs @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { productionConfig, preflight } from "./production.mjs"; +const base = JSON.parse( + await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), +); +const env = { + BUILD_SHA: "a".repeat(40), + HYPERDRIVE_ID: "b".repeat(32), + CLOUDFLARE_ACCOUNT_ID: "c".repeat(32), + DATABASE_URL: "postgres://migration:synthetic@ep-production.neon.tech/neondb", +}; +const config = productionConfig(base, env); +test("production config keeps canonical domain and production entry, excludes public aliases", () => { + assert.equal(config.main, "../../server/worker.ts"); + assert.equal(config.vars.APP_ORIGIN, "https://hosted.dormouse.sh"); + assert.equal(config.vars.BUILD_SHA, env.BUILD_SHA); + assert.equal(config.workers_dev, false); + assert.equal(config.preview_urls, false); + assert.equal(config.hyperdrive[0].id, env.HYPERDRIVE_ID); + assert.throws(() => + productionConfig(base, { ...env, HYPERDRIVE_ID: "0".repeat(32) }), + ); + assert.throws(() => productionConfig(base, { ...env, BUILD_SHA: "main" })); + assert.throws(() => productionConfig({ ...base, workers_dev: true }, env)); +}); +function provider({ + host = "ep-production.neon.tech", + database = "neondb", + user = "runtime", + disabled = true, + secrets = ["AUTH_SECRET", "POSTMARK_SERVER_TOKEN"], +} = {}) { + return async (path) => { + if (path.startsWith("hyperdrive/configs/")) + return { + result: { origin: { host, database, user }, caching: { disabled } }, + }; + assert.equal(path, "workers/scripts/dormouse-hosted/secrets"); + return { result: secrets.map((name) => ({ name })) }; + }; +} +test("preflight rejects wrong databases, caching, reused roles, and incomplete provider secrets", async () => { + await preflight(env, config, provider()); + for (const override of [ + { host: "ep-preview.neon.tech" }, + { database: "preview" }, + { user: "migration" }, + { disabled: false }, + { secrets: ["AUTH_SECRET"] }, + ]) + await assert.rejects(preflight(env, config, provider(override))); + const oauth = { + ...config, + vars: { ...config.vars, OAUTH_PROVIDERS: "github" }, + }; + await assert.rejects(preflight(env, oauth, provider())); + await preflight( + env, + oauth, + provider({ + secrets: [ + "AUTH_SECRET", + "POSTMARK_SERVER_TOKEN", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + ], + }), + ); + await assert.rejects( + preflight( + env, + { ...config, vars: { ...config.vars, OAUTH_PROVIDERS: "unknown" } }, + provider(), + ), + ); +}); diff --git a/hosted/scripts/setup-github.mjs b/hosted/scripts/setup-github.mjs new file mode 100644 index 000000000..2ff606d10 --- /dev/null +++ b/hosted/scripts/setup-github.mjs @@ -0,0 +1,58 @@ +import { spawnSync } from "node:child_process"; + +// Uses the operator's existing gh keychain authentication. Never handles secret values. +const repository = "diffplug/dormouse"; +function api(path, method = "GET", body) { + const args = ["api", "--method", method, `repos/${repository}/${path}`]; + if (body) args.push("--input", "-"); + const result = spawnSync("gh", args, { + input: body && JSON.stringify(body), + encoding: "utf8", + }); + if (result.status !== 0) + throw new Error(`GitHub setup failed: ${method} ${path}`); + return result.stdout ? JSON.parse(result.stdout) : undefined; +} +// Reviewed preview code can receive only dedicated test credentials. Production and tag +// identities additionally require main, preserving the repository's admin-only merge gate. +const reviewers = [ + { type: "User", id: 2924992 }, + { type: "User", id: 68454991 }, +]; +for (const name of [ + "hosted-preview", + "hosted-production", + "hosted-release-tag", +]) { + api(`environments/${name}`, "PUT", { + reviewers, + prevent_self_review: false, + can_admins_bypass: false, + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: true, + }, + }); + const expected = + name === "hosted-preview" ? ["main", "refs/pull/*/merge"] : ["main"]; + const policies = api( + `environments/${name}/deployment-branch-policies`, + ).branch_policies; + for (const policy of policies) { + if (policy.type !== "branch" || !expected.includes(policy.name)) + throw new Error( + `Unexpected deployment policy in ${name}; review it before continuing`, + ); + } + for (const pattern of expected) { + if (!policies.some((p) => p.name === pattern)) + api(`environments/${name}/deployment-branch-policies`, "POST", { + name: pattern, + type: "branch", + }); + } + console.log(`Configured ${name}`); +} +console.log( + "Next: hosted/DEPLOYMENT.md. Previews remain disabled until HOSTED_PREVIEWS_ENABLED=true.", +); diff --git a/hosted/server/db.ts b/hosted/server/db.ts new file mode 100644 index 000000000..24848f70f --- /dev/null +++ b/hosted/server/db.ts @@ -0,0 +1,25 @@ +import { + readMigrations, + migrate, + validateMigrations, + appliedMigrations, +} from "pgstencil/database"; +import { migrations } from "./migrations"; +import { previewMigrations } from "./preview-migrations"; +const url = process.env.DATABASE_URL; +if (!url) + throw new Error( + "Set DATABASE_URL using your secret manager; never put it in a command argument.", + ); +const files = await readMigrations( + process.argv.includes("--preview") ? previewMigrations : migrations, +); +const action = process.argv[2]; +if (action === "migrate") await migrate(url, files); +else if (action === "validate") await validateMigrations(url, files); +else if (action === "status") { + const applied = new Set(await appliedMigrations(url)); + console.table( + files.map((file) => ({ name: file.name, applied: applied.has(file.name) })), + ); +} else throw new Error("Use migrate, validate or status."); diff --git a/hosted/server/dev-host-guard.ts b/hosted/server/dev-host-guard.ts new file mode 100644 index 000000000..896f4d31b --- /dev/null +++ b/hosted/server/dev-host-guard.ts @@ -0,0 +1,13 @@ +import type { IncomingMessage } from "node:http"; + +// The local inbox holds login codes. Loopback binding alone is not access control. +export function allowedDevRequest( + request: IncomingMessage, + origin: string, +): boolean { + return ( + request.headers.host === new URL(origin).host && + (!request.headers.origin || request.headers.origin === origin) && + request.headers["sec-fetch-site"] !== "cross-site" + ); +} diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts new file mode 100644 index 000000000..eb328171a --- /dev/null +++ b/hosted/server/dev.ts @@ -0,0 +1,60 @@ +import { createServer } from "node:http"; +import { getRequestListener } from "@hono/node-server"; +import { createServer as createViteServer } from "vite"; +import { createAuthApp } from "@pgstencil/auth/better-auth"; +import { developmentDatabase } from "pgstencil/database"; +import { EmailDev, SystemTime } from "pgstencil"; +import { authPolicy } from "./policy"; +import { migrations } from "./migrations"; +import { allowedDevRequest } from "./dev-host-guard"; + +const port = Number(process.env.PORT ?? 5188); +const origin = `http://127.0.0.1:${port}`; +const email = new EmailDev(new SystemTime()); +const auth = createAuthApp({ + ...authPolicy, + databaseUrl: await developmentDatabase(true, migrations), + origin, + secret: "dormouse-hosted-local-development-only", + email, +}); +auth.app.get("/api/dev/emails", (c) => + c.json(email.all().map(({ to, text }) => ({ to, text }))), +); +const listener = getRequestListener((request) => auth.app.fetch(request)); +const server = createServer((request, response) => { + if (!allowedDevRequest(request, origin)) { + response.writeHead(403).end("Local development origin required."); + return; + } + if (request.url?.startsWith("/api/")) { + void listener(request, response); + return; + } + vite.middlewares(request, response, () => response.writeHead(404).end()); +}); +server.on("upgrade", (request, socket) => { + if (!allowedDevRequest(request, origin)) socket.destroy(); +}); +const vite = await createViteServer({ + server: { + middlewareMode: true, + hmr: { server }, + cors: { origin }, + allowedHosts: ["127.0.0.1"], + }, + appType: "spa", +}); +server.listen(port, "127.0.0.1", () => { + console.log( + `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.`, + ); +}); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.once(signal, async () => { + server.close(); + await vite.close(); + await auth.close(); + email.close(); + process.exit(0); + }); diff --git a/hosted/server/headers.ts b/hosted/server/headers.ts new file mode 100644 index 000000000..25c61ac21 --- /dev/null +++ b/hosted/server/headers.ts @@ -0,0 +1,21 @@ +import type { Hono } from "hono"; + +// Applied to the HTML shell as well as APIs: auth's own middleware only covers its routes. +export function secureHeaders(app: Hono, development = false) { + app.use("*", async (c, next) => { + await next(); + c.header("Cache-Control", "no-store"); + c.header("Referrer-Policy", "no-referrer"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("X-Frame-Options", "DENY"); + c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + c.header("X-Robots-Tag", "noindex, nofollow"); + if (!development) { + c.header("Strict-Transport-Security", "max-age=31536000"); + c.header( + "Content-Security-Policy", + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'", + ); + } + }); +} diff --git a/hosted/server/migrations.ts b/hosted/server/migrations.ts new file mode 100644 index 000000000..4690fb0c9 --- /dev/null +++ b/hosted/server/migrations.ts @@ -0,0 +1,2 @@ +import { betterAuthMigrations } from "@pgstencil/auth/better-auth-migrations"; +export const migrations = [betterAuthMigrations]; diff --git a/hosted/server/policy.ts b/hosted/server/policy.ts new file mode 100644 index 000000000..cc275f802 --- /dev/null +++ b/hosted/server/policy.ts @@ -0,0 +1,35 @@ +import type { AuthAppOptions } from "@pgstencil/auth/better-auth"; + +export const authPolicy = { + appName: "Dormouse Hosted", + sessionPolicy: "multiple", + accountLinking: "explicit", + allowMissingEmail: true, + rememberLoginMethod: false, + successPath: "/account", + errorPath: "/login", +} satisfies Partial; + +export const providerIds = ["github", "google", "microsoft", "apple"] as const; +export type ProviderId = (typeof providerIds)[number]; + +// Only an explicit deployment allowlist enables a provider; stale secrets do not. +export function providerBindings(env: Record) { + const enabled = String(env.OAUTH_PROVIDERS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const bindings: Record = {}; + for (const provider of enabled) { + if (!providerIds.includes(provider as ProviderId)) + throw new Error("Unknown OAuth provider"); + for (const suffix of ["CLIENT_ID", "CLIENT_SECRET"]) { + const key = `${provider.toUpperCase()}_${suffix}`; + const value = env[key]; + if (typeof value !== "string" || !value.trim()) + throw new Error(`Missing ${key}`); + bindings[key] = value; + } + } + return bindings; +} diff --git a/hosted/server/preview-inbox.ts b/hosted/server/preview-inbox.ts new file mode 100644 index 000000000..f51c76787 --- /dev/null +++ b/hosted/server/preview-inbox.ts @@ -0,0 +1,61 @@ +import { queryDatabase } from "pgstencil/postgres"; +import type { EmailMessage, EmailSender } from "pgstencil"; +import { escape } from "@pgstencil/auth/email"; + +export interface InboxMessage extends EmailMessage { + id: string; + capturedAt: string; +} + +// Each operation closes its pool; mail survives Worker isolate replacement. +export function postgresInbox(url: string): EmailSender & { + all(): Promise; + get(id: string): Promise; +} { + const read = async (id?: string) => { + const rows = await queryDatabase<{ + id: string; + captured_at: Date; + message: EmailMessage; + }>( + url, + `SELECT id, captured_at, message FROM preview.email_messages + WHERE captured_at > now() - interval '24 hours' + ${id ? "AND id = $1" : ""} ORDER BY id DESC LIMIT 100`, + id ? [id] : [], + ); + return rows.map((row) => ({ + ...row.message, + id: row.id, + capturedAt: row.captured_at.toISOString(), + })); + }; + return { + async send(message) { + await queryDatabase( + url, + `WITH expired AS ( + DELETE FROM preview.email_messages WHERE captured_at <= now() - interval '24 hours' + ) INSERT INTO preview.email_messages (message) VALUES ($1::jsonb)`, + [JSON.stringify(message)], + ); + }, + all: () => read(), + get: async (id) => (await read(id))[0], + }; +} + +function page(body: string) { + return `Preview inbox · Dormouse
${body}
`; +} +export function inboxPage(messages: InboxMessage[]) { + return page( + `

Preview inbox

Public test inbox for this PR. Use disposable addresses. Nothing is sent to a real mailbox. Latest 100 messages from the last 24 hours.

    ${messages.map((mail) => `
  1. ${escape(mail.subject)} — ${escape(mail.to.join(", "))} — ${escape(mail.capturedAt)}
  2. `).join("")}
`, + ); +} +export function messagePage(mail: InboxMessage) { + // Render escaped text only: never execute captured HTML or email links. + return page( + `Back to inbox

${escape(mail.subject)}

To ${escape(mail.to.join(", "))}

${escape(mail.text)}
`, + ); +} diff --git a/hosted/server/preview-migrations.ts b/hosted/server/preview-migrations.ts new file mode 100644 index 000000000..d8da73a07 --- /dev/null +++ b/hosted/server/preview-migrations.ts @@ -0,0 +1,6 @@ +import { fileURLToPath } from "node:url"; +import { migrations } from "./migrations.ts"; +export const previewMigrations = [ + ...migrations, + fileURLToPath(new URL("./preview-migrations/", import.meta.url)), +]; diff --git a/hosted/server/preview-migrations/001_preview_email.sql b/hosted/server/preview-migrations/001_preview_email.sql new file mode 100644 index 000000000..d5467461e --- /dev/null +++ b/hosted/server/preview-migrations/001_preview_email.sql @@ -0,0 +1,12 @@ +-- Up Migration +CREATE SCHEMA preview; +CREATE TABLE preview.email_messages ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + captured_at timestamptz NOT NULL DEFAULT now(), + message jsonb NOT NULL +); +CREATE INDEX email_messages_captured_at ON preview.email_messages (captured_at); + +-- Down Migration +DROP TABLE preview.email_messages; +DROP SCHEMA preview; diff --git a/hosted/server/preview-worker.ts b/hosted/server/preview-worker.ts new file mode 100644 index 000000000..dd3140bc7 --- /dev/null +++ b/hosted/server/preview-worker.ts @@ -0,0 +1,49 @@ +import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers"; +import { authPolicy } from "./policy"; +import { workerApp } from "./worker-app"; +import { postgresInbox, inboxPage, messagePage } from "./preview-inbox"; +import type { Env } from "./worker"; + +const auth = createBetterAuthWorker({ + ...authPolicy, + email: (env) => postgresInbox(env.HYPERDRIVE.connectionString), +}); +const app = workerApp( + (request, env, ctx) => auth.fetch(request, env, ctx), + (app) => { + app.get("/api/dev/emails", async (c) => + c.json(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()), + ); + app.get("/dev/emails", async (c) => + c.html( + inboxPage(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()), + ), + ); + app.get("/dev/emails/:id", async (c) => { + const id = c.req.param("id"); + if (!/^[1-9]\d{0,17}$/.test(id)) return c.notFound(); + const mail = await postgresInbox(c.env.HYPERDRIVE.connectionString).get( + id, + ); + return mail ? c.html(messagePage(mail)) : c.notFound(); + }); + }, +); +export default { + fetch(request: Request, env: Env, ctx: Parameters[2]) { + // Ignore stale production/OAuth bindings on an existing preview Worker. + return app.fetch( + request, + { + HYPERDRIVE: env.HYPERDRIVE, + ASSETS: env.ASSETS, + APP_ORIGIN: env.APP_ORIGIN, + AUTH_SECRET: env.AUTH_SECRET, + BUILD_SHA: env.BUILD_SHA, + EMAIL_FROM: "", + POSTMARK_SERVER_TOKEN: "", + }, + ctx, + ); + }, +}; diff --git a/hosted/server/tests/artifacts.test.ts b/hosted/server/tests/artifacts.test.ts new file mode 100644 index 000000000..53cd120ea --- /dev/null +++ b/hosted/server/tests/artifacts.test.ts @@ -0,0 +1,39 @@ +import { test, expect } from "vitest"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +test("consumed package bytes match the recorded source snapshot", () => { + const provenance = JSON.parse( + readFileSync("../vendor/build.json", "utf8"), + ) as { + commit: string; + files: { filename: string; sha256: string }[]; + }; + expect(provenance.commit).toMatch(/^[a-f0-9]{40}$/); + expect(provenance.files.map((file) => file.filename).sort()).toEqual([ + "pgstencil-0.1.0.tgz", + "pgstencil-auth-0.1.0.tgz", + ]); + for (const file of provenance.files) + expect( + createHash("sha256") + .update(readFileSync("../vendor/" + file.filename)) + .digest("hex"), + ).toBe(file.sha256); + // Same-version tarball refreshes must update installed code as well as metadata. + for (const [archive, entry] of [ + ["pgstencil-0.1.0.tgz", "pgstencil"], + ["pgstencil-auth-0.1.0.tgz", "@pgstencil/auth/better-auth"], + ]) { + const file = fileURLToPath(import.meta.resolve(entry)); + const archivePath = `package/dist/${file.split("/").at(-1)}`; + const packed = execFileSync("tar", [ + "-xOf", + "../vendor/" + archive, + archivePath, + ]); + expect(readFileSync(file)).toEqual(packed); + } +}); diff --git a/hosted/server/tests/oauth-server.ts b/hosted/server/tests/oauth-server.ts new file mode 100644 index 000000000..594102bcc --- /dev/null +++ b/hosted/server/tests/oauth-server.ts @@ -0,0 +1,357 @@ +// Synced from pgstencil test fixtures for provider protocol parity. +import { createServer } from "node:http"; +import { once } from "node:events"; +import { createHash, createHmac, generateKeyPairSync, sign } from "node:crypto"; +import type { OAuthFetch } from "@pgstencil/auth/oauth-providers"; +import type { + Provider, + OAuthSettings, +} from "@pgstencil/auth/better-auth-oauth"; + +export const allOAuthCredentials = { + google: { + clientId: "test-google-client", + clientSecret: "test-google-secret", + }, + apple: { clientId: "test-apple-client", clientSecret: "test-apple-secret" }, + facebook: { + clientId: "test-facebook-client", + clientSecret: "test-facebook-secret", + }, + github: { + clientId: "test-github-client", + clientSecret: "test-github-secret", + }, +} satisfies OAuthSettings; +export const microsoftTenant = "9188040d-6c67-4c5b-b112-36a304b66dad"; +export const betterAuthCredentials = { + ...allOAuthCredentials, + microsoft: { + clientId: "test-microsoft-client", + clientSecret: "test-microsoft-secret", + }, +} satisfies OAuthSettings; +export const oauthCredentials = { + google: allOAuthCredentials.google, + github: allOAuthCredentials.github, +}; +const key = generateKeyPairSync("rsa", { modulusLength: 2048 }); +// Only the badSignature grant needs a second key; generating it is ~40ms. +let wrongKey: typeof key | undefined; +const otherKey = () => + (wrongKey ??= generateKeyPairSync("rsa", { modulusLength: 2048 })); +const jwk = { + ...key.publicKey.export({ format: "jwk" }), + kid: "test-key", + use: "sig", + alg: "RS256", +}; +export const endpointPaths: Record = { + "https://login.microsoftonline.com/common/oauth2/v2.0/token": + "/microsoft/token", + "https://login.microsoftonline.com/common/discovery/v2.0/keys": "/keys", + "https://appleid.apple.com/.well-known/openid-configuration": + "/apple/discovery", + "https://appleid.apple.com/auth/token": "/apple/token", + "https://appleid.apple.com/auth/keys": "/keys", + "https://graph.facebook.com/oauth/access_token": "/facebook/token", + "https://graph.facebook.com/me": "/facebook/user", + "https://graph.facebook.com/debug_token": "/facebook/debug", + "https://graph.facebook.com/v24.0/oauth/access_token": "/facebook/token", + "https://accounts.google.com/.well-known/openid-configuration": "/discovery", + "https://oauth2.googleapis.com/token": "/google/token", + "https://www.googleapis.com/oauth2/v3/certs": "/keys", + "https://github.com/login/oauth/access_token": "/github/token", + "https://api.github.com/user": "/user", + "https://api.github.com/user/emails": "/emails", +}; +export interface GrantOptions { + subject?: string; + email?: string; + verified?: boolean; + claims?: Record; + badSignature?: boolean; + missingIdToken?: boolean; + tokenFailure?: boolean; + profileFailure?: boolean; + githubEmails?: unknown; +} +interface Grant { + provider: Provider; + authorization: URL; + options: GrantOptions; +} + +/** Real local HTTP endpoints, with test-only signing keys and dummy OAuth clients. */ +export async function mockOAuthServer( + settings: { betterAuth?: boolean; now?: () => Date } = {}, +) { + const grants = new Map(); + const accessTokens = new Map(); + const requests: { + url: string; + method: string; + body: string; + headers: Record; + }[] = []; + let sequence = 0; + let discoveryFailure = false; + const server = createServer((req, res) => { + void (async () => { + const url = new URL(req.url!, "http://mock.test"); + let body = ""; + for await (const chunk of req) body += String(chunk); + const json = (value: unknown, status = 200) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); + }; + if (url.pathname.endsWith("/discovery")) { + if (discoveryFailure) return json({ error: "unavailable" }, 503); + const apple = url.pathname.startsWith("/apple"); + return json({ + issuer: apple + ? "https://appleid.apple.com" + : "https://accounts.google.com", + authorization_endpoint: apple + ? "https://appleid.apple.com/auth/authorize" + : "https://accounts.google.com/o/oauth2/v2/auth", + token_endpoint: apple + ? "https://appleid.apple.com/auth/token" + : "https://oauth2.googleapis.com/token", + jwks_uri: apple + ? "https://appleid.apple.com/auth/keys" + : "https://www.googleapis.com/oauth2/v3/certs", + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + ...(apple ? {} : { code_challenge_methods_supported: ["S256"] }), + }); + } + if (url.pathname === "/keys") return json({ keys: [jwk] }); + if (url.pathname.endsWith("/token")) { + const form = new URLSearchParams(body); + const code = form.get("code") ?? ""; + const grant = grants.get(code); + grants.delete(code); + if (!grant) return json({ error: "invalid_grant" }, 400); + const credentials = betterAuthCredentials[grant.provider]; + const challenge = createHash("sha256") + .update(form.get("code_verifier") ?? "") + .digest("base64url"); + if ( + req.method !== "POST" || + url.pathname !== `/${grant.provider}/token` || + form.get("grant_type") !== "authorization_code" || + form.get("client_id") !== credentials.clientId || + form.get("client_secret") !== credentials.clientSecret || + form.get("redirect_uri") !== + grant.authorization.searchParams.get("redirect_uri") || + (settings.betterAuth + ? grant.authorization.searchParams.has("code_challenge") && + challenge !== + grant.authorization.searchParams.get("code_challenge") + : grant.provider === "google" || grant.provider === "github" + ? challenge !== + grant.authorization.searchParams.get("code_challenge") + : form.has("code_verifier")) || + grant.options.tokenFailure + ) + return json( + { + error: "invalid_grant", + error_description: "synthetic-secret-never-render-this", + }, + 400, + ); + const accessToken = `mock-access-${code}`; + accessTokens.set(accessToken, grant); + const response: Record = { + access_token: accessToken, + token_type: "Bearer", + scope: + grant.provider === "google" + ? "openid email" + : "read:user,user:email", + }; + if ( + ["google", "apple", "microsoft"].includes(grant.provider) && + !grant.options.missingIdToken + ) { + const now = Math.floor( + (settings.now?.().getTime() ?? Date.now()) / 1000, + ); // Upstream protocol clock, independent of application DevTime. + const claims = { + iss: + grant.provider === "apple" + ? "https://appleid.apple.com" + : "https://accounts.google.com", + aud: credentials.clientId, + sub: grant.options.subject ?? `${grant.provider}-person-1`, + email: grant.options.email ?? "oauth@example.test", + email_verified: grant.options.verified ?? true, + nonce: grant.authorization.searchParams.get("nonce"), + iat: now, + exp: now + 3600, + ...(grant.provider === "microsoft" + ? { + iss: `https://login.microsoftonline.com/${microsoftTenant}/v2.0`, + tid: microsoftTenant, + oid: + grant.options.subject ?? + "11111111-1111-4111-8111-111111111111", + name: "Mock Microsoft", + // Real Microsoft tokens typically use optional xms_edov, not email_verified. + email_verified: undefined, + xms_edov: grant.options.verified ?? true, + } + : {}), + ...grant.options.claims, + }; + const unsigned = [ + Buffer.from( + JSON.stringify({ alg: "RS256", kid: "test-key" }), + ).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + ].join("."); + response.id_token = `${unsigned}.${sign("RSA-SHA256", Buffer.from(unsigned), grant.options.badSignature ? otherKey().privateKey : key.privateKey).toString("base64url")}`; + } + return json(response); + } + if (url.pathname === "/facebook/debug") { + const grant = accessTokens.get( + url.searchParams.get("input_token") ?? "", + ); + const credentials = allOAuthCredentials.facebook; + const valid = + !!grant && + url.searchParams.get("access_token") === + `${credentials.clientId}|${credentials.clientSecret}`; + return json({ + data: { + is_valid: valid, + app_id: credentials.clientId, + user_id: grant?.options.subject ?? "12345", + }, + }); + } + const accessToken = + req.headers.authorization?.replace(/^Bearer /i, "") ?? ""; + const grant = accessTokens.get(accessToken); + if (grant?.provider === "facebook" && url.pathname === "/facebook/user") { + const expected = createHmac( + "sha256", + allOAuthCredentials.facebook.clientSecret, + ) + .update(accessToken) + .digest("hex"); + if ( + !settings.betterAuth && + (url.searchParams.get("appsecret_proof") !== expected || + url.searchParams.get("fields") !== "id,email") + ) + return json({ error: "invalid_proof" }, 400); + if (grant.options.profileFailure) + return json({ error: "unavailable" }, 503); + return json({ + id: grant.options.subject ?? "12345", + ...(settings.betterAuth + ? { + name: "Mock Facebook", + picture: { data: { url: "https://example.test/avatar" } }, + } + : {}), + email: grant.options.email ?? "oauth@example.test", + }); + } + if (!grant || grant.provider !== "github") + return json({ error: "unauthorized" }, 401); + if (grant.options.profileFailure) + return json({ error: "unavailable" }, 503); + if (url.pathname === "/user") + return json({ + id: Number(grant.options.subject ?? "12345"), + login: "changeable-handle", + email: "untrusted-public@example.test", + }); + if (url.pathname === "/emails") { + const addresses = grant.options.githubEmails ?? [ + { + email: grant.options.email ?? "oauth@example.test", + primary: true, + verified: grant.options.verified ?? true, + visibility: "private", + }, + ]; + const page = Number(url.searchParams.get("page") ?? 1); + return json( + Array.isArray(addresses) + ? addresses.slice((page - 1) * 100, page * 100) + : addresses, + ); + } + json({ error: "not_found" }, 404); + })().catch(() => { + res.writeHead(500); + res.end(); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing mock server address"); + const origin = `http://127.0.0.1:${address.port}`; + const transport: OAuthFetch = async (input, init) => { + const url = new URL(input); + const path = endpointPaths[url.origin + url.pathname]; + if (!path) + throw new Error( + `Unexpected OAuth network destination: ${url.origin}${url.pathname}`, + ); + requests.push({ + url: url.href, + method: init.method, + headers: { ...init.headers }, + body: String(init.body ?? ""), + }); + const { body, ...options } = init; + return fetch(origin + path + url.search, { + ...options, + ...(body === undefined + ? {} + : { + body: + body instanceof Uint8Array ? new Uint8Array(body).buffer : body, + }), + }); + }; + return { + transport, + origin, + requests, + failDiscovery(value: boolean) { + discoveryFailure = value; + }, + authorize( + provider: Provider, + authorization: URL, + options: GrantOptions = {}, + ): URL { + const code = `code-${provider}-${++sequence}`; + grants.set(code, { provider, authorization, options }); + const callback = new URL(authorization.searchParams.get("redirect_uri")!); + callback.searchParams.set( + "state", + authorization.searchParams.get("state")!, + ); + callback.searchParams.set("code", code); + return callback; + }, + async close() { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }, + }; +} diff --git a/hosted/server/tests/policy.test.ts b/hosted/server/tests/policy.test.ts new file mode 100644 index 000000000..e080bcbf7 --- /dev/null +++ b/hosted/server/tests/policy.test.ts @@ -0,0 +1,29 @@ +import { test, expect } from "vitest"; +import { providerBindings } from "../policy"; +import { allowedDevRequest } from "../dev-host-guard"; +import type { IncomingMessage } from "node:http"; +test("provider allowlist fails closed on typos and partial credentials", () => { + expect( + providerBindings({ + GOOGLE_CLIENT_ID: "stale", + GOOGLE_CLIENT_SECRET: "stale", + }), + ).toEqual({}); + expect(() => providerBindings({ OAUTH_PROVIDERS: "facebook" })).toThrow(); + expect(() => + providerBindings({ OAUTH_PROVIDERS: "github", GITHUB_CLIENT_ID: "id" }), + ).toThrow(); +}); +test("local inbox is guarded against rebinding and cross-origin requests", () => { + const origin = "http://127.0.0.1:5188"; + const check = (headers: IncomingMessage["headers"]) => + allowedDevRequest({ headers } as IncomingMessage, origin); + expect(check({ host: "127.0.0.1:5188" })).toBe(true); + expect(check({ host: "attacker.test:5188" })).toBe(false); + expect(check({ host: "127.0.0.1:5188", origin: "https://dormouse.sh" })).toBe( + false, + ); + expect( + check({ host: "127.0.0.1:5188", "sec-fetch-site": "cross-site" }), + ).toBe(false); +}); diff --git a/hosted/server/tests/worker-entry.ts b/hosted/server/tests/worker-entry.ts new file mode 100644 index 000000000..70f1a062d --- /dev/null +++ b/hosted/server/tests/worker-entry.ts @@ -0,0 +1,20 @@ +import { DevTime, DevRandom } from "pgstencil"; +import { deterministicScope } from "@pgstencil/auth/better-auth-testing"; +import worker from "../worker"; +const time = new DevTime(); +const scope = { time, random: new DevRandom("dormouse-hosted-test") }; +export default { + fetch( + request: Request, + env: Parameters[1], + ctx: Parameters[2], + ) { + if (new URL(request.url).pathname === "/__test/time") { + return request.text().then((value) => { + time.set(value); + return new Response("ok"); + }); + } + return deterministicScope.run(scope, () => worker.fetch(request, env, ctx)); + }, +}; diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts new file mode 100644 index 000000000..8461c855d --- /dev/null +++ b/hosted/server/tests/workers.test.ts @@ -0,0 +1,456 @@ +import { test, expect } from "vitest"; +import { build } from "esbuild"; +import { builtinModules } from "node:module"; +import { fileURLToPath } from "node:url"; +import { + Miniflare, + convertV4MiniflareOptions, + Response as WorkerResponse, +} from "miniflare"; +import { createTestContext } from "pgstencil/testing"; +import { queryDatabase } from "pgstencil/postgres"; +import { migrations } from "../migrations"; +import { previewMigrations } from "../preview-migrations"; +import { postgresInbox } from "../preview-inbox"; +// @ts-expect-error Deployment smoke is shared with the Node CLI. +import { smoke } from "../../scripts/preview-smoke.mjs"; +import { providerIds } from "../policy"; +import { + betterAuthCredentials, + endpointPaths, + mockOAuthServer, +} from "./oauth-server"; +import type { Session } from "../../src/api"; + +const origin = "https://hosted.dormouse.sh"; +const bundle = (production: boolean | "preview") => + build({ + entryPoints: [ + production === "preview" + ? "server/preview-worker.ts" + : production + ? "server/worker.ts" + : "server/tests/worker-entry.ts", + ], + inject: production + ? [] + : [ + fileURLToPath( + import.meta.resolve("@pgstencil/auth/better-auth-testing"), + ), + ], + bundle: true, + write: false, + format: "esm", + platform: "node", + conditions: ["workerd", "worker"], + external: ["node:*", "cloudflare:*"], + alias: Object.fromEntries( + builtinModules + .filter((name) => !name.startsWith("node:")) + .map((name) => [name, `node:${name}`]), + ), + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire('/worker.js');", + }, + }); +const testBundle = bundle(false); +const productionBundle = bundle(true); +const previewBundle = bundle("preview"); +type Result = Awaited>; + +async function fixture( + production: boolean | "preview" = false, + enabled = providerIds.join(","), +) { + const context = await createTestContext({ + migrations: production === "preview" ? previewMigrations : migrations, + }); + const provider = await mockOAuthServer({ + betterAuth: true, + now: production ? undefined : () => context.time.now(), + }); + const bindings: Record = { + APP_ORIGIN: origin, + BUILD_SHA: "a".repeat(40), + AUTH_SECRET: "dormouse-test-secret-with-at-least-32-characters", + EMAIL_FROM: "signin@example.test", + POSTMARK_SERVER_TOKEN: "test-token", + OAUTH_PROVIDERS: enabled, + }; + for (const [id, credentials] of Object.entries(betterAuthCredentials)) { + bindings[`${id.toUpperCase()}_CLIENT_ID`] = credentials.clientId; + bindings[`${id.toUpperCase()}_CLIENT_SECRET`] = credentials.clientSecret; + } + const worker = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: ( + await (production === "preview" + ? previewBundle + : production + ? productionBundle + : testBundle) + ).outputFiles![0].text, + compatibilityDate: "2026-09-08", + compatibilityFlags: ["nodejs_compat"], + bindings, + hyperdrives: { HYPERDRIVE: context.database.url }, + serviceBindings: { + ASSETS: () => + new WorkerResponse( + "Dormouse Hosted", + { + headers: { "content-type": "text/html" }, + }, + ), + }, + async outboundService(request) { + if (production === "preview") + throw new Error( + "Preview must never send external mail or OAuth requests", + ); + const url = new URL(request.url); + if (url.href === "https://api.postmarkapp.com/email") { + expect(request.headers.get("x-postmark-server-token")).toBe( + "test-token", + ); + const mail = (await request.json()) as { + To: string; + From: string; + Subject: string; + HtmlBody: string; + TextBody: string; + }; + await context.email.send({ + to: [mail.To], + from: mail.From, + subject: mail.Subject, + html: mail.HtmlBody, + text: mail.TextBody, + }); + return new WorkerResponse(JSON.stringify({ ErrorCode: 0 }), { + headers: { "content-type": "application/json" }, + }); + } + const path = endpointPaths[url.origin + url.pathname]; + if (!path) throw new Error(`Unexpected outbound host: ${url.hostname}`); + const response = await fetch(provider.origin + path + url.search, { + method: request.method, + headers: Object.fromEntries(request.headers), + ...(request.method === "POST" ? { body: await request.text() } : {}), + }); + return new WorkerResponse(await response.arrayBuffer(), { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); + }, + }), + ); + try { + await worker.ready; + } catch (error) { + await worker.dispose(); + await provider.close(); + await context.close(); + throw error; + } + function browser() { + const jar = new Map(); + let csrf = ""; + async function request( + path: string, + init: { + method?: string; + body?: string; + headers?: Record; + } = {}, + ) { + const response = await worker.dispatchFetch(new URL(path, origin).href, { + ...init, + redirect: "manual", + headers: { + cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "), + "cf-connecting-ip": "203.0.113.10", + ...init.headers, + }, + }); + for (const cookie of response.headers.getSetCookie()) { + const pair = cookie.split(";")[0]; + const split = pair.indexOf("="); + jar.set(pair.slice(0, split), pair.slice(split + 1)); + } + return response; + } + const post = async ( + path: string, + body: unknown = {}, + requestOrigin = origin, + ) => { + if (!csrf) + csrf = ( + (await (await request("/api/auth/csrf")).json()) as { csrf: string } + ).csrf; + return request("/api/auth/" + path, { + method: "POST", + body: JSON.stringify(body), + headers: { + origin: requestOrigin, + "content-type": "application/json", + "x-csrf-token": csrf, + }, + }); + }; + const session = async () => + ( + await request("/api/auth/get-session") + ).json() as Promise; + const email = async (address: string) => { + expect( + ( + await post("email-otp/send-verification-otp", { + email: address, + type: "sign-in", + }) + ).status, + ).toBe(200); + const message = await context.email.next(); + const result = await post("sign-in/email-otp", { + email: address, + otp: message.text.match(/\b\d{8}\b/)![0], + }); + expect(result.status).toBe(200); + return result; + }; + async function oauth( + id: (typeof providerIds)[number], + profile: Record = {}, + link = false, + ) { + const started = await post(link ? "link-social" : "sign-in/social", { + provider: id, + }); + expect(started.status).toBe(200); + const url = new URL(((await started.json()) as { url: string }).url); + const callback = provider.authorize(id, url, profile); + let path = callback.href; + if (id === "apple") { + const relay = await worker.dispatchFetch(origin + callback.pathname, { + method: "POST", + redirect: "manual", + headers: { + origin: "https://appleid.apple.com", + "content-type": "application/x-www-form-urlencoded", + }, + body: callback.searchParams.toString(), + }); + expect(relay.status).toBe(302); + path = relay.headers.get("location")!; + } + return { path, result: await request(path) }; + } + return { request, post, session, email, oauth }; + } + return { + ...context, + worker, + provider, + browser, + advance: (time: string) => + worker.dispatchFetch(origin + "/__test/time", { + method: "POST", + body: time, + }), + close: async () => { + await worker.dispose(); + await provider.close(); + await context.close(); + }, + }; +} + +test("independent logins, current-browser logout, 24-hour expiry and private cookies", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const first = f.browser(), + second = f.browser(); + const login = await first.email("owner@example.test"); + const cookie = login.headers + .getSetCookie() + .find((value) => value.startsWith("__Host-pgstencil.session_token="))!; + for (const attribute of ["Secure", "HttpOnly", "SameSite=Lax", "Path=/"]) + expect(cookie).toContain(attribute); + expect(cookie).not.toContain("Domain="); + expect(JSON.stringify(await first.session())).not.toContain("token"); + await f.advance("2020-01-01T00:01:01Z"); + await second.email("owner@example.test"); + expect((await second.session())!.user.id).toBe( + (await first.session())!.user.id, + ); + expect((await first.post("sign-out")).status).toBe(200); + expect(await first.session()).toBeNull(); + expect(await second.session()).not.toBeNull(); + await f.advance("2020-01-02T00:01:01.001Z"); + expect(await second.session()).toBeNull(); +}); + +test.for(providerIds)( + "%s: callback, replay and explicit linking", + async (id, { onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = f.browser(), + other = f.browser(); + await owner.email("oauth@example.test"); + const ownerId = (await owner.session())!.user.id; + const collision = await other.oauth(id); + expect(collision.result.headers.get("location")).toContain("/login?error="); + expect(await other.session()).toBeNull(); + const linked = await owner.oauth( + id, + { + email: + id === "apple" + ? "private@privaterelay.appleid.com" + : "oauth@example.test", + }, + true, + ); + expect(linked.result.headers.get("location")).toBe(origin + "/account"); + expect((await other.oauth(id)).result.headers.get("location")).toBe( + origin + "/account", + ); + expect((await other.session())!.user.id).toBe(ownerId); + expect(await owner.session()).not.toBeNull(); + expect( + (await owner.request(linked.path)).headers.get("location"), + ).toContain("/login?error="); + }, +); + +test.for(providerIds)( + "%s: missing email can create an account without a mailbox identity", + async (id, { onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const browser = f.browser(); + const result = await browser.oauth( + id, + id === "github" ? { githubEmails: [] } : { email: "" }, + ); + expect(result.result.headers.get("location")).toBe(origin + "/account"); + expect((await browser.session())!.user.email).toBeNull(); + }, +); + +test("same-site marketing requests fail; production excludes dev endpoints and unconfigured providers", async ({ + onTestFinished, +}) => { + const f = await fixture(true, "github"); + onTestFinished(f.close); + const browser = f.browser(); + expect(await (await browser.request("/api/providers")).json()).toEqual([ + "github", + ]); + expect( + ( + await browser.post( + "email-otp/send-verification-otp", + { email: "x@example.test", type: "sign-in" }, + "https://dormouse.sh", + ) + ).status, + ).toBe(403); + const shell = await browser.request("/login"); + expect(shell.headers.get("content-security-policy")).toContain( + "script-src 'self'", + ); + expect(shell.headers.get("cache-control")).toBe("no-store"); + expect(shell.headers.get("access-control-allow-origin")).toBeNull(); + for (const path of [ + "/api/dev/emails", + "/dev/emails", + "/__test/time", + "/api/auth/revoke-sessions", + ]) + expect((await browser.request(path)).status).toBe(404); + expect( + (await f.worker.dispatchFetch("https://dormouse.sh/api/auth/csrf")).status, + ).toBe(421); + await browser.email("real-clock@example.test"); + expect( + Math.abs( + Date.parse((await browser.session())!.session.createdAt) - Date.now(), + ), + ).toBeLessThan(60000); + expect((await browser.request("/api/ready")).status).toBe(200); + await queryDatabase( + f.database.url, + 'ALTER TABLE "session" DROP COLUMN "emailAuthenticated"', + ); + expect((await browser.request("/api/ready")).status).toBe(503); +}); + +test("explicit connection callback cannot outlive its initiating login", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const browser = f.browser(); + await browser.email("owner@example.test"); + const started = await browser.post("link-social", { provider: "github" }); + const callback = f.provider.authorize( + "github", + new URL(((await started.json()) as { url: string }).url), + ); + await browser.post("sign-out"); + expect( + (await browser.request(callback.href)).headers.get("location"), + ).toContain("/login?error="); +}); + +test("preview runs cloud smoke against real auth, ignores stale providers, and persists escaped inbox messages", async ({ + onTestFinished, +}) => { + const f = await fixture("preview"); + onTestFinished(f.close); + await smoke( + origin, + "a".repeat(40), + (url: string, init: Parameters[1]) => + f.worker.dispatchFetch(url, init), + true, + ); + const inbox = postgresInbox(f.database.url); + await inbox.send({ + to: ["", + text: "", + html: "", + }); + const messages = await inbox.all(); + const id = messages[0].id; + const detail = await f.worker.dispatchFetch(origin + `/dev/emails/${id}`); + expect(await detail.text()).toContain("<img"); + const page = await f.worker.dispatchFetch(origin + "/dev/emails"); + expect(await page.text()).not.toContain("