diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index d4219101d..5a6a1b3fd 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -10,7 +10,7 @@ This is a code-and-specs audit of Hosted's three Workers — the account applica Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hosted rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", "Setup tokens and the pairing QR", "WebAuthn without a WebAuthn library", "Routing", "Web Push", and "State files", whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, `hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`, `remote-lib-common/src/remote/one-time-wire.ts`, `remote-lib-common/src/remote/relay-common.ts`, `remote-lib-common/src/remote/relay-routing.ts`, `remote-lib-common/src/remote/enroll-code.ts`, `remote-lib-common/src/remote/wire.ts`, `scripts/e2e-lint.mjs`, `lib/package.json`, `remote-lib-common/src/remote/web-push.ts` and its test `remote-lib-common/test/web-push.test.mjs` with the vectors under `remote-lib-common/test/vectors/`, the Pocket build the relay serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and `.github/workflows/hosted-preview.yml` and `.github/workflows/hosted-production.yml` — `docs/specs/security-hosted.md`'s Deployment boundary quantifies over the preview and production paths, which live in those scripts and workflows rather than in the Worker. -Check the installed `pgstencil` and `@pgstencil/auth` packages against "Deployment boundary" by reading each `dist/provenance.json`, `pnpm-lock.yaml` and `pnpm-workspace.yaml` (no `.npmrc` is tracked, so the registry is npm's default), and Hosted's runtime imports. Audit no package code, except that the `FAIL IF`s which name the packed adapter (CSRF and CORS, cookie attributes and JSON scrubbing, the revoked-login callback) may read `@pgstencil/auth`'s `dist/` to verify how Hosted configures it. +Check the installed `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe` packages against "Deployment boundary" by reading each `dist/provenance.json`, `pnpm-lock.yaml` and `pnpm-workspace.yaml` (no `.npmrc` is tracked, so the registry is npm's default), and Hosted's runtime imports. Audit no package code, except that the `FAIL IF`s which name the packed adapter (CSRF and CORS, cookie attributes and JSON scrubbing, the revoked-login callback) may read `@pgstencil/auth`'s `dist/` to verify how Hosted configures it. Verify npm's signed SLSA provenance for each installed package/version. Run `npm audit signatures --json --include-attestations` in `hosted/`, as the production `verify` job does. A package counts only from `verified`, never from `invalid` or `missing`. Decode the verified SLSA DSSE payload and the Fulcio certificate in that bundle and judge S119's certificate and payload clauses against them, and its job clause against that step, the payload's `externalParameters.workflow` and `resolvedDependencies` included. npm verifies the signature and subject digest, but the payload's workflow claim alone is not the signer identity. Then check the commit against pgstencil `main` and its audit: diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index e2d3379ee..68f00a132 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -35,8 +35,9 @@ jobs: working-directory: hosted run: | npm audit signatures --json --include-attestations > "$RUNNER_TEMP/signatures.json" || true - jq -e '[.invalid[], .missing[] | select(.name == "pgstencil" or .name == "@pgstencil/auth")] == [] - and (["pgstencil", "@pgstencil/auth"] - [.verified[] | select(.attestations.provenance) | .name] == [])' \ + jq -e '["pgstencil", "@pgstencil/auth", "@pgstencil/stripe"] as $ours + | [.invalid[], .missing[] | select(.name as $name | $ours | index($name))] == [] + and ($ours - [.verified[] | select(.attestations.provenance) | .name] == [])' \ "$RUNNER_TEMP/signatures.json" # Every Worker's static files, each under its own `dist//`. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 414698c58..f23b243f0 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -9,7 +9,7 @@ | Worker | Origin | Serves | Holds | |---|---|---|---| -| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom` | +| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment"), billing's resync ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe bindings | | `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | | `dormouse-voice` | `https://voice.dormouse.sh` | speak, a token's own delete, and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | @@ -19,9 +19,9 @@ Every Worker answers `/api/health` and `/api/ready`, 404s anything else under it **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, one per Worker. -**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`). +**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user and subscription columns, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`). -**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`). +**Must install released core, auth, and stripe packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth and billing migrations remain owned by their packages; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`). **Must declare every required peer dependency of the installed packages in `hosted/package.json`'s `dependencies`**, so they share Hosted's copy and Renovate updates them. **Never put an optional peer (pgstencil's tooling) in `dependencies`.** @@ -67,10 +67,25 @@ Source of truth: `termsRoutes` in `hosted/server/terms.ts`; `TERMS_VERSION` in ` ## Entitlement -**Must read the entitlement (`docs/specs/pricing.md` -> "Checkout and entitlement") on the server, per request, through one SQL predicate over the account's `"user"` row**, so a bearer and its owner's entitlement resolve in one query. Until billing ships it admits only `ADMIN_EMAIL` while that is the account's verified email, one of the exceptions to "never email" ("Identity and login"); nothing else may key on an address. +**Must read the entitlement (`docs/specs/pricing.md` -> "Checkout and entitlement") on the server, per request, through one SQL predicate over the account's `"user"` row**, so a bearer and its owner's entitlement resolve in one query. + +- **Must entitle an account only while it holds exactly one current subscription, `active` before its period end or `trialing` before its trial end** by the database's clock, as `@pgstencil/stripe`'s `status()` grants access; no other status grants anything. +- **Must entitle `ADMIN_EMAIL` as a standing comp** while it is the account's verified email, one of the exceptions to "never email" ("Identity and login"); nothing else may key on an address. Source of truth: `entitledSql` and `entitled` in `hosted/server/entitlement.ts`; `cookieEntitled` in `hosted/server/account-gate.ts`. +## Billing + +The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> "Checkout and entitlement"); its routes are unbuilt. + +- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix. +- **Never offer a trial or Managed Payments; must enable Stripe Tax on every Checkout.** +- **Must offer founding at the first ladder step whose completed purchases, refunds included, are short of `FOUNDING_COHORT_SIZE`**; its seats left exclude purchases ended within `REFUND_DAYS`. Past the last step, founding checkout answers 409. +- **Must resync, every 10 minutes from the account's Cron Trigger, a bounded random batch of owners whose `active` or `trialing` row no longer grants access**, so a missed renewal webhook lapses a member only until a run picks them; a failure fails the invocation after every owner was tried. +- **Must grant the account's role the `pgstencil_billing` schema from `runtime-roles.sql`**, additively: the package's migration creates it. + +Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`. Pinned by `hosted/server/tests/billing.test.ts`, on StripeDev. + ## Managed voice A desktop with Natural Voice on exchanges its voice token for ElevenLabs speech in a voice of the curated set (`MANAGED_VOICES` in `remote-lib-common/src/remote/managed-voice.ts`). A voice-purpose device-code approval ("Burrow enrollment") mints only the token at redemption. @@ -304,5 +319,5 @@ Source of truth: `.github/workflows/hosted-production.yml`; `hosted/scripts/prod 1. Deploy the configured providers and pass real production acceptance. pgstencil includes the Microsoft fix; personal and work/school callbacks need acceptance. 2. Add per-browser login listing/revocation, sign-out-everywhere, and account recovery before broad paid use. Revisit the fixed 24-hour login lifetime for daily voice use. -3. Managed voice for every member: the subscription as the entitlement in place of `ADMIN_EMAIL` (`docs/specs/pricing.md` -> "Checkout and entitlement"), per-account quotas, usage accounting, spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure. +3. Managed voice for every member: per-account quotas, usage accounting, spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure. 4. Open sign-in: delete `hosted/server/prelaunch.ts` and the frontend's pre-launch notice. diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 5c97d679b..01a55b515 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -3,7 +3,7 @@ > - See `docs/specs/glossary.md` for Burrow / Client / Relay and Pane / Session vocabulary. > - **Owns:** the plans, the founding ladder, what a plan grants, how a desktop proves membership, the managed-voice boundary, and the content contract of the Hosted page. > - **Defers:** page chrome, rail, and link obligations to `docs/specs/website-docs.md` -> "Reference page chrome"; the Hosted Relay's accounts, enrollment, and entitlement, and managed voice's routes, to `docs/specs/hosted.md`; the cloud-hosted trust boundary to `docs/specs/security-remote.md` -> "Cloud-hosted mode"; alarm delivery to `docs/specs/alert.md` -> "Spoken alarms". -> - **Status:** the Hosted page publishes the plans and the FAQ, and a desktop enrolls with Hosted for managed voice (`docs/specs/hosted.md` -> "Managed voice"); everything that takes money — checkout, the subscription as the entitlement, the hosted Relay — is under [Future](#future). +> - **Status:** the Hosted page publishes the plans and the FAQ, a desktop enrolls with Hosted for managed voice and the Hosted Relay, and the account's subscription is the entitlement, with billing closed until checkout opens; checkout itself, and everything else that takes money, is under [Future](#future). ## The Hosted page @@ -51,15 +51,23 @@ Prices in USD; DiffPlug adds or includes applicable tax by jurisdiction. Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; the `/pricing` rule in `website/public/_redirects`, pinned by `checkPricingRedirect` in `scripts/public-docs-lint.mjs`. `website/src/pages/Hosted.test.tsx` pins the page contract. +## Checkout and entitlement + +- **Must use Stripe Billing, Stripe-hosted Checkout, and its customer portal through `@pgstencil/stripe`; DiffPlug is the seller and merchant of record**, responsible for refunds and applicable tax registration, collection, filing, and remittance; Stripe Tax calculates the tax. **Never receive or store full card numbers in Dormouse.** A founding lock is a per-cohort Price; cohort counts come from the billing provider's completed subscriptions. +- **The entitlement is the account's subscription, read on the server on every voice and Relay request** (`docs/specs/hosted.md` -> "Entitlement"). No licence, no offline verification, and no grace past what the subscription grants; a lapsed member's voices fall back to the system voice and its Burrows to `not-entitled`. +- **Cohorts close by count, never by date**: completed purchases at the billing provider; a purchase ending within the 30-day refund window, as a full refund or finally reversed payment ends it ("Checkout"), returns its seat to its cohort. **Never reopen the ladder at a lower step** once a cohort has closed. **When a cohort closes the price rises one step and the counter resets to 100**, and **founding closes only when the ladder reaches list**: founding means bought at launch pricing, and the Hosted Relay shipping does not close it. +- **Checkout honors the price it opened at.** Concurrent checkouts may oversell a cohort by a few seats; the overage is the customer's, and the next cohort still opens at a full 100. + +Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; `docs/specs/hosted.md` -> "Billing" owns the rest. + ## Future **Scope: hosted-sales** — what remains, in staged order: 1. **The cohort endpoint** the page already calls: the open cohort's seats and the opted-in founders, avatars proxied onto this origin. -2. **Checkout and entitlement**: purchase, the subscription as the account's entitlement, revocation. -3. **Managed voice for members**: the subscription replacing the admin-only entitlement (`docs/specs/hosted.md` -> "Entitlement"), one voice per Pane. -4. **Hosted Relay inclusion**: the subscription as the Relay's entitlement (`docs/specs/hosted.md` -> "Relay"). -5. **Renewal, cancellation, and refund** paths. +2. **Checkout** ("Checkout"): the routes, the webhook, and the account pages that sell, then opening billing. +3. **Managed voice for members**: one voice per Pane. +4. **Renewal, cancellation, and refund** paths. Team and enterprise tiers are never sold through this page. A free hosted tier is undecided — see [Open questions](#open-questions). @@ -69,12 +77,7 @@ What each plan grants once checkout can sell it; [Published prices](#published-p - **Founding grants the Individual plan plus a founding badge**; monthly and yearly grant the plan alone. - **Must lock the founding base yearly price in USD while the subscription remains active**, excluding applicable taxes; a lapsed founder re-subscribes at list. **Must preserve the lock through billing-provider migrations and failures caused by DiffPlug**, allowing payment restoration. -- **Cohorts close by count, never by date.** The count is completed purchases at the billing provider; a full refund or finally reversed payment returns the seat to its cohort. -- **When a cohort closes the price rises one step and the counter resets to 100.** -- **Founding closes only when the ladder reaches list.** Founding means bought at launch pricing; the Hosted Relay shipping does not close it. -- **Checkout honors the price it opened at.** Concurrent checkouts may oversell a cohort by a few seats; the overage is the customer's, and the next cohort still opens at a full 100. - **List may rise while founding is open, and never falls.** Monthly and yearly move together the same day, so list is always a price someone can buy at; the ladder keeps climbing $10 per cohort toward the new list; every founder's lock and the struck price they were shown are unchanged. -- **Never reopen the ladder at a lower step** once a cohort has closed. - **Founding badges are cosmetic**: in-app and on the credits page, never a capability. ### The Individual plan @@ -87,16 +90,16 @@ What each plan grants once checkout can sell it; [Published prices](#published-p | Founding badge | live for founding | - **The plan never grants team or enterprise capability** — org accounts, SSO, SCIM, BYOT, audit export. -- **The Hosted Relay is part of the plan, never a second purchase.** Reserved: the **hosted-sales** scope reads the plan from the Hosted account's subscription ("Checkout and entitlement"), so a member never signs up twice. +- **The Hosted Relay is part of the plan, never a second purchase.** The entitlement reads it from the Hosted account's subscription ("Checkout and entitlement"), so a member never signs up twice. - **Nothing shipped free is ever gated**: the terminal, `dor`, browser panes, the notepad, alerts with the system voice, the self-host Relay, and Pocket over a self-hosted Relay stay free, with no login. -### Checkout and entitlement +### Checkout + +The rest of "Checkout and entitlement", once checkout can sell: -- **Must use Stripe Billing, Stripe-hosted Checkout, and its customer portal through `@pgstencil/stripe`; DiffPlug is the seller and merchant of record**, responsible for refunds and applicable tax registration, collection, filing, and remittance. **Never receive or store full card numbers in Dormouse.** A founding lock is a per-cohort Price; cohort counts come from the billing provider's completed subscriptions. - **Checkout starts from a Hosted account**: a buy button lands on the account origin, which asks for sign-in first, so the subscription belongs to an account from its first event. - **Founding checkout offers the founders-row opt-in, unticked**; the account can withdraw it at any time. - **The success page asks the four Van Westendorp questions**, optional and unsent until answered: too expensive to consider, too cheap to trust, expensive but would consider, a bargain. Their answers inform later list changes. -- **The entitlement is the account's subscription, read on the server on every voice and Relay request.** No licence, no offline verification, and no grace past what the subscription grants; a lapsed member's voices fall back to the system voice and its Burrows to `not-entitled`. - **Must license one individual, including work use, with no device limit and no per-device charge**; enrollment's abuse ceiling is far above what a person owns (`docs/specs/hosted.md` -> "Burrow enrollment"). **Must disclose material usage limits before purchase**, including managed voice's daily cap. - **A full refund or a finally reversed payment ends the subscription**, so the next request is refused. A partial refund or billing correction never ends it, and an open dispute only suspends it ("Paid-launch requirements"). diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index ac3ee2187..d9c5f4f7d 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -14,6 +14,7 @@ - **FAIL IF** `S77` a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. - **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). - **FAIL IF** `S79` the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth, or `WORKERS` in `hosted/scripts/workers.mjs` provisions either an auth secret; inspect `hosted/server/bindings.ts` and each entry's import graph. +- **FAIL IF** `S281` any bindings mapper but the production account's passes a `STRIPE_*` binding; `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is anything but an account Worker secret, a Wrangler `vars` entry or a preview config included; or the relay or voice entry's import graph reaches `@pgstencil/stripe`, `stripe`, or `hosted/server/billing.ts`. Inspect `hosted/server/bindings.ts`, `hosted/wrangler.jsonc`, `hosted/scripts/preview.mjs`, and each entry's import graph. - **FAIL IF** `S80` authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or account (Better Auth) session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. - **FAIL IF** `S81` the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`), carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's `/assets/` or the relay's `/assets/` and `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `relayPathKind` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc` and `hosted/wrangler.relay.jsonc`. - **FAIL IF** `S82` marketing scripts, analytics, provider avatars, or remote fonts enter the Hosted frontend; inspect the frontend import graph and deployed response when available. @@ -29,10 +30,11 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t - **FAIL IF** `S83` 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** `S84` 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** `S85` speak or a voice token's mint admits an account the entitlement (`docs/specs/hosted.md` -> "Entitlement") does not, without rechecking per request, stores a voice token other than as its SHA-256, accepts one idle past `VOICE_TOKEN_IDLE_DAYS`, lets a bearer delete any token but its own, logs or stores speak text, forwards an ElevenLabs error body or status, or lets a binding or request field choose the upstream origin, path prefix, model, or format, or a voice outside `MANAGED_VOICES`; or the relay mints a voice token without redeeming a voice-purpose approval, or remote enrollment mints voice credentials; inspect `hosted/server/entitlement.ts`, `hosted/server/voice.ts`, and `hosted/server/relay-api.ts`. +- **FAIL IF** `S282` the entitlement admits an account but by `ADMIN_EMAIL` verified or by exactly one current subscription that is `active` before its period end or `trialing` before its trial end by the database's clock, or reads that state from anywhere but the synchronized `pgstencil_billing.subscriptions` rows in the query that resolves the bearer; inspect `entitledSql` in `hosted/server/entitlement.ts`. - **FAIL IF** `S279` a `dormouse_metrics_daily` row can hold anything but a UTC day, an event, a label on that event's `METRIC_LABELS` list, and a count; or `GET /api/admin/metrics` answers anyone but the verified `ADMIN_EMAIL` (401 without a login, 404 to any other), skips the cookie read gate, or answers more than those rows. Inspect `hosted/server/dormouse-migrations/013_metrics.sql`, `countMetric` in `hosted/server/metrics.ts`, `metricLabel` in `hosted/server/metric-labels.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `adminSql` in `hosted/server/entitlement.ts`. - **FAIL IF** `S86` Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. The one-time rendezvous carries only handshake ciphertext and authorizes nothing; the ends' handshake and the laptop's confirmation do. -What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, and `hosted/server/tests/metrics.test.ts`. +What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, `hosted/server/tests/metrics.test.ts`, and `hosted/server/tests/billing.test.ts`. ## Rendezvous boundary @@ -77,7 +79,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S107` a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or keeps more than 1 KiB of a response body; inspect `deliverPush` and `reasonOf`. - **FAIL IF** `S108` `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf` in `hosted/server/relay-push.ts`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`. - **FAIL IF** `S109` the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test. -- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables and `user` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a user column but the entitlement's; inspect the relay bundle's imports and queries. +- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables, `user`, and `pgstencil_billing.subscriptions` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a column of the latter two but the entitlement's; inspect the relay bundle's imports and queries. - **FAIL IF** `S111` `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or puts anything but its account id in `ctx.storage` (routing state lives in hibernation attachments); `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** `S112` a `scripts/e2e-lint.mjs` rule whose `spec` is this file names a line it does not enforce, or one outside "Rendezvous boundary" and "Relay boundary". - **FAIL IF** `S113` a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. @@ -94,8 +96,8 @@ Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.t | **Hosted runs only pgstencil releases that passed pgstencil's own audit.** A production deploy carries no test controls and first verifies each pgstencil package's provenance and npm attestation; a preview reaches no real provider. | One-time link, Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/server/tests/artifacts.test.ts` | - **FAIL IF** `S117` a production Worker exposes the captured-email inbox or deterministic clock controls, or imports the testing injection module; inspect `hosted/server/worker.ts`, `hosted/server/relay-worker.ts`, `hosted/server/voice-worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`. -- **FAIL IF** `S118` either installed pgstencil package lacks `dist/provenance.json`, records `dirty`, names a commit that is not 40 lowercase hex or differs from the other's; `pnpm-lock.yaml` resolves either package from outside npm or without an integrity hash; or a runtime import depends on a sibling pgstencil checkout. Inspect `verifyPackages` in `hosted/scripts/production.mjs`, `hosted/server/tests/artifacts.test.ts`, and Hosted runtime imports. -- **FAIL IF** `S119` the production `verify` job, which `deploy` needs, can pass while `npm audit signatures --include-attestations` finds either installed pgstencil package's registry signature or SLSA provenance attestation missing or invalid; or, beyond what npm checks (rationale), either package's verified attestation has a Fulcio certificate SAN not naming `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, a source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) other than `dist/provenance.json`'s commit, or a signed subject/payload that disagrees with the installed package, certificate, or commit. +- **FAIL IF** `S118` any installed pgstencil package (core, auth, stripe) lacks `dist/provenance.json`, records `dirty`, names a commit that is not 40 lowercase hex or differs from another's; `pnpm-lock.yaml` resolves any of them from outside npm or without an integrity hash; or a runtime import depends on a sibling pgstencil checkout. Inspect `verifyPackages` in `hosted/scripts/production.mjs`, `hosted/server/tests/artifacts.test.ts`, and Hosted runtime imports. +- **FAIL IF** `S119` the production `verify` job, which `deploy` needs, can pass while `npm audit signatures --include-attestations` finds any installed pgstencil package's registry signature or SLSA provenance attestation missing or invalid; or, beyond what npm checks (rationale), any of their verified attestations has a Fulcio certificate SAN not naming `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, a source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) other than `dist/provenance.json`'s commit, or a signed subject/payload that disagrees with the installed package, certificate, or commit. - **FAIL IF** `S120` that commit is not on pgstencil `main` (`gh api repos/diffplug/pgstencil/compare/...main`, status `ahead` or `identical`), or its `security-audit` check runs (`gh api repos/diffplug/pgstencil/commits//check-runs`) include no `success`, or any conclusion other than `success` and `cancelled`. pgstencil audits the released code; Dormouse audits only how Hosted configures it. - **FAIL IF** `S121` 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`. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index a92ab030b..040a33e32 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -221,7 +221,7 @@ The HTTPS origin may be public. Tailnet-only Serve is the installer default and ### Cloud-hosted mode -Hosted's admin-entitled routing is implemented (`docs/specs/security-hosted.md` -> "Relay boundary"). Broad paid activation remains staged; its review must cover these operator responsibilities: +Hosted's subscription-entitled routing is implemented, with billing closed until checkout opens (`docs/specs/security-hosted.md` -> "Relay boundary"; `docs/specs/hosted.md` -> "Billing"). Broad paid activation remains staged; its review must cover these operator responsibilities: - **Must review Hosted operator handling of residual metadata before paid activation.** The visible metadata is `docs/specs/remote-security-model.md` -> "Residual metadata"; the trust boundary above still excludes plaintext and new Burrow authorization. - **Never rely on tailnet reachability for paid Hosted admission.** Review public admission and the multi-tenant account boundary before activation (`docs/specs/hosted.md` -> "Burrow enrollment"); the self-host setup password supplies no Hosted identity. diff --git a/hosted/README.md b/hosted/README.md index 9c2e25b1a..18a3e1f35 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -204,6 +204,8 @@ rm vapid.json The public half is public (`GET /api/push/config` serves it); the private half signs every push. Both are required for a production deploy: preflight refuses a relay Worker missing either. Cloudflare exposes a secret's name, never its value, so preflight cannot tell whether the two match; the relay answers push off for a pair that does not, and the release's live verification fails unless `/api/push/config` answers the key. Rotating the pair makes every phone's subscription stale until Pocket re-registers it, so rotate only on compromise. Previews derive their own pair and never need this one. +Billing stays closed, and its Cron Trigger idle, until the account Worker holds all five Stripe bindings: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` in `wrangler.jsonc` (`docs/specs/hosted.md` -> "Billing"). Set none of them before checkout opens. `db:migrate` grants `dormouse_app` the `pgstencil_billing` schema itself (`hosted/server/runtime-roles.sql`), since that schema is the package's and no hand grant can precede it. + Configure the sender and enable each ready provider in `OAUTH_PROVIDERS` in `wrangler.jsonc`, comma separated, reviewed in a PR. The checked-in file enables GitHub, Google, Microsoft, and Apple; `docs/specs/hosted.md` -> "Identity and login" owns what a name and a credential pair do and do not enable. Facebook is outside this milestone. ## Release diff --git a/hosted/package.json b/hosted/package.json index 790f8a386..47a5a0cae 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -22,37 +22,39 @@ "test:dom": "vitest run src" }, "dependencies": { + "@fontsource/ubuntu-mono": "^5.3.0", + "@fontsource/ubuntu-sans-mono": "^5.3.0", + "@hono/node-server": "^2.0.10", "@pgstencil/auth": "^0.4.1", - "pgstencil": "^0.4.1", - "kysely": "^0.29.5", + "@pgstencil/stripe": "^0.4.1", + "@phosphor-icons/react": "^2.1.10", + "dormouse-lib": "workspace:*", "hono": "^4.13.8", - "@hono/node-server": "^2.0.10", + "kysely": "^0.29.5", + "pgstencil": "^0.4.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "dormouse-lib": "workspace:*", "remote-lib-common": "workspace:*", - "@fontsource/ubuntu-mono": "^5.3.0", - "@fontsource/ubuntu-sans-mono": "^5.3.0", - "@phosphor-icons/react": "^2.1.10" + "stripe": "^22.6.2" }, "devDependencies": { + "@storybook/react": "11.0.0-alpha.6", + "@tailwindcss/vite": "^4.3.0", "@types/node": "^24.13.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "typescript": "^7.0.0", - "vite": "^8.0.14", - "vitest": "^5.0.0", - "tsx": "4.23.15", "esbuild": "^0.28.2", - "wrangler": "4.151.1", + "jsdom": "^30.0.0", "miniflare": "5.20260908.0-alpha", "node-pg-migrate": "^9.0.0", - "testcontainers": "^12.2.1", - "jsdom": "^30.0.0", - "@storybook/react": "11.0.0-alpha.6", "storybook": "11.0.0-alpha.6", "tailwindcss": "^4.3.0", - "@tailwindcss/vite": "^4.3.0" + "testcontainers": "^12.2.1", + "tsx": "4.23.15", + "typescript": "^7.0.0", + "vite": "^8.0.14", + "vitest": "^5.0.0", + "wrangler": "4.151.1" } } diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index d044eb12e..2a9ab44b3 100644 --- a/hosted/scripts/production.mjs +++ b/hosted/scripts/production.mjs @@ -161,9 +161,11 @@ export function productionSmoke(configs, sha, options = {}) { ...options, }); } +/** Each pgstencil package Hosted installs, by an entrypoint beside its `dist/provenance.json`. */ +const PGSTENCIL_PACKAGES = ["pgstencil", "@pgstencil/auth/better-auth", "@pgstencil/stripe"]; export async function verifyPackages() { const commits = []; - for (const name of ["pgstencil", "@pgstencil/auth/better-auth"]) { + for (const name of PGSTENCIL_PACKAGES) { // Resolve the installed entrypoint, then read the provenance beside it. const entry = import.meta.resolve(name); const provenance = JSON.parse( @@ -181,9 +183,8 @@ export async function verifyPackages() { ); commits.push(provenance.commit); } - assert.equal( - commits[0], - commits[1], + assert.ok( + commits.every((commit) => commit === commits[0]), "Production requires accepted, clean pgstencil provenance", ); } diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index cc7e352cc..b20b73e76 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -268,11 +268,10 @@ test("live verification retries the relay and voice while their domains come up, assert.deepEqual(ready.sort(), ["https://relay.dormouse.sh", "https://voice.dormouse.sh"]); assert.equal(rendezvous, 1); }); -test("the history and idle-token sweeps' crons are the voice Worker's, the relay's sweeps its expired rows, and the account's removes its old one", () => { +test("the history and idle-token sweeps' crons are the voice Worker's, the relay's sweeps its expired rows, and the account's resyncs billing", () => { assert.deepEqual(configs.voice.triggers, { crons: ["*/5 * * * *", "0 4 * * *"] }); assert.deepEqual(configs.relay.triggers, { crons: ["0 * * * *"] }); - // An absent `triggers` would leave a deployed schedule in place. - assert.deepEqual(configs.account.triggers, { crons: [] }); + assert.deepEqual(configs.account.triggers, { crons: ["*/10 * * * *"] }); }); test("the Durable Objects are the relay's, each rate limit its Worker's, and Durable Object migrations are append-only", () => { assert.deepEqual(configs.relay.durable_objects, { diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts index ce6725459..1702e480b 100644 --- a/hosted/server/account-app.ts +++ b/hosted/server/account-app.ts @@ -1,5 +1,6 @@ import type { Context, ExecutionContext, Hono } from "hono"; import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate"; +import { billingSetup, resyncBilling } from "./billing"; import type { AccountEnv } from "./bindings"; import { accountRules } from "./headers"; import { ADMIN_METRICS_PATH } from "./metric-labels"; @@ -13,7 +14,7 @@ import { workerApp } from "./worker-app"; /** * The account Worker (`hosted.dormouse.sh`): auth, providers, readiness, * terms acceptance, Natural Voice on or off, the Relay's account routes, the - * admin's metrics, and the frontend. The production and preview entries differ only in `fetchAuth`'s + * admin's metrics, the frontend, and billing's resync. The production and preview entries differ only in `fetchAuth`'s * mail and in `bindings`. */ export function accountApp( @@ -61,5 +62,7 @@ export function accountApp( // Every desktop since v1.2.0 opens `/account` by a hard-coded path // (`ACCOUNT_PAGE_PATH`), which this SPA fallback answers. fallback: (app) => app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw)), + scheduled: (_controller, env) => + resyncBilling(billingSetup(env), env.HYPERDRIVE.connectionString, env.APP_ORIGIN), }); } diff --git a/hosted/server/billing-dev.ts b/hosted/server/billing-dev.ts new file mode 100644 index 000000000..c43788180 --- /dev/null +++ b/hosted/server/billing-dev.ts @@ -0,0 +1,29 @@ +// Node-side only: StripeDev runs an HTTP server, so no Worker entry imports this. +import { createStripeDev } from "@pgstencil/stripe/testing"; +import type { RandomSource, Time } from "pgstencil"; +import { FOUNDING_LADDER } from "../../website/src/lib/hosted-pricing"; +import type { BillingSetup } from "./billing"; + +/** StripeDev's founding Prices, one per ladder step. */ +export const DEV_FOUNDING_PRICES = FOUNDING_LADDER.map((price) => `price_founding_${price}`); + +/** + * StripeDev, a local Stripe stand-in that takes no card and charges nothing, + * with the founding ladder's yearly Prices, and the billing setup that runs + * on it. + */ +export async function stripeDevBilling(time: Time, random: RandomSource, statePath?: string) { + const dev = await createStripeDev(time, random, statePath, { + recurring: Object.fromEntries(DEV_FOUNDING_PRICES.map((price) => [price, "year" as const])), + }); + const setup: BillingSetup = { + secretKey: "sk_test_dormouse_local_only", + webhookSecret: dev.webhookSecret, + live: false, + monthly: dev.prices.monthly, + yearly: dev.prices.yearly, + founding: DEV_FOUNDING_PRICES, + stripe: dev.stripe, + }; + return { dev, setup }; +} diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts new file mode 100644 index 000000000..532a5d3fb --- /dev/null +++ b/hosted/server/billing.ts @@ -0,0 +1,177 @@ +// Rules: docs/specs/hosted.md -> "Billing". +import { Billing, BillingError, Stripe, type BillingDB } from "@pgstencil/stripe"; +import { SecureRandom, SystemTime } from "pgstencil"; +import { connectDatabase, queryDatabase } from "pgstencil/postgres"; +import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER } from "../../website/src/lib/hosted-pricing"; +import type { BillingEnv } from "./bindings"; +import { grantsAccessSql } from "./entitlement"; +import { BILLING_RETURN_PATH } from "./policy-constants"; + +/** The plans checkout sells, by the names `status()` and a checkout use. */ +export type Plan = "monthly" | "yearly" | "founding"; + +/** A purchase that ends within this many days of starting is a refund: its seat returns to its cohort. */ +export const REFUND_DAYS = 30; + +/** A deployment's billing configuration, read from its bindings. */ +export interface BillingSetup { + secretKey: string; + webhookSecret: string; + live: boolean; + monthly: string; + yearly: string; + /** One Price per `FOUNDING_LADDER` step, in cohort order. */ + founding: readonly string[]; + /** StripeDev's client in place of Stripe's API, for tests and the dev loop; never from bindings. */ + stripe?: Stripe; +} + +const PRICE = /^price_[A-Za-z0-9_]{1,200}$/; + +/** + * The setup `env` carries, or null while any binding is missing: billing is + * then closed. A ladder whose length differs from the published + * `FOUNDING_LADDER`, or a malformed or repeated Price, throws, since the page + * and checkout would disagree on a price. + */ +export function billingSetup(env: BillingEnv): BillingSetup | null { + const { + STRIPE_SECRET_KEY: secretKey, + STRIPE_WEBHOOK_SECRET: webhookSecret, + STRIPE_PRICE_MONTHLY: monthly, + STRIPE_PRICE_YEARLY: yearly, + STRIPE_PRICES_FOUNDING: ladder, + } = env; + if (!secretKey || !webhookSecret || !monthly || !yearly || !ladder) return null; + const founding = ladder.split(",").map((price) => price.trim()); + if (founding.length !== FOUNDING_LADDER.length) + throw new Error( + `STRIPE_PRICES_FOUNDING names ${founding.length} Prices; the published ladder has ${FOUNDING_LADDER.length}`, + ); + const prices = [monthly, yearly, ...founding]; + if (!prices.every((price) => PRICE.test(price)) || new Set(prices).size !== prices.length) + throw new Error("Stripe Prices must be distinct price_ ids"); + return { + secretKey, + webhookSecret, + live: /^(?:sk|rk)_live_/.test(secretKey), + monthly, + yearly, + founding, + }; +} + +/** The open cohort and its seats left; null once founding has closed. */ +export interface OpenCohort { + /** Its index in `FOUNDING_LADDER`: how many cohorts have closed. */ + cohort: number; + seatsLeft: number; +} + +/** + * The open cohort, from purchases per ladder step: `gross` counts every + * completed purchase, refunded or not, and `net` leaves refunds out. A cohort + * closes when its gross count fills, so a refund after it closed never + * reopens the cheaper step; seats left in the open one come from its net + * count, so a refund there returns the seat. + */ +export function openCohort(gross: readonly number[], net: readonly number[]): OpenCohort | null { + const cohort = gross.findIndex((sold) => sold < FOUNDING_COHORT_SIZE); + if (cohort === -1) return null; + return { cohort, seatsLeft: Math.max(0, FOUNDING_COHORT_SIZE - (net[cohort] ?? 0)) }; +} + +/** The open founding cohort, from the synchronized subscriptions. */ +export async function foundingCohort(billing: Billing, founding: readonly string[]) { + // A refund window of 0 counts every completed purchase, refunded or not. + const [gross, net] = await Promise.all( + [0, REFUND_DAYS].map(async (refundDays) => { + const counts = await billing.purchaseCounts(founding, { refundDays }); + return founding.map((price) => counts[price] ?? 0); + }), + ); + return openCohort(gross!, net!); +} + +// The system clock: a test bundle's injected `Date` scopes it to the test's. +const time = new SystemTime(); +const random = new SecureRandom(); + +/** + * Runs `action` with a `Billing` over `databaseUrl` for the account origin + * `origin`, closing its connections after. No trial; DiffPlug is the merchant + * of record on Stripe Billing, never Managed Payments, with Stripe Tax on + * every Checkout; founding checkout offers the open cohort's Price and + * refuses once founding has closed. + */ +export async function withBilling( + setup: BillingSetup, + databaseUrl: string, + origin: string, + action: (billing: Billing) => Promise, +): Promise { + const db = connectDatabase(databaseUrl); + try { + const billing = new Billing( + db, + setup.stripe ?? + new Stripe(setup.secretKey, { + httpClient: Stripe.createFetchHttpClient(), + maxNetworkRetries: 1, + }), + time, + random, + { + prices: { + monthly: setup.monthly, + yearly: setup.yearly, + founding: { + recognized: setup.founding, + async offer(billing) { + const open = await foundingCohort(billing as Billing, setup.founding); + if (!open) throw new BillingError("Founding is closed.", 409); + return setup.founding[open.cohort]!; + }, + }, + }, + trialDays: 0, + managedPayments: false, + automaticTax: true, + webhookSecret: setup.webhookSecret, + live: setup.live, + origin, + returnPath: BILLING_RETURN_PATH, + }, + ); + return await action(billing); + } finally { + await db.destroy(); + } +} + +/** How many owners one resync reconciles at most. */ +const RESYNC_OWNERS = 20; + +/** + * The account Worker's Cron Trigger: reconciles up to {@link RESYNC_OWNERS} + * random owners whose `active` or `trialing` row no longer grants access, so + * a missed renewal webhook costs a member one run at most. Billing closed + * does nothing. Every owner is tried; any failure fails the invocation after. + */ +export async function resyncBilling(setup: BillingSetup | null, databaseUrl: string, origin: string) { + if (!setup) return; + const due = await queryDatabase<{ owner: string }>( + databaseUrl, + `SELECT owner_id AS owner FROM pgstencil_billing.subscriptions s + WHERE s.status IN ('active', 'trialing') AND NOT ${grantsAccessSql("s")} + GROUP BY owner_id ORDER BY random() LIMIT $1`, + [RESYNC_OWNERS], + ); + if (!due.length) return; + const failed: unknown[] = []; + await withBilling(setup, databaseUrl, origin, async (billing) => { + for (const { owner } of due) + await billing.reconcile(owner).catch((error: unknown) => failed.push(error)); + }); + if (failed.length) throw new AggregateError(failed, `${failed.length} billing resyncs failed`); +} diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts index 9da8e3623..177df237b 100644 --- a/hosted/server/bindings.ts +++ b/hosted/server/bindings.ts @@ -20,8 +20,18 @@ interface Assets { fetch(request: Request): Promise; } -/** `hosted.dormouse.sh`: the account frontend, auth, and voice-token minting. */ -export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv { +/** The account Worker's billing bindings; billing is off unless all five are set. */ +export interface BillingEnv { + STRIPE_SECRET_KEY?: string; + STRIPE_WEBHOOK_SECRET?: string; + STRIPE_PRICE_MONTHLY?: string; + STRIPE_PRICE_YEARLY?: string; + /** The founding ladder's Prices, comma-separated, in cohort order. */ + STRIPE_PRICES_FOUNDING?: string; +} + +/** `hosted.dormouse.sh`: the account frontend, auth, voice-token minting, and billing. */ +export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv, BillingEnv { HYPERDRIVE: { connectionString: string }; ASSETS: Assets; /** Enrollment approvals, per account. */ @@ -75,10 +85,15 @@ export const accountBindings = (env: AccountEnv): AccountEnv => ({ EMAIL_FROM: env.EMAIL_FROM, POSTMARK_SERVER_TOKEN: env.POSTMARK_SERVER_TOKEN, BUILD_SHA: env.BUILD_SHA, + STRIPE_SECRET_KEY: env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: env.STRIPE_WEBHOOK_SECRET, + STRIPE_PRICE_MONTHLY: env.STRIPE_PRICE_MONTHLY, + STRIPE_PRICE_YEARLY: env.STRIPE_PRICE_YEARLY, + STRIPE_PRICES_FOUNDING: env.STRIPE_PRICES_FOUNDING, ...providerBindings(env as unknown as Record), }); -/** Ignores stale production, OAuth, and mail bindings on an existing preview Worker. */ +/** Ignores stale production, OAuth, mail, and Stripe bindings on an existing preview Worker: a preview never bills. */ export const accountPreviewBindings = (env: AccountEnv): AccountEnv => ({ HYPERDRIVE: env.HYPERDRIVE, ASSETS: env.ASSETS, diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts index 7f06d9be6..be4c821bb 100644 --- a/hosted/server/entitlement.ts +++ b/hosted/server/entitlement.ts @@ -1,20 +1,47 @@ // Rules: docs/specs/hosted.md -> "Entitlement". import { queryDatabase } from "pgstencil/postgres"; -/** The admin's address: the one the entitlement keys on until billing ships, and the admin page's. */ +/** The admin's address: a standing comp the entitlement admits without a subscription, and the admin page's. */ export const ADMIN_EMAIL = "ned.twigg@diffplug.com"; // Inlined into SQL below, so it may never carry a quote or a backslash. if (!/^[^'\\]+$/.test(ADMIN_EMAIL)) throw new Error("ADMIN_EMAIL cannot be inlined into SQL"); +/** + * The statuses `@pgstencil/stripe`'s `status()` counts as a current + * subscription; access needs exactly one of them. + */ +const CURRENT = `'trialing', 'active', 'past_due', 'unpaid', 'paused', 'incomplete'`; + +/** + * Whether subscription row `s` grants access now, by the database's clock: + * `active` before its period end or `trialing` before its trial end, as + * `status()` reads it. No other status grants anything, `past_due` included. + */ +export const grantsAccessSql = (s: string) => + `((${s}.status = 'active' AND ${s}.period_end > now()) + OR (${s}.status = 'trialing' AND ${s}.trial_end > now()))`; + +/** + * Whether the SQL expression `owner` holds exactly one current subscription + * and it grants access, as `status()` decides. + */ +function subscribedSql(owner: string): string { + // An alias no caller's query uses, so `owner` can name the outer row. + return `coalesce((SELECT count(*) FILTER (WHERE entitling.status IN (${CURRENT})) = 1 + AND bool_or(${grantsAccessSql("entitling")}) + FROM pgstencil_billing.subscriptions entitling WHERE entitling.owner_id = ${owner}), false)`; +} + /** * The entitlement (docs/specs/pricing.md -> "Checkout and entitlement"): a - * SQL boolean over the `"user"` row aliased by `user` (default `u`), so a query that resolves - * a bearer resolves its owner's entitlement in the same statement. Admin-only - * until billing ships: the verified `ADMIN_EMAIL`. + * SQL boolean over the `"user"` row aliased by `user` (default `u`), so a + * query that resolves a bearer resolves its owner's entitlement in the same + * statement. The account's subscription grants it, and the verified + * `ADMIN_EMAIL` is a standing comp. */ export function entitledSql(user = "u"): string { - return adminSql(user); + return `(${adminSql(user)} OR ${subscribedSql(`${user}.id`)})`; } /** Whether the `"user"` row aliased by `user` is the admin's: `ADMIN_EMAIL`, verified. */ diff --git a/hosted/server/migrations.ts b/hosted/server/migrations.ts index 42b919ed3..2fe2c19ed 100644 --- a/hosted/server/migrations.ts +++ b/hosted/server/migrations.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from "node:url"; import { betterAuthMigrations } from "@pgstencil/auth/better-auth-migrations"; +import { billingMigrations } from "@pgstencil/stripe/migrations"; export const migrations = [ betterAuthMigrations, + billingMigrations, fileURLToPath(new URL("./dormouse-migrations/", import.meta.url)), ]; diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index 69abb9efa..f989913e1 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -33,3 +33,7 @@ export const ENROLLMENT_POLL_INTERVAL_S = 5; // `hosted/server/tests/policy.test.ts` pins this to. A new version is a new // acceptance row, never an edit of an old one. export const TERMS_VERSION = "2026-10-28"; + +// Where Stripe returns the browser from Checkout and the customer portal: an +// account page on the account origin. +export const BILLING_RETURN_PATH = "/billing"; diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql index 4d64048bc..5eee92df9 100644 --- a/hosted/server/runtime-roles.sql +++ b/hosted/server/runtime-roles.sql @@ -1,6 +1,7 @@ -- The relay and voice Workers' Postgres roles (docs/specs/hosted.md -> -- "Application boundary"): each reaches only the rows its Worker's queries --- name. The account Worker keeps its own runtime role. +-- name. The account Worker keeps its own runtime role; the one grant made to +-- it here is at the end. -- -- The rule: every privilege either role holds on this database's schemas, -- tables, sequences and functions is granted below, by name, and nothing @@ -60,8 +61,12 @@ $$; GRANT USAGE ON SCHEMA public TO dormouse_relay, dormouse_voice; --- Both: the entitlement check reads the owner's address and its verification. +-- Both: the entitlement (hosted/server/entitlement.ts) reads the owner's +-- address and its verification, and the owner's subscriptions. GRANT SELECT (id, email, "emailVerified") ON "user" TO dormouse_relay, dormouse_voice; +GRANT USAGE ON SCHEMA pgstencil_billing TO dormouse_relay, dormouse_voice; +GRANT SELECT (owner_id, status, period_end, trial_end) + ON pgstencil_billing.subscriptions TO dormouse_relay, dormouse_voice; -- Both: their metrics' upsert-increment (hosted/server/metrics.ts), whose -- conflict needs the read. A row is an aggregate count. @@ -100,4 +105,19 @@ GRANT DELETE ON dormouse_voice_tokens TO dormouse_voice; GRANT SELECT, INSERT ON dormouse_voice_usage TO dormouse_voice; GRANT UPDATE (count) ON dormouse_voice_usage TO dormouse_voice; +-- The account Worker's role, made by hand (hosted/README.md), owns billing: +-- @pgstencil/stripe reads and writes every pgstencil_billing table, and the +-- entitlement reads its subscriptions. Granted here, after every migration, +-- because its schema is the package's and no hand grant can precede it. +-- Additive only: nothing here revokes from it, and a database without the +-- role (tests, previews) skips it. +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'dormouse_app') THEN + GRANT USAGE ON SCHEMA pgstencil_billing TO dormouse_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA pgstencil_billing TO dormouse_app; + END IF; +END +$$; + COMMIT; diff --git a/hosted/server/tests/artifacts.test.ts b/hosted/server/tests/artifacts.test.ts index 51fb1f939..a931233b1 100644 --- a/hosted/server/tests/artifacts.test.ts +++ b/hosted/server/tests/artifacts.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; const packages = [ ["pgstencil", "pgstencil"], ["@pgstencil/auth", "@pgstencil/auth/better-auth"], + ["@pgstencil/stripe", "@pgstencil/stripe"], ] as const; test("Hosted declares every required peer of the installed pgstencil packages, and no optional one, in dependencies", () => { @@ -27,7 +28,7 @@ test("Hosted declares every required peer of the installed pgstencil packages, a } }); -test("the lockfile resolves both pgstencil packages from npm", () => { +test("the lockfile resolves every pgstencil package from npm", () => { const lockfile = readFileSync("../pnpm-lock.yaml", "utf8"); const lines = lockfile.split("\n"); for (const [name, entry] of packages) { diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts new file mode 100644 index 000000000..5a6cd4c69 --- /dev/null +++ b/hosted/server/tests/billing.test.ts @@ -0,0 +1,330 @@ +import { test, expect } from "vitest"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { Miniflare, Response as WorkerResponse } from "miniflare"; +import { BillingError } from "@pgstencil/stripe"; +import { createTestContext } from "pgstencil/testing"; +import { queryDatabase } from "pgstencil/postgres"; +import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER } from "../../../website/src/lib/hosted-pricing"; +import { billingSetup, openCohort, resyncBilling, withBilling, type Plan } from "../billing"; +import { DEV_FOUNDING_PRICES, stripeDevBilling } from "../billing-dev"; +import { ADMIN_EMAIL, entitled, entitledSql } from "../entitlement"; +import { migrations } from "../migrations"; +import { ORIGINS, bundleWorker, miniflareOptions, alone } from "./bundle"; +import { workerDatabases } from "./worker-roles"; + +// Billing's core: the setup from bindings, the founding cohort, the +// subscription as the entitlement on every Worker's role, and the resync, +// against StripeDev on real Postgres. + +const origin = ORIGINS.account; +const LADDER = DEV_FOUNDING_PRICES; +const zeros = () => Array(FOUNDING_LADDER.length).fill(0); + +test("billing is closed without every Stripe binding, and refuses a ladder the page does not publish", () => { + const env = { + STRIPE_SECRET_KEY: "sk_live_x", + STRIPE_WEBHOOK_SECRET: "whsec_x", + STRIPE_PRICE_MONTHLY: "price_m", + STRIPE_PRICE_YEARLY: "price_y", + STRIPE_PRICES_FOUNDING: LADDER.join(", "), + }; + expect(billingSetup(env)).toEqual({ + secretKey: "sk_live_x", + webhookSecret: "whsec_x", + live: true, + monthly: "price_m", + yearly: "price_y", + founding: LADDER, + }); + for (const name of Object.keys(env)) { + expect(billingSetup({ ...env, [name]: undefined }), name).toBeNull(); + expect(billingSetup({ ...env, [name]: "" }), name).toBeNull(); + } + expect(billingSetup({ ...env, STRIPE_SECRET_KEY: "rk_live_x" })?.live).toBe(true); + expect(billingSetup({ ...env, STRIPE_SECRET_KEY: "sk_test_x" })?.live).toBe(false); + expect(() => billingSetup({ ...env, STRIPE_PRICES_FOUNDING: LADDER.slice(1).join(",") })).toThrow( + /published ladder/, + ); + expect(() => billingSetup({ ...env, STRIPE_PRICE_YEARLY: "price_m" })).toThrow(/distinct/); + expect(() => billingSetup({ ...env, STRIPE_PRICE_MONTHLY: "prod_m" })).toThrow(/distinct/); + expect(() => billingSetup({ ...env, STRIPE_PRICE_MONTHLY: "price_m'" })).toThrow(/distinct/); +}); + +test("a cohort closes on its gross purchases, so a refund after it closed never reopens it", () => { + const full = FOUNDING_COHORT_SIZE; + const counts = (...head: number[]) => [...head, ...zeros().slice(head.length)]; + expect(openCohort(zeros(), zeros())).toEqual({ cohort: 0, seatsLeft: full }); + expect(openCohort(counts(3), counts(3))).toEqual({ cohort: 0, seatsLeft: full - 3 }); + // A refund in the open cohort returns its seat. + expect(openCohort(counts(3), counts(2))).toEqual({ cohort: 0, seatsLeft: full - 2 }); + expect(openCohort(counts(full), counts(full))).toEqual({ cohort: 1, seatsLeft: full }); + // Refunded right after it closed: the price stays up. + expect(openCohort(counts(full), counts(full - 1))).toEqual({ cohort: 1, seatsLeft: full }); + // Concurrent checkouts oversold it; the next still opens at a full count. + expect(openCohort(counts(full + 4), counts(full + 4))).toEqual({ cohort: 1, seatsLeft: full }); + expect(openCohort(counts(full, 2), counts(full - 1, 1))).toEqual({ cohort: 1, seatsLeft: full - 1 }); + const all = Array(FOUNDING_LADDER.length).fill(full); + expect(openCohort(all, all.map((n) => n - 1))).toBeNull(); +}); + +async function fixture() { + // The entitlement reads the database's clock, so the test clock starts at it. + const context = await createTestContext({ migrations, now: new Date().toISOString() }); + const databases = await workerDatabases(context.database.url); + const { dev, setup } = await stripeDevBilling(context.time, context.random); + const sql = >(text: string, values?: unknown[]) => + queryDatabase(context.database.url, text, values); + const billing = (action: Parameters>[3]) => + withBilling(setup, context.database.url, origin, action); + + /** A user row; verified unless said otherwise. */ + const user = async (email = `${randomUUID()}@example.test`, emailVerified = true) => { + const id = randomUUID(); + await sql(`INSERT INTO "user" (id, name, email, "emailVerified") VALUES ($1, $1, $2, $3)`, [ + id, + email, + emailVerified, + ]); + return id; + }; + /** Delivers every pending StripeDev event, signed, to the webhook. */ + const deliver = async () => { + while (dev.events.length) { + const { body, signature } = dev.signed(dev.events.shift()!); + await billing((b) => b.webhook(body, signature)); + } + }; + /** Checks `owner` out on `plan` and completes it in StripeDev. */ + const buy = async (owner: string, plan: Plan) => { + const { url } = await billing((b) => b.checkout(owner, `${owner}@example.test`, plan)); + const session = [...dev.checkouts.values()].find((s) => s.url === url)!; + const subscription = dev.completeCheckout(session.id); + await deliver(); + return subscription; + }; + /** Whether `userId` is entitled, as each Worker's own role reads it. */ + const entitledOn = async (userId: string) => + Object.fromEntries( + await Promise.all( + Object.entries(databases).map(async ([worker, url]) => { + const [row] = await queryDatabase<{ is: boolean }>( + url, + `SELECT ${entitledSql("u")} AS is FROM "user" u WHERE u.id = $1`, + [userId], + ); + return [worker, row?.is]; + }), + ), + ); + return { + ...context, + dev, + setup, + sql, + billing, + user, + deliver, + buy, + entitledOn, + close: async () => { + await dev.close(); + await context.close(); + }, + }; +} + +const everywhere = (is: boolean) => ({ account: is, relay: is, voice: is }); + +test("a subscription entitles its account on every Worker's role while it grants access, and a payment due grants nothing", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.user(); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + + const subscription = await f.buy(member, "monthly"); + // Stripe Billing with Stripe Tax: no Managed Payments, no trial. + const sent = f.dev.requests.find((r) => r.path === "/v1/checkout/sessions")!.body; + expect(sent).toMatchObject({ + "line_items[0][price]": f.setup.monthly, + "automatic_tax[enabled]": "true", + "customer_update[address]": "auto", + success_url: expect.stringMatching(new RegExp(`^${origin}/billing\\?`)), + }); + expect(sent["managed_payments[enabled]"]).toBeUndefined(); + expect(sent["subscription_data[trial_period_days]"]).toBeUndefined(); + expect(await f.entitledOn(member)).toEqual(everywhere(true)); + expect(await entitled(f.database.url, member)).toBe(true); + + // A failed renewal ends access until it is paid. + f.dev.transition(subscription.id, "payment-failed"); + await f.deliver(); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + f.dev.transition(subscription.id, "renew"); + await f.deliver(); + expect(await f.entitledOn(member)).toEqual(everywhere(true)); + + // A period that ended without a renewal grants nothing, whatever the row's status. + await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = now() - interval '1 second'`); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = now() + interval '1 day'`); + // Two current subscriptions grant nothing, as `status()` reads them. + await f.sql( + `INSERT INTO pgstencil_billing.subscriptions + (id, owner_id, price_id, status, period_end, cancel_at_period_end, updated_at) + VALUES ('sub_second', $1, $2, 'past_due', now() + interval '1 day', false, now())`, + [member, f.setup.yearly], + ); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + await f.sql(`DELETE FROM pgstencil_billing.subscriptions WHERE id = 'sub_second'`); + expect(await f.entitledOn(member)).toEqual(everywhere(true)); + + // Canceled, as a refund cancels: refused on the next read. + f.dev.transition(subscription.id, "cancel"); + await f.deliver(); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + expect(await f.sql(`SELECT owner_id, status FROM pgstencil_billing.subscriptions`)).toEqual([ + { owner_id: member, status: "canceled" }, + ]); + + // The admin is a standing comp, while that address is verified; an unverified one is not. + expect(await f.entitledOn(await f.user(ADMIN_EMAIL))).toEqual(everywhere(true)); + await f.sql(`DELETE FROM "user" WHERE email = $1`, [ADMIN_EMAIL]); + expect(await f.entitledOn(await f.user(ADMIN_EMAIL, false))).toEqual(everywhere(false)); +}); + +test("founding checkout offers the open cohort's Price, holds it after a refund, and refuses once the ladder is full", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + /** `count` paid founders at ladder step `step`, started 40 days ago. */ + const fill = (step: number, count: number) => + f.sql( + `WITH owners AS ( + INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at) + SELECT 'filler-' || $2::int || '-' || n, 'filler@example.test', now() FROM generate_series(1, $1::int) n + RETURNING owner_id) + INSERT INTO pgstencil_billing.subscriptions + (id, owner_id, price_id, status, started_at, period_end, cancel_at_period_end, updated_at) + SELECT 'sub_' || owner_id, owner_id, $3, 'active', now() - interval '40 days', + now() + interval '325 days', false, now() FROM owners`, + [count, step, LADDER[step]], + ); + const offered = () => f.billing((b) => b.offeredPrice("founding")); + + expect(await offered()).toBe(LADDER[0]); + const ada = await f.user(); + await f.buy(ada, "founding"); + expect(await f.entitledOn(ada)).toEqual(everywhere(true)); + await fill(0, FOUNDING_COHORT_SIZE - 1); + expect(await offered()).toBe(LADDER[1]); + // Ada is refunded right after the cohort closed: her seat returns, and the price stays up. + await f.sql( + `UPDATE pgstencil_billing.subscriptions SET status = 'canceled', ended_at = started_at + interval '1 day' + WHERE owner_id = $1`, + [ada], + ); + expect(await offered()).toBe(LADDER[1]); + + for (let step = 1; step < LADDER.length; step++) await fill(step, FOUNDING_COHORT_SIZE); + const late = await f.user(); + await expect(f.billing((b) => b.checkout(late, null, "founding"))).rejects.toEqual( + new BillingError("Founding is closed.", 409), + ); + // Every past cohort's Price still grants its plan. + expect(await f.billing(async (b) => LADDER.map((price) => b.planOf(price)))).toEqual( + LADDER.map(() => "founding"), + ); +}); + +test("the resync repairs a missed renewal, and does nothing while billing is closed", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + // Closed: no setup, so not even the database is asked. + await expect(resyncBilling(null, "postgres://user:pass@127.0.0.1:9/none", origin)).resolves.toBeUndefined(); + + const member = await f.user(); + const subscription = await f.buy(member, "yearly"); + // Stripe renewed, but the webhook never arrived, and the stored period has ended. + f.dev.transition(subscription.id, "renew"); + f.dev.events.length = 0; + await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = now() - interval '1 minute'`); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + await resyncBilling(f.setup, f.database.url, origin); + expect(await f.entitledOn(member)).toEqual(everywhere(true)); +}); + +test("the account Worker's cron resyncs through Stripe's API in workerd, and only with every binding", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.user(); + const subscription = await f.buy(member, "yearly"); + f.dev.transition(subscription.id, "renew"); + f.dev.events.length = 0; + await f.sql(`UPDATE pgstencil_billing.subscriptions SET period_end = now() - interval '1 minute'`); + + const bundle = await bundleWorker("server/tests/worker-entry.ts", [ + fileURLToPath(import.meta.resolve("@pgstencil/auth/better-auth-testing")), + ]); + const outbound: string[] = []; + const worker = async (stripe: Record) => { + const mf = new Miniflare( + alone( + "account", + miniflareOptions("account", bundle.outputFiles[0].text, { + bindings: { + APP_ORIGIN: origin, + AUTH_SECRET: "dormouse-test-secret-with-at-least-32-characters", + EMAIL_FROM: "signin@example.test", + POSTMARK_SERVER_TOKEN: "test-token", + ...stripe, + }, + hyperdrives: { HYPERDRIVE: f.database.url }, + serviceBindings: { ASSETS: () => new WorkerResponse("") }, + // Stripe's API is StripeDev here; nothing else may be reached. + async outboundService(request: Request) { + const url = new URL(request.url); + outbound.push(url.origin); + if (url.origin !== "https://api.stripe.com") throw new Error(`Unexpected outbound host: ${url.host}`); + const response = await fetch(f.dev.origin + url.pathname + 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": "application/json" }, + }); + }, + }), + ), + ); + onTestFinished(() => mf.dispose()); + await mf.ready; + // Without @cloudflare/workers-types, the Fetcher's scheduled() is untyped. + return (await mf.getWorker()) as unknown as { + scheduled(options: { cron: string }): Promise<{ outcome: string }>; + }; + }; + const bindings = { + STRIPE_SECRET_KEY: f.setup.secretKey, + STRIPE_WEBHOOK_SECRET: f.setup.webhookSecret, + STRIPE_PRICE_MONTHLY: f.setup.monthly, + STRIPE_PRICE_YEARLY: f.setup.yearly, + STRIPE_PRICES_FOUNDING: f.setup.founding.join(","), + }; + const { STRIPE_SECRET_KEY: _, ...missingKey } = bindings; + expect((await (await worker(missingKey)).scheduled({ cron: "*/10 * * * *" })).outcome).toBe("ok"); + expect(outbound).toEqual([]); + expect(await f.entitledOn(member)).toEqual(everywhere(false)); + + expect((await (await worker(bindings)).scheduled({ cron: "*/10 * * * *" })).outcome).toBe("ok"); + expect(outbound.length).toBeGreaterThan(0); + expect(new Set(outbound)).toEqual(new Set(["https://api.stripe.com"])); + expect(await f.entitledOn(member)).toEqual(everywhere(true)); +}); diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 0baae561f..2b6c68ce1 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -76,6 +76,15 @@ const everything = { ...testVapidKeys(), }; +/** Billing's, which only the production account's mapper passes; never in {@link everything}, so billing stays closed. */ +const STRIPE = { + STRIPE_SECRET_KEY: "sk_test_x", + STRIPE_WEBHOOK_SECRET: "whsec_x", + STRIPE_PRICE_MONTHLY: "price_m", + STRIPE_PRICE_YEARLY: "price_y", + STRIPE_PRICES_FOUNDING: "price_f", +}; + const IMMUTABLE = "public, max-age=31536000, immutable"; const HASHED_FILES = ["/assets/app-B39o2E6i.js", `${ONE_TIME_PAGE_PATH}assets/page-B39o2E6i.js`]; @@ -407,6 +416,7 @@ test("each bindings mapper passes only what its Worker uses", () => { RELAY_ENROLL_POLL_LIMIT: {} as RateLimit, RELAY_APPROVE_LIMIT: {} as RateLimit, ACCOUNT_ORIGIN: "https://account.example.test", + ...STRIPE, }; const keys = (bindings: object) => Object.keys(bindings).sort(); expect(keys(accountBindings(env))).toEqual( @@ -422,6 +432,7 @@ test("each bindings mapper passes only what its Worker uses", () => { "POSTMARK_SERVER_TOKEN", "RELAY_APPROVE_LIMIT", "RELAY_ROOM", + ...Object.keys(STRIPE), ].sort(), ); expect(accountPreviewBindings(env)).toEqual({ @@ -502,7 +513,8 @@ test("only the push send outgrows the request body bound, and only to its derive expect(MAX_PUSH_SEND_BODY_BYTES).toBeGreaterThan(MAX_REQUEST_BODY_BYTES); }); -test("neither the relay nor the voice bundle carries Better Auth", async () => { +test("neither the relay nor the voice bundle carries Better Auth or billing", async () => { + const pattern = /better-auth|postmark|stripe|server\/billing/; for (const [entry, bundle] of [ [ENTRIES.relay, bundles.relay], [ENTRIES.voice, bundles.voice], @@ -510,13 +522,14 @@ test("neither the relay nor the voice bundle carries Better Auth", async () => { ] as const) { const inputs = Object.keys(bundle.metafile.inputs); expect( - inputs.filter((input) => /better-auth|postmark/.test(input)), + inputs.filter((input) => pattern.test(input)), entry, ).toEqual([]); } - // The pattern finds it where it is. + // The pattern finds each where it is. const account = Object.keys(bundles.account.metafile.inputs); - expect(account.some((input) => /better-auth/.test(input))).toBe(true); + for (const name of ["better-auth", "@pgstencil/stripe", "node_modules/stripe/", "server/billing.ts"]) + expect(account.filter((input) => pattern.test(input) && input.includes(name)), name).not.toEqual([]); }); test("the relay bundle reads no cookie and never asks auth", () => { diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts index a18c82004..544ae9bc7 100644 --- a/hosted/server/tests/runtime-roles.test.ts +++ b/hosted/server/tests/runtime-roles.test.ts @@ -4,6 +4,7 @@ import { queryDatabase, withClient } from "pgstencil/postgres"; import { migrations } from "../migrations"; import { applyRuntimeRoles, runtimeRolesSql } from "../runtime-roles"; import { RUNTIME_ROLES, workerDatabases, type Worker } from "./worker-roles"; +import { PRODUCTION } from "../../scripts/production.mjs"; // `hosted/server/runtime-roles.sql` in real Postgres: what each restricted role // may touch, exactly. The relay and voice suites run their Workers on these @@ -93,7 +94,12 @@ const EXPECTED: Record = { "dormouse_relay_setup_tokens: DELETE, INSERT, SELECT", "dormouse_voice_tokens.hash: INSERT", "dormouse_voice_tokens.userId: INSERT", + "schema pgstencil_billing: USAGE", "schema public: USAGE", + "subscriptions.owner_id: SELECT", + "subscriptions.period_end: SELECT", + "subscriptions.status: SELECT", + "subscriptions.trial_end: SELECT", "user.email: SELECT", "user.emailVerified: SELECT", "user.id: SELECT", @@ -110,7 +116,12 @@ const EXPECTED: Record = { "dormouse_voice_tokens: DELETE", "dormouse_voice_usage.count: UPDATE", "dormouse_voice_usage: INSERT, SELECT", + "schema pgstencil_billing: USAGE", "schema public: USAGE", + "subscriptions.owner_id: SELECT", + "subscriptions.period_end: SELECT", + "subscriptions.status: SELECT", + "subscriptions.trial_end: SELECT", "user.email: SELECT", "user.emailVerified: SELECT", "user.id: SELECT", @@ -133,9 +144,23 @@ const outcome = (worker: Worker, text: string) => (error: { code?: string }) => error.code, ); +/** Billing beyond the entitlement's subscription columns, refused to both roles. */ +const BILLING_REFUSED = [ + `SELECT price_id FROM pgstencil_billing.subscriptions`, + `SELECT customer_id FROM pgstencil_billing.accounts`, + `SELECT id FROM pgstencil_billing.checkouts`, + `SELECT id FROM pgstencil_billing.events`, + `UPDATE pgstencil_billing.subscriptions SET status = status`, + `DELETE FROM pgstencil_billing.subscriptions`, +]; + +/** What the entitlement reads of the subscriptions, which both roles may. */ +const ENTITLEMENT_READ = `SELECT owner_id, status, period_end, trial_end FROM pgstencil_billing.subscriptions`; + test("the relay's role is refused the account's tables, the user row's other columns and writes, voice but a token's mint, and Burrow removal", async () => { // It does log in, and reads what the entitlement check reads. expect(await outcome("relay", `SELECT id, email, "emailVerified" FROM "user"`)).toBe("ok"); + expect(await outcome("relay", ENTITLEMENT_READ)).toBe("ok"); for (const text of [ `SELECT * FROM "session"`, `SELECT * FROM account`, @@ -151,6 +176,7 @@ test("the relay's role is refused the account's tables, the user row's other col `UPDATE dormouse_relay_burrows SET "userId" = "userId"`, `INSERT INTO dormouse_relay_enrollment_approvals ("userCode", "userId", "expiresAt") VALUES ('x', 'x', now())`, `CREATE TABLE dormouse_relay_extra (id int)`, + ...BILLING_REFUSED, ]) expect([text, await outcome("relay", text)]).toEqual([text, "42501"]); }); @@ -160,6 +186,7 @@ test("the voice role is refused the relay's tables, the account's, and its token await outcome("voice", `SELECT id, "userId", hash, "createdAt", "lastUsedAt" FROM dormouse_voice_tokens`), ).toBe("ok"); expect(await outcome("voice", `DELETE FROM dormouse_voice_tokens WHERE hash = 'x'`)).toBe("ok"); + expect(await outcome("voice", ENTITLEMENT_READ)).toBe("ok"); for (const text of [ `SELECT * FROM dormouse_relay_sessions`, `SELECT * FROM dormouse_relay_burrows`, @@ -170,6 +197,7 @@ test("the voice role is refused the relay's tables, the account's, and its token `UPDATE dormouse_voice_tokens SET "userId" = "userId"`, `INSERT INTO dormouse_voice_tokens ("userId", hash) VALUES ('x', 'x')`, `DELETE FROM dormouse_voice_usage`, + ...BILLING_REFUSED, ]) expect([text, await outcome("voice", text)]).toEqual([text, "42501"]); }); @@ -222,3 +250,30 @@ test("once both roles exist, a migration role without CREATEROLE reapplies the f ]), ).resolves.toBeUndefined(); }); + +test("the account's hand-made role, where it exists, is granted every billing table and nothing taken away", async () => { + // The name production's account Hyperdrive connects as. + expect(PRODUCTION.account.role).toBe("dormouse_app"); + const granted = await withClient(context.database.url, async (db) => { + await db.query("BEGIN"); + try { + await db.query(`CREATE ROLE dormouse_app NOLOGIN`); + await db.query(`GRANT SELECT ON "session" TO dormouse_app`); + await db.query((await runtimeRolesSql()).replace(/^(BEGIN|COMMIT);$/gm, "")); + const { rows } = await db.query<{ table: string; ok: boolean }>( + `SELECT c.relname AS table, has_schema_privilege('dormouse_app', 'pgstencil_billing', 'USAGE') + AND has_table_privilege('dormouse_app', c.oid, 'SELECT, INSERT, UPDATE, DELETE') AS ok + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'pgstencil_billing' AND c.relkind = 'r' + UNION ALL SELECT 'session', has_table_privilege('dormouse_app', '"session"', 'SELECT') + ORDER BY 1`, + ); + return rows; + } finally { + await db.query("ROLLBACK"); + } + }); + expect(granted).toEqual( + ["accounts", "checkouts", "events", "session", "subscriptions"].map((table) => ({ table, ok: true })), + ); +}); diff --git a/hosted/server/tests/worker-entry.ts b/hosted/server/tests/worker-entry.ts index cee2a7739..500348332 100644 --- a/hosted/server/tests/worker-entry.ts +++ b/hosted/server/tests/worker-entry.ts @@ -24,4 +24,7 @@ export default { worker.fetch(request, env, ctx), ); }, + // On the same test clock, so billing's resync reads the time `fetch` does. + scheduled: (...args: Parameters>) => + deterministicScope.run(scope, () => worker.scheduled!(...args)), }; diff --git a/hosted/wrangler.jsonc b/hosted/wrangler.jsonc index 2538ab65a..6d1974d53 100644 --- a/hosted/wrangler.jsonc +++ b/hosted/wrangler.jsonc @@ -68,9 +68,11 @@ } ], "triggers": { - // Empty, never absent: an absent `triggers` leaves a deployed schedule in - // place, and the history sweep's moved to the voice Worker. - "crons": [] + // Billing's resync of subscriptions whose paid period has ended + // (`resyncBilling`); it reads nothing while billing is closed. + "crons": [ + "*/10 * * * *" + ] }, "observability": { "enabled": false diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc551b9be..e365fa784 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,6 +341,9 @@ importers: '@pgstencil/auth': specifier: ^0.4.1 version: 0.4.1(hono@4.13.14)(kysely@0.29.7)(pg@8.23.1)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5) + '@pgstencil/stripe': + specifier: ^0.4.1 + version: 0.4.1(kysely@0.29.7)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(stripe@22.6.2(@types/node@24.19.2)) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -365,6 +368,9 @@ importers: remote-lib-common: specifier: workspace:* version: link:../remote-lib-common + stripe: + specifier: ^22.6.2 + version: 22.6.2(@types/node@24.19.2) devDependencies: '@storybook/react': specifier: 11.0.0-alpha.6 @@ -2996,6 +3002,14 @@ packages: kysely: ^0.29.5 pgstencil: ^0.4.1 + '@pgstencil/stripe@0.4.1': + resolution: {integrity: sha512-f8rHAd+Jnnkmjmo5TBg9qabY4nEhczZSndYxpRGlNlT1mw5CSDdNb7aXqG7aJ+VRNUoz8FtR8a3MUfGHRboM5g==} + engines: {node: '>=24'} + peerDependencies: + kysely: ^0.29.5 + pgstencil: ^0.4.1 + stripe: ^22.6.1 + '@phosphor-icons/react@2.1.10': resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} engines: {node: '>=10'} @@ -7259,6 +7273,15 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} + stripe@22.6.2: + resolution: {integrity: sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} @@ -9981,6 +10004,12 @@ snapshots: - vitest - vue + '@pgstencil/stripe@0.4.1(kysely@0.29.7)(pgstencil@0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4))(stripe@22.6.2(@types/node@24.19.2))': + dependencies: + kysely: 0.29.7 + pgstencil: 0.4.1(cheerio@1.2.0)(kysely@0.29.7)(node-pg-migrate@9.0.0(pg@8.23.1))(testcontainers@12.2.1(supports-color@10.2.2))(turndown@7.2.4) + stripe: 22.6.2(@types/node@24.19.2) + '@phosphor-icons/react@2.1.10(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: react: 19.3.1 @@ -14420,6 +14449,10 @@ snapshots: strip-indent@4.1.1: {} + stripe@22.6.2(@types/node@24.19.2): + optionalDependencies: + '@types/node': 24.19.2 + structured-source@4.0.0: dependencies: boundary: 2.0.0 diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index f05cd7db4..a90602be0 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 281, + "next": 283, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index f678c7c5c..738447a0f 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1750, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 4850, + "docs/specs/hosted.md": 5100, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, @@ -26,7 +26,7 @@ "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, - "docs/specs/security-hosted.md": 3250, + "docs/specs/security-hosted.md": 3350, "docs/specs/security-local.md": 6400, "docs/specs/security-remote.md": 7200, "docs/specs/security-supply-chain.md": 1650, diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index d5b5214a3..bb27fff03 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -4101,6 +4101,15 @@ "0.4.1" ] }, + { + "name": "@pgstencil/stripe", + "license": "MIT", + "author": "DiffPlug", + "homepage": "https://github.com/diffplug/pgstencil#readme", + "versions": [ + "0.4.1" + ] + }, { "name": "@standard-schema/spec", "license": "MIT", @@ -4326,6 +4335,15 @@ "4.2.0" ] }, + { + "name": "stripe", + "license": "MIT", + "author": "Stripe (https://stripe.com/)", + "homepage": "https://github.com/stripe/stripe-node", + "versions": [ + "22.6.2" + ] + }, { "name": "xtend", "license": "MIT",