diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md
index 8f65a6b81..e749fdf90 100644
--- a/.github/audit/hosted.md
+++ b/.github/audit/hosted.md
@@ -36,6 +36,7 @@ Be adversarial, and go past the `FAIL IF` list; a bare section name is `docs/spe
- **Can a Hosted login become terminal access, or an account become someone else's?** Trace linking, a revoked login's callback, an unused or unknown provider credential, and every path from a login toward a Burrow ACL grant ("Account boundary"; the relay Worker's cookie rule is "Relay boundary"). Pocket and `/connect/` share the relay origin; check what each page's policy lets it reach of the other.
- **Can one account's relay socket reach another's?** Trace an upgrade through `relaySocketRoutes` (`hosted/server/relay-sockets.ts`) into `RelayRoom` (`hosted/server/relay-room.ts`) against "Relay boundary". Look for routing state kept in memory that a hibernated object would lose, a socket torn down twice or routed after its close began, a frame bounded in characters rather than bytes, a `ct` touched outside the shared frame layer's field copy, a Client cap one socket can evict past, a session that outlives its alarm, a ping that wakes the object, a Burrow socket accepted on a row removed after the token check, and a removed or de-entitled Burrow's socket outliving the sweep.
- **Can the rendezvous become more than a handshake pipe?** Trace a frame through `OneTimeRoom` against "Rendezvous boundary". Look for a second phone admitted across an await or a hibernation, a room that outlives its alarm, a web page that can mint a room, a join from another origin, a room id a caller can choose, and a limit a caller can step around. The relay is same-site with the account, so account cookies can ride the phone's upgrade.
+- **Can billing grant what Stripe did not sell?** Trace checkout, confirm, the webhook, and the cron resync in `hosted/server/billing-routes.ts` against "Billing boundary": a browser-chosen Price, owner, or return URL, an unsigned or replayed event, a 2xx before the commit, a subscription that lapsed but still admits, and a cohort answer that names more than an opted-in founder chose to show.
- **Does anything from the test or preview build reach production?** Trace the production entries and the preview configuration and workers against "Deployment boundary". Preview credentials and the cleanup checkout are `ci-and-secrets`' (`docs/specs/security-ci.md` -> "Hosted Deployments").
Does the shipped code still match what the spec and this section claim? Spec drift is a finding; say which side is wrong.
diff --git a/AGENTS.md b/AGENTS.md
index 4175b5839..1c3010e52 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,7 +79,7 @@ A spec is the accurate reference for the current code: it states the invariants
- **`docs/specs/one-time.md`** — One-time connection: the link a laptop shows, its Settings panel and Baseboard indicator, the Hosted rendezvous wire that carries only its handshake, the phone page Hosted serves, and the direct-only session; no account, nothing saved.
- **`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/pricing.md`** — Pricing (design-stage): the tiers and founding ladder, what the Individual plan grants, how a desktop proves membership, the managed-voice boundary, and the `/hosted` page contract.
+- **`docs/specs/pricing.md`** — Pricing: the tiers and founding ladder, what the Individual plan grants, checkout and the subscription entitlement, how a desktop proves membership, the managed-voice boundary, and the `/hosted` page contract.
- **`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.
- **`docs/specs/security.md`** — The guarantees Dormouse makes, what it does not defend, the known gaps, and how it is all checked; published at `/security`, rows split by audience. Read first for anything security. It alone states each known gap and accepted risk; other security specs and the audit preamble point at it. Root `SECURITY.md` is the GitHub policy pointer at it.
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index 2da3ad204..baf33fb49 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`, `/api/ready`, voice tokens, 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`, `/api/ready`, voice tokens, the Relay's account routes ("Burrow enrollment"), billing ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe secrets |
| `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 and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive |
@@ -17,7 +17,7 @@ Every Worker answers `/api/health`, 404s anything else under its non-page prefix
**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.
-**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, and the relay inserts a sign-in's voice token ("Managed voice"). Both Workers still bind the account's role (`docs/specs/security.md` -> "Known gaps").
+**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, and the relay inserts a sign-in's voice token ("Managed voice"). Both Workers still bind the account's role (`docs/specs/security.md` -> "Known gaps").
**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`).
@@ -53,13 +53,16 @@ Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/ma
## 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, the only exception 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, and that one is `active` before its period end or `trialing` before its trial end**, against the database's clock: `@pgstencil/stripe`'s `status()` access, so `past_due` is refused at once.
+- **Must entitle `ADMIN_EMAIL` as a standing comp** while it is the account's verified email, the only exception 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`.
## Managed voice
-A signed-in desktop 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`). Signing in is the device-code enrollment ("Burrow enrollment"), whose redemption mints the token; the account Worker's token routes are the admin test path. The voice Worker serves speak.
+A signed-in desktop 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`). Signing in is the device-code enrollment ("Burrow enrollment"), whose redemption mints the token; the account Worker's token routes mint one by hand. The voice Worker serves speak.
| Route | Credential | Success |
|---|---|---|
@@ -211,9 +214,37 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their
Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `enrollUserCode` in `remote-lib-common/src/remote/enroll-code.ts`; `takeEnrollment` in `hosted/src/enrollment.ts`; `ENROLLMENT_TTL_MS` in `hosted/server/policy-constants.ts`.
+## Billing
+
+The account Worker sells the plans `monthly`, `yearly`, and `founding` through `@pgstencil/stripe` (`docs/specs/pricing.md` -> "Checkout and entitlement").
+
+| Route | Credential | Success |
+|---|---|---|
+| `GET /api/billing` | login cookie | 200 `{ plan, active, until, renews, entitled, founder, founding }` from the synchronized rows; `plan` names a subscription Stripe still holds, `active` whether it grants access |
+| `POST /api/billing/checkout` | login cookie, exact `Origin`, JSON `{ plan }` | 200 `{ url }` of Stripe Checkout |
+| `POST /api/billing/confirm` | login cookie, exact `Origin`, JSON `{ checkout }` | 200, the `GET` body |
+| `POST /api/billing/portal` | login cookie, exact `Origin` | 200 `{ url }` of the customer portal |
+| `PUT /api/billing/founder` | login cookie, exact `Origin`, JSON `{ shown, name }` | 204; 409 for an account with no current founding subscription |
+| `PUT /api/billing/survey` | login cookie, exact `Origin`, JSON of the four answers | 204 |
+| `POST /api/billing/webhook` | `Stripe-Signature` over the raw body | 200 once `webhook()` committed |
+| `GET /api/hosted/cohorts` | none | 200 `{ cohort, seatsLeft, founders: { total, shown } }` |
+
+Errors are JSON `{ message }`; the cookie routes answer 401 without a login and need no entitlement.
+
+- **Must turn billing off, not half-working**, without all five bindings — the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order): every billing route answers 503 `CHECKOUT_CLOSED` and the cron does nothing. **Must refuse a ladder whose length differs from `FOUNDING_LADDER`** in `website/src/lib/hosted-pricing.ts`, the one owner of prices. Previews never bill; the dev loop bills on StripeDev, which takes no card.
+- **Never take a Price, customer, owner, quantity, or return URL from a request**: checkout takes a plan name, the owner is the login, and Stripe returns to `/billing`. An account without a public email checks out with Stripe Checkout collecting one. A checkout left open for another plan is expired first.
+- **Must offer founding at the open cohort's Price**: the highest ladder step with a completed purchase, or the next once it holds `FOUNDING_COHORT_SIZE`; 409 once the ladder is full. A purchase counts unless it ended within `REFUND_DAYS` of starting, so a refund returns its seat only when the subscription is canceled at once in Stripe.
+- **Must verify the signature over the raw body before any write**, cap the body at `WEBHOOK_BODY_BYTES` (413), and answer 2xx only once `webhook()` has committed; any other failure answers 503, which Stripe retries.
+- **Must answer from the cohort endpoint only the open cohort's index and seats (both absent once founding closes), the count of founding purchases, and the chosen names of opted-in current founders**, at most `MAX_SHOWN_FOUNDERS`, cached at most `COHORT_CACHE_MS`. **Must answer `SITE_ORIGIN` (`https://dormouse.sh`) this one `GET` path and 421 every other**, through the zone route `PRODUCTION` pins.
+- **Must resync, from the account's Cron Trigger every 10 minutes, every subscription still `active` or `trialing` past its period or trial end**, so a missed renewal webhook lapses a member until the next run at most; a failed resync fails the invocation, and an account that always fails cannot hold the others back.
+- **Must serve the account pages** `/checkout?plan=` (signed in, the plan and its price now, then Stripe), Stripe's return to `/billing?checkout=` (confirmed, then the founders-row opt-in and the survey for a completed checkout), and the account page's Plan section, which keeps Manage billing for a held subscription with a payment due. **May keep a pending checkout's plan name, and nothing else, in the tab's session storage** for a provider sign-in to return to.
+- **Must keep a founder's opt-in as the name they chose to show** (`dormouse_founders`, deleted on withdrawal) and the survey as one set of answers per account (`dormouse_price_survey`), each answer whole dollars or null.
+
+Source of truth: `billingRoutes` and `reconcileDue` in `hosted/server/billing-routes.ts`; `billingSetup`, `openCohort`, and `withBilling` in `hosted/server/billing.ts`; `hosted/src/Billing.tsx` and `takeCheckout` in `hosted/src/checkout.ts`; `hosted/server/dormouse-migrations/006_billing_founders.sql`. Pinned by `hosted/server/tests/billing.test.ts`.
+
## Development and release
-**Must run local development with `dor tool hosted` inside Dormouse.** Its single loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the voice token and Relay account routes but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback without a database, so its Relay routes answer 503 (`docs/specs/one-time.md` -> "Dev loop").
+**Must run local development with `dor tool hosted` inside Dormouse.** Its single loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the voice token, Relay account, and billing routes (on StripeDev) but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback without a database, so its Relay routes answer 503 (`docs/specs/one-time.md` -> "Dev loop").
**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs Hosted's deploy-script and Docker-free suites; `pnpm test:hosted` adds the suites that need Docker. Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery.
@@ -225,7 +256,7 @@ Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `host
**Must deploy only verified same-repository PR merge revisions touching Hosted or its shared build inputs.** Drafts qualify; forks receive no deployment credentials.
-**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until close or merge deletes them. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespaces, each preview preview-only rate-limit namespaces, and wire each preview to its own PR's siblings** (the account's `RELAY_ROOM`, the relay's `ACCOUNT_ORIGIN`). **Must derive every preview secret from `PREVIEW_AUTH_SECRET` and its Worker's name**, the relay's VAPID pair included, so no production key reaches a preview and subscriptions survive the PR's redeploys.
+**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until close or merge deletes them. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, ElevenLabs, or Stripe. **Must give each relay preview its own Durable Object namespaces, each preview preview-only rate-limit namespaces, and wire each preview to its own PR's siblings** (the account's `RELAY_ROOM`, the relay's `ACCOUNT_ORIGIN`). **Must derive every preview secret from `PREVIEW_AUTH_SECRET` and its Worker's name**, the relay's VAPID pair included, so no production key reaches a preview and subscriptions survive the PR's redeploys.
**Must run cleanup from the base branch's checkout, never the closed PR's.**
@@ -235,7 +266,7 @@ Source of truth: `.github/workflows/hosted-preview.yml`; `touchesHosted` in `hos
## Production releases
-**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `productionConfig` holds each config to its Worker's pinned name, origin, and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; `preflight` checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair) — names only, as Cloudflare exposes no value. **Must back up, encrypt, decrypt, and restore-test before applying migrations**, uploading only the encrypted archive. **Must deploy relay, voice, then account, stopping at a failure**; the relay must pass its revision check, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL.
+**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `productionConfig` holds each config to its Worker's pinned name, origin, and lone custom domain (the account also to its site route, "Billing"), and the relay's `ACCOUNT_ORIGIN` to the account's origin; `preflight` checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair) — names only, as Cloudflare exposes no value. **Must back up, encrypt, decrypt, and restore-test before applying migrations**, uploading only the encrypted archive. **Must deploy relay, voice, then account, stopping at a failure**; the relay must pass its revision check, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL.
**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay has its own `v1` (`OneTimeRoom`) and `v2` (`RelayRoom`). A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and, once the relay's passes, requires its `/api/push/config` to answer a key and runs `oneTimeSmoke` on it, whatever the account's outcome.
@@ -251,4 +282,4 @@ 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 retiring the account page's hand-minted tokens.
+3. Managed voice for every member: per-account quotas, usage accounting, spending bounds beyond the fixed daily cap, and retiring the account page's hand-minted tokens.
diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md
index a78ef4d98..17330c9c2 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 signs in to 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 signs in to Hosted for managed voice (`docs/specs/hosted.md` -> "Managed voice"), and checkout, the account pages, and the subscription as the entitlement are built and off until Stripe is configured ([Checkout and entitlement](#checkout-and-entitlement)); turning billing on is under [Future](#future).
## The Hosted page
@@ -13,7 +13,7 @@
**Content, in order:** the plan cards, directly under the title and anchored `#pricing`; what a member gets, as prose; "Self-hosting stays free"; and a short FAQ — refunds and cancellation, the founding lock, who appears in the founders row, what happens if Hosted shuts down, and that team pricing goes by email to `teams@dormouse.sh`.
-**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers stay `PreOrder` while checkout is unbuilt.**
+**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers are `PreOrder` until `CHECKOUT_OPEN`, then `InStock`.**
**Every price on the site has one owner**: the page, the structured data, and the tests read `website/src/lib/hosted-pricing.ts` rather than restating a number.
@@ -31,17 +31,19 @@ Three cards — Free, Hosted, Founding — side by side from `md` up, stacked in
- **Mark the Hosted card with the accent border, never a surface of its own**, which would be a tint no docs token is derived against.
- **The toggle defaults to Monthly**, the prerendered state; switching swaps the price, the billing line, and the buy target in place.
-- **A buy button opens the unbuilt-checkout notice** — the plan's name, that nothing was charged and no seat taken, and the devlog. **Never render a buy button that silently does nothing.**
+- **A buy button links to the account origin's `/checkout?plan=` once `CHECKOUT_OPEN`**, by checkout's plan names; until then it opens the unbuilt-checkout notice — the plan's name, that nothing was charged and no seat taken, and the devlog. **Never render a buy button that silently does nothing.**
### The founding card's live half
Seats left in the open cohort and the founders row load after hydration from one endpoint; the price beside them is prerendered.
-- **Count seats on the server** from the billing provider behind a cache of at most 60 seconds, never on the client and never stored. **Never show a count for a closed cohort.**
+- **Count seats on the server** from the billing provider behind a cache of at most 60 seconds, never on the client and never stored. **Never show a count for a closed cohort**: the endpoint names the cohort its seats belong to, and the page drops the seats unless that is the cohort its price was prerendered at.
- **Show a founder only if they opted in at checkout**; the box starts unticked and the account can untick it. Every other founder counts toward the `+N` that ends the row, as does everyone past the row's cap.
-- **Must serve avatars from this origin, never the OAuth provider**, so loading the page tells no provider about the reader. The client draws an initial for any avatar that is not a same-origin path, or that fails to load.
+- **Never send an OAuth provider's avatar**, so loading the page tells no provider about the reader: the endpoint sends names only, and the client draws an initial for any avatar that is not a same-origin path, or that fails to load. Reserved: avatars proxied onto this origin ([Future](#future)).
- **The page prerenders without the endpoint**: the seats line is reserved and the row absent; an unreachable endpoint, a non-2xx, or a malformed field drops only that field, never an error. **A cohort closing raises the price at the next deploy.**
+The endpoint and its cache: `docs/specs/hosted.md` -> "Billing".
+
**Every existing link keeps working unchanged**: the `linkedFrom` obligations, the root README, `vscode-ext/README.md`, the Settings dialog's voice link, and the hosting notice all already point at `/hosted`. `docs/specs/website-docs.md` -> "Reference page chrome" owns the mechanics.
### Published prices
@@ -59,17 +61,30 @@ Prices in USD, and the merchant of record adds or includes tax by jurisdiction.
- **The step is $10 per cohort of 100, fixed**, and the ladder's last step is the one below list — reaching list closes founding.
- **Show the current price, the struck list price, and the seats left at that price — never the next step or how many cohorts remain.**
-Source of truth: `tiersOnSale`, `foundingTier`, and `pricingJsonLd` in `website/src/lib/hosted-pricing.ts`; `fetchCohort` in `website/src/lib/hosted-cohorts.ts`; `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.
+Source of truth: `tiersOnSale`, `foundingTier`, `CHECKOUT_OPEN`, and `pricingJsonLd` in `website/src/lib/hosted-pricing.ts`; `fetchCohort` in `website/src/lib/hosted-cohorts.ts`; `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
+
+Built on the account Worker and off until Stripe is configured; routes, bindings, and the cohort count: `docs/specs/hosted.md` -> "Billing".
+
+- **Stripe Managed Payments runs checkout, subscriptions, and the customer portal as merchant of record, through `@pgstencil/stripe`**, so tax is Stripe's. Dormouse never stores card data. A founding lock is a per-cohort Price; every past cohort's Price keeps granting the plan.
+- **Checkout belongs to a signed-in Hosted account**, so the subscription belongs to an account from its first event. A buy link lands on the account origin's `/checkout`, which asks for sign-in first, then shows the plan and its price now before handing off to Stripe. The browser names a plan, never a Price.
+- **No trial**: the first payment is taken at checkout, and the 30-day refund is the trial.
+- **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`.
+- **One account covers every machine the member uses.** No device count, no seat count, no activation limit.
+- **Sign-in is the only account surface in the free client** (`docs/specs/hosted.md` -> "Managed voice").
+- **A refund or chargeback ends the subscription**, so the next request is refused, and the seat returns to its cohort.
+- **Stripe's return shows the founders-row opt-in, unticked, to a founder**; the account page's Plan section can withdraw it at any time, beside Manage billing.
+- **The return also 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.
## 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"), gated on the independent review `docs/specs/security-remote.md` -> "Cloud-hosted mode" requires.
-5. **Renewal, cancellation, and refund** paths.
+1. **Managed voice for members**: one voice per Pane.
+2. **Turning billing on**: the Stripe products, Prices, portal, and webhook, then the bindings (`docs/specs/hosted.md` -> "Billing"), then `CHECKOUT_OPEN`. The subscription admits members to the Hosted Relay (`docs/specs/hosted.md` -> "Relay"), so this is gated on the independent review `docs/specs/security-remote.md` -> "Cloud-hosted mode" requires.
+3. **Founder avatars** proxied onto this origin.
+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).
@@ -97,20 +112,9 @@ 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 Relay reads the same subscription ([Checkout and entitlement](#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
-
-- **Stripe Managed Payments runs checkout, subscriptions, and the customer portal as merchant of record, through `@pgstencil/stripe`**, so tax is Stripe's. Dormouse never stores card data. 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`.
-- **Sign-in is the only account surface in the free client** (`docs/specs/hosted.md` -> "Managed voice").
-- **One account covers every machine the member uses.** No device count, no seat count, no activation limit.
-- **A refund or chargeback ends the subscription**, so the next request is refused, and the seat returns to its cohort.
-
### Managed voice
What is built — the endpoint, the disclosure, the clip cache, the daily cap, and the fallback — is `docs/specs/hosted.md` -> "Managed voice" and `docs/specs/alert.md` -> "Managed voice".
@@ -124,7 +128,6 @@ What is built — the endpoint, the disclosure, the clip cache, the daily cap, a
- **30-day refund on every plan.** A refund revokes.
- **A failed founding renewal gets 30 days of grace before the lock is lost.**
- **A subscription is personal and non-transferable.**
-- **No trial**: the 30-day refund is the trial.
### Open questions
diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md
index 8b5cd7194..bef10b4ee 100644
--- a/docs/specs/security-hosted.md
+++ b/docs/specs/security-hosted.md
@@ -7,9 +7,9 @@
## Origin boundary
-- **FAIL IF** 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** a cookie route admits any presented `Origin` but its own exactly, sibling origins under `dormouse.sh` included, or a state-changing cookie request lacks that Origin, a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `cookieEntitled` in `hosted/server/account-gate.ts` and the packed adapter.
-- **FAIL IF** 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** a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421, except the account's `GET` of the cohort endpoint on `SITE_ORIGIN` alone (rationale); inspect `workerApp` in `hosted/server/worker-app.ts` and `accountApp` in `hosted/server/account-app.ts`.
+- **FAIL IF** a cookie route admits any presented `Origin` but its own exactly, sibling origins under `dormouse.sh` included, or a state-changing cookie request lacks that Origin, a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `cookieLogin` and `cookieEntitled` in `hosted/server/account-gate.ts` and the packed adapter.
+- **FAIL IF** 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`, any mapper but the production account's passes a `STRIPE_*` binding, or the relay or voice entry imports Better Auth or `@pgstencil/stripe`, 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** 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** 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** marketing scripts, analytics, provider avatars, or remote fonts enter the Hosted frontend; inspect the frontend import graph and deployed response when available.
@@ -59,7 +59,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.
- **FAIL IF** 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** `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** 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** the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables and `user`, or reads a user column but the entitlement's; inspect the relay bundle's imports and queries.
+- **FAIL IF** the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables, `user`, and `pgstencil_billing.subscriptions`, or reads a column of the latter two but the entitlement's; inspect the relay bundle's imports and queries.
- **FAIL IF** `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** 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** 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.
@@ -69,11 +69,23 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.
Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/web-push.test.mjs`.
+## Billing boundary
+
+**Billing** on the account Worker: `docs/specs/hosted.md` -> "Billing" and "Entitlement" own it; these are the checks on it. Inspect `billingRoutes` and `reconcileDue` in `hosted/server/billing-routes.ts`, `hosted/server/billing.ts`, `entitledSql` in `hosted/server/entitlement.ts`, and `hosted/server/dormouse-migrations/006_billing_founders.sql`.
+
+- **FAIL IF** 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, or reads that state from anywhere but the synchronized `pgstencil_billing.subscriptions` rows in the same query as the bearer (rationale).
+- **FAIL IF** a billing route takes a Stripe Price, customer, owner, quantity, success or return URL from the request, or acts for any owner but the cookie's login; or a Price, the secret key, or the webhook secret comes from anywhere but the account's bindings, `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` being Worker secrets rather than `vars`.
+- **FAIL IF** the webhook writes before verifying `Stripe-Signature` over the raw body, reads a body past `WEBHOOK_BODY_BYTES`, answers 2xx before `webhook()` has committed, or accepts an event of the other mode, another API version, or a Connect account.
+- **FAIL IF** the cohort endpoint answers anything about a founder but the chosen name of an opted-in founder holding a current founding subscription, or any field that identifies an account or reaches a provider (an email, a user id, an avatar URL; rationale).
+- **FAIL IF** the founders row admits a name over 64 characters or carrying a control character, or a founder joins it without a current founding subscription.
+
+Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/boundary.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`, `hosted/server/relay-worker.ts`, `hosted/server/voice-worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`.
-- **FAIL IF** 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** either installed package lacks a verified npm SLSA provenance attestation whose Fulcio certificate SAN names `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, whose source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) equals `dist/provenance.json`'s commit, or whose signed subject/payload disagrees with the installed package, certificate, or commit.
+- **FAIL IF** 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** any installed package lacks a verified npm SLSA provenance attestation whose Fulcio certificate SAN names `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, whose source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) equals `dist/provenance.json`'s commit, or whose signed subject/payload disagrees with the installed package, certificate, or commit.
- **FAIL IF** 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** 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-hosted.rationale.md b/docs/specs/security-hosted.rationale.md
new file mode 100644
index 000000000..901fc903e
--- /dev/null
+++ b/docs/specs/security-hosted.rationale.md
@@ -0,0 +1,11 @@
+# Hosted account security: rationale
+
+## Origin boundary
+
+The cohort exception (2026-10): the Hosted page on `dormouse.sh` reads seats and founders after hydration. Serving that one `GET` path same-origin through a zone route keeps the page free of CORS and of any request to another host, and the account Worker answers that origin nothing else, so no cookie route, auth route, or page of the account becomes reachable as `dormouse.sh`.
+
+## Billing boundary
+
+- Stripe's synchronized rows are the only billing state the entitlement trusts: `@pgstencil/stripe` rewrites them from Stripe's own subscription list under the owner's lock on every webhook, confirm, view, and resync, so a forged or replayed event can at most trigger a resync. A success URL alone never grants access.
+- The entitlement compares against the database's `now()` rather than a Worker clock so the relay, voice, and account Workers agree on one instant within the same query that resolves the bearer.
+- The cohort endpoint is public and cached, so anything it carries is published. A founder's chosen name is the only per-person field, and only an opted-in, current founder appears.
diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md
index 69fd0aaf5..06cc311b7 100644
--- a/docs/specs/security-remote.md
+++ b/docs/specs/security-remote.md
@@ -168,7 +168,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 and off until billing is configured (`docs/specs/security-hosted.md` -> "Relay boundary"; `docs/specs/hosted.md` -> "Billing"). Turning billing on is broad paid activation; 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.
- **An independent cryptographic review is a precondition** of claiming this model for a paid service (`docs/specs/remote-security-model.md` -> "Security Guarantees").
diff --git a/hosted/README.md b/hosted/README.md
index 8da56167f..9b6bb87ef 100644
--- a/hosted/README.md
+++ b/hosted/README.md
@@ -32,7 +32,7 @@ It serves `http://localhost:8787` (or `PORT`) and prints the `DORMOUSE_RELAY_ORI
## Update pgstencil
-`hosted/package.json` installs released `pgstencil` and `@pgstencil/auth` from npm. They share a version and Hosted declares their peer dependencies. Approved pgstencil releases are exempt from the pnpm and Renovate cooldowns because the release workflow requires a passing security audit, stages the archives, and requires a maintainer's 2FA approval before publishing.
+`hosted/package.json` installs released `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe` from npm. They share a version and Hosted declares their peer dependencies. Approved pgstencil releases are exempt from the pnpm and Renovate cooldowns because the release workflow requires a passing security audit, stages the archives, and requires a maintainer's 2FA approval before publishing.
Run `pnpm install` and re-run Hosted's integration tests after an update. The production preflight reads `dist/provenance.json` from the installed packages and requires matching clean commits. `docs/specs/security-hosted.md` -> "Deployment boundary" owns the upstream audit check. For an unreleased change, pack pgstencil in a clean checkout and use a temporary pnpm override on a branch.
@@ -199,6 +199,23 @@ The public half is public (`GET /api/push/config` serves it); the private half s
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.
+### Billing
+
+Billing stays off until all five Stripe bindings are set; `docs/specs/hosted.md` -> "Billing" owns what each route does. Turning it on is paid activation, which `docs/specs/security-remote.md` -> "Cloud-hosted mode" gates on its review. In the Stripe account, with Managed Payments enabled:
+
+1. Create one product, Dormouse Hosted, and its recurring USD Prices: $10 monthly, $100 yearly, and one yearly Price per `FOUNDING_LADDER` step in `website/src/lib/hosted-pricing.ts` ($50, $60, $70, $80, $90). No trial on any of them.
+2. Configure the customer portal for payment-method updates, invoices, and cancellation at period end, restricted to these Prices; leave plan switching off. Set failed-payment retries to cancel the subscription after 30 days, which is the founding lock's grace (`docs/specs/pricing.md` -> "Renewal, cancellation, refund").
+3. Add the webhook endpoint `https://hosted.dormouse.sh/api/billing/webhook` at the installed SDK's `STRIPE_API_VERSION`, sending `customer.subscription.*`, `checkout.session.*`, and `invoice.*`.
+4. Put the Price ids in `wrangler.jsonc` `vars`, reviewed in a PR: `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (comma-separated, cohort order). A ladder of any other length leaves billing answering 503.
+5. Load the two secrets on the account Worker, a restricted key if Managed Payments accepts one:
+
+```sh
+pnpm exec wrangler secret put STRIPE_SECRET_KEY
+pnpm exec wrangler secret put STRIPE_WEBHOOK_SECRET
+```
+
+Refund from the Stripe dashboard and **cancel the subscription immediately** in the same visit: a refund alone neither ends access nor returns a founding seat. Confirm checkout, the webhook, and the portal once in a Stripe sandbox with test keys before live keys; StripeDev records but does not model Managed Payments. Watch for failed webhook events (`pgstencil_billing.events` rows with `failed`) and failed account cron invocations.
+
## Release
1. Review the exact package provenance and code revision. Run `pnpm test:hosted`, `pnpm build:hosted`, and the repository lints. Validate production migrations.
diff --git a/hosted/package.json b/hosted/package.json
index 5c74ae1ff..ec3f8f233 100644
--- a/hosted/package.json
+++ b/hosted/package.json
@@ -19,8 +19,10 @@
"preview:smoke": "node scripts/preview-smoke.mjs"
},
"dependencies": {
- "@pgstencil/auth": "^0.2.1",
- "pgstencil": "^0.2.1",
+ "@pgstencil/auth": "^0.3.0",
+ "@pgstencil/stripe": "^0.3.0",
+ "pgstencil": "^0.3.0",
+ "stripe": "^22.6.1",
"kysely": "^0.29.5",
"hono": "^4.13.8",
"@hono/node-server": "^2.0.10",
diff --git a/hosted/scripts/changed.mjs b/hosted/scripts/changed.mjs
index e0d66af37..d79fa81b1 100644
--- a/hosted/scripts/changed.mjs
+++ b/hosted/scripts/changed.mjs
@@ -2,13 +2,14 @@ import { readFileSync, realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
// Shared build inputs can change Hosted without editing its directory: the
-// theme the account frontend imports, and the whole Pocket bundle and one-time
-// phone page the relay stages. Whole packages and directories, never a
+// theme the account frontend imports, the whole Pocket bundle and one-time
+// phone page the relay stages, and the website's `hosted-*` price and cohort
+// modules billing and the account pages read. Whole packages and directories, never a
// hand-picked subset of Pocket's import graph, so a new import cannot slip past.
export function touchesHosted(paths) {
return paths.some(
(path) =>
- /^(?:hosted\/|remote-lib-common\/|dor-lib-common\/|lib\/(?:src|pocket|one-time)\/|lib\/vite[^/]*\.config\.ts$|lib\/tsconfig[^/]*\.json$|\.github\/workflows\/hosted-[^/]+\.yml$)/.test(
+ /^(?:hosted\/|website\/src\/lib\/hosted-|remote-lib-common\/|dor-lib-common\/|lib\/(?:src|pocket|one-time)\/|lib\/vite[^/]*\.config\.ts$|lib\/tsconfig[^/]*\.json$|\.github\/workflows\/hosted-[^/]+\.yml$)/.test(
path,
) ||
[
diff --git a/hosted/scripts/changed.test.mjs b/hosted/scripts/changed.test.mjs
index d4dc8d5fa..5a67a7c25 100644
--- a/hosted/scripts/changed.test.mjs
+++ b/hosted/scripts/changed.test.mjs
@@ -35,6 +35,8 @@ test("Hosted and shared inputs trigger previews; unrelated application changes d
"remote-lib-common/package.json",
"remote-lib-common/test/harness/actors.mjs",
"lib/tsconfig.app.json",
+ "website/src/lib/hosted-pricing.ts",
+ "website/src/lib/hosted-cohorts.ts",
])
assert.equal(touchesHosted([path]), true, path);
for (const path of [
diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs
index b37e2d9d4..3a7effed6 100644
--- a/hosted/scripts/preview-smoke.mjs
+++ b/hosted/scripts/preview-smoke.mjs
@@ -150,7 +150,8 @@ export async function smoke(
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);
+ // Billing's routes want a login, whether or not the deployment sells.
+ assert.equal((await request("/api/billing")).status, 401);
assert.equal((await request("/__test/time")).status, 404);
for (const provider of preview ? [] : expectedProviders) {
const started = await request("/api/auth/sign-in/social", {
diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs
index 754844519..fe2116b3e 100644
--- a/hosted/scripts/preview.test.mjs
+++ b/hosted/scripts/preview.test.mjs
@@ -427,6 +427,7 @@ test("the smoke runs its parts concurrently, retries each alone, and checks the
);
if (pathname === "/api/auth/get-session") return Response.json(null);
if (pathname === "/api/providers") return Response.json([]);
+ if (pathname === "/api/billing") return Response.json({ message: "Sign in first." }, { status: 401 });
if (init.method === "POST") return new Response(null, { status: 403 });
if (pathname === "/login")
return new Response("", { headers: { "content-type": "text/html" } });
diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs
index aa3404cbb..cf981ffd8 100644
--- a/hosted/scripts/production.mjs
+++ b/hosted/scripts/production.mjs
@@ -17,7 +17,12 @@ import {
* cannot redirect production or stand in for a sibling.
*/
export const PRODUCTION = {
- account: { name: "dormouse-hosted", origin: "https://hosted.dormouse.sh" },
+ account: {
+ name: "dormouse-hosted",
+ origin: "https://hosted.dormouse.sh",
+ // The Hosted page's cohort endpoint, which the Worker answers alone on that origin.
+ siteRoutes: [{ pattern: "dormouse.sh/api/hosted/*", zone_name: "dormouse.sh" }],
+ },
relay: { name: "dormouse-relay", origin: "https://relay.dormouse.sh" },
voice: { name: "dormouse-voice", origin: "https://voice.dormouse.sh" },
};
@@ -32,9 +37,11 @@ export function productionConfig(base, env, worker) {
// The relay's enrollment links name production's account, and only it.
if (worker === "relay")
assert.equal(base.vars.ACCOUNT_ORIGIN, PRODUCTION.account.origin);
- // The canonical domain alone: no public alias, candidate, or preview URL.
+ // The canonical domain, and the account's pinned site route: no public
+ // alias, candidate, or preview URL.
assert.deepEqual(base.routes, [
{ pattern: new URL(identity.origin).host, custom_domain: true },
+ ...(identity.siteRoutes ?? []),
]);
assert.equal(base.workers_dev, false);
assert.equal(base.preview_urls, false);
@@ -122,7 +129,7 @@ export function productionSmoke(configs, sha, options = {}) {
}
export async function verifyPackages() {
const commits = [];
- for (const name of ["pgstencil", "@pgstencil/auth/better-auth"]) {
+ for (const name of ["pgstencil", "@pgstencil/auth/better-auth", "@pgstencil/stripe"]) {
// Resolve the installed entrypoint, then read the provenance beside it.
const entry = import.meta.resolve(name);
const provenance = JSON.parse(
@@ -140,9 +147,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 78e7e5c2b..0253ecc22 100644
--- a/hosted/scripts/production.test.mjs
+++ b/hosted/scripts/production.test.mjs
@@ -32,7 +32,13 @@ test("each production config keeps its canonical domain and production entry, an
assert.equal(config.vars.BUILD_SHA, env.BUILD_SHA, worker);
assert.deepEqual(
config.routes,
- [{ pattern: new URL(origin).host, custom_domain: true }],
+ [
+ { pattern: new URL(origin).host, custom_domain: true },
+ // The account answers the Hosted page's cohort endpoint on the site.
+ ...(worker === "account"
+ ? [{ pattern: "dormouse.sh/api/hosted/*", zone_name: "dormouse.sh" }]
+ : []),
+ ],
worker,
);
assert.equal(config.workers_dev, false, worker);
@@ -209,11 +215,10 @@ test("live verification retries the relay and voice while their domains come up,
});
assert.equal(rendezvous, 1);
});
-test("the history sweep's cron is the voice Worker's, the relay's sweeps its expired rows, and the account's removes its old one", () => {
+test("the history sweep's cron is the voice Worker's, the relay's sweeps its expired rows, and the account's resyncs billing", () => {
assert.deepEqual(configs.voice.triggers, { crons: ["*/5 * * * *"] });
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 58cd044bc..c278b0023 100644
--- a/hosted/server/account-app.ts
+++ b/hosted/server/account-app.ts
@@ -1,5 +1,8 @@
import type { Context, ExecutionContext, Hono } from "hono";
import { queryDatabase } from "pgstencil/postgres";
+import { billingSetup } from "./billing";
+import { COHORT_ENDPOINT } from "../../website/src/lib/hosted-cohorts";
+import { billingRoutes, reconcileDue, type BillingHost } from "./billing-routes";
import type { AccountEnv } from "./bindings";
import { accountRules } from "./headers";
import { relayAccountRoutes, type RelayAccountHost } from "./relay-account";
@@ -7,11 +10,14 @@ import { relayRoom } from "./relay-room-contract";
import { voiceTokenRoutes } from "./voice";
import { workerApp } from "./worker-app";
+/** The marketing site, whose Hosted page reads the cohort endpoint same-origin. */
+export const SITE_ORIGIN = "https://dormouse.sh";
+
/**
* The account Worker (`hosted.dormouse.sh`): auth, providers, readiness,
- * voice-token minting, the Relay's account routes, and the frontend. The
- * production and preview entries differ only in `fetchAuth`'s mail and in
- * `bindings`.
+ * voice-token minting, the Relay's account routes, billing, and the
+ * frontend. The production and preview entries differ only in `fetchAuth`'s
+ * mail and in `bindings`.
*/
export function accountApp(
fetchAuth: (
@@ -26,6 +32,7 @@ export function accountApp(
bindings,
rules: accountRules,
unavailable: "Sign-in is temporarily unavailable. Please try again.",
+ site: { origin: SITE_ORIGIN, paths: [COHORT_ENDPOINT] },
routes(app) {
configure?.(app);
app.get("/api/ready", async (c) => {
@@ -44,15 +51,19 @@ export function accountApp(
app.get("/api/providers", (c) =>
fetchAuth(c.req.raw, c.env, c.executionCtx),
);
- const host = (c: Context<{ Bindings: AccountEnv }>): RelayAccountHost => ({
+ const host = (c: Context<{ Bindings: AccountEnv }>): RelayAccountHost & BillingHost => ({
databaseUrl: c.env.HYPERDRIVE.connectionString,
auth: (request) => fetchAuth(request, c.env, c.executionCtx),
approveLimit: c.env.RELAY_APPROVE_LIMIT,
closeBurrow: (userId, burrowId) => relayRoom(c.env.RELAY_ROOM, userId).closeBurrow(burrowId),
+ setup: () => billingSetup(c.env),
});
voiceTokenRoutes(app, host);
relayAccountRoutes(app, host);
+ billingRoutes(app, host);
},
fallback: (app) => app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw)),
+ scheduled: (_controller, env) =>
+ reconcileDue(billingSetup(env), env.HYPERDRIVE.connectionString, env.APP_ORIGIN),
});
}
diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts
index f77d1e744..5ca3f8123 100644
--- a/hosted/server/account-gate.ts
+++ b/hosted/server/account-gate.ts
@@ -1,6 +1,7 @@
-// Rules: docs/specs/hosted.md -> "Managed voice" and "Burrow enrollment";
-// docs/specs/security-hosted.md -> "Origin boundary".
+// Rules: docs/specs/hosted.md -> "Managed voice", "Burrow enrollment", and
+// "Billing"; docs/specs/security-hosted.md -> "Origin boundary".
import type { Context, MiddlewareHandler } from "hono";
+import { bodyLimit } from "hono/body-limit";
import { queryDatabase } from "pgstencil/postgres";
import { entitled } from "./entitlement";
@@ -18,9 +19,15 @@ export const accountQuery = >(
values: unknown[],
) => queryDatabase(host.databaseUrl, text, values);
+/** A body limit answering the account's JSON 413. */
+export const jsonBodyLimit = (maxSize: number) =>
+ bodyLimit({ maxSize, onError: (c) => c.json({ message: "Request too large." }, 413) });
+
/** The login a cookie route acts for. */
export interface AccountLogin {
userId: string;
+ /** The account's public email, null for a provider-only account. */
+ email: string | null;
/**
* `get-session`'s `session.createdAt`, unparsed: only a route that needs a
* recent login reads it, and it fails closed on a value it cannot read.
@@ -28,44 +35,69 @@ export interface AccountLogin {
createdAt: unknown;
}
+type Gate = MiddlewareHandler<{ Variables: { login: AccountLogin } }>;
+
+/**
+ * A presented `Origin` is exactly this origin, and a state-changing request
+ * must present one — same-site pages, the relay and voice origins among
+ * them, share the login cookie — then the Better Auth handler's
+ * `get-session` answers the login: the login, or the refusal to send.
+ */
+async function login(c: Context, host: AccountHost): Promise {
+ const origin = new URL(c.req.url).origin;
+ const presented = c.req.header("origin");
+ // A read may omit `Origin`; nothing may present a foreign one.
+ const safe = c.req.method === "GET" || c.req.method === "HEAD";
+ if (presented === undefined ? !safe : presented !== origin)
+ return c.json({ message: "Invalid origin." }, 403);
+ const headers = new Headers();
+ for (const name of ["cookie", "cf-connecting-ip"]) {
+ const value = c.req.header(name);
+ if (value) headers.set(name, value);
+ }
+ const response = await host.auth(
+ new Request(new URL("/api/auth/get-session", origin), { headers }),
+ );
+ if (!response.ok) throw new Error("Login lookup failed");
+ const session = (await response.json()) as {
+ user?: { id: string; email?: unknown };
+ session?: { createdAt?: unknown };
+ } | null;
+ if (!session?.user) return c.json({ message: "Sign in first." }, 401);
+ return {
+ userId: session.user.id,
+ email: typeof session.user.email === "string" ? session.user.email : null,
+ createdAt: session.session?.createdAt,
+ };
+}
+
+/**
+ * The account Worker's gate for a cookie route any signed-in account may use
+ * (billing): the origin and login checks, 401 without a login. Sets `login`.
+ */
+export function cookieLogin(host: (c: Context) => AccountHost): Gate {
+ return async (c, next) => {
+ const result = await login(c, host(c));
+ if (result instanceof Response) return result;
+ c.set("login", result);
+ await next();
+ };
+}
+
/**
- * The account Worker's cookie routes' gate: a presented `Origin` is exactly
- * this origin, and a state-changing request must present one — same-site
- * pages, the relay and voice origins among them, share the login cookie —
- * then the Better Auth handler's `get-session` answers the login (401
- * without one), and only an entitled account passes, read per request
- * (`refuse` answers anyone else). Sets `login`.
+ * The account Worker's gate for an entitled account's cookie routes: the
+ * origin and login checks, then only an entitled account passes, read per
+ * request (`refuse` answers anyone else). Sets `login`.
*/
export function cookieEntitled(
host: (c: Context) => AccountHost,
refuse: (c: Context) => Response,
-): MiddlewareHandler<{ Variables: { login: AccountLogin } }> {
+): Gate {
return async (c, next) => {
- const origin = new URL(c.req.url).origin;
- const presented = c.req.header("origin");
- // A read may omit `Origin`; nothing may present a foreign one.
- const safe = c.req.method === "GET" || c.req.method === "HEAD";
- if (presented === undefined ? !safe : presented !== origin)
- return c.json({ message: "Invalid origin." }, 403);
- const headers = new Headers();
- for (const name of ["cookie", "cf-connecting-ip"]) {
- const value = c.req.header(name);
- if (value) headers.set(name, value);
- }
- const response = await host(c).auth(
- new Request(new URL("/api/auth/get-session", origin), { headers }),
- );
- if (!response.ok) throw new Error("Login lookup failed");
- const session = (await response.json()) as {
- user?: { id: string };
- session?: { createdAt?: unknown };
- } | null;
- if (!session?.user) return c.json({ message: "Sign in first." }, 401);
- if (!(await entitled(host(c).databaseUrl, session.user.id))) return refuse(c);
- c.set("login", {
- userId: session.user.id,
- createdAt: session.session?.createdAt,
- });
+ const result = await login(c, host(c));
+ if (result instanceof Response) return result;
+ if (!(await entitled(host(c).databaseUrl, result.userId))) return refuse(c);
+ c.set("login", result);
await next();
};
}
diff --git a/hosted/server/billing-dev.ts b/hosted/server/billing-dev.ts
new file mode 100644
index 000000000..d5efd9345
--- /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 Prices, and the billing setup that runs on it:
+ * the dev loop's and the tests'.
+ */
+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-routes.ts b/hosted/server/billing-routes.ts
new file mode 100644
index 000000000..42f8dc70a
--- /dev/null
+++ b/hosted/server/billing-routes.ts
@@ -0,0 +1,269 @@
+// Rules: docs/specs/hosted.md -> "Billing"; docs/specs/pricing.md ->
+// "Checkout and entitlement" and "The founding card's live half".
+import type { Billing } from "@pgstencil/stripe";
+import type { Context, Hono } from "hono";
+import { sql } from "kysely";
+import { queryDatabase } from "pgstencil/postgres";
+import { readJson } from "remote-lib-common";
+import { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } from "../../website/src/lib/hosted-cohorts";
+import { isCheckoutPlan } from "../../website/src/lib/hosted-pricing";
+import { accountQuery, cookieLogin, jsonBodyLimit, type AccountHost } from "./account-gate";
+import {
+ BillingError,
+ foundingSold,
+ openCohort,
+ withBilling,
+ type BillingSetup,
+ type Plan,
+} from "./billing";
+import { accessSql, entitledSql, subscribedSql } from "./entitlement";
+
+/** What one request's account deployment provides to the billing routes. */
+export interface BillingHost extends AccountHost {
+ /** The deployment's billing setup, null while billing is off; throws when misconfigured. */
+ setup(): BillingSetup | null;
+}
+
+/** The billing routes' answer while a deployment has no Stripe configuration. */
+export const CHECKOUT_CLOSED = "Checkout is not open yet.";
+
+/** Stripe's events are larger than any request a browser sends here. */
+export const WEBHOOK_BODY_BYTES = 1024 * 1024;
+
+/** How long one isolate reuses its cohort answer. */
+export const COHORT_CACHE_MS = 60_000;
+
+/** Stripe's webhook path. */
+export const BILLING_WEBHOOK_PATH = "/api/billing/webhook";
+
+const CHECKOUT_ID = /^[A-Za-z0-9_-]{1,64}$/;
+/** Subscription statuses Stripe has finished with (pgstencil's `missing` included). */
+const ENDED = ["canceled", "incomplete_expired", "missing"];
+// A shown name: no control characters, nothing a row could not print.
+const SHOWN_NAME = /^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]{1,64}$/u;
+const SURVEY = ["tooExpensive", "tooCheap", "expensive", "bargain"] as const;
+
+/** Whether founder row `f` is a current founder: subscribed on a founding Price (`$1`). */
+const CURRENT_FOUNDER = subscribedSql(`f."userId"`, "$1");
+
+/**
+ * The billing routes on the account origin: the cookie routes a signed-in
+ * account drives, Stripe's webhook, and the public cohort endpoint the
+ * Hosted page reads (also served from the site origin, `workerApp`'s
+ * `site`).
+ */
+export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) {
+ const gate = cookieLogin(host);
+ const small = jsonBodyLimit(4096);
+ const closed = (c: Context) => c.json({ message: CHECKOUT_CLOSED }, 503);
+
+ /** Runs `action` with this deployment's `Billing`; 503 while billing is off, a `BillingError` as its status. */
+ const billed = async (
+ c: Context,
+ action: (billing: Billing, setup: BillingSetup) => Promise,
+ ) => {
+ const deployment = host(c);
+ const setup = deployment.setup();
+ if (!setup) return closed(c);
+ try {
+ return await withBilling(setup, deployment.databaseUrl, new URL(c.req.url).origin, (billing) =>
+ action(billing, setup),
+ );
+ } catch (error) {
+ if (error instanceof BillingError)
+ return c.json({ message: error.message }, error.status as 400);
+ throw error;
+ }
+ };
+
+ /**
+ * The account's plan as the account app shows it, from the synchronized
+ * rows: webhooks, confirm, and the cron resync keep them current.
+ */
+ const summary = async (c: Context, billing: Billing, setup: BillingSetup, userId: string) => {
+ const [status, account, sold] = await Promise.all([
+ billing.status(userId),
+ sql<{ entitled: boolean; founder: string | null }>`SELECT ${sql.raw(entitledSql())} AS entitled,
+ (SELECT name FROM dormouse_founders WHERE "userId" = u.id) AS founder
+ FROM "user" u WHERE u.id = ${userId}`.execute(billing.db),
+ foundingSold(billing, setup),
+ ]);
+ const [row] = account.rows;
+ // A subscription Stripe still holds, paid up or not: its owner manages it
+ // in the portal (a failed card included) and cannot check out again.
+ const held = !!status.subscription && !ENDED.includes(status.subscription.status);
+ return c.json({
+ plan: held ? status.plan : null,
+ active: status.access,
+ until: held ? status.accessUntil : null,
+ renews: held && !status.subscription?.cancel_at_period_end,
+ entitled: row?.entitled === true,
+ founder: row?.founder ?? null,
+ founding: openCohort(sold),
+ });
+ };
+
+ app.get("/api/billing", gate, (c) =>
+ billed(c, (billing, setup) => summary(c, billing, setup, c.get("login").userId)),
+ );
+
+ app.post("/api/billing/checkout", small, gate, async (c) => {
+ const { userId, email } = c.get("login");
+ const plan = (await readJson<{ plan?: unknown }>(c))?.plan;
+ if (!isCheckoutPlan(plan)) return c.json({ message: "Choose monthly, yearly, or founding." }, 400);
+ // A provider-only account has no public email: Stripe Checkout asks for one.
+ return billed(c, async (billing) => {
+ // A checkout left open for another plan gives way to this one.
+ const other = await billing.db
+ .selectFrom("checkouts")
+ .select("plan")
+ .where("owner_id", "=", userId)
+ .where("status", "in", ["pending", "open"])
+ .executeTakeFirst();
+ if (other && other.plan !== plan) await billing.cancelCheckout(userId);
+ const { url } = await billing.checkout(userId, email, plan);
+ return c.json({ url });
+ });
+ });
+
+ app.post("/api/billing/confirm", small, gate, async (c) => {
+ const { userId } = c.get("login");
+ const checkout = (await readJson<{ checkout?: unknown }>(c))?.checkout;
+ if (typeof checkout !== "string" || !CHECKOUT_ID.test(checkout))
+ return c.json({ message: "Checkout not found." }, 404);
+ return billed(c, async (billing, setup) => {
+ // A complete checkout resyncs from Stripe before the summary reads.
+ await billing.confirmCheckout(userId, checkout);
+ return summary(c, billing, setup, userId);
+ });
+ });
+
+ app.post("/api/billing/portal", gate, (c) =>
+ billed(c, async (billing) => c.json({ url: await billing.portal(c.get("login").userId) })),
+ );
+
+ // The founders row: only a current founder may appear, under a name they chose.
+ app.put("/api/billing/founder", small, gate, async (c) => {
+ const { userId } = c.get("login");
+ const body = await readJson<{ shown?: unknown; name?: unknown }>(c);
+ if (body?.shown === false) {
+ await accountQuery(host(c), `DELETE FROM dormouse_founders WHERE "userId" = $1`, [userId]);
+ return c.body(null, 204);
+ }
+ const name = typeof body?.name === "string" ? body.name.trim() : "";
+ if (body?.shown !== true || !SHOWN_NAME.test(name))
+ return c.json({ message: "Give a name of 1 to 64 characters to show." }, 400);
+ const setup = host(c).setup();
+ if (!setup) return closed(c);
+ const [row] = await accountQuery<{ userId: string }>(
+ host(c),
+ `INSERT INTO dormouse_founders ("userId", name)
+ SELECT "userId", $3 FROM (SELECT $2::text AS "userId") f WHERE ${CURRENT_FOUNDER}
+ ON CONFLICT ("userId") DO UPDATE SET name = EXCLUDED.name
+ RETURNING "userId"`,
+ [setup.founding, userId, name],
+ );
+ return row
+ ? c.body(null, 204)
+ : c.json({ message: "Only a founding member can join the founders row." }, 409);
+ });
+
+ // The Van Westendorp answers: optional, each a whole-dollar price or null.
+ app.put("/api/billing/survey", small, gate, async (c) => {
+ const body = await readJson>(c);
+ const answers = SURVEY.map((field) => body?.[field] ?? null);
+ if (
+ !body ||
+ answers.every((answer) => answer === null) ||
+ !answers.every(
+ (answer) =>
+ answer === null || (Number.isInteger(answer) && (answer as number) >= 0 && (answer as number) <= 100_000),
+ )
+ )
+ return c.json({ message: "Answer with whole dollars." }, 400);
+ await accountQuery(
+ host(c),
+ `INSERT INTO dormouse_price_survey ("userId", "tooExpensive", "tooCheap", expensive, bargain)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT ("userId") DO UPDATE SET "tooExpensive" = $2, "tooCheap" = $3,
+ expensive = $4, bargain = $5, "answeredAt" = now()`,
+ [c.get("login").userId, ...answers],
+ );
+ return c.body(null, 204);
+ });
+
+ // Stripe's: the raw body and its signature, 2xx only once `webhook()` has
+ // committed; an unexpected failure throws to `onError`'s 503, which Stripe retries.
+ app.post(
+ BILLING_WEBHOOK_PATH,
+ jsonBodyLimit(WEBHOOK_BODY_BYTES),
+ async (c) => {
+ const signature = c.req.header("stripe-signature");
+ if (!signature) return c.json({ message: "Missing signature." }, 400);
+ const body = await c.req.text();
+ return billed(c, async (billing) => {
+ await billing.webhook(body, signature);
+ return c.json({ received: true });
+ });
+ },
+ );
+
+ // The founding card's live half, unauthenticated and cached per isolate as
+ // text: a Response, and a promise its I/O settles, belong to one request.
+ let cohorts: { at: number; status: number; text: string } | undefined;
+ app.get(COHORT_ENDPOINT, async (c) => {
+ const now = Date.now();
+ if (!cohorts || now - cohorts.at >= COHORT_CACHE_MS) {
+ const response = await billed(c, async (billing, setup) => {
+ const [sold, shown] = await Promise.all([
+ foundingSold(billing, setup),
+ accountQuery<{ name: string }>(
+ host(c),
+ `SELECT f.name FROM dormouse_founders f WHERE ${CURRENT_FOUNDER}
+ ORDER BY f."shownSince", f."userId" LIMIT ${MAX_SHOWN_FOUNDERS}`,
+ [setup.founding],
+ ),
+ ]);
+ const open = openCohort(sold);
+ return c.json({
+ // Which cohort the seats belong to, so a page prerendered at another
+ // price can drop them; absent with the seats once founding closes.
+ ...(open && { cohort: open.cohort, seatsLeft: open.seatsLeft }),
+ founders: { total: sold.reduce((sum, count) => sum + count, 0), shown },
+ });
+ });
+ cohorts = { at: now, status: response.status, text: await response.text() };
+ }
+ return c.body(cohorts.text, cohorts.status as 200, { "content-type": "application/json" });
+ });
+}
+
+/**
+ * The account Worker's Cron Trigger: resyncs from Stripe every subscription
+ * still `active` or `trialing` whose period or trial has ended, so a missed
+ * renewal webhook lapses a member until the next run at most. At most
+ * `limit` accounts a run, picked at random so one that always fails cannot
+ * hold the rest back.
+ */
+export async function reconcileDue(
+ setup: BillingSetup | null,
+ databaseUrl: string,
+ origin: string,
+ limit = 20,
+) {
+ if (!setup) return;
+ const due = await queryDatabase<{ owner: string }>(
+ databaseUrl,
+ `SELECT owner_id AS owner FROM pgstencil_billing.subscriptions s
+ WHERE status IN ('active', 'trialing') AND NOT ${accessSql("s")}
+ GROUP BY owner_id ORDER BY random() LIMIT $1`,
+ [limit],
+ );
+ 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} reconciles failed`);
+}
diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts
new file mode 100644
index 000000000..2b1f01681
--- /dev/null
+++ b/hosted/server/billing.ts
@@ -0,0 +1,158 @@
+// Rules: docs/specs/hosted.md -> "Billing".
+import { Billing, BillingError, Stripe, type BillingDB } from "@pgstencil/stripe";
+import { SecureRandom, SystemTime } from "pgstencil";
+import { connectDatabase } from "pgstencil/postgres";
+import {
+ FOUNDING_COHORT_SIZE,
+ FOUNDING_LADDER,
+ type CheckoutPlan,
+} from "../../website/src/lib/hosted-pricing";
+import { BILLING_RETURN_PATH } from "./policy-constants";
+
+/** The plans checkout sells, by the names a buy link and `status()` use. */
+export type Plan = CheckoutPlan;
+
+/** A refund inside this window, which also cancels, returns the seat to its cohort. */
+export const REFUND_DAYS = 30;
+
+
+/** The account Worker's billing bindings; billing is off unless all 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;
+}
+
+// The system clock: a test bundle's injected `Date` scopes it to the test's.
+const time = new SystemTime();
+const random = new SecureRandom();
+
+/** 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[];
+ /** The dev loop's StripeDev client in place of Stripe's API; 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. A ladder
+ * whose length differs from the published `FOUNDING_LADDER`, or a malformed
+ * or repeated Price, throws: the page and checkout would disagree on 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 completed purchases per ladder step. It is the
+ * highest step anyone has bought at, or the one after it once that is full,
+ * so a refund in a closed cohort never reopens a lower step.
+ */
+export function openCohort(sold: readonly number[]): OpenCohort | null {
+ let last = 0;
+ sold.forEach((count, step) => {
+ if (count > 0) last = step;
+ });
+ const cohort = (sold[last] ?? 0) >= FOUNDING_COHORT_SIZE ? last + 1 : last;
+ if (cohort >= sold.length) return null;
+ return { cohort, seatsLeft: FOUNDING_COHORT_SIZE - (sold[cohort] ?? 0) };
+}
+
+/** Completed purchases at each founding step, in cohort order. */
+export async function foundingSold(billing: Billing, setup: BillingSetup) {
+ const counts = await billing.purchaseCounts(setup.founding, { refundDays: REFUND_DAYS });
+ return setup.founding.map((price) => counts[price] ?? 0);
+}
+
+/**
+ * Runs `action` with a `Billing` over `databaseUrl` for the account origin
+ * `origin`, closing its connections after. No trial; Stripe Managed Payments
+ * is the merchant of record; 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 = openCohort(await foundingSold(billing as Billing, setup));
+ if (!open) throw new BillingError("Founding is closed.", 409);
+ return setup.founding[open.cohort]!;
+ },
+ },
+ },
+ trialDays: 0,
+ managedPayments: true,
+ webhookSecret: setup.webhookSecret,
+ live: setup.live,
+ origin,
+ returnPath: BILLING_RETURN_PATH,
+ },
+ );
+ return await action(billing);
+ } finally {
+ await db.destroy();
+ }
+}
+
+export { BillingError };
diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts
index 372dd8e3f..682aafce1 100644
--- a/hosted/server/bindings.ts
+++ b/hosted/server/bindings.ts
@@ -1,4 +1,5 @@
import type { BetterAuthWorkerBindings } from "@pgstencil/auth/better-auth-workers";
+import type { BillingEnv } from "./billing";
import { exactOrigin } from "./headers";
import { providerBindings } from "./policy";
import type { RelayRoomRpc } from "./relay-room-contract";
@@ -18,8 +19,8 @@ interface Assets {
fetch(request: Request): Promise;
}
-/** `hosted.dormouse.sh`: the account frontend, auth, and voice-token minting. */
-export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv {
+/** `hosted.dormouse.sh`: the account frontend, auth, voice-token minting, and billing. */
+export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv, BillingEnv {
ASSETS: Assets;
/** Enrollment approvals, per account. */
RELAY_APPROVE_LIMIT: RateLimit;
@@ -72,10 +73,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: billing is off. */
export const accountPreviewBindings = (env: AccountEnv): AccountEnv => ({
HYPERDRIVE: env.HYPERDRIVE,
ASSETS: env.ASSETS,
diff --git a/hosted/server/dev-host-guard.ts b/hosted/server/dev-host-guard.ts
index 896f4d31b..bb3804cb8 100644
--- a/hosted/server/dev-host-guard.ts
+++ b/hosted/server/dev-host-guard.ts
@@ -1,13 +1,20 @@
import type { IncomingMessage } from "node:http";
+import { BILLING_RETURN_PATH } from "./policy-constants";
// The local inbox holds login codes. Loopback binding alone is not access control.
export function allowedDevRequest(
request: IncomingMessage,
origin: string,
): boolean {
+ const { headers } = request;
+ if (headers.host !== new URL(origin).host) return false;
+ if (headers.origin && headers.origin !== origin) return false;
+ if (headers["sec-fetch-site"] !== "cross-site") return true;
+ // The one cross-site request it takes: a top-level GET of Stripe's return
+ // (StripeDev's page is on 127.0.0.1), which serves the shell and reads nothing.
return (
- request.headers.host === new URL(origin).host &&
- (!request.headers.origin || request.headers.origin === origin) &&
- request.headers["sec-fetch-site"] !== "cross-site"
+ request.method === "GET" &&
+ headers["sec-fetch-mode"] === "navigate" &&
+ new URL(request.url ?? "/", origin).pathname === BILLING_RETURN_PATH
);
}
diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts
index 1634f5503..ecc14ea83 100644
--- a/hosted/server/dev.ts
+++ b/hosted/server/dev.ts
@@ -1,4 +1,5 @@
import { createServer } from "node:http";
+import { fileURLToPath } from "node:url";
import type { AddressInfo } from "node:net";
import { once } from "node:events";
import { getRequestListener } from "@hono/node-server";
@@ -6,7 +7,9 @@ import { Hono } from "hono";
import { createServer as createViteServer, type ViteDevServer } from "vite";
import { createAuthApp } from "@pgstencil/auth/better-auth";
import { developmentDatabase } from "pgstencil/database";
-import { EmailDev, SystemTime } from "pgstencil";
+import { EmailDev, SecureRandom, SystemTime } from "pgstencil";
+import { stripeDevBilling } from "./billing-dev";
+import { BILLING_WEBHOOK_PATH, billingRoutes } from "./billing-routes";
import { authPolicy } from "./policy";
import { migrations } from "./migrations";
import { allowedDevRequest } from "./dev-host-guard";
@@ -70,6 +73,14 @@ const host: RelayAccountHost = {
};
voiceTokenRoutes(app, () => host);
relayAccountRoutes(app, () => host);
+// Billing against StripeDev, its state beside the development database.
+const { dev: stripeDev, setup: billing } = await stripeDevBilling(
+ new SystemTime(),
+ new SecureRandom(),
+ fileURLToPath(new URL("../.pgstencil/stripe-dev.json", import.meta.url)),
+);
+stripeDev.setWebhookTarget(origin + BILLING_WEBHOOK_PATH);
+billingRoutes(app, () => ({ ...host, setup: () => billing }));
// No speak: a Hosted build speaks only at the fixed voice origin, so no
// Dormouse build could reach one here.
app.all("*", (c) => auth.app.fetch(c.req.raw));
@@ -88,7 +99,7 @@ ready = {
vite,
};
console.log(
- `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.`,
+ `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.\nCheckout: ${origin}/checkout?plan=founding (StripeDev at ${stripeDev.origin}; no card, no charge)`,
);
/** A `ratelimits` binding's stand-in: `perMinute` per key per wall-clock minute. */
function devLimit(perMinute: number): RateLimit {
@@ -112,6 +123,7 @@ for (const signal of ["SIGINT", "SIGTERM"] as const)
server.close();
await vite.close();
await auth.close();
+ await stripeDev.close();
email.close();
process.exit(0);
});
diff --git a/hosted/server/dormouse-migrations/006_billing_founders.sql b/hosted/server/dormouse-migrations/006_billing_founders.sql
new file mode 100644
index 000000000..1e4f95c6f
--- /dev/null
+++ b/hosted/server/dormouse-migrations/006_billing_founders.sql
@@ -0,0 +1,25 @@
+-- Up Migration
+-- Billing's own rows beside @pgstencil/stripe's pgstencil_billing schema
+-- (docs/specs/hosted.md -> "Billing").
+
+-- A founder who opted into the founders row, under the name they chose to show.
+CREATE TABLE dormouse_founders (
+ "userId" text PRIMARY KEY REFERENCES "user" (id) ON DELETE CASCADE,
+ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 64),
+ "shownSince" timestamptz NOT NULL DEFAULT now()
+);
+
+-- The optional Van Westendorp answers from the checkout success page, one
+-- set per account, each a whole-dollar yearly price or unanswered.
+CREATE TABLE dormouse_price_survey (
+ "userId" text PRIMARY KEY REFERENCES "user" (id) ON DELETE CASCADE,
+ "tooExpensive" integer CHECK ("tooExpensive" BETWEEN 0 AND 100000),
+ "tooCheap" integer CHECK ("tooCheap" BETWEEN 0 AND 100000),
+ expensive integer CHECK (expensive BETWEEN 0 AND 100000),
+ bargain integer CHECK (bargain BETWEEN 0 AND 100000),
+ "answeredAt" timestamptz NOT NULL DEFAULT now()
+);
+
+-- Down Migration
+DROP TABLE dormouse_price_survey;
+DROP TABLE dormouse_founders;
diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts
index 364227e05..40166faa4 100644
--- a/hosted/server/entitlement.ts
+++ b/hosted/server/entitlement.ts
@@ -1,20 +1,50 @@
// Rules: docs/specs/hosted.md -> "Entitlement".
import { queryDatabase } from "pgstencil/postgres";
-/** The one address the entitlement keys on until billing ships. */
+/** The standing comp: Dormouse's own dogfooding account, entitled without a subscription. */
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` counts as a current subscription; access
+ * needs exactly one of them (its `status()`).
+ */
+const CURRENT = `'trialing', 'active', 'past_due', 'unpaid', 'paused', 'incomplete'`;
+
+/**
+ * Whether subscription row `s` grants access at `at`: `active` before its
+ * period end or `trialing` before its trial end, as `status()` reads it.
+ */
+export const accessSql = (s = "s", at = "now()") =>
+ `((${s}.status = 'active' AND ${s}.period_end > ${at})
+ OR (${s}.status = 'trialing' AND ${s}.trial_end > ${at}))`;
+
/**
* The entitlement (docs/specs/pricing.md -> "Checkout and entitlement"): a
* SQL boolean over the `"user"` row aliased `user`, 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`.
+ * a bearer resolves its owner's entitlement in the same statement.
+ *
+ * A subscription grants it as `@pgstencil/stripe`'s `status()` grants access:
+ * exactly one current subscription, and it granting access by the database's
+ * clock. The verified `ADMIN_EMAIL` is a standing comp, so Dormouse's own
+ * dogfooding never holds a subscription.
*/
export function entitledSql(user = "u"): string {
- return `(${user}."emailVerified" IS TRUE AND ${user}.email = '${ADMIN_EMAIL}')`;
+ return `((${user}."emailVerified" IS TRUE AND ${user}.email = '${ADMIN_EMAIL}')
+ OR ${subscribedSql(`${user}.id`)})`;
+}
+
+/**
+ * Whether `owner` holds exactly one current subscription and it grants
+ * access, as `status()` decides; with `prices`, an SQL array, only one on
+ * those Prices counts.
+ */
+export function subscribedSql(owner: string, prices?: string): string {
+ const granting = prices ? `${accessSql()} AND s.price_id = ANY(${prices})` : accessSql();
+ return `coalesce((SELECT count(*) FILTER (WHERE s.status IN (${CURRENT})) = 1 AND bool_or(${granting})
+ FROM pgstencil_billing.subscriptions s WHERE s.owner_id = ${owner}), false)`;
}
/** Whether `userId` is entitled now, read from `databaseUrl` in one query. */
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 54cdb70af..fc38a14db 100644
--- a/hosted/server/policy-constants.ts
+++ b/hosted/server/policy-constants.ts
@@ -20,3 +20,6 @@ export const RECENT_LOGIN_WINDOW = `${LOGIN_FRESH_AGE_MS / 60_000} minutes`;
// How long an enrollment's device code lives (the relay mints it) and how long
// an approval waits for its poll (the account writes it).
export const ENROLLMENT_TTL_MS = 10 * 60 * 1000;
+
+/** Where Stripe returns the browser: checkout success and cancel, and the portal. */
+export const BILLING_RETURN_PATH = "/billing";
diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts
index 4582b7cbe..7342be5fe 100644
--- a/hosted/server/relay-account.ts
+++ b/hosted/server/relay-account.ts
@@ -1,13 +1,12 @@
// Rules: docs/specs/hosted.md -> "Burrow enrollment".
import type { Context, Hono } from "hono";
-import { bodyLimit } from "hono/body-limit";
import {
NOT_ENTITLED_ERROR,
isE2eId,
normalizeEnrollUserCode,
readJson,
} from "remote-lib-common";
-import { accountQuery, cookieEntitled, type AccountHost } from "./account-gate";
+import { accountQuery, cookieEntitled, jsonBodyLimit, type AccountHost } from "./account-gate";
import { ENROLLMENT_TTL_MS, LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "./policy-constants";
/** What one request's account deployment provides to the Relay's account routes. */
@@ -32,10 +31,7 @@ export const ALREADY_APPROVED = "That code is already approved.";
export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAccountHost) {
const gate = cookieEntitled(host, (c) => c.json({ message: NOT_ENTITLED_ERROR }, 403));
- const small = bodyLimit({
- maxSize: 1024,
- onError: (c) => c.json({ message: "Request too large." }, 413),
- });
+ const small = jsonBodyLimit(1024);
app.post("/api/relay/enrollments/approve", small, gate, async (c) => {
const login = c.get("login");
diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql
index 7cd199230..a1c0d9856 100644
--- a/hosted/server/runtime-roles.sql
+++ b/hosted/server/runtime-roles.sql
@@ -60,8 +60,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;
-- The relay Worker, its Cron sweep, and its RelayRoom's RelayRows.
GRANT SELECT, INSERT ON dormouse_relay_burrows TO dormouse_relay;
diff --git a/hosted/server/tests/artifacts.test.ts b/hosted/server/tests/artifacts.test.ts
index 3f5c69cc6..b1f2d75da 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 peer of the installed pgstencil packages", () => {
@@ -20,7 +21,9 @@ test("Hosted declares every peer of the installed pgstencil packages", () => {
}
});
-test("the lockfile resolves both pgstencil packages from npm", () => {
+// TEMP: pgstencil 0.3.0 is unreleased and installed from vendor/, so this must
+// fail until the commit that vendors it is dropped and 0.3.0 comes from npm.
+test.fails("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..df1b33ed4
--- /dev/null
+++ b/hosted/server/tests/billing.test.ts
@@ -0,0 +1,502 @@
+import { test, expect } from "vitest";
+import { Hono } from "hono";
+import { fileURLToPath } from "node:url";
+import { Miniflare, Response as WorkerResponse } from "miniflare";
+import { createTestContext } from "pgstencil/testing";
+import { queryDatabase } from "pgstencil/postgres";
+import { API_ROUTES, DEFAULT_MANAGED_VOICE_ID, NOT_ENTITLED_ERROR } from "remote-lib-common";
+import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER } from "../../../website/src/lib/hosted-pricing";
+import { SITE_ORIGIN } from "../account-app";
+import { COHORT_ENDPOINT } from "../../../website/src/lib/hosted-cohorts";
+import { billingSetup, openCohort } from "../billing";
+import {
+ BILLING_WEBHOOK_PATH,
+ CHECKOUT_CLOSED,
+ COHORT_CACHE_MS,
+ billingRoutes,
+} from "../billing-routes";
+import { DEV_FOUNDING_PRICES, stripeDevBilling } from "../billing-dev";
+import { migrations } from "../migrations";
+import {
+ ORIGINS,
+ TEST_ENROLL_SECRET,
+ bundleWorker,
+ miniflareOptions,
+ together,
+ wrangler,
+} from "./bundle";
+import { workerDatabases } from "./worker-roles";
+
+// Checkout, the webhook, and the subscription as the entitlement, end to end:
+// the account, relay, and voice Workers in workerd on their own roles, and
+// StripeDev answering for api.stripe.com.
+
+const origin = ORIGINS.account;
+const LADDER = DEV_FOUNDING_PRICES;
+const accountBundle = bundleWorker("server/tests/worker-entry.ts", [
+ fileURLToPath(import.meta.resolve("@pgstencil/auth/better-auth-testing")),
+]);
+const relayBundle = bundleWorker("server/relay-worker.ts");
+const voiceBundle = bundleWorker("server/tests/voice-entry.ts");
+
+async function fixture({ billing = true } = {}) {
+ // 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 outboundService = async (request: Request) => {
+ const url = new URL(request.url);
+ if (url.href === "https://api.postmarkapp.com/email") {
+ 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 WorkerResponse.json({ ErrorCode: 0 });
+ }
+ if (url.origin === "https://api.elevenlabs.io")
+ return url.pathname.startsWith("/v1/history")
+ ? WorkerResponse.json({ history: [] })
+ : new WorkerResponse(new Uint8Array([0xff, 0xfb]), { headers: { "content-type": "audio/mpeg" } });
+ if (url.origin !== "https://api.stripe.com") throw new Error(`Unexpected outbound host: ${url.hostname}`);
+ const response = await fetch(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" },
+ });
+ };
+ const stripe = billing
+ ? {
+ STRIPE_SECRET_KEY: setup.secretKey,
+ STRIPE_WEBHOOK_SECRET: setup.webhookSecret,
+ STRIPE_PRICE_MONTHLY: setup.monthly,
+ STRIPE_PRICE_YEARLY: setup.yearly,
+ STRIPE_PRICES_FOUNDING: setup.founding.join(","),
+ }
+ : {};
+ const worker = new Miniflare(
+ together(
+ miniflareOptions("account", (await accountBundle).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: databases.account },
+ serviceBindings: { ASSETS: () => new WorkerResponse("", { headers: { "content-type": "text/html" } }) },
+ outboundService,
+ }),
+ miniflareOptions("relay", (await relayBundle).outputFiles[0].text, {
+ bindings: { APP_ORIGIN: ORIGINS.relay, ACCOUNT_ORIGIN: origin, RELAY_ENROLL_SECRET: TEST_ENROLL_SECRET },
+ hyperdrives: { HYPERDRIVE: databases.relay },
+ serviceBindings: { ASSETS: () => new WorkerResponse("") },
+ outboundService,
+ routes: [`${new URL(ORIGINS.relay).host}/*`],
+ }),
+ miniflareOptions("voice", (await voiceBundle).outputFiles[0].text, {
+ bindings: { APP_ORIGIN: ORIGINS.voice, ELEVENLABS_API_KEY: "test-elevenlabs-key" },
+ hyperdrives: { HYPERDRIVE: databases.voice },
+ outboundService,
+ routes: [`${new URL(ORIGINS.voice).host}/*`],
+ }),
+ ),
+ );
+ await worker.ready;
+ const call = (url: string, init: RequestInit = {}) =>
+ worker.dispatchFetch(url, { redirect: "manual", ...init } as never);
+ const now = () => call(origin + "/__test/time", { method: "POST", body: context.time.now().toISOString() });
+ await now();
+
+ /** Delivers every pending StripeDev event, signed, to the webhook. */
+ const deliver = async () => {
+ while (dev.events.length) {
+ const { body, signature } = dev.signed(dev.events[0]!);
+ const response = await call(origin + BILLING_WEBHOOK_PATH, {
+ method: "POST",
+ headers: { "stripe-signature": signature, "content-type": "application/json" },
+ body,
+ });
+ expect(response.status).toBe(200);
+ await response.text();
+ dev.events.shift();
+ }
+ };
+
+ /** A browser on the account origin with its own cookie jar. */
+ function browser() {
+ const jar = new Map();
+ let csrf = "";
+ const request = async (
+ path: string,
+ init: { method?: string; body?: unknown; origin?: string | null; headers?: Record } = {},
+ ) => {
+ const response = await call(new URL(path, origin).href, {
+ method: init.method ?? "GET",
+ headers: {
+ cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
+ "cf-connecting-ip": "203.0.113.10",
+ "content-type": "application/json",
+ ...(init.origin !== null && { origin: init.origin ?? origin }),
+ ...init.headers,
+ },
+ ...(init.body !== undefined && { body: JSON.stringify(init.body) }),
+ });
+ for (const cookie of response.headers.getSetCookie()) {
+ const pair = cookie.split(";")[0]!;
+ jar.set(pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1));
+ }
+ return response;
+ };
+ const auth = async (path: string, body: unknown) => {
+ csrf ||= ((await (await request("/api/auth/csrf")).json()) as { csrf: string }).csrf;
+ return request("/api/auth/" + path, { method: "POST", body, headers: { "x-csrf-token": csrf } });
+ };
+ const signIn = async (address: string) => {
+ expect((await auth("email-otp/send-verification-otp", { email: address, type: "sign-in" })).status).toBe(200);
+ const message = await context.email.next();
+ expect((await auth("sign-in/email-otp", { email: address, otp: message.text.match(/\b\d{8}\b/)![0] })).status).toBe(200);
+ return ((await (await request("/api/auth/get-session")).json()) as { user: { id: string } }).user.id;
+ };
+ /** Starts checkout for `plan`, completes it in StripeDev, and delivers its events. */
+ const buy = async (plan: string) => {
+ const started = await request("/api/billing/checkout", { method: "POST", body: { plan } });
+ expect(started.status).toBe(200);
+ const { url } = (await started.json()) as { url: string };
+ const session = [...dev.checkouts.values()].find((s) => s.url === url)!;
+ const subscription = dev.completeCheckout(session.id);
+ await deliver();
+ return { session, subscription };
+ };
+ const summary = async () => (await (await request("/api/billing")).json()) as Record;
+ return { request, signIn, buy, summary };
+ }
+
+ const burrowCall = async (path: string, body?: unknown, bearer?: string) => {
+ const response = await call(ORIGINS.relay + path, {
+ method: "POST",
+ headers: { "content-type": "application/json", ...(bearer && { authorization: `Bearer ${bearer}` }) },
+ body: JSON.stringify(body ?? {}),
+ });
+ return { status: response.status, json: (await response.json()) as Record };
+ };
+ const speak = (token: string) =>
+ call(ORIGINS.voice + "/api/voice/speak", {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ body: JSON.stringify({ text: "Build finished", voiceId: DEFAULT_MANAGED_VOICE_ID }),
+ });
+ /** The account Worker's Cron Trigger, run now. */
+ const scheduled = async () => {
+ const fetcher = (await worker.getWorker(wrangler.account.name)) as unknown as {
+ scheduled(options: { cron: string }): Promise<{ outcome: string }>;
+ };
+ return fetcher.scheduled({ cron: "*/10 * * * *" });
+ };
+ return {
+ ...context,
+ dev,
+ call,
+ now,
+ deliver,
+ browser,
+ burrowCall,
+ speak,
+ scheduled,
+ close: async () => {
+ await worker.dispose();
+ await dev.close();
+ await context.close();
+ },
+ };
+}
+
+test("the open cohort is the highest step bought at, and a refund never reopens a lower one", () => {
+ const full = FOUNDING_COHORT_SIZE;
+ const steps = FOUNDING_LADDER.length;
+ const zeros = Array(steps).fill(0);
+ expect(openCohort(zeros)).toEqual({ cohort: 0, seatsLeft: full });
+ expect(openCohort([3, ...zeros.slice(1)])).toEqual({ cohort: 0, seatsLeft: full - 3 });
+ expect(openCohort([full, ...zeros.slice(1)])).toEqual({ cohort: 1, seatsLeft: full });
+ // Concurrent checkouts may oversell a cohort; the next still opens at a full count.
+ expect(openCohort([full + 4, 0, ...zeros.slice(2)])).toEqual({ cohort: 1, seatsLeft: full });
+ // A refund in a closed cohort returns its seat there, and the price stays up.
+ expect(openCohort([full - 1, 2, ...zeros.slice(2)])).toEqual({ cohort: 1, seatsLeft: full - 2 });
+ expect(openCohort(Array(steps).fill(full))).toBeNull();
+});
+
+test("billing is off 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)).toMatchObject({ live: true, founding: LADDER });
+ for (const name of Object.keys(env))
+ expect(billingSetup({ ...env, [name]: undefined }), name).toBeNull();
+ 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/);
+});
+
+test("checkout to webhook to entitlement: voice and the Relay admit a member, and refuse once the subscription ends", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const member = f.browser();
+ // Signed out: no plan, no checkout.
+ expect((await member.request("/api/billing")).status).toBe(401);
+ expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly" } })).status).toBe(401);
+ const userId = await member.signIn("member@example.test");
+ expect(await member.summary()).toMatchObject({ plan: null, entitled: false, founder: null });
+ expect((await member.request("/api/voice/tokens", { method: "POST" })).status).toBe(403);
+
+ // The browser names a plan, never a Price.
+ for (const plan of ["price_dev_monthly", "annual", "", 7])
+ expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan } })).status, String(plan)).toBe(400);
+ const { session, subscription } = await member.buy("monthly");
+ const sent = f.dev.requests.find((r) => r.path === "/v1/checkout/sessions")!.body;
+ expect(sent).toMatchObject({
+ "line_items[0][price]": "price_dev_monthly",
+ "managed_payments[enabled]": "true",
+ success_url: expect.stringMatching(new RegExp(`^${origin}/billing\\?checkout=`)),
+ });
+ // No trial: the first payment is taken at checkout.
+ expect(sent["subscription_data[trial_period_days]"]).toBeUndefined();
+
+ // The return page confirms the operation it names.
+ const confirmed = await member.request("/api/billing/confirm", {
+ method: "POST",
+ body: { checkout: session.metadata!.pgstencil_operation },
+ });
+ expect(confirmed.status).toBe(200);
+ expect(await confirmed.json()).toMatchObject({ plan: "monthly", active: true, entitled: true, renews: true });
+ expect((await member.request("/api/billing/confirm", { method: "POST", body: { checkout: "unknown" } })).status).toBe(404);
+
+ // Voice: mint and speak.
+ const minted = await member.request("/api/voice/tokens", { method: "POST" });
+ expect(minted.status).toBe(201);
+ const { token } = (await minted.json()) as { token: string };
+ expect((await f.speak(token)).status).toBe(200);
+
+ // The Relay: approve a device code, and the Burrow enrolls and mints a setup token.
+ const begun = (await f.burrowCall(API_ROUTES.burrowEnrollBegin, { origin: ORIGINS.relay })).json;
+ expect((await member.request("/api/relay/enrollments/approve", { method: "POST", body: { userCode: begun.userCode } })).status).toBe(204);
+ const enrolled = await f.burrowCall(API_ROUTES.burrowEnrollPoll, { deviceCode: begun.deviceCode });
+ expect(enrolled.json.status).toBe("enrolled");
+ const { burrowToken } = enrolled.json.enrollment;
+ expect((await f.burrowCall(API_ROUTES.burrowSetupToken, undefined, burrowToken)).status).toBe(200);
+
+ // A failed renewal refuses at once: no grace past what the subscription grants.
+ f.dev.transition(subscription.id, "payment-failed");
+ await f.deliver();
+ expect((await f.speak(token)).status).toBe(403);
+ // The plan stays on the account page, so its owner can fix the card.
+ expect(await member.summary()).toMatchObject({ plan: "monthly", active: false, entitled: false });
+ f.dev.transition(subscription.id, "renew");
+ await f.deliver();
+ expect((await f.speak(token)).status).toBe(200);
+
+ // A refund cancels at once: voice, the Relay, and the account's routes refuse.
+ f.dev.transition(subscription.id, "cancel");
+ await f.deliver();
+ expect((await f.speak(token)).status).toBe(403);
+ expect(await f.burrowCall(API_ROUTES.burrowSetupToken, undefined, burrowToken)).toEqual({
+ status: 403,
+ json: { error: NOT_ENTITLED_ERROR },
+ });
+ expect((await member.request("/api/voice/tokens", { method: "POST" })).status).toBe(403);
+ expect(await member.summary()).toMatchObject({ plan: null, entitled: false });
+ expect(
+ await queryDatabase(f.database.url, `SELECT owner_id, status FROM pgstencil_billing.subscriptions`),
+ ).toEqual([{ owner_id: userId, status: "canceled" }]);
+});
+
+test("a missed renewal webhook is repaired by the next resync", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const member = f.browser();
+ await member.signIn("renewing@example.test");
+ const { subscription } = await member.buy("yearly");
+ const token = ((await (await member.request("/api/voice/tokens", { method: "POST" })).json()) as { token: string }).token;
+ // Stripe renewed, but the webhook never arrived; the stored period has ended.
+ f.dev.transition(subscription.id, "renew");
+ f.dev.events.length = 0;
+ await queryDatabase(f.database.url, `UPDATE pgstencil_billing.subscriptions SET period_end = now() - interval '1 minute'`);
+ expect((await f.speak(token)).status).toBe(403);
+ expect((await f.scheduled()).outcome).toBe("ok");
+ expect((await f.speak(token)).status).toBe(200);
+});
+
+test("founding: the open cohort's Price at checkout, its seats and opted-in founders on the site origin, and refunds returning seats", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const cohorts = async (at = SITE_ORIGIN) => {
+ const response = await f.call(at + COHORT_ENDPOINT);
+ expect(response.status).toBe(200);
+ return response.json();
+ };
+ /** Moves both clocks past the cohort cache. */
+ const later = () => {
+ f.time.advanceMilliseconds(COHORT_CACHE_MS + 1);
+ return f.now();
+ };
+ expect(await cohorts()).toEqual({ cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE, founders: { total: 0, shown: [] } });
+ // Only the cohort path answers on the site origin.
+ for (const path of ["/api/billing", "/api/auth/csrf", "/api/hosted/other", "/"])
+ expect((await f.call(SITE_ORIGIN + path)).status, path).toBe(421);
+ expect((await f.call(SITE_ORIGIN + COHORT_ENDPOINT, { method: "POST" })).status).toBe(421);
+
+ const ada = f.browser();
+ await ada.signIn("ada@example.test");
+ // Only a founder joins the row.
+ expect((await ada.request("/api/billing/founder", { method: "PUT", body: { shown: true, name: "Ada" } })).status).toBe(409);
+ await ada.buy("founding");
+ for (const name of ["", " ", "x".repeat(65), "Ada\u0000"])
+ expect((await ada.request("/api/billing/founder", { method: "PUT", body: { shown: true, name } })).status, name).toBe(400);
+ expect((await ada.request("/api/billing/founder", { method: "PUT", body: { shown: true, name: " Ada L. " } })).status).toBe(204);
+ expect(await ada.summary()).toMatchObject({ plan: "founding", founder: "Ada L." });
+
+ const bob = f.browser();
+ await bob.signIn("bob@example.test");
+ const { subscription: bobs } = await bob.buy("founding");
+ // Within the cache, the answer is the one already served.
+ expect(await cohorts()).toEqual({ cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE, founders: { total: 0, shown: [] } });
+ await later();
+ expect(await cohorts(origin)).toEqual({
+ cohort: 0,
+ seatsLeft: FOUNDING_COHORT_SIZE - 2,
+ founders: { total: 2, shown: [{ name: "Ada L." }] },
+ });
+ // A refund, which cancels at once, returns Bob's seat.
+ f.dev.transition(bobs.id, "cancel");
+ await f.deliver();
+ await later();
+ expect(await cohorts()).toMatchObject({ seatsLeft: FOUNDING_COHORT_SIZE - 1, founders: { total: 1 } });
+ // Withdrawing leaves the row; the purchase still counts.
+ expect((await ada.request("/api/billing/founder", { method: "PUT", body: { shown: false } })).status).toBe(204);
+ await later();
+ expect(await cohorts()).toMatchObject({ founders: { total: 1, shown: [] } });
+
+ // The first cohort sells out: checkout offers the next step's Price.
+ await queryDatabase(
+ f.database.url,
+ `WITH owners AS (
+ INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at)
+ SELECT 'filler-' || n, 'filler-' || n || '@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, $2, 'active', now(), now() + interval '1 year', false, now() FROM owners`,
+ [FOUNDING_COHORT_SIZE - 1, LADDER[0]],
+ );
+ await later();
+ expect(await cohorts()).toMatchObject({ cohort: 1, seatsLeft: FOUNDING_COHORT_SIZE });
+ const carol = f.browser();
+ await carol.signIn("carol@example.test");
+ await carol.buy("founding");
+ expect(
+ f.dev.requests.filter((r) => r.path === "/v1/checkout/sessions").map((r) => r.body["line_items[0][price]"]),
+ ).toEqual([LADDER[0], LADDER[0], LADDER[1]]);
+ expect(await carol.summary()).toMatchObject({ plan: "founding", founding: { cohort: 1, seatsLeft: FOUNDING_COHORT_SIZE - 1 } });
+});
+
+test("the webhook takes only Stripe's signed body; checkout takes only this origin; billing off answers 503", async ({
+ onTestFinished,
+}) => {
+ const f = await fixture();
+ onTestFinished(f.close);
+ const webhook = (headers: Record, body: string) =>
+ f.call(origin + BILLING_WEBHOOK_PATH, { method: "POST", headers, body });
+ const member = f.browser();
+ await member.signIn("signed@example.test");
+ await member.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly" } });
+ const session = [...f.dev.checkouts.values()][0]!;
+ f.dev.completeCheckout(session.id);
+ const event = f.dev.events[0]!;
+ const { body, signature } = f.dev.signed(event);
+ expect((await webhook({}, body)).status).toBe(400);
+ expect((await webhook({ "stripe-signature": signature }, body.replace(event.id, "evt_forged"))).status).toBe(400);
+ expect((await webhook({ "stripe-signature": "t=1,v1=00" }, body)).status).toBe(400);
+ expect((await webhook({ "stripe-signature": signature }, "x".repeat(1024 * 1024 + 1))).status).toBe(413);
+ expect(await queryDatabase(f.database.url, `SELECT id FROM pgstencil_billing.events`)).toEqual([]);
+ await f.deliver();
+ expect(await member.summary()).toMatchObject({ plan: "monthly" });
+
+ // Checkout, the portal, and the founders row: this origin only, and a state change must say so.
+ const other = f.browser();
+ await other.signIn("other@example.test");
+ for (const from of [null, ORIGINS.relay, ORIGINS.voice, SITE_ORIGIN])
+ for (const [method, path] of [
+ ["POST", "/api/billing/checkout"],
+ ["POST", "/api/billing/portal"],
+ ["PUT", "/api/billing/founder"],
+ ["PUT", "/api/billing/survey"],
+ ])
+ expect((await other.request(path, { method, body: { plan: "monthly" }, origin: from })).status, `${from} ${path}`).toBe(403);
+ const portal = await member.request("/api/billing/portal", { method: "POST" });
+ expect(portal.status).toBe(200);
+ expect(((await portal.json()) as { url: string }).url).toBe(`${f.dev.origin}/portal/${session.customer}`);
+ // An existing subscription is managed in the portal, never bought twice.
+ expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan: "yearly" } })).status).toBe(409);
+
+ // The survey: optional answers in whole dollars, one set per account.
+ for (const answers of [{}, { bargain: -1 }, { bargain: 1.5 }, { bargain: "50" }])
+ expect((await member.request("/api/billing/survey", { method: "PUT", body: answers })).status).toBe(400);
+ expect((await member.request("/api/billing/survey", { method: "PUT", body: { tooCheap: 20, bargain: 60 } })).status).toBe(204);
+ expect((await member.request("/api/billing/survey", { method: "PUT", body: { tooExpensive: 200 } })).status).toBe(204);
+ expect(
+ await queryDatabase(f.database.url, `SELECT "tooExpensive", "tooCheap", expensive, bargain FROM dormouse_price_survey`),
+ ).toEqual([{ tooExpensive: 200, tooCheap: null, expensive: null, bargain: null }]);
+
+ const off = await fixture({ billing: false });
+ onTestFinished(off.close);
+ const visitor = off.browser();
+ await visitor.signIn("visitor@example.test");
+ for (const [method, path] of [
+ ["GET", "/api/billing"],
+ ["POST", "/api/billing/checkout"],
+ ]) {
+ const response = await visitor.request(path, { method, ...(method === "POST" && { body: { plan: "monthly" } }) });
+ expect([path, response.status, await response.json()]).toEqual([path, 503, { message: CHECKOUT_CLOSED }]);
+ }
+ expect((await off.call(SITE_ORIGIN + COHORT_ENDPOINT)).status).toBe(503);
+ expect((await off.scheduled()).outcome).toBe("ok");
+});
+
+test("an account without a public email checks out, and Stripe Checkout collects one", async ({ onTestFinished }) => {
+ const context = await createTestContext({ migrations });
+ const { dev, setup } = await stripeDevBilling(context.time, context.random);
+ onTestFinished(async () => {
+ await dev.close();
+ await context.close();
+ });
+ // The routes in Node on StripeDev's client, for a login whose email is null.
+ const app = new Hono();
+ billingRoutes(app, () => ({
+ databaseUrl: context.database.url,
+ auth: async () => Response.json({ user: { id: "provider-only", email: null }, session: {} }),
+ setup: () => setup,
+ }));
+ const response = await app.request(`${origin}/api/billing/checkout`, {
+ method: "POST",
+ headers: { origin, "content-type": "application/json" },
+ body: JSON.stringify({ plan: "yearly" }),
+ });
+ expect(response.status).toBe(200);
+ // No email is sent, so Stripe's hosted page asks the buyer for one.
+ expect(dev.requests.find((r) => r.path === "/v1/customers")!.body).toEqual({
+ "metadata[pgstencil_owner]": "provider-only",
+ });
+});
diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts
index 511fe0982..c9da4bf65 100644
--- a/hosted/server/tests/boundary.test.ts
+++ b/hosted/server/tests/boundary.test.ts
@@ -27,6 +27,9 @@ import {
type RulesFor,
} from "../headers";
import { cookieEntitled } from "../account-gate";
+import { SITE_ORIGIN } from "../account-app";
+import { COHORT_ENDPOINT } from "../../../website/src/lib/hosted-cohorts";
+import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED } from "../billing-routes";
import { RECENT_LOGIN_REQUIRED, relayAccountRoutes } from "../relay-account";
import type { RelayRoomRpc } from "../relay-room-contract";
import { voiceApp } from "../voice-app";
@@ -126,6 +129,17 @@ const ACCOUNT_RELAY: [string, string][] = [
["DELETE", "/api/relay/burrows/AAAAAAAAAAAAAAAAAAAAAA"],
];
+/** Billing's routes, which only the account serves. */
+const ACCOUNT_BILLING: [string, string][] = [
+ ["GET", "/api/billing"],
+ ["POST", "/api/billing/checkout"],
+ ["POST", "/api/billing/confirm"],
+ ["POST", "/api/billing/portal"],
+ ["PUT", "/api/billing/founder"],
+ ["PUT", "/api/billing/survey"],
+ ["POST", BILLING_WEBHOOK_PATH],
+];
+
/** A route each Worker serves, as method and path. */
const SERVED: Record = {
account: [
@@ -134,6 +148,7 @@ const SERVED: Record = {
["GET", "/api/voice/tokens"],
["POST", "/api/voice/tokens"],
...ACCOUNT_RELAY,
+ ...ACCOUNT_BILLING,
["GET", "/login"],
],
relay: [
@@ -211,6 +226,8 @@ const ABSENT: Record = {
["POST", "/api/voice/tokens"],
["DELETE", "/api/voice/tokens/00000000-0000-4000-8000-000000000000"],
...ACCOUNT_RELAY,
+ ...ACCOUNT_BILLING,
+ ["GET", COHORT_ENDPOINT],
["POST", "/api/voice/speak"],
// The self-host installers' probe; neither a Burrow nor Pocket asks Hosted for it.
["GET", "/api/hello"],
@@ -233,6 +250,8 @@ const ABSENT: Record = {
["GET", "/login"],
...RELAY_API,
...ACCOUNT_RELAY,
+ ...ACCOUNT_BILLING,
+ ["GET", COHORT_ENDPOINT],
["GET", WS_ROUTES.burrow],
["GET", WS_ROUTES.client],
],
@@ -250,6 +269,24 @@ test.for(NAMES)("%s: serves only its own routes", async (name) => {
expect(outbound).toEqual([]);
});
+test("the account answers the site origin the cohort endpoint alone, and its siblings never", async () => {
+ // Billing is off here (no Stripe bindings), so the endpoint answers before any database.
+ const cohorts = await send("account", SITE_ORIGIN + COHORT_ENDPOINT);
+ expect([cohorts.status, await cohorts.json()]).toEqual([503, { message: CHECKOUT_CLOSED }]);
+ expect(cohorts.headers.get("content-security-policy")).toBe(ACCOUNT_POLICY);
+ for (const [method, path] of [
+ ["HEAD", COHORT_ENDPOINT],
+ ["POST", COHORT_ENDPOINT],
+ ["GET", `${COHORT_ENDPOINT}/`],
+ ["GET", "/api/hosted/other"],
+ ...ACCOUNT_BILLING,
+ ])
+ expect((await send("account", SITE_ORIGIN + path, method)).status, `${method} ${path}`).toBe(421);
+ for (const name of ["relay", "voice"] as const)
+ expect((await send(name, SITE_ORIGIN + COHORT_ENDPOINT)).status, name).toBe(421);
+ expect(outbound).toEqual([]);
+});
+
test("the account answers /connect/ with its own shell and policy, never the phone page", async () => {
for (const path of [ONE_TIME_PAGE_PATH, `${ONE_TIME_PAGE_PATH}assets/x.js`]) {
const response = await send("account", ORIGINS.account + path);
@@ -373,6 +410,12 @@ 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",
+ // Billing's, which only the production account reads.
+ 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 keys = (bindings: object) => Object.keys(bindings).sort();
expect(keys(accountBindings(env))).toEqual(
@@ -388,6 +431,11 @@ test("each bindings mapper passes only what its Worker uses", () => {
"POSTMARK_SERVER_TOKEN",
"RELAY_APPROVE_LIMIT",
"RELAY_ROOM",
+ "STRIPE_PRICES_FOUNDING",
+ "STRIPE_PRICE_MONTHLY",
+ "STRIPE_PRICE_YEARLY",
+ "STRIPE_SECRET_KEY",
+ "STRIPE_WEBHOOK_SECRET",
].sort(),
);
expect(accountPreviewBindings(env)).toEqual({
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index 7ec2bb917..efb852bb1 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -15,6 +15,7 @@ const PINNED: Record = {
"004_relay_enrollment_redeemed.sql":
"dd36852ac3efbdc7f0dc2b9ee449f8f213c3c63982c4ab176a27e4a19e08a74d",
"005_voice_token_burrow.sql": "ccb395314dd49f9b5c72560121994fd7a21d349580c0d746d0d380e29d0aefec",
+ "006_billing_founders.sql": "4e9f6b8f86eec407cfc4a6864557893639e87930051a4265c3b72ac25b09679a",
};
const directory = new URL("../dormouse-migrations/", import.meta.url);
diff --git a/hosted/server/tests/policy.test.ts b/hosted/server/tests/policy.test.ts
index 359547e4e..0e5b74648 100644
--- a/hosted/server/tests/policy.test.ts
+++ b/hosted/server/tests/policy.test.ts
@@ -16,8 +16,8 @@ test("provider allowlist fails closed on typos and partial credentials", () => {
});
test("local inbox is guarded against rebinding and cross-origin requests", () => {
const origin = "http://localhost:5188";
- const check = (headers: IncomingMessage["headers"]) =>
- allowedDevRequest({ headers } as IncomingMessage, origin);
+ const check = (headers: IncomingMessage["headers"], method = "GET", url = "/api/dev/emails") =>
+ allowedDevRequest({ headers, method, url } as IncomingMessage, origin);
expect(check({ host: "localhost:5188" })).toBe(true);
expect(check({ host: "attacker.test:5188" })).toBe(false);
expect(check({ host: "localhost:5188", origin: "https://dormouse.sh" })).toBe(
@@ -26,6 +26,13 @@ test("local inbox is guarded against rebinding and cross-origin requests", () =>
expect(
check({ host: "localhost:5188", "sec-fetch-site": "cross-site" }),
).toBe(false);
+ // StripeDev's page returns the browser to /billing, and only that navigation crosses.
+ const crossing = { host: "localhost:5188", "sec-fetch-site": "cross-site", "sec-fetch-mode": "navigate" };
+ expect(check(crossing, "GET", "/billing?checkout=x")).toBe(true);
+ expect(check(crossing, "GET", "/api/dev/emails")).toBe(false);
+ expect(check(crossing, "POST", "/billing")).toBe(false);
+ expect(check({ ...crossing, "sec-fetch-mode": "cors" }, "GET", "/billing")).toBe(false);
+ expect(check({ ...crossing, origin: "http://127.0.0.1:9" }, "GET", "/billing")).toBe(false);
});
// The account screen and the packed adapter gate on the same window; nothing
// else would notice a pgstencil bump moving one of them.
diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts
index b2c0cb209..63c3c6eb9 100644
--- a/hosted/server/tests/runtime-roles.test.ts
+++ b/hosted/server/tests/runtime-roles.test.ts
@@ -90,7 +90,12 @@ const EXPECTED: Record = {
"dormouse_voice_tokens.burrowId: INSERT",
"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",
@@ -104,7 +109,12 @@ const EXPECTED: Record = {
"dormouse_voice_tokens.userId: SELECT",
"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",
@@ -127,9 +137,22 @@ 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 customer_id FROM pgstencil_billing.accounts`,
+ `SELECT price_id FROM pgstencil_billing.subscriptions`,
+ `SELECT id FROM pgstencil_billing.checkouts`,
+ `UPDATE pgstencil_billing.subscriptions SET status = status`,
+ `SELECT name FROM dormouse_founders`,
+ `SELECT bargain FROM dormouse_price_survey`,
+];
+
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", `SELECT owner_id, status, period_end, trial_end FROM pgstencil_billing.subscriptions`),
+ ).toBe("ok");
for (const text of [
`SELECT * FROM "session"`,
`SELECT * FROM account`,
@@ -145,6 +168,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"]);
});
@@ -163,6 +187,7 @@ test("the voice role is refused the relay's tables, the account's, and its token
`UPDATE dormouse_voice_tokens SET "revokedAt" = NULL`,
`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"]);
});
diff --git a/hosted/server/tests/worker-entry.ts b/hosted/server/tests/worker-entry.ts
index 9dd5ad4ef..5accdb6a7 100644
--- a/hosted/server/tests/worker-entry.ts
+++ b/hosted/server/tests/worker-entry.ts
@@ -19,4 +19,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/server/worker-app.ts b/hosted/server/worker-app.ts
index e1c34c0ad..d5184d54e 100644
--- a/hosted/server/worker-app.ts
+++ b/hosted/server/worker-app.ts
@@ -17,6 +17,7 @@ export function workerApp({
fallback,
scheduled,
unavailable,
+ site,
}: {
/** The only bindings that reach the routes. */
bindings: (env: E) => E;
@@ -34,6 +35,11 @@ export function workerApp({
) => Promise;
/** `onError`'s message. */
unavailable: string;
+ /**
+ * Another origin this Worker answers, for exactly these `GET` paths
+ * (the account's cohort endpoint on the marketing site); 421 for any other.
+ */
+ site?: { origin: string; paths: readonly string[] };
}) {
const app = new Hono<{ Bindings: E }>();
secureHeaders(app, rules);
@@ -45,7 +51,10 @@ export function workerApp({
});
app.use("*", async (c, next) => {
// A candidate/preview hostname, or a sibling Worker's, must never act as an alias for this one.
- if (new URL(c.req.url).origin !== c.env.APP_ORIGIN)
+ const origin = new URL(c.req.url).origin;
+ const sited =
+ origin === site?.origin && c.req.method === "GET" && site.paths.includes(c.req.path);
+ if (origin !== c.env.APP_ORIGIN && !sited)
return c.json({ message: "Unknown origin." }, 421);
await next();
});
diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx
index 2b808d907..7c1e74f3a 100644
--- a/hosted/src/App.tsx
+++ b/hosted/src/App.tsx
@@ -7,27 +7,72 @@ import {
} from "react";
import {
approveEnrollment,
+ confirmCheckout,
createVoiceToken,
+ getBilling,
getAccounts,
getComputers,
getProviders,
getSession,
getVoiceTokens,
+ openPortal,
post,
providerNames,
removeComputer,
revokeVoiceToken,
+ sendSurvey,
+ setFounder,
social,
+ startCheckout,
type Account,
+ type BillingSummary,
type Computer,
+ type Plan,
type Provider,
type Session,
type VoiceToken,
} from "./api";
-import { LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW } from "../server/policy-constants";
+import {
+ BILLING_RETURN_PATH,
+ LOGIN_FRESH_AGE_MS,
+ RECENT_LOGIN_WINDOW,
+} from "../server/policy-constants";
import { takeEnrollment, type Enrollment } from "./enrollment";
+import { CheckoutView, PLAN_NAMES, PlanSection, WelcomeView } from "./Billing";
+import { forgetCheckout } from "./checkout";
+
+type Page = "enroll" | "checkout" | "welcome" | "account" | "login";
+const PATHS: Record = {
+ enroll: "/enroll",
+ checkout: "/checkout",
+ welcome: BILLING_RETURN_PATH,
+ account: "/account",
+ login: "/login",
+};
+const TITLES: Record = {
+ enroll: "Approve a computer",
+ checkout: "Subscribe to Dormouse Hosted",
+ welcome: "Welcome to Dormouse Hosted",
+ account: "Your account",
+ login: "Sign in to Dormouse Hosted",
+};
+const INTROS: Record<"welcome" | "account" | "login", string> = {
+ welcome: "Your subscription is active.",
+ account: "Manage your plan and how you sign in.",
+ login: "One account for Dormouse’s hosted services.",
+};
-export function App({ enrollment }: { enrollment: Enrollment | null }) {
+export function App({
+ enrollment,
+ checkout,
+ returned,
+}: {
+ enrollment: Enrollment | null;
+ /** A pending `/checkout` (null for a link naming no plan), undefined for none. */
+ checkout: Plan | null | undefined;
+ /** The checkout operation Stripe returned to `/billing` with. */
+ returned: string | null;
+}) {
const [session, setSession] = useState(null);
const [enabled, setEnabled] = useState([]);
const [accounts, setAccounts] = useState([]);
@@ -37,12 +82,12 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
const [computers, setComputers] = useState(null);
// The enrollment awaiting approval, in memory only: through sign-in and
// back, never into storage (docs/specs/hosted.md -> "Burrow enrollment").
- const [enrolling, setEnrollingState] = useState(enrollment);
- const enrollingRef = useRef(enrolling);
- const setEnrolling = (next: Enrollment | null) => {
- enrollingRef.current = next;
- setEnrollingState(next);
- };
+ const [enrolling, setEnrolling] = useState(enrollment);
+ // Null while this deployment does not sell, or before sign-in.
+ const [billing, setBilling] = useState(null);
+ const [buying, setBuying] = useState(checkout);
+ // Set once Stripe's return is confirmed: the welcome page shows.
+ const [welcome, setWelcome] = useState(false);
const [minted, setMinted] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
@@ -65,29 +110,37 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
getProviders(),
]);
// A failed list hides its section, never the account page.
- const [linked, tokens, enrolled] = current
+ const [linked, tokens, enrolled, plan] = current
? await Promise.all([
getAccounts(),
getVoiceTokens().catch(() => null),
getComputers().catch(() => null),
+ getBilling().catch(() => null),
])
- : [[], null, null];
+ : [[], null, null, null];
if (generation !== refreshGeneration.current) return;
setSession(current);
setEnabled(providers);
setAccounts(linked);
setVoiceTokens(tokens);
setComputers(enrolled);
- history.replaceState(
- null,
- "",
- enrollingRef.current ? "/enroll" : current ? "/account" : "/login",
- );
+ setBilling(plan);
}, []);
useEffect(() => {
// An auth callback's query parameters (`?error=`) never stay in history.
history.replaceState(null, "", location.pathname);
void refresh()
+ .then(async () => {
+ // Stripe's return: confirm the checkout it names, then welcome.
+ if (returned)
+ await act("confirm", async () => {
+ const summary = await confirmCheckout(returned);
+ setBilling(summary);
+ // An open, expired, or still-processing checkout bought nothing yet.
+ if (summary.plan) setWelcome(true);
+ else setNotice("That checkout is not complete. Nothing was charged.");
+ });
+ })
.catch((error) => setError(error.message))
.finally(() => {
setLoading(false);
@@ -213,7 +266,6 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
const clearEnrollment = () => {
setEnrolling(null);
setError("");
- history.replaceState(null, "", session ? "/account" : "/login");
};
const removeOne = (burrowId: string) =>
act(`remove-${burrowId}`, async () => {
@@ -224,11 +276,45 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
null,
);
});
+ const buy = async (plan: Plan) => {
+ const url = await startCheckout(plan);
+ forgetCheckout();
+ location.assign(url);
+ };
+ const portal = async () => location.assign(await openPortal());
+ const founder = async (name: string | null) => {
+ await setFounder(name);
+ setBilling((summary) => summary && { ...summary, founder: name });
+ setNotice(name === null ? "You are no longer shown in the founders row." : `Shown in the founders row as ${name}.`);
+ };
+ const declineCheckout = () => {
+ forgetCheckout();
+ setBuying(undefined);
+ setError("");
+ };
const copyMinted = () =>
act("copy", async () => {
await navigator.clipboard.writeText(minted);
setNotice("Token copied. Paste it into Dormouse’s voice settings.");
});
+ // The one page this state shows, and the address it keeps.
+ const page: Page = enrolling
+ ? "enroll"
+ : buying !== undefined
+ ? "checkout"
+ : !session
+ ? "login"
+ : welcome && billing
+ ? "welcome"
+ : "account";
+ useEffect(() => {
+ if (loading) return;
+ const path =
+ page === "checkout"
+ ? `/checkout${buying ? `?plan=${buying}` : ""}`
+ : PATHS[page];
+ history.replaceState(null, "", path);
+ }, [loading, page, buying]);
const fresh =
session &&
Date.now() - Date.parse(session.session.createdAt) < LOGIN_FRESH_AGE_MS;
@@ -244,23 +330,19 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
-
- {enrolling
- ? "Approve a computer"
- : session
- ? "Your account"
- : "Sign in to Dormouse Hosted"}
-
+
{TITLES[page]}
- {enrolling
- ? !enrolling.code
+ {page === "enroll"
+ ? !enrolling!.code
? "This link cannot be approved."
: session
? "Dormouse on your computer asked to join this account."
: "Sign in to approve the computer that sent you here."
- : session
- ? "Manage how you sign in."
- : "One account for Dormouse’s hosted services."}
+ : page === "checkout"
+ ? session
+ ? "Check the plan, then pay on Stripe."
+ : `Sign in first, so your ${buying ? `${PLAN_NAMES[buying]} ` : ""}subscription belongs to your account.`
+ : INTROS[page]}
Signing in from Dormouse gives that computer its own
- token; removing the computer below revokes it. Managed
- voice is in admin-only testing.
+ token; removing the computer below revokes it.
+ This account already has {PLAN_NAMES[summary.plan]}. Change or cancel it in Manage billing.
+
,
+ ,
+ );
+ if (!price)
+ return page(
+
+ Founding has closed. {HOSTED_YEARLY.name} is ${HOSTED_YEARLY.price} a year; see{" "}
+ the plans.
+
,
+ );
+ return page(
+ <>
+
+
Plan
+
{PLAN_NAMES[plan]}
+
Price
+
{price}, in USD; tax is added where it applies
+
+
+ No trial: you pay today, cancel any time, and every plan has a 30-day
+ refund.{plan === "founding" && " The founding price stays yours while the subscription does."}
+
+ >,
+ ,
+ );
+}
+
+/** The plan line: its name and when it renews or ends. */
+function PlanLine({ summary }: { summary: BillingSummary }) {
+ if (!summary.plan)
+ return (
+
+ {summary.entitled
+ ? "Hosted is included with this account."
+ : "No plan. Hosted adds managed voices and the Hosted Relay."}{" "}
+ {!summary.entitled && See the plans}
+
+ );
+ return (
+
+
+ {PLAN_NAMES[summary.plan]}
+
+ {!summary.active
+ ? "A payment is due. Update your card in Manage billing."
+ : summary.until && `${summary.renews ? "Renews" : "Ends"} ${date(summary.until)}`}
+
+
+
+ );
+}
+
+/** The founders-row opt-in: unticked until the founder ticks it. */
+function FounderForm({
+ shown,
+ defaultName,
+ busy,
+ act,
+ onSave,
+}: Shared & {
+ shown: string | null;
+ defaultName: string;
+ onSave: (name: string | null) => Promise;
+}) {
+ const [checked, setChecked] = useState(shown !== null);
+ const [name, setName] = useState(shown ?? defaultName);
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ void act("founder", () => onSave(checked ? name.trim() : null));
+ };
+ const unchanged = checked ? name.trim() === shown : shown === null;
+ return (
+
+ );
+}
+
+const QUESTIONS: [keyof SurveyAnswers, string][] = [
+ ["tooExpensive", "At what yearly price would Hosted be too expensive to consider?"],
+ ["tooCheap", "At what price would it be so cheap you would doubt it?"],
+ ["expensive", "At what price is it getting expensive, though you would still consider it?"],
+ ["bargain", "At what price is it a bargain?"],
+];
+
+/** The four Van Westendorp questions: optional, sent only when the buyer sends them. */
+function SurveyForm({
+ busy,
+ act,
+ onSend,
+}: Shared & { onSend: (answers: SurveyAnswers) => Promise }) {
+ const [values, setValues] = useState>>({});
+ const [sent, setSent] = useState(false);
+ const answers = Object.fromEntries(
+ QUESTIONS.map(([key]) => [key, values[key] ? Number(values[key]) : null]),
+ ) as Record;
+ const answered = Object.values(answers).some((answer) => answer !== null);
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ void act("survey", async () => {
+ await onSend(answers);
+ setSent(true);
+ });
+ };
+ if (sent) return