From d03a9991ad224386ee8c1233d1501040a105e39b Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:13:48 -0700
Subject: [PATCH 01/11] Hosted billing: checkout, Stripe webhook, cohorts, and
the subscription as the entitlement
The account Worker sells monthly, yearly and founding through
@pgstencil/stripe (named plans, no trial, Managed Payments): checkout,
the return's confirm, the customer portal, the founders-row opt-in, the
Van Westendorp answers, and Stripe's signed webhook. Price ids come from
Worker vars; billing is off (503) until all five Stripe bindings are set,
and a founding ladder whose length differs from the published one fails.
The entitlement predicate is now an active subscription under status()
semantics, resolved in SQL beside each bearer, plus the verified
ADMIN_EMAIL as a standing comp. The relay and voice roles read only the
subscription columns it needs.
GET /api/hosted/cohorts answers the open cohort, its seats, and the
opted-in founders (names only), cached 60 s per isolate; the account
Worker also answers it, and nothing else, on dormouse.sh through a
pinned zone route. An hourly cron resyncs subscriptions whose period ends
within the hour, so a missed renewal webhook never lapses a member.
Co-Authored-By: Claude Opus 5.5
---
hosted/package.json | 6 +-
hosted/scripts/changed.mjs | 7 +-
hosted/scripts/changed.test.mjs | 2 +
hosted/scripts/preview-smoke.mjs | 3 +-
hosted/scripts/preview.test.mjs | 1 +
hosted/scripts/production.mjs | 18 +-
hosted/scripts/production.test.mjs | 13 +-
hosted/server/account-app.ts | 20 +-
hosted/server/account-gate.ts | 95 ++--
hosted/server/billing-routes.ts | 279 ++++++++++
hosted/server/billing.ts | 162 ++++++
hosted/server/bindings.ts | 12 +-
.../005_billing_founders.sql | 25 +
hosted/server/entitlement.ts | 22 +-
hosted/server/migrations.ts | 2 +
hosted/server/preview-worker.ts | 2 +
hosted/server/runtime-roles.sql | 6 +-
hosted/server/tests/artifacts.test.ts | 3 +-
hosted/server/tests/billing.test.ts | 481 ++++++++++++++++++
hosted/server/tests/boundary.test.ts | 47 ++
hosted/server/tests/migrations.test.ts | 1 +
hosted/server/tests/runtime-roles.test.ts | 25 +
hosted/server/tests/worker-entry.ts | 8 +-
hosted/server/worker-app.ts | 11 +-
hosted/server/worker.ts | 8 +-
hosted/wrangler.jsonc | 14 +-
26 files changed, 1204 insertions(+), 69 deletions(-)
create mode 100644 hosted/server/billing-routes.ts
create mode 100644 hosted/server/billing.ts
create mode 100644 hosted/server/dormouse-migrations/005_billing_founders.sql
create mode 100644 hosted/server/tests/billing.test.ts
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..dc52f0609 100644
--- a/hosted/scripts/changed.mjs
+++ b/hosted/scripts/changed.mjs
@@ -2,8 +2,9 @@ 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 price and cohort modules
+// billing reads. 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(
@@ -17,6 +18,8 @@ export function touchesHosted(paths) {
"pnpm-workspace.yaml",
"lib/package.json",
"lib/scripts/assert-pocket-worker.mjs",
+ "website/src/lib/hosted-pricing.ts",
+ "website/src/lib/hosted-cohorts.ts",
].includes(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..4bb9d424e 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: ["30 * * * *"] });
});
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..09845942d 100644
--- a/hosted/server/account-app.ts
+++ b/hosted/server/account-app.ts
@@ -1,5 +1,7 @@
import type { Context, ExecutionContext, Hono } from "hono";
import { queryDatabase } from "pgstencil/postgres";
+import { billingSetup, type Clock } from "./billing";
+import { COHORT_PATH, 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 +9,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`; a test entry also supplies its `clock`.
*/
export function accountApp(
fetchAuth: (
@@ -20,12 +25,14 @@ export function accountApp(
ctx: ExecutionContext,
) => Response | Promise,
bindings: (env: AccountEnv) => AccountEnv,
+ clock: Clock,
configure?: (app: Hono<{ Bindings: AccountEnv }>) => void,
) {
return workerApp({
bindings,
rules: accountRules,
unavailable: "Sign-in is temporarily unavailable. Please try again.",
+ site: { origin: SITE_ORIGIN, paths: [COHORT_PATH] },
routes(app) {
configure?.(app);
app.get("/api/ready", async (c) => {
@@ -44,15 +51,20 @@ 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),
+ clock,
});
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, clock, env.APP_ORIGIN),
});
}
diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts
index f77d1e744..0cc57b7b8 100644
--- a/hosted/server/account-gate.ts
+++ b/hosted/server/account-gate.ts
@@ -1,5 +1,5 @@
-// 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 { queryDatabase } from "pgstencil/postgres";
import { entitled } from "./entitlement";
@@ -21,6 +21,8 @@ export const accountQuery = >(
/** 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 +30,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-routes.ts b/hosted/server/billing-routes.ts
new file mode 100644
index 000000000..b8761979b
--- /dev/null
+++ b/hosted/server/billing-routes.ts
@@ -0,0 +1,279 @@
+// 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 { bodyLimit } from "hono/body-limit";
+import { queryDatabase } from "pgstencil/postgres";
+import { readJson } from "remote-lib-common";
+import { MAX_SHOWN_FOUNDERS } from "../../website/src/lib/hosted-cohorts";
+import { accountQuery, cookieLogin, type AccountHost } from "./account-gate";
+import {
+ BillingError,
+ PLANS,
+ foundingSold,
+ openCohort,
+ withBilling,
+ type BillingSetup,
+ type Clock,
+ type Plan,
+} from "./billing";
+import { entitled } 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;
+ clock: Clock;
+}
+
+/** 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;
+
+/** The webhook's and the cohort endpoint's paths. */
+export const BILLING_WEBHOOK_PATH = "/api/billing/webhook";
+export const COHORT_PATH = "/api/hosted/cohorts";
+
+const CHECKOUT_ID = /^[A-Za-z0-9_-]{1,64}$/;
+// 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;
+
+const isPlan = (value: unknown): value is Plan => PLANS.includes(value as Plan);
+
+/** Whether founder row `f` holds a current subscription on a founding Price (`$1`): `status()`'s access, in SQL. */
+const CURRENT_FOUNDER = `EXISTS (SELECT FROM pgstencil_billing.subscriptions s
+ WHERE s.owner_id = f."userId" AND s.price_id = ANY($1)
+ AND ((s.status = 'active' AND s.period_end > now())
+ OR (s.status = 'trialing' AND s.trial_end > now())))`;
+
+/**
+ * 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 = bodyLimit({
+ maxSize: 4096,
+ onError: (c) => c.json({ message: "Request too large." }, 413),
+ });
+
+ /** 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 c.json({ message: CHECKOUT_CLOSED }, 503);
+ try {
+ return await withBilling(
+ setup,
+ deployment.databaseUrl,
+ deployment.clock,
+ 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, after a resync from Stripe. */
+ const summary = (c: Context, userId: string) =>
+ billed(c, async (billing, setup) => {
+ const customer = await billing.db
+ .selectFrom("accounts")
+ .select("customer_id")
+ .where("owner_id", "=", userId)
+ .executeTakeFirst();
+ if (customer?.customer_id) await billing.reconcile(userId);
+ const status = await billing.status(userId);
+ const [founder] = await accountQuery<{ name: string }>(
+ host(c),
+ `SELECT name FROM dormouse_founders WHERE "userId" = $1`,
+ [userId],
+ );
+ return c.json({
+ plan: status.access ? status.plan : null,
+ until: status.access ? status.accessUntil : null,
+ renews: status.access && !status.subscription?.cancel_at_period_end,
+ entitled: await entitled(host(c).databaseUrl, userId),
+ founder: founder?.name ?? null,
+ founding: openCohort(await foundingSold(billing, setup)),
+ });
+ });
+
+ app.get("/api/billing", gate, (c) => summary(c, 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 (!isPlan(plan)) return c.json({ message: "Choose monthly, yearly, or founding." }, 400);
+ // Stripe's receipts and the refund promise need a mailbox.
+ if (email === null)
+ return c.json({ message: "Sign in with an email address to subscribe." }, 409);
+ 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);
+ const confirmed = await billed(c, async (billing) => {
+ await billing.confirmCheckout(userId, checkout);
+ return c.body(null, 204);
+ });
+ return confirmed.status === 204 ? summary(c, userId) : confirmed;
+ });
+
+ 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 c.json({ message: CHECKOUT_CLOSED }, 503);
+ 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,
+ bodyLimit({
+ maxSize: WEBHOOK_BODY_BYTES,
+ onError: (c) => c.json({ message: "Request too large." }, 413),
+ }),
+ 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.
+ let cohorts: { at: number; body: unknown } | undefined;
+ app.get(COHORT_PATH, async (c) => {
+ const deployment = host(c);
+ const now = deployment.clock.time.now().getTime();
+ if (cohorts && now - cohorts.at < COHORT_CACHE_MS && now >= cohorts.at)
+ return c.json(cohorts.body);
+ return billed(c, async (billing, setup) => {
+ const sold = await foundingSold(billing, setup);
+ const open = openCohort(sold);
+ const shown = await queryDatabase<{ name: string }>(
+ deployment.databaseUrl,
+ `SELECT f.name FROM dormouse_founders f WHERE ${CURRENT_FOUNDER}
+ ORDER BY f."shownSince", f."userId" LIMIT ${MAX_SHOWN_FOUNDERS}`,
+ [setup.founding],
+ );
+ const body = {
+ // 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, body };
+ return c.json(body);
+ });
+ });
+}
+
+/**
+ * The account Worker's Cron Trigger: resyncs from Stripe every subscription
+ * whose paid period or trial ends within the hour, so a missed renewal
+ * webhook never lapses a member. At most `limit` accounts a run.
+ */
+export async function reconcileDue(
+ setup: BillingSetup | null,
+ databaseUrl: string,
+ clock: Clock,
+ origin: string,
+ limit = 20,
+) {
+ if (!setup) return;
+ const due = await queryDatabase<{ owner: string }>(
+ databaseUrl,
+ `SELECT owner_id AS owner FROM pgstencil_billing.subscriptions
+ WHERE (status = 'active' AND period_end < now() + interval '1 hour')
+ OR (status = 'trialing' AND trial_end < now() + interval '1 hour')
+ GROUP BY owner_id ORDER BY min(period_end) LIMIT $1`,
+ [limit],
+ );
+ const failed: unknown[] = [];
+ await withBilling(setup, databaseUrl, clock, 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..5ff036171
--- /dev/null
+++ b/hosted/server/billing.ts
@@ -0,0 +1,162 @@
+// Rules: docs/specs/hosted.md -> "Billing".
+import { Billing, BillingError, Stripe, type BillingDB } from "@pgstencil/stripe";
+import { SecureRandom, SystemTime, type RandomSource, type Time } from "pgstencil";
+import { connectDatabase } from "pgstencil/postgres";
+import {
+ FOUNDING_COHORT_SIZE,
+ FOUNDING_LADDER,
+} from "../../website/src/lib/hosted-pricing";
+
+/** The plans checkout sells, by the names a buy link and `status()` use. */
+export const PLANS = ["monthly", "yearly", "founding"] as const;
+export type Plan = (typeof PLANS)[number];
+
+/** A refund inside this window, which also cancels, returns the seat to its cohort. */
+export const REFUND_DAYS = 30;
+
+/** Where Stripe returns the browser: checkout success and cancel, and the portal. */
+export const BILLING_RETURN_PATH = "/billing";
+
+/** 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 clock and randomness billing runs on: the system's, or a test's. */
+export interface Clock {
+ time: Time;
+ random: RandomSource;
+}
+
+/** Every deployed entry's clock. */
+export const SYSTEM_CLOCK: Clock = { time: new SystemTime(), 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[];
+}
+
+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,
+ clock: Clock,
+ origin: string,
+ action: (billing: Billing) => Promise,
+): Promise {
+ const db = connectDatabase(databaseUrl);
+ try {
+ const billing = new Billing(
+ db,
+ new Stripe(setup.secretKey, {
+ httpClient: Stripe.createFetchHttpClient(),
+ maxNetworkRetries: 1,
+ }),
+ clock.time,
+ clock.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/dormouse-migrations/005_billing_founders.sql b/hosted/server/dormouse-migrations/005_billing_founders.sql
new file mode 100644
index 000000000..1e4f95c6f
--- /dev/null
+++ b/hosted/server/dormouse-migrations/005_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..66244a880 100644
--- a/hosted/server/entitlement.ts
+++ b/hosted/server/entitlement.ts
@@ -7,14 +7,30 @@ 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'`;
+
/**
* 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 `active` before its period end or
+ * `trialing` before its trial end, read against 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}')`;
+ const owned = `FROM pgstencil_billing.subscriptions s WHERE s.owner_id = ${user}.id`;
+ return `((${user}."emailVerified" IS TRUE AND ${user}.email = '${ADMIN_EMAIL}')
+ OR ((SELECT count(*) ${owned} AND s.status IN (${CURRENT})) = 1
+ AND EXISTS (SELECT ${owned}
+ AND ((s.status = 'active' AND s.period_end > now())
+ OR (s.status = 'trialing' AND s.trial_end > now())))))`;
}
/** 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/preview-worker.ts b/hosted/server/preview-worker.ts
index ee9716657..8f2f6100a 100644
--- a/hosted/server/preview-worker.ts
+++ b/hosted/server/preview-worker.ts
@@ -1,5 +1,6 @@
import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers";
import { accountApp } from "./account-app";
+import { SYSTEM_CLOCK } from "./billing";
import { accountPreviewBindings, type AccountEnv } from "./bindings";
import { authPolicy } from "./policy";
import { postgresInbox, inboxPage, messagePage } from "./preview-inbox";
@@ -12,6 +13,7 @@ const auth = createBetterAuthWorker({
export default accountApp(
auth.fetch,
accountPreviewBindings,
+ SYSTEM_CLOCK,
(app) => {
app.get("/api/dev/emails", async (c) =>
c.json(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()),
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..20a406bc4 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,7 @@ test("Hosted declares every peer of the installed pgstencil packages", () => {
}
});
-test("the lockfile resolves both pgstencil packages from npm", () => {
+test("the lockfile resolves every pgstencil package from npm", () => {
const lockfile = readFileSync("../pnpm-lock.yaml", "utf8");
const lines = lockfile.split("\n");
for (const [name, entry] of packages) {
diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts
new file mode 100644
index 000000000..f05c77eba
--- /dev/null
+++ b/hosted/server/tests/billing.test.ts
@@ -0,0 +1,481 @@
+import { test, expect } from "vitest";
+import { fileURLToPath } from "node:url";
+import { Miniflare, Response as WorkerResponse } from "miniflare";
+import { createStripeDev } from "@pgstencil/stripe/testing";
+import { createTestContext } from "pgstencil/testing";
+import { queryDatabase } from "pgstencil/postgres";
+import { API_ROUTES, 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 { billingSetup, openCohort } from "../billing";
+import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED, COHORT_CACHE_MS, COHORT_PATH } from "../billing-routes";
+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 = FOUNDING_LADDER.map((price) => `price_founding_${price}`);
+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 = await createStripeDev(context.time, context.random, undefined, {
+ recurring: Object.fromEntries(LADDER.map((price) => [price, "year" as const])),
+ });
+ 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: "sk_test_dormouse_local_only",
+ STRIPE_WEBHOOK_SECRET: dev.webhookSecret,
+ STRIPE_PRICE_MONTHLY: dev.prices.monthly,
+ STRIPE_PRICE_YEARLY: dev.prices.yearly,
+ STRIPE_PRICES_FOUNDING: LADDER.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 } = {}) => {
+ 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.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 call(origin + "/api/auth/" + path, {
+ method: "POST",
+ headers: {
+ cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
+ "cf-connecting-ip": "203.0.113.10",
+ origin,
+ "content-type": "application/json",
+ "x-csrf-token": csrf,
+ },
+ body: JSON.stringify(body),
+ }).then((response) => {
+ 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 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: "abc" }),
+ });
+ /** 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: "30 * * * *" });
+ };
+ 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", 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);
+ 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 hourly resync before the member is refused", 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_PATH);
+ 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_PATH, { 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_PATH)).status).toBe(503);
+ expect((await off.scheduled()).outcome).toBe("ok");
+});
diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts
index 511fe0982..c3c773db5 100644
--- a/hosted/server/tests/boundary.test.ts
+++ b/hosted/server/tests/boundary.test.ts
@@ -27,6 +27,8 @@ import {
type RulesFor,
} from "../headers";
import { cookieEntitled } from "../account-gate";
+import { SITE_ORIGIN } from "../account-app";
+import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED, COHORT_PATH } 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 +128,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 +147,7 @@ const SERVED: Record = {
["GET", "/api/voice/tokens"],
["POST", "/api/voice/tokens"],
...ACCOUNT_RELAY,
+ ...ACCOUNT_BILLING,
["GET", "/login"],
],
relay: [
@@ -211,6 +225,8 @@ const ABSENT: Record = {
["POST", "/api/voice/tokens"],
["DELETE", "/api/voice/tokens/00000000-0000-4000-8000-000000000000"],
...ACCOUNT_RELAY,
+ ...ACCOUNT_BILLING,
+ ["GET", COHORT_PATH],
["POST", "/api/voice/speak"],
// The self-host installers' probe; neither a Burrow nor Pocket asks Hosted for it.
["GET", "/api/hello"],
@@ -233,6 +249,8 @@ const ABSENT: Record = {
["GET", "/login"],
...RELAY_API,
...ACCOUNT_RELAY,
+ ...ACCOUNT_BILLING,
+ ["GET", COHORT_PATH],
["GET", WS_ROUTES.burrow],
["GET", WS_ROUTES.client],
],
@@ -250,6 +268,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_PATH);
+ 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_PATH],
+ ["POST", COHORT_PATH],
+ ["GET", `${COHORT_PATH}/`],
+ ["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_PATH)).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 +409,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 +430,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..89aaf8b56 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -14,6 +14,7 @@ const PINNED: Record = {
"003_relay_push.sql": "ee8cca19bb70eb89fcba708b54d8a188347caf0f32ec23551c56d27676ab094f",
"004_relay_enrollment_redeemed.sql":
"dd36852ac3efbdc7f0dc2b9ee449f8f213c3c63982c4ab176a27e4a19e08a74d",
+ "005_billing_founders.sql": "4e9f6b8f86eec407cfc4a6864557893639e87930051a4265c3b72ac25b09679a",
"005_voice_token_burrow.sql": "ccb395314dd49f9b5c72560121994fd7a21d349580c0d746d0d380e29d0aefec",
};
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..e2f18ef0c 100644
--- a/hosted/server/tests/worker-entry.ts
+++ b/hosted/server/tests/worker-entry.ts
@@ -1,8 +1,11 @@
import { DevTime, DevRandom } from "pgstencil";
import { deterministicScope } from "@pgstencil/auth/better-auth-testing";
-import worker from "../worker";
+import { accountWorker } from "../worker";
const time = new DevTime();
-const scope = { time, random: new DevRandom("dormouse-hosted-test") };
+const random = new DevRandom("dormouse-hosted-test");
+const scope = { time, random };
+// Billing runs on the same test clock, so StripeDev's signed events verify.
+const worker = accountWorker({ time, random: new DevRandom("dormouse-hosted-billing") });
export default {
fetch(
request: Request,
@@ -19,4 +22,5 @@ export default {
worker.fetch(request, env, ctx),
);
},
+ scheduled: worker.scheduled,
};
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/server/worker.ts b/hosted/server/worker.ts
index b680decb1..fdd8b736f 100644
--- a/hosted/server/worker.ts
+++ b/hosted/server/worker.ts
@@ -1,13 +1,17 @@
import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers";
import { postmarkEmail } from "@pgstencil/auth/postmark";
import { accountApp } from "./account-app";
+import { SYSTEM_CLOCK, type Clock } from "./billing";
import { accountBindings, type AccountEnv } from "./bindings";
import { authPolicy } from "./policy";
-/** The account Worker, `dormouse-hosted` on `hosted.dormouse.sh`. */
const auth = createBetterAuthWorker({
...authPolicy,
email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM),
});
-export default accountApp(auth.fetch, accountBindings);
+/** The account Worker on `clock`; a test entry supplies its own. */
+export const accountWorker = (clock: Clock) => accountApp(auth.fetch, accountBindings, clock);
+
+/** The account Worker, `dormouse-hosted` on `hosted.dormouse.sh`. */
+export default accountWorker(SYSTEM_CLOCK);
diff --git a/hosted/wrangler.jsonc b/hosted/wrangler.jsonc
index 2538ab65a..2430368fa 100644
--- a/hosted/wrangler.jsonc
+++ b/hosted/wrangler.jsonc
@@ -12,6 +12,12 @@
{
"pattern": "hosted.dormouse.sh",
"custom_domain": true
+ },
+ // The Hosted page's cohort endpoint, same-origin on the marketing site
+ // (`SITE_ORIGIN`); the Worker answers that origin nothing else.
+ {
+ "pattern": "dormouse.sh/api/hosted/*",
+ "zone_name": "dormouse.sh"
}
],
"assets": {
@@ -68,9 +74,11 @@
}
],
"triggers": {
- // Empty, never absent: an absent `triggers` leaves a deployed schedule in
- // place, and the history sweep's moved to the voice Worker.
- "crons": []
+ // Billing's resync of subscriptions whose period ends within the hour
+ // (`reconcileDue`); a no-op while billing is off.
+ "crons": [
+ "30 * * * *"
+ ]
},
"observability": {
"enabled": false
From a61ac5d2be19da9f6b3949653ace0ddbe7f9082a Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:18:00 -0700
Subject: [PATCH 02/11] Specs and site: billing, the subscription entitlement,
and the cohort guard
hosted.md gains "Billing" (routes, bindings, the founding offer, the
webhook, the cohort endpoint and its site route, the hourly resync) and
states the entitlement as the subscription plus the ADMIN_EMAIL comp.
pricing.md promotes what is built into "Checkout and entitlement" and
keeps the account pages, the site's buy links, turning billing on, and
founder avatars under Future. security-hosted.md gets a Billing boundary,
the site-origin exception, and the Stripe package in the provenance
checks; the Hosted audit prompt asks about billing.
The page now drops seats unless the endpoint's cohort is the one its
price was prerendered at, so a stale deploy never prints the next
cohort's seats beside the old price. The README lists the Stripe setup.
Co-Authored-By: Claude Opus 5.5
---
.github/audit/hosted.md | 1 +
AGENTS.md | 2 +-
docs/specs/hosted.md | 42 ++++++++++++++++---
docs/specs/pricing.md | 56 +++++++++++++++----------
docs/specs/security-hosted.md | 24 ++++++++---
docs/specs/security-hosted.rationale.md | 11 +++++
docs/specs/security-remote.md | 2 +-
hosted/README.md | 19 ++++++++-
scripts/spec-word-budgets.json | 6 +--
website/src/lib/hosted-cohorts.ts | 10 ++++-
website/src/pages/Hosted.test.tsx | 12 +++++-
11 files changed, 142 insertions(+), 43 deletions(-)
create mode 100644 docs/specs/security-hosted.rationale.md
diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md
index 8f65a6b81..b3c6ab0b9 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 hourly 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..0fcfed70a 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,7 +53,10 @@ 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.
+
+- **An account is entitled 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.
+- **`ADMIN_EMAIL` is 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`.
@@ -211,6 +214,33 @@ 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, until, renews, entitled, founder, founding }`, resynced from Stripe |
+| `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.
+
+- **Billing is 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 and the dev loop never bill.
+- **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`. **Must refuse checkout to an account without a public email** (409). 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.
+- **The cohort endpoint answers 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`, from a per-isolate cache of `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 hourly Cron Trigger, every subscription whose paid period or trial ends within the hour**, so a missed renewal webhook never lapses a member; a failed resync fails the invocation.
+- **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/server/dormouse-migrations/005_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").
@@ -225,7 +255,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 +265,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 +281,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..c643dc5a3 100644
--- a/docs/specs/pricing.md
+++ b/docs/specs/pricing.md
@@ -3,13 +3,13 @@
> - 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, and the account Worker's billing is built and off until Stripe is configured ([Checkout and entitlement](#checkout-and-entitlement)); the account pages, the site's buy links, and turning billing on are under [Future](#future).
## The Hosted page
**`/hosted` is canonical, titled "Dormouse Hosted"; `/pricing` 301-redirects to it.** The header nav and the rail label do not change: the tool is free and open source, and Hosted is the optional service with a price, so pricing is a section of the Hosted page, never a page of its own.
-**Settings is the front door.** Its sign-in, in Notifications' managed voice and Network's Remote control, starts membership on the desktop; the playground tutorial lands on `/hosted#voice`, and the plan cards sit within one screen of that anchor. An account with no plan is linked to `#pricing` from Settings and from the baseboard alarm buttons' offers (`docs/specs/alert.md` -> "Settings dialog"). `#remote-control` and `#voice` keep resolving as section ids.
+**Settings is the front door.** The spoken-alarm row's managed-voice link and the playground tutorial land on `/hosted#voice`, and the plan cards sit within one screen of that anchor. `#remote-control` and `#voice` keep resolving as section ids.
**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`.
@@ -37,11 +37,13 @@ Three cards — Free, Hosted, Founding — side by side from `md` up, stacked in
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
@@ -61,15 +63,28 @@ Prices in USD, and the merchant of record adds or includes tax by jurisdiction.
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.
+## 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. 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.
+- **A refund or chargeback ends the subscription**, so the next request is refused, and the seat returns to its cohort.
+- **The founders-row opt-in and the four Van Westendorp answers are stored per account**: too expensive to consider, too cheap to trust, expensive but would consider, a bargain, each optional. 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. **The account pages** ("Checkout and the account pages" below) and the site's buy links into them.
+2. **Desktop sign-in** by device code ("Checkout and the account pages").
+3. **Managed voice for members**: the disclosure, one voice per Pane.
+4. **Turning billing on**: the Stripe products, Prices, portal, and webhook, then the bindings (`docs/specs/hosted.md` -> "Billing"). 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.
+5. **Founder avatars** proxied onto this origin.
+6. **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,24 +112,22 @@ 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
+### Checkout and the account pages
-- **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.
+- **A buy button lands on the account origin**, which asks for sign-in first, then shows the plan and its current price before handing off to Stripe.
- **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.
+- **The success page asks the four Van Westendorp questions**, optional and unsent until answered.
+- **A desktop signs in from Settings by device code**, the flow Burrow enrollment already runs (`docs/specs/hosted.md` -> "Burrow enrollment"). The approval mints a desktop credential the host keeps and never hands a webview. Sign-in is the only account surface in the free client.
### 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".
-
+- **Dormouse operates the endpoint and holds the vendor key** (ElevenLabs). A request carries the desktop credential, a voice id, and the text; the response is audio.
+- **What leaves the machine is exactly the sanitized spoken label and the voice id** — the `toSpokenText` output in `lib/src/lib/alert-speech.ts`, never terminal content, never a notification body, never a Session id. **Disclose this in the enable flow before the first request**, honoring the promise the Hosted page makes.
+- **Cache clips by voice and text on the client** and regenerate only when the label changes; a cache hit makes no request. **Fair use is a daily request cap per member**; past it, the system voice speaks.
+- **The system voice is the fallback**, for offline, unentitled, endpoint error, or cap: same delivery rules, same cut-off on attend, never silence because the service failed. Delivery identity, queueing, and cut-off stay owned by `docs/specs/alert.md` -> "Spoken alarms".
- **One voice per Pane.** The member default applies everywhere; a per-Pane override is persisted with the pane's settings and follows the Session through minimize and restore. Doors and headers show nothing new.
- **Pocket speaks only in the foreground** — a web app cannot voice a background push — so the desktop is the primary voice sink. A native Pocket is out of scope here.
@@ -124,9 +137,8 @@ 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
- A free hosted tier, no card. It is the only way a stock binary can try Pocket, since the shipped bundle reaches only `*.dormouse.sh` (`docs/specs/relay.md` -> "Relay origin").
-- Whether members may bring their own ElevenLabs voice id beyond the curated set.
+- The curated voice set and whether members may bring their own ElevenLabs voice id.
diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md
index 8b5cd7194..c29c50ef6 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/005_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/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 7c4c15d12..7d1ea18ed 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,13 +12,13 @@
"docs/specs/dor-tools-builtin.md": 1450,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 3000,
- "docs/specs/hosted.md": 4150,
+ "docs/specs/hosted.md": 4550,
"docs/specs/layout.md": 7450,
"docs/specs/mobile-terminal-ui.md": 1550,
"docs/specs/mouse-and-clipboard.md": 4300,
"docs/specs/one-time.md": 2900,
"docs/specs/pocket-app.md": 3450,
- "docs/specs/pricing.md": 1900,
+ "docs/specs/pricing.md": 2150,
"docs/specs/relay.md": 5700,
"docs/specs/remote-api.md": 4550,
"docs/specs/remote-network.md": 2400,
@@ -26,7 +26,7 @@
"docs/specs/reopen.md": 1300,
"docs/specs/security-audit.md": 1900,
"docs/specs/security-ci.md": 2750,
- "docs/specs/security-hosted.md": 2500,
+ "docs/specs/security-hosted.md": 2800,
"docs/specs/security-local.md": 3750,
"docs/specs/security-remote.md": 5700,
"docs/specs/security-supply-chain.md": 1300,
diff --git a/website/src/lib/hosted-cohorts.ts b/website/src/lib/hosted-cohorts.ts
index c51313fb8..0bcba1598 100644
--- a/website/src/lib/hosted-cohorts.ts
+++ b/website/src/lib/hosted-cohorts.ts
@@ -13,7 +13,9 @@
* prerendered, so a provider outage costs the page nothing it sells.
*/
-/** Where both come from, in one request. Not deployed until checkout ships. */
+import { FOUNDING_COHORTS_CLOSED } from "./hosted-pricing";
+
+/** Where both come from, in one request: the account Worker's route on this origin. */
export const COHORT_ENDPOINT = "/api/hosted/cohorts";
/** The most avatars the row draws; every other founder joins the `+N`. */
@@ -79,7 +81,11 @@ export async function fetchCohort(signal?: AbortSignal): Promise {
const body: unknown = await response.json();
if (typeof body !== "object" || body === null) return { seatsLeft: null, founders: null };
const fields = body as Record;
- return { seatsLeft: count(fields.seatsLeft) ?? null, founders: founders(fields.founders) };
+ // Seats belong to the cohort the server names. One other than the
+ // prerendered price's means a cohort closed since this deploy, and its
+ // seats are not the ones beside the printed price.
+ const seatsLeft = fields.cohort === FOUNDING_COHORTS_CLOSED ? count(fields.seatsLeft) : undefined;
+ return { seatsLeft: seatsLeft ?? null, founders: founders(fields.founders) };
} catch {
return { seatsLeft: null, founders: null };
}
diff --git a/website/src/pages/Hosted.test.tsx b/website/src/pages/Hosted.test.tsx
index 239363a20..9831b1454 100644
--- a/website/src/pages/Hosted.test.tsx
+++ b/website/src/pages/Hosted.test.tsx
@@ -18,6 +18,7 @@ const { default: Hosted } = await import("./Hosted");
const { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } = await import("../lib/hosted-cohorts");
const {
FOUNDING_COHORT_SIZE,
+ FOUNDING_COHORTS_CLOSED,
HOSTED_MONTHLY,
HOSTED_YEARLY,
LIST_ANNUAL,
@@ -150,10 +151,19 @@ const mountCohort = (body: unknown) =>
describe("the founding card's live half", () => {
it("fills in the seats left after hydration", async () => {
- const el = await mountCohort({ seatsLeft: 73 });
+ const el = await mountCohort({ cohort: FOUNDING_COHORTS_CLOSED, seatsLeft: 73 });
expect(el.textContent).toContain(`73 of ${FOUNDING_COHORT_SIZE} seats left at $${foundingTier().price}`);
});
+ // A cohort closed since the deploy: the seats are the next price's, not this one's.
+ it.each([FOUNDING_COHORTS_CLOSED + 1, undefined, String(FOUNDING_COHORTS_CLOSED)])(
+ "drops seats of cohort %j, not the prerendered price's",
+ async (cohort) => {
+ const el = await mountCohort({ cohort, seatsLeft: 73 });
+ expect(el.textContent).not.toContain("seats left");
+ },
+ );
+
it("draws opted-in founders, then +N for everyone else", async () => {
const el = await mountCohort({
founders: { total: 12, shown: [{ name: "Ada", avatar: "/api/hosted/founders/ada.png" }, { name: "kim" }] },
From 5f8cdd934ec7c7fd57a03fd44b854eb6bb7dd926 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:53:15 -0700
Subject: [PATCH 03/11] Billing: an account without a public email checks out,
Checkout collecting one
pgstencil's checkout now takes a null email (named-plans dab7292), so a
provider-only account or a GitHub login with a private address can buy:
the Stripe customer is created without one and Checkout asks the buyer.
Co-Authored-By: Claude Opus 5.5
---
docs/specs/hosted.md | 2 +-
hosted/server/billing-routes.ts | 4 +--
hosted/server/billing.ts | 11 ++++---
hosted/server/tests/billing.test.ts | 46 ++++++++++++++++++++++++++++-
4 files changed, 54 insertions(+), 9 deletions(-)
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index 0fcfed70a..b07b50a2c 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -232,7 +232,7 @@ The account Worker sells the plans `monthly`, `yearly`, and `founding` through `
Errors are JSON `{ message }`; the cookie routes answer 401 without a login and need no entitlement.
- **Billing is 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 and the dev loop never bill.
-- **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`. **Must refuse checkout to an account without a public email** (409). A checkout left open for another plan is expired first.
+- **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.
- **The cohort endpoint answers 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`, from a per-isolate cache of `COHORT_CACHE_MS`. **Must answer `SITE_ORIGIN` (`https://dormouse.sh`) this one `GET` path and 421 every other**, through the zone route `PRODUCTION` pins.
diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts
index b8761979b..1cabca1cc 100644
--- a/hosted/server/billing-routes.ts
+++ b/hosted/server/billing-routes.ts
@@ -119,9 +119,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
const { userId, email } = c.get("login");
const plan = (await readJson<{ plan?: unknown }>(c))?.plan;
if (!isPlan(plan)) return c.json({ message: "Choose monthly, yearly, or founding." }, 400);
- // Stripe's receipts and the refund promise need a mailbox.
- if (email === null)
- return c.json({ message: "Sign in with an email address to subscribe." }, 409);
+ // 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
diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts
index 5ff036171..be660b87a 100644
--- a/hosted/server/billing.ts
+++ b/hosted/server/billing.ts
@@ -45,6 +45,8 @@ export interface BillingSetup {
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}$/;
@@ -126,10 +128,11 @@ export async function withBilling(
try {
const billing = new Billing(
db,
- new Stripe(setup.secretKey, {
- httpClient: Stripe.createFetchHttpClient(),
- maxNetworkRetries: 1,
- }),
+ setup.stripe ??
+ new Stripe(setup.secretKey, {
+ httpClient: Stripe.createFetchHttpClient(),
+ maxNetworkRetries: 1,
+ }),
clock.time,
clock.random,
{
diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts
index f05c77eba..a3740b357 100644
--- a/hosted/server/tests/billing.test.ts
+++ b/hosted/server/tests/billing.test.ts
@@ -1,4 +1,5 @@
import { test, expect } from "vitest";
+import { Hono } from "hono";
import { fileURLToPath } from "node:url";
import { Miniflare, Response as WorkerResponse } from "miniflare";
import { createStripeDev } from "@pgstencil/stripe/testing";
@@ -8,7 +9,13 @@ import { API_ROUTES, 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 { billingSetup, openCohort } from "../billing";
-import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED, COHORT_CACHE_MS, COHORT_PATH } from "../billing-routes";
+import {
+ BILLING_WEBHOOK_PATH,
+ CHECKOUT_CLOSED,
+ COHORT_CACHE_MS,
+ COHORT_PATH,
+ billingRoutes,
+} from "../billing-routes";
import { migrations } from "../migrations";
import {
ORIGINS,
@@ -479,3 +486,40 @@ test("the webhook takes only Stripe's signed body; checkout takes only this orig
expect((await off.call(SITE_ORIGIN + COHORT_PATH)).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 = await createStripeDev(context.time, context.random, undefined, {
+ recurring: Object.fromEntries(LADDER.map((price) => [price, "year" as const])),
+ });
+ 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: () => ({
+ secretKey: "sk_test_dormouse_local_only",
+ webhookSecret: dev.webhookSecret,
+ live: false,
+ monthly: dev.prices.monthly,
+ yearly: dev.prices.yearly,
+ founding: LADDER,
+ stripe: dev.stripe,
+ }),
+ clock: context,
+ }));
+ 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",
+ });
+});
From 6cb66f1ba83187b19c347b7e21c55d8ac08c66c1 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:30:59 -0700
Subject: [PATCH 04/11] Hosted account pages: checkout, the welcome page, and
the Plan panel, on StripeDev in the dev loop
`/checkout?plan=` signs in first, shows the plan and its price now (the
founding step from the server's open cohort), then hands off to Stripe.
Stripe's return to `/billing?checkout=` confirms the checkout and shows
the welcome page: the founders-row opt-in, unticked, and the four Van
Westendorp questions, sent only when the buyer sends them. The account
page gains a Plan section with Manage billing and the founders toggle.
A pending checkout's plan name rides provider sign-in in session storage.
`dor tool hosted` now bills against StripeDev (no card, no charge), and
the dev guard admits StripeDev's one cross-site navigation back to
/billing.
Co-Authored-By: Claude Opus 5.5
---
hosted/server/dev-host-guard.ts | 15 +-
hosted/server/dev.ts | 28 ++-
hosted/server/tests/policy.test.ts | 11 +-
hosted/src/App.tsx | 141 +++++++++++--
hosted/src/Billing.tsx | 318 +++++++++++++++++++++++++++++
hosted/src/api.ts | 97 +++++++++
hosted/src/checkout.ts | 47 +++++
hosted/src/main.tsx | 5 +-
hosted/src/style.css | 21 ++
9 files changed, 662 insertions(+), 21 deletions(-)
create mode 100644 hosted/src/Billing.tsx
create mode 100644 hosted/src/checkout.ts
diff --git a/hosted/server/dev-host-guard.ts b/hosted/server/dev-host-guard.ts
index 896f4d31b..689b1e26d 100644
--- a/hosted/server/dev-host-guard.ts
+++ b/hosted/server/dev-host-guard.ts
@@ -1,13 +1,22 @@
import type { IncomingMessage } from "node:http";
+/** Stripe's return path, which StripeDev's page on 127.0.0.1 navigates back to. */
+const STRIPE_RETURN = "/billing";
+
// 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,
+ // which serves the page 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 === STRIPE_RETURN
);
}
diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts
index 1634f5503..09f2cfbac 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";
@@ -7,6 +8,10 @@ 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 { createStripeDev } from "@pgstencil/stripe/testing";
+import { FOUNDING_LADDER } from "../../website/src/lib/hosted-pricing";
+import { SYSTEM_CLOCK, type BillingSetup } from "./billing";
+import { BILLING_WEBHOOK_PATH, billingRoutes } from "./billing-routes";
import { authPolicy } from "./policy";
import { migrations } from "./migrations";
import { allowedDevRequest } from "./dev-host-guard";
@@ -70,6 +75,26 @@ const host: RelayAccountHost = {
};
voiceTokenRoutes(app, () => host);
relayAccountRoutes(app, () => host);
+// Billing against StripeDev: a local Stripe stand-in whose checkout page takes
+// no card and charges nothing, its state beside the development database.
+const founding = FOUNDING_LADDER.map((price) => `price_dev_founding_${price}`);
+const stripeDev = await createStripeDev(
+ SYSTEM_CLOCK.time,
+ SYSTEM_CLOCK.random,
+ fileURLToPath(new URL("../.pgstencil/stripe-dev.json", import.meta.url)),
+ { recurring: Object.fromEntries(founding.map((price) => [price, "year" as const])) },
+);
+stripeDev.setWebhookTarget(origin + BILLING_WEBHOOK_PATH);
+const billing: BillingSetup = {
+ secretKey: "sk_test_dormouse_local_only",
+ webhookSecret: stripeDev.webhookSecret,
+ live: false,
+ monthly: stripeDev.prices.monthly,
+ yearly: stripeDev.prices.yearly,
+ founding,
+ stripe: stripeDev.stripe,
+};
+billingRoutes(app, () => ({ ...host, setup: () => billing, clock: SYSTEM_CLOCK }));
// 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 +113,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 +137,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/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/src/App.tsx b/hosted/src/App.tsx
index 2b808d907..5f21f054c 100644
--- a/hosted/src/App.tsx
+++ b/hosted/src/App.tsx
@@ -7,27 +7,47 @@ 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 { takeEnrollment, type Enrollment } from "./enrollment";
+import { CheckoutView, PLAN_NAMES, PlanSection, WelcomeView } from "./Billing";
+import { forgetCheckout } from "./checkout";
-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([]);
@@ -43,6 +63,20 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
enrollingRef.current = next;
setEnrollingState(next);
};
+ // Null while this deployment does not sell, or before sign-in.
+ const [billing, setBilling] = useState(null);
+ const [buying, setBuyingState] = useState(checkout);
+ // Set once Stripe's return is confirmed: the welcome page shows.
+ const [welcome, setWelcomeState] = useState(false);
+ const pageRef = useRef({ buying: checkout, welcome: false });
+ const setBuying = (next: Plan | null | undefined) => {
+ pageRef.current.buying = next;
+ setBuyingState(next);
+ };
+ const setWelcome = (next: boolean) => {
+ pageRef.current.welcome = next;
+ setWelcomeState(next);
+ };
const [minted, setMinted] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
@@ -65,29 +99,49 @@ 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);
+ setBilling(plan);
+ const { buying, welcome } = pageRef.current;
history.replaceState(
null,
"",
- enrollingRef.current ? "/enroll" : current ? "/account" : "/login",
+ enrollingRef.current
+ ? "/enroll"
+ : buying !== undefined
+ ? `/checkout${buying ? `?plan=${buying}` : ""}`
+ : welcome
+ ? "/billing"
+ : current
+ ? "/account"
+ : "/login",
);
}, []);
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 () => {
+ setBilling(await confirmCheckout(returned));
+ setWelcome(true);
+ history.replaceState(null, "", "/billing");
+ });
+ })
.catch((error) => setError(error.message))
.finally(() => {
setLoading(false);
@@ -224,6 +278,27 @@ 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("");
+ history.replaceState(null, "", session ? "/account" : "/login");
+ };
+ const leaveWelcome = () => {
+ setWelcome(false);
+ history.replaceState(null, "", "/account");
+ };
const copyMinted = () =>
act("copy", async () => {
await navigator.clipboard.writeText(minted);
@@ -247,9 +322,13 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
{enrolling
? "Approve a computer"
- : session
- ? "Your account"
- : "Sign in to Dormouse Hosted"}
+ : buying !== undefined
+ ? "Subscribe to Dormouse Hosted"
+ : welcome && session
+ ? "Welcome to Dormouse Hosted"
+ : session
+ ? "Your account"
+ : "Sign in to Dormouse Hosted"}
{enrolling
@@ -258,9 +337,15 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
: 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."}
+ : buying !== undefined
+ ? session
+ ? "Check the plan, then pay on Stripe."
+ : `Sign in first, so your ${buying ? `${PLAN_NAMES[buying]} ` : ""}subscription belongs to your account.`
+ : welcome && session
+ ? "Your subscription is active."
+ : session
+ ? "Manage your plan and how you sign in."
+ : "One account for Dormouse’s hosted services."}
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.
+ Founding has closed. {HOSTED_YEARLY.name} is ${HOSTED_YEARLY.price} a year;
+ see the plans.
+
+ {decline}
+
+ );
+ return (
+
+
+
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."}
+
+
+ {decline}
+
+ );
+}
+
+/** 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}
+
+
+ {summary.plan && (
+
+ )}
+ {summary.plan === "founding" && (
+
+ )}
+
+ );
+}
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index add0043c1..2fd5537df 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -148,3 +148,100 @@ export async function approveEnrollment(userCode: string) {
});
if (!response.ok) throw await refused(response);
}
+
+export const PLANS = ["monthly", "yearly", "founding"] as const;
+export type Plan = (typeof PLANS)[number];
+export const isPlan = (value: unknown): value is Plan =>
+ PLANS.includes(value as Plan);
+
+/** `GET /api/billing`: the account's plan (docs/specs/hosted.md -> "Billing"). */
+export interface BillingSummary {
+ plan: Plan | null;
+ /** When the paid period ends. */
+ until: string | null;
+ /** False once cancelled to end at `until`. */
+ renews: boolean;
+ entitled: boolean;
+ /** The name shown in the founders row, or null when not shown. */
+ founder: string | null;
+ /** The open founding cohort, null once founding has closed. */
+ founding: { cohort: number; seatsLeft: number } | null;
+}
+/** The four Van Westendorp answers, whole dollars a year, each optional. */
+export interface SurveyAnswers {
+ tooExpensive: number | null;
+ tooCheap: number | null;
+ expensive: number | null;
+ bargain: number | null;
+}
+
+/** Billing's own call: its 503 says checkout is not open, in words for this page. */
+async function billing(path: string, init: RequestInit = {}): Promise {
+ let response: Response;
+ try {
+ response = await fetch(`/api/billing${path}`, {
+ ...init,
+ credentials: "same-origin",
+ cache: "no-store",
+ headers: init.body ? { "content-type": "application/json" } : undefined,
+ });
+ } catch {
+ throw new Error("Could not connect. Check your connection and try again.");
+ }
+ if (!response.ok) throw await refused(response);
+ return response;
+}
+/** A Stripe page to send the browser to: https, or StripeDev on loopback in the dev loop. */
+function stripePage(url: string): string {
+ const page = new URL(url);
+ if (
+ page.protocol !== "https:" &&
+ !(location.protocol === "http:" && page.hostname === "127.0.0.1")
+ )
+ throw failed();
+ return page.href;
+}
+/** Null while this deployment does not sell. */
+export async function getBilling(): Promise {
+ const response = await fetch("/api/billing", {
+ credentials: "same-origin",
+ cache: "no-store",
+ }).catch(() => null);
+ if (!response || response.status === 503 || response.status === 401) return null;
+ if (!response.ok) throw await refused(response);
+ return (await response.json()) as BillingSummary;
+}
+/** The open founding cohort, read from the public endpoint; null when unknown or closed. */
+export async function getCohort(): Promise {
+ const response = await fetch("/api/hosted/cohorts", { cache: "no-store" }).catch(() => null);
+ if (!response?.ok) return null;
+ const { cohort } = (await response.json()) as { cohort?: unknown };
+ return typeof cohort === "number" ? cohort : null;
+}
+export async function startCheckout(plan: Plan): Promise {
+ const response = await billing("/checkout", {
+ method: "POST",
+ body: JSON.stringify({ plan }),
+ });
+ return stripePage(((await response.json()) as { url: string }).url);
+}
+export async function confirmCheckout(checkout: string): Promise {
+ const response = await billing("/confirm", {
+ method: "POST",
+ body: JSON.stringify({ checkout }),
+ });
+ return (await response.json()) as BillingSummary;
+}
+export async function openPortal(): Promise {
+ const response = await billing("/portal", { method: "POST" });
+ return stripePage(((await response.json()) as { url: string }).url);
+}
+export async function setFounder(name: string | null) {
+ await billing("/founder", {
+ method: "PUT",
+ body: JSON.stringify(name === null ? { shown: false } : { shown: true, name }),
+ });
+}
+export async function sendSurvey(answers: SurveyAnswers) {
+ await billing("/survey", { method: "PUT", body: JSON.stringify(answers) });
+}
diff --git a/hosted/src/checkout.ts b/hosted/src/checkout.ts
new file mode 100644
index 000000000..c1b2c4b32
--- /dev/null
+++ b/hosted/src/checkout.ts
@@ -0,0 +1,47 @@
+import { isPlan, type Plan } from "./api";
+
+// The plan a `/checkout?plan=` link asks for survives provider sign-in, which
+// leaves the page, in this tab's session storage: a plan name, nothing else.
+const KEY = "dormouse-hosted-checkout";
+
+function remember(plan: Plan | null) {
+ try {
+ if (plan) sessionStorage.setItem(KEY, plan);
+ else sessionStorage.removeItem(KEY);
+ } catch {
+ // Without storage, provider sign-in returns to the account page instead.
+ }
+}
+
+/**
+ * The checkout this load asks for: a plan from a `/checkout?plan=` link (null
+ * for a link naming no plan sold), or one a provider sign-in carried back to
+ * `/account`; undefined when none is pending.
+ */
+export function takeCheckout(): Plan | null | undefined {
+ const { pathname, search } = location;
+ if (pathname === "/checkout") {
+ const plan = new URLSearchParams(search).get("plan");
+ const sold = isPlan(plan) ? plan : null;
+ remember(sold);
+ return sold;
+ }
+ if (pathname !== "/account") return undefined;
+ try {
+ const plan = sessionStorage.getItem(KEY);
+ return isPlan(plan) ? plan : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Ends a pending checkout: bought, declined, or never valid. */
+export const forgetCheckout = () => remember(null);
+
+/** The checkout operation Stripe's return names (`/billing?checkout=`), taken off the address bar. */
+export function takeReturn(): string | null {
+ if (location.pathname !== "/billing") return null;
+ const checkout = new URLSearchParams(location.search).get("checkout");
+ history.replaceState(null, "", "/billing");
+ return checkout;
+}
diff --git a/hosted/src/main.tsx b/hosted/src/main.tsx
index 19476fb3f..f74e254a8 100644
--- a/hosted/src/main.tsx
+++ b/hosted/src/main.tsx
@@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
import { applyTheme } from "../../lib/src/lib/themes/apply";
import { getBundledThemes } from "../../lib/src/lib/themes/store";
import { App } from "./App";
+import { takeCheckout, takeReturn } from "./checkout";
import { takeEnrollment } from "./enrollment";
import "./style.css";
@@ -18,6 +19,8 @@ restoreTheme();
preference.addEventListener("change", restoreTheme);
// Taken before anything renders; a later fragment change on `/enroll` is App's.
const enrollment = takeEnrollment();
+const checkout = takeCheckout();
+const returned = takeReturn();
createRoot(document.getElementById("root")!).render(
- ,
+ ,
);
diff --git a/hosted/src/style.css b/hosted/src/style.css
index 29af47541..16559f16b 100644
--- a/hosted/src/style.css
+++ b/hosted/src/style.css
@@ -266,3 +266,24 @@ footer span {
gap: 8px;
}
}
+.check {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 44px;
+ margin: 8px 0 0;
+}
+.check input {
+ width: auto;
+ min-height: 0;
+}
+.founder button,
+form > button:last-child {
+ margin-top: 12px;
+}
+.terms {
+ margin-top: 16px;
+}
+.check input {
+ accent-color: var(--vscode-focusBorder);
+}
From d1a738aa26d7bd8c363174392fff8fc273120af0 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:32:27 -0700
Subject: [PATCH 05/11] Website: buy buttons link to checkout behind
CHECKOUT_OPEN, left false
`CHECKOUT_OPEN` in hosted-pricing.ts decides both what a buy button does
(the unbuilt-checkout notice, or a link to the account origin's
/checkout?plan= by checkout's plan names) and the offers' JSON-LD
availability (PreOrder or InStock). It ships false. The founding card
promises a name, not an avatar, in the founders row.
The specs promote the account pages and the buy links; desktop sign-in
and turning billing on stay under Future.
Co-Authored-By: Claude Opus 5.5
---
docs/specs/hosted.md | 7 ++++---
docs/specs/pricing.md | 29 ++++++++++++-------------
hosted/src/Billing.tsx | 6 +++---
scripts/spec-word-budgets.json | 2 +-
website/src/lib/hosted-pricing.ts | 26 ++++++++++++++++++-----
website/src/pages/Hosted.test.tsx | 35 +++++++++++++++++++++++++++++--
website/src/pages/Hosted.tsx | 27 +++++++++++++++++-------
7 files changed, 94 insertions(+), 38 deletions(-)
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index b07b50a2c..a188fcedf 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -231,19 +231,20 @@ The account Worker sells the plans `monthly`, `yearly`, and `founding` through `
Errors are JSON `{ message }`; the cookie routes answer 401 without a login and need no entitlement.
-- **Billing is 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 and the dev loop never bill.
+- **Billing is 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.
- **The cohort endpoint answers 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`, from a per-isolate cache of `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 hourly Cron Trigger, every subscription whose paid period or trial ends within the hour**, so a missed renewal webhook never lapses a member; a failed resync fails the invocation.
+- **The account pages** are `/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), and the account page's Plan section. **May keep a pending checkout's plan name, and nothing else, in the tab's session storage**, so a provider sign-in returns to it.
- **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/server/dormouse-migrations/005_billing_founders.sql`. Pinned by `hosted/server/tests/billing.test.ts`.
+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/005_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.
diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md
index c643dc5a3..4cfbcb3b9 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 the account Worker's billing is built and off until Stripe is configured ([Checkout and entitlement](#checkout-and-entitlement)); the account pages, the site's buy links, and turning billing on are under [Future](#future).
+> - **Status:** the Hosted page publishes the plans and the FAQ, and checkout, the account pages, and the subscription entitlement are built and off until Stripe is configured ([Checkout and entitlement](#checkout-and-entitlement)); turning billing on and desktop sign-in are 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,7 +31,7 @@ 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
@@ -61,30 +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. The browser names a plan, never a Price.
+- **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.
- **A refund or chargeback ends the subscription**, so the next request is refused, and the seat returns to its cohort.
-- **The founders-row opt-in and the four Van Westendorp answers are stored per account**: too expensive to consider, too cheap to trust, expensive but would consider, a bargain, each optional. Their answers inform later list changes.
+- **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 account pages** ("Checkout and the account pages" below) and the site's buy links into them.
-2. **Desktop sign-in** by device code ("Checkout and the account pages").
-3. **Managed voice for members**: the disclosure, one voice per Pane.
-4. **Turning billing on**: the Stripe products, Prices, portal, and webhook, then the bindings (`docs/specs/hosted.md` -> "Billing"). 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.
-5. **Founder avatars** proxied onto this origin.
-6. **Renewal, cancellation, and refund** paths.
+1. **Desktop sign-in** by device code ("Desktop sign-in").
+2. **Managed voice for members**: the disclosure, one voice per Pane.
+3. **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.
+4. **Founder avatars** proxied onto this origin.
+5. **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).
@@ -115,11 +115,8 @@ What each plan grants once checkout can sell it; [Published prices](#published-p
- **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 the account pages
+### Desktop sign-in
-- **A buy button lands on the account origin**, which asks for sign-in first, then shows the plan and its current price before handing off to Stripe.
-- **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.
- **A desktop signs in from Settings by device code**, the flow Burrow enrollment already runs (`docs/specs/hosted.md` -> "Burrow enrollment"). The approval mints a desktop credential the host keeps and never hands a webview. Sign-in is the only account surface in the free client.
### Managed voice
diff --git a/hosted/src/Billing.tsx b/hosted/src/Billing.tsx
index fa63bb497..cafa57cf5 100644
--- a/hosted/src/Billing.tsx
+++ b/hosted/src/Billing.tsx
@@ -7,9 +7,9 @@ import {
} from "../../website/src/lib/hosted-pricing";
import type { BillingSummary, Plan, SurveyAnswers } from "./api";
-// The account pages billing adds (docs/specs/pricing.md -> "Checkout and the
-// account pages"). Prices come from the website's one owner of them; the
-// founding step from the server's open cohort.
+// The account pages billing adds (docs/specs/hosted.md -> "Billing"). Prices
+// come from the website's one owner of them; the founding step from the
+// server's open cohort.
export const PLAN_NAMES: Record = {
monthly: HOSTED_MONTHLY.name,
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 7d1ea18ed..d84cf095f 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,7 +12,7 @@
"docs/specs/dor-tools-builtin.md": 1450,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 3000,
- "docs/specs/hosted.md": 4550,
+ "docs/specs/hosted.md": 4650,
"docs/specs/layout.md": 7450,
"docs/specs/mobile-terminal-ui.md": 1550,
"docs/specs/mouse-and-clipboard.md": 4300,
diff --git a/website/src/lib/hosted-pricing.ts b/website/src/lib/hosted-pricing.ts
index a30e739ac..2e1e95a1b 100644
--- a/website/src/lib/hosted-pricing.ts
+++ b/website/src/lib/hosted-pricing.ts
@@ -49,6 +49,16 @@ export const FOUNDING_LADDER: readonly number[] = Array.from(
*/
export const FOUNDING_COHORTS_CLOSED = 0;
+/**
+ * Whether the buy buttons link to checkout on the Hosted account origin.
+ * False keeps the unbuilt-checkout notice and `PreOrder` offers; flip it once
+ * billing is on (docs/specs/hosted.md -> "Billing").
+ */
+export const CHECKOUT_OPEN = false;
+
+/** Where a buy button sends the buyer: the account origin's checkout page. */
+export const CHECKOUT_PAGE = "https://hosted.dormouse.sh/checkout";
+
/** One purchasable plan, at the price it is on sale at today. */
export type Tier = {
id: "monthly" | "annual" | "founding";
@@ -93,6 +103,12 @@ export function foundingTier(): Tier {
};
}
+/** The checkout link for `tier`, by the plan name checkout sells it under. */
+export function checkoutUrl(tier: Tier): string {
+ const plan = tier.id === "annual" ? "yearly" : tier.id;
+ return `${CHECKOUT_PAGE}?plan=${plan}`;
+}
+
/** Every paid plan on sale, in the order the page shows them. */
export function tiersOnSale(): Tier[] {
return [HOSTED_MONTHLY, HOSTED_YEARLY, foundingTier()];
@@ -103,11 +119,11 @@ export function tiersOnSale(): Tier[] {
* page prints.
*
* Prerendered with the rest of the prices so an assistant fetching the page
- * can quote them without running the counters. Availability is `PreOrder`
- * until checkout opens — the buttons explain what is left to build, so
- * claiming `InStock` here would be the page's only false statement.
+ * can quote them without running the counters. Availability follows
+ * `checkoutOpen`: `PreOrder` while the buttons explain what is left to build,
+ * since claiming `InStock` then would be the page's only false statement.
*/
-export function pricingJsonLd(pageUrl: string): string {
+export function pricingJsonLd(pageUrl: string, checkoutOpen = CHECKOUT_OPEN): string {
const data = {
"@context": "https://schema.org",
"@type": "Product",
@@ -123,7 +139,7 @@ export function pricingJsonLd(pageUrl: string): string {
price: String(tier.price),
priceCurrency: "USD",
url: `${pageUrl}#pricing`,
- availability: "https://schema.org/PreOrder",
+ availability: `https://schema.org/${checkoutOpen ? "InStock" : "PreOrder"}`,
priceSpecification: {
"@type": "UnitPriceSpecification",
price: String(tier.price),
diff --git a/website/src/pages/Hosted.test.tsx b/website/src/pages/Hosted.test.tsx
index 9831b1454..362e9abbf 100644
--- a/website/src/pages/Hosted.test.tsx
+++ b/website/src/pages/Hosted.test.tsx
@@ -17,12 +17,15 @@ vi.mock("dormouse-lib/lib/themes", () => ({
const { default: Hosted } = await import("./Hosted");
const { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } = await import("../lib/hosted-cohorts");
const {
+ CHECKOUT_OPEN,
+ CHECKOUT_PAGE,
FOUNDING_COHORT_SIZE,
FOUNDING_COHORTS_CLOSED,
HOSTED_MONTHLY,
HOSTED_YEARLY,
LIST_ANNUAL,
foundingTier,
+ pricingJsonLd,
tiersOnSale,
} = await import("../lib/hosted-pricing");
@@ -32,13 +35,13 @@ let root: Root | null = null;
let container: HTMLDivElement | null = null;
/** Mounts the page with `fetch` answering the seat endpoint however `respond` says. */
-async function mount(respond: (url: string) => Promise) {
+async function mount(respond: (url: string) => Promise, checkoutOpen?: boolean) {
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => respond(String(input))));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
- root?.render();
+ root?.render();
});
return container;
}
@@ -237,6 +240,34 @@ describe("the buy buttons", () => {
expect(dialog?.textContent).toContain("You have not been charged");
});
+ it("stay closed in the shipped build", () => {
+ // Flipped only once billing is on; the notice and `PreOrder` go with it.
+ expect(CHECKOUT_OPEN).toBe(false);
+ });
+
+ it("link to checkout on the account origin once it opens, by checkout's plan names", async () => {
+ const el = await mount(async () => new Response("{}", { status: 404 }), true);
+ const links = [...el.querySelectorAll('a[aria-label^="Buy "]')].map((a) => a.getAttribute("href"));
+ expect(links).toEqual([`${CHECKOUT_PAGE}?plan=monthly`, `${CHECKOUT_PAGE}?plan=founding`]);
+ expect(buyButtons(el)).toHaveLength(0);
+ const yearly = [...el.querySelectorAll("button")].find((b) => b.textContent === "Yearly")!;
+ await act(async () => {
+ yearly.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(el.querySelector('[aria-label="Buy Hosted yearly"]')?.getAttribute("href")).toBe(
+ `${CHECKOUT_PAGE}?plan=yearly`,
+ );
+ });
+
+ it("make the offers InStock only once checkout opens", () => {
+ const availability = (open: boolean) =>
+ JSON.parse(pricingJsonLd("https://dormouse.sh/hosted", open)).offers.map(
+ (offer: { availability: string }) => offer.availability,
+ );
+ expect(new Set(availability(false))).toEqual(new Set(["https://schema.org/PreOrder"]));
+ expect(new Set(availability(true))).toEqual(new Set(["https://schema.org/InStock"]));
+ });
+
it("close on Escape", async () => {
await openFirst();
await act(async () => {
diff --git a/website/src/pages/Hosted.tsx b/website/src/pages/Hosted.tsx
index 88074b54e..d706f9caf 100644
--- a/website/src/pages/Hosted.tsx
+++ b/website/src/pages/Hosted.tsx
@@ -40,10 +40,12 @@ import {
type Founders,
} from "../lib/hosted-cohorts";
import {
+ CHECKOUT_OPEN,
FOUNDING_COHORT_SIZE,
HOSTED_MONTHLY,
HOSTED_YEARLY,
YEARLY_SAVING,
+ checkoutUrl,
foundingTier,
pricingJsonLd,
type Tier,
@@ -156,11 +158,20 @@ function Price({ tier }: { tier: Pick }) {
);
}
+/** What a buy button does: open the unbuilt-checkout notice, or null to link to checkout. */
+type OnBuy = ((tier: Tier) => void) | null;
+
/**
* "Buy" plus what it buys on the label a screen reader reads, since it hears
* the buttons out of their cards.
*/
-function BuyButton({ tier, label, onBuy }: { tier: Tier; label: string; onBuy: (tier: Tier) => void }) {
+function BuyButton({ tier, label, onBuy }: { tier: Tier; label: string; onBuy: OnBuy }) {
+ if (!onBuy)
+ return (
+
+ {label}
+
+ );
return (
- ) : buying !== undefined && session ? (
+ ) : page === "checkout" && session ? (
- ) : welcome && session && billing ? (
+ ) : page === "welcome" ? (
sendSurvey(answers)}
- onDone={leaveWelcome}
+ onSurvey={sendSurvey}
+ onDone={() => setWelcome(false)}
/>
) : session ? (
<>
diff --git a/hosted/src/Billing.tsx b/hosted/src/Billing.tsx
index cafa57cf5..ac75ced59 100644
--- a/hosted/src/Billing.tsx
+++ b/hosted/src/Billing.tsx
@@ -1,9 +1,10 @@
-import { useState, type FormEvent } from "react";
+import { useState, type FormEvent, type ReactNode } from "react";
import {
FOUNDING_LADDER,
HOSTED_MONTHLY,
HOSTED_YEARLY,
LIST_ANNUAL,
+ foundingTier,
} from "../../website/src/lib/hosted-pricing";
import type { BillingSummary, Plan, SurveyAnswers } from "./api";
@@ -14,7 +15,7 @@ import type { BillingSummary, Plan, SurveyAnswers } from "./api";
export const PLAN_NAMES: Record = {
monthly: HOSTED_MONTHLY.name,
yearly: HOSTED_YEARLY.name,
- founding: "Founding",
+ founding: foundingTier().name,
};
/** What `plan` costs now, or null for founding once it has closed. */
@@ -33,6 +34,15 @@ interface Shared {
act: (label: string, action: () => Promise) => Promise;
}
+/** Opens the customer portal. */
+function PortalButton({ busy, act, onPortal, primary }: Shared & { onPortal: () => Promise; primary?: boolean }) {
+ return (
+
+ );
+}
+
/** `/checkout?plan=`: the plan and its price now, then Stripe. */
export function CheckoutView({
plan,
@@ -50,53 +60,34 @@ export function CheckoutView({
onPortal: () => Promise;
onDecline: () => void;
}) {
- const decline = (
-
+ const page = (body: ReactNode, action?: ReactNode) => (
+
+ {body}
+ {action}
+
+
);
- if (!plan)
- return (
-
-
- Founding has closed. {HOSTED_YEARLY.name} is ${HOSTED_YEARLY.price} a year;
- see the plans.
-
- {decline}
-
+ return page(
+
+ Founding has closed. {HOSTED_YEARLY.name} is ${HOSTED_YEARLY.price} a year; see{" "}
+ the plans.
+
,
);
- return (
-
+ return page(
+ <>
Plan
{PLAN_NAMES[plan]}
@@ -107,11 +98,10 @@ export function CheckoutView({
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."}
- {summary.plan && (
-
- )}
+ {summary.plan && }
{summary.plan === "founding" && (
)}
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index 2fd5537df..9eaaee0e9 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -1,3 +1,4 @@
+import type { CheckoutPlan as Plan } from "../../website/src/lib/hosted-pricing";
import { providerIds, providerNames } from "../server/providers.js";
import type { ProviderId } from "../server/providers.js";
@@ -67,10 +68,9 @@ export async function social(provider: Provider, linking: boolean) {
linking ? "link-social" : "sign-in/social",
{ provider },
);
- const url = new URL(result.url);
- if (url.protocol !== "https:")
- throw new Error("Sign-in is temporarily unavailable. Please try again.");
- window.location.assign(url.href);
+ window.location.assign(
+ awayTo(result.url, "Sign-in is temporarily unavailable. Please try again."),
+ );
}
export interface VoiceToken {
@@ -149,10 +149,18 @@ export async function approveEnrollment(userCode: string) {
if (!response.ok) throw await refused(response);
}
-export const PLANS = ["monthly", "yearly", "founding"] as const;
-export type Plan = (typeof PLANS)[number];
-export const isPlan = (value: unknown): value is Plan =>
- PLANS.includes(value as Plan);
+/**
+ * A page off this origin to send the browser to (a provider's sign-in,
+ * Stripe's): https only, but for StripeDev on loopback in the dev loop.
+ */
+function awayTo(url: string, refused: string): string {
+ const page = new URL(url);
+ const devStripe = location.protocol === "http:" && page.hostname === "127.0.0.1";
+ if (page.protocol !== "https:" && !devStripe) throw new Error(refused);
+ return page.href;
+}
+
+export type { Plan };
/** `GET /api/billing`: the account's plan (docs/specs/hosted.md -> "Billing"). */
export interface BillingSummary {
@@ -175,67 +183,25 @@ export interface SurveyAnswers {
bargain: number | null;
}
-/** Billing's own call: its 503 says checkout is not open, in words for this page. */
async function billing(path: string, init: RequestInit = {}): Promise {
- let response: Response;
- try {
- response = await fetch(`/api/billing${path}`, {
- ...init,
- credentials: "same-origin",
- cache: "no-store",
- headers: init.body ? { "content-type": "application/json" } : undefined,
- });
- } catch {
- throw new Error("Could not connect. Check your connection and try again.");
- }
+ const response = await request(
+ `/api/billing${path}`,
+ init.body ? { ...init, headers: { "content-type": "application/json" } } : init,
+ "Billing is temporarily unavailable. Try again.",
+ );
if (!response.ok) throw await refused(response);
return response;
}
-/** A Stripe page to send the browser to: https, or StripeDev on loopback in the dev loop. */
-function stripePage(url: string): string {
- const page = new URL(url);
- if (
- page.protocol !== "https:" &&
- !(location.protocol === "http:" && page.hostname === "127.0.0.1")
- )
- throw failed();
- return page.href;
-}
-/** Null while this deployment does not sell. */
-export async function getBilling(): Promise {
- const response = await fetch("/api/billing", {
- credentials: "same-origin",
- cache: "no-store",
- }).catch(() => null);
- if (!response || response.status === 503 || response.status === 401) return null;
- if (!response.ok) throw await refused(response);
- return (await response.json()) as BillingSummary;
-}
-/** The open founding cohort, read from the public endpoint; null when unknown or closed. */
-export async function getCohort(): Promise {
- const response = await fetch("/api/hosted/cohorts", { cache: "no-store" }).catch(() => null);
- if (!response?.ok) return null;
- const { cohort } = (await response.json()) as { cohort?: unknown };
- return typeof cohort === "number" ? cohort : null;
-}
-export async function startCheckout(plan: Plan): Promise {
- const response = await billing("/checkout", {
- method: "POST",
- body: JSON.stringify({ plan }),
- });
- return stripePage(((await response.json()) as { url: string }).url);
-}
-export async function confirmCheckout(checkout: string): Promise {
- const response = await billing("/confirm", {
- method: "POST",
- body: JSON.stringify({ checkout }),
- });
- return (await response.json()) as BillingSummary;
-}
-export async function openPortal(): Promise {
- const response = await billing("/portal", { method: "POST" });
- return stripePage(((await response.json()) as { url: string }).url);
-}
+const toStripe = async (response: Response) =>
+ awayTo(((await response.json()) as { url: string }).url, "That did not work. Reload the page and try again.");
+
+/** Throws while this deployment does not sell, and when signed out. */
+export const getBilling = async () => (await (await billing("")).json()) as BillingSummary;
+export const startCheckout = async (plan: Plan) =>
+ toStripe(await billing("/checkout", { method: "POST", body: JSON.stringify({ plan }) }));
+export const confirmCheckout = async (checkout: string) =>
+ (await (await billing("/confirm", { method: "POST", body: JSON.stringify({ checkout }) })).json()) as BillingSummary;
+export const openPortal = async () => toStripe(await billing("/portal", { method: "POST" }));
export async function setFounder(name: string | null) {
await billing("/founder", {
method: "PUT",
diff --git a/hosted/src/checkout.ts b/hosted/src/checkout.ts
index c1b2c4b32..1521a8dfa 100644
--- a/hosted/src/checkout.ts
+++ b/hosted/src/checkout.ts
@@ -1,4 +1,5 @@
-import { isPlan, type Plan } from "./api";
+import { BILLING_RETURN_PATH } from "../server/policy-constants";
+import { isCheckoutPlan as isPlan, type CheckoutPlan as Plan } from "../../website/src/lib/hosted-pricing";
// The plan a `/checkout?plan=` link asks for survives provider sign-in, which
// leaves the page, in this tab's session storage: a plan name, nothing else.
@@ -40,8 +41,8 @@ export const forgetCheckout = () => remember(null);
/** The checkout operation Stripe's return names (`/billing?checkout=`), taken off the address bar. */
export function takeReturn(): string | null {
- if (location.pathname !== "/billing") return null;
+ if (location.pathname !== BILLING_RETURN_PATH) return null;
const checkout = new URLSearchParams(location.search).get("checkout");
- history.replaceState(null, "", "/billing");
+ history.replaceState(null, "", BILLING_RETURN_PATH);
return checkout;
}
diff --git a/website/src/lib/hosted-pricing.ts b/website/src/lib/hosted-pricing.ts
index 2e1e95a1b..62dc8b1b2 100644
--- a/website/src/lib/hosted-pricing.ts
+++ b/website/src/lib/hosted-pricing.ts
@@ -59,9 +59,16 @@ export const CHECKOUT_OPEN = false;
/** Where a buy button sends the buyer: the account origin's checkout page. */
export const CHECKOUT_PAGE = "https://hosted.dormouse.sh/checkout";
+/** The plans on sale, by the names checkout sells under, which a buy link and the account Worker share. */
+export const CHECKOUT_PLANS = ["monthly", "yearly", "founding"] as const;
+export type CheckoutPlan = (typeof CHECKOUT_PLANS)[number];
+export const isCheckoutPlan = (value: unknown): value is CheckoutPlan =>
+ CHECKOUT_PLANS.includes(value as CheckoutPlan);
+
/** One purchasable plan, at the price it is on sale at today. */
export type Tier = {
- id: "monthly" | "annual" | "founding";
+ /** The plan checkout sells it under. */
+ id: CheckoutPlan;
/** What the buy button and the checkout notice call it. */
name: string;
/** What the buyer pays today, in whole US dollars. */
@@ -84,7 +91,7 @@ export const HOSTED_MONTHLY: Tier = {
};
export const HOSTED_YEARLY: Tier = {
- id: "annual",
+ id: "yearly",
name: "Hosted yearly",
price: LIST_ANNUAL,
per: "/year",
@@ -103,11 +110,8 @@ export function foundingTier(): Tier {
};
}
-/** The checkout link for `tier`, by the plan name checkout sells it under. */
-export function checkoutUrl(tier: Tier): string {
- const plan = tier.id === "annual" ? "yearly" : tier.id;
- return `${CHECKOUT_PAGE}?plan=${plan}`;
-}
+/** The checkout link for `tier`. */
+export const checkoutUrl = (tier: Tier) => `${CHECKOUT_PAGE}?plan=${tier.id}`;
/** Every paid plan on sale, in the order the page shows them. */
export function tiersOnSale(): Tier[] {
From 48b5d3a6e63beb7ac6ca1d2bc5761bd7a4c3dbf9 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:44:11 -0700
Subject: [PATCH 07/11] Number the billing migration 006, after hosted-signin's
005_voice_token_burrow
Co-Authored-By: Claude Opus 5.5
---
docs/specs/hosted.md | 2 +-
docs/specs/security-hosted.md | 2 +-
.../{005_billing_founders.sql => 006_billing_founders.sql} | 0
hosted/server/tests/migrations.test.ts | 2 +-
4 files changed, 3 insertions(+), 3 deletions(-)
rename hosted/server/dormouse-migrations/{005_billing_founders.sql => 006_billing_founders.sql} (100%)
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index 449573a10..ec6d652f5 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -240,7 +240,7 @@ Errors are JSON `{ message }`; the cookie routes answer 401 without a login and
- **The account pages** are `/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), and the account page's Plan section. **May keep a pending checkout's plan name, and nothing else, in the tab's session storage**, so a provider sign-in returns to it.
- **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/005_billing_founders.sql`. Pinned by `hosted/server/tests/billing.test.ts`.
+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
diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md
index c29c50ef6..bef10b4ee 100644
--- a/docs/specs/security-hosted.md
+++ b/docs/specs/security-hosted.md
@@ -71,7 +71,7 @@ Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.t
## 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/005_billing_founders.sql`.
+**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`.
diff --git a/hosted/server/dormouse-migrations/005_billing_founders.sql b/hosted/server/dormouse-migrations/006_billing_founders.sql
similarity index 100%
rename from hosted/server/dormouse-migrations/005_billing_founders.sql
rename to hosted/server/dormouse-migrations/006_billing_founders.sql
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index 89aaf8b56..efb852bb1 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -14,8 +14,8 @@ const PINNED: Record = {
"003_relay_push.sql": "ee8cca19bb70eb89fcba708b54d8a188347caf0f32ec23551c56d27676ab094f",
"004_relay_enrollment_redeemed.sql":
"dd36852ac3efbdc7f0dc2b9ee449f8f213c3c63982c4ab176a27e4a19e08a74d",
- "005_billing_founders.sql": "4e9f6b8f86eec407cfc4a6864557893639e87930051a4265c3b72ac25b09679a",
"005_voice_token_burrow.sql": "ccb395314dd49f9b5c72560121994fd7a21d349580c0d746d0d380e29d0aefec",
+ "006_billing_founders.sql": "4e9f6b8f86eec407cfc4a6864557893639e87930051a4265c3b72ac25b09679a",
};
const directory = new URL("../dormouse-migrations/", import.meta.url);
From 62292afd1c72823ab8319debf050bac43191d6db Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:52:49 -0700
Subject: [PATCH 08/11] Code review: lapsed payers keep Manage billing; resync
after period end, every 10 minutes
- A subscription Stripe still holds (past_due, unpaid, incomplete) keeps
its plan on the account page with Manage billing and "a payment is
due", rather than "No plan" and a checkout that refuses; `active` says
whether it grants access.
- The resync now runs every 10 minutes on subscriptions still active or
trialing past their period or trial end (a resync before the renewal
read the old period), picking at random so one account that always
fails cannot starve the rest.
- The welcome page shows only for a completed checkout; a pending plan
rides a provider sign-in for 10 minutes and once.
- Spec rules lead with Must/May; cleanups in billing-dev.ts and style.css.
Co-Authored-By: Claude Opus 5.5
---
.github/audit/hosted.md | 2 +-
docs/specs/hosted.md | 14 +++++++-------
hosted/scripts/production.test.mjs | 2 +-
hosted/server/billing-dev.ts | 11 +++++------
hosted/server/billing-routes.ts | 24 ++++++++++++++++--------
hosted/server/tests/billing.test.ts | 8 +++++---
hosted/src/App.tsx | 7 +++++--
hosted/src/Billing.tsx | 10 +++++-----
hosted/src/api.ts | 3 +++
hosted/src/checkout.ts | 10 +++++++---
hosted/src/style.css | 4 +---
hosted/wrangler.jsonc | 6 +++---
scripts/spec-word-budgets.json | 2 +-
13 files changed, 60 insertions(+), 43 deletions(-)
diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md
index b3c6ab0b9..e749fdf90 100644
--- a/.github/audit/hosted.md
+++ b/.github/audit/hosted.md
@@ -36,7 +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 hourly 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.
+- **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/docs/specs/hosted.md b/docs/specs/hosted.md
index ec6d652f5..4507eabb4 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -55,8 +55,8 @@ Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/ma
**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.
-- **An account is entitled 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.
-- **`ADMIN_EMAIL` is 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.
+- **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`.
@@ -220,7 +220,7 @@ The account Worker sells the plans `monthly`, `yearly`, and `founding` through `
| Route | Credential | Success |
|---|---|---|
-| `GET /api/billing` | login cookie | 200 `{ plan, until, renews, entitled, founder, founding }` from the synchronized rows |
+| `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 |
@@ -231,13 +231,13 @@ The account Worker sells the plans `monthly`, `yearly`, and `founding` through `
Errors are JSON `{ message }`; the cookie routes answer 401 without a login and need no entitlement.
-- **Billing is 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.
+- **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.
-- **The cohort endpoint answers 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`, from a per-isolate cache of `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 hourly Cron Trigger, every subscription whose paid period or trial ends within the hour**, so a missed renewal webhook never lapses a member; a failed resync fails the invocation.
-- **The account pages** are `/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), and the account page's Plan section. **May keep a pending checkout's plan name, and nothing else, in the tab's session storage**, so a provider sign-in returns to it.
+- **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`.
diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs
index 4bb9d424e..0253ecc22 100644
--- a/hosted/scripts/production.test.mjs
+++ b/hosted/scripts/production.test.mjs
@@ -218,7 +218,7 @@ test("live verification retries the relay and voice while their domains come up,
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 * * * *"] });
- assert.deepEqual(configs.account.triggers, { crons: ["30 * * * *"] });
+ 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/billing-dev.ts b/hosted/server/billing-dev.ts
index a11ec3630..d5efd9345 100644
--- a/hosted/server/billing-dev.ts
+++ b/hosted/server/billing-dev.ts
@@ -4,18 +4,17 @@ 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'.
*/
-/** StripeDev's founding Prices, one per ladder step. */
-export const DEV_FOUNDING_PRICES = FOUNDING_LADDER.map((price) => `price_founding_${price}`);
-
export async function stripeDevBilling(time: Time, random: RandomSource, statePath?: string) {
- const founding = DEV_FOUNDING_PRICES;
const dev = await createStripeDev(time, random, statePath, {
- recurring: Object.fromEntries(founding.map((price) => [price, "year" as const])),
+ recurring: Object.fromEntries(DEV_FOUNDING_PRICES.map((price) => [price, "year" as const])),
});
const setup: BillingSetup = {
secretKey: "sk_test_dormouse_local_only",
@@ -23,7 +22,7 @@ export async function stripeDevBilling(time: Time, random: RandomSource, statePa
live: false,
monthly: dev.prices.monthly,
yearly: dev.prices.yearly,
- founding,
+ founding: DEV_FOUNDING_PRICES,
stripe: dev.stripe,
};
return { dev, setup };
diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts
index 26a472ea9..42f8dc70a 100644
--- a/hosted/server/billing-routes.ts
+++ b/hosted/server/billing-routes.ts
@@ -37,6 +37,8 @@ export const COHORT_CACHE_MS = 60_000;
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;
@@ -76,7 +78,7 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
/**
* The account's plan as the account app shows it, from the synchronized
- * rows: webhooks, confirm, and the hourly resync keep them current.
+ * 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([
@@ -87,10 +89,14 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
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: status.access ? status.plan : null,
- until: status.access ? status.accessUntil : null,
- renews: status.access && !status.subscription?.cancel_at_period_end,
+ 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),
@@ -234,8 +240,10 @@ export function billingRoutes(app: Hono, host: (c: Context) => BillingHost)
/**
* The account Worker's Cron Trigger: resyncs from Stripe every subscription
- * whose paid period or trial ends within the hour, so a missed renewal
- * webhook never lapses a member. At most `limit` accounts a run.
+ * 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,
@@ -247,8 +255,8 @@ export async function reconcileDue(
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", "now() + interval '1 hour'")}
- GROUP BY owner_id ORDER BY min(period_end) LIMIT $1`,
+ WHERE status IN ('active', 'trialing') AND NOT ${accessSql("s")}
+ GROUP BY owner_id ORDER BY random() LIMIT $1`,
[limit],
);
if (!due.length) return;
diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts
index a30cf40f7..e5c7fc4ec 100644
--- a/hosted/server/tests/billing.test.ts
+++ b/hosted/server/tests/billing.test.ts
@@ -193,7 +193,7 @@ async function fixture({ billing = true } = {}) {
const fetcher = (await worker.getWorker(wrangler.account.name)) as unknown as {
scheduled(options: { cron: string }): Promise<{ outcome: string }>;
};
- return fetcher.scheduled({ cron: "30 * * * *" });
+ return fetcher.scheduled({ cron: "*/10 * * * *" });
};
return {
...context,
@@ -277,7 +277,7 @@ test("checkout to webhook to entitlement: voice and the Relay admit a member, an
body: { checkout: session.metadata!.pgstencil_operation },
});
expect(confirmed.status).toBe(200);
- expect(await confirmed.json()).toMatchObject({ plan: "monthly", entitled: true, renews: true });
+ 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.
@@ -298,6 +298,8 @@ test("checkout to webhook to entitlement: voice and the Relay admit a member, an
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);
@@ -317,7 +319,7 @@ test("checkout to webhook to entitlement: voice and the Relay admit a member, an
).toEqual([{ owner_id: userId, status: "canceled" }]);
});
-test("a missed renewal webhook is repaired by the hourly resync before the member is refused", async ({
+test("a missed renewal webhook is repaired by the next resync", async ({
onTestFinished,
}) => {
const f = await fixture();
diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx
index 12301d6ff..7c1e74f3a 100644
--- a/hosted/src/App.tsx
+++ b/hosted/src/App.tsx
@@ -134,8 +134,11 @@ export function App({
// Stripe's return: confirm the checkout it names, then welcome.
if (returned)
await act("confirm", async () => {
- setBilling(await confirmCheckout(returned));
- setWelcome(true);
+ 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))
diff --git a/hosted/src/Billing.tsx b/hosted/src/Billing.tsx
index ac75ced59..4103145ed 100644
--- a/hosted/src/Billing.tsx
+++ b/hosted/src/Billing.tsx
@@ -120,11 +120,11 @@ function PlanLine({ summary }: { summary: BillingSummary }) {
);
diff --git a/hosted/src/api.ts b/hosted/src/api.ts
index 9eaaee0e9..780ebbeaa 100644
--- a/hosted/src/api.ts
+++ b/hosted/src/api.ts
@@ -164,7 +164,10 @@ export type { Plan };
/** `GET /api/billing`: the account's plan (docs/specs/hosted.md -> "Billing"). */
export interface BillingSummary {
+ /** The plan of the subscription Stripe holds, paid up or not. */
plan: Plan | null;
+ /** Whether it grants access now; false while a payment is due. */
+ active: boolean;
/** When the paid period ends. */
until: string | null;
/** False once cancelled to end at `until`. */
diff --git a/hosted/src/checkout.ts b/hosted/src/checkout.ts
index 1521a8dfa..cc47f9546 100644
--- a/hosted/src/checkout.ts
+++ b/hosted/src/checkout.ts
@@ -5,9 +5,12 @@ import { isCheckoutPlan as isPlan, type CheckoutPlan as Plan } from "../../websi
// leaves the page, in this tab's session storage: a plan name, nothing else.
const KEY = "dormouse-hosted-checkout";
+/** How long a pending plan waits for a provider sign-in to come back. */
+const PENDING_MS = 10 * 60 * 1000;
+
function remember(plan: Plan | null) {
try {
- if (plan) sessionStorage.setItem(KEY, plan);
+ if (plan) sessionStorage.setItem(KEY, JSON.stringify({ plan, at: Date.now() }));
else sessionStorage.removeItem(KEY);
} catch {
// Without storage, provider sign-in returns to the account page instead.
@@ -29,8 +32,9 @@ export function takeCheckout(): Plan | null | undefined {
}
if (pathname !== "/account") return undefined;
try {
- const plan = sessionStorage.getItem(KEY);
- return isPlan(plan) ? plan : undefined;
+ const { plan, at } = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
+ remember(null);
+ return isPlan(plan) && Date.now() - at < PENDING_MS ? plan : undefined;
} catch {
return undefined;
}
diff --git a/hosted/src/style.css b/hosted/src/style.css
index 16559f16b..f5845c97c 100644
--- a/hosted/src/style.css
+++ b/hosted/src/style.css
@@ -276,6 +276,7 @@ footer span {
.check input {
width: auto;
min-height: 0;
+ accent-color: var(--vscode-focusBorder);
}
.founder button,
form > button:last-child {
@@ -284,6 +285,3 @@ form > button:last-child {
.terms {
margin-top: 16px;
}
-.check input {
- accent-color: var(--vscode-focusBorder);
-}
diff --git a/hosted/wrangler.jsonc b/hosted/wrangler.jsonc
index 2430368fa..6c0a40d6a 100644
--- a/hosted/wrangler.jsonc
+++ b/hosted/wrangler.jsonc
@@ -74,10 +74,10 @@
}
],
"triggers": {
- // Billing's resync of subscriptions whose period ends within the hour
- // (`reconcileDue`); a no-op while billing is off.
+ // Billing's resync of subscriptions whose paid period has ended
+ // (`reconcileDue`); no database read while billing is off.
"crons": [
- "30 * * * *"
+ "*/10 * * * *"
]
},
"observability": {
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index d84cf095f..a68fe607e 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,7 +12,7 @@
"docs/specs/dor-tools-builtin.md": 1450,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 3000,
- "docs/specs/hosted.md": 4650,
+ "docs/specs/hosted.md": 4700,
"docs/specs/layout.md": 7450,
"docs/specs/mobile-terminal-ui.md": 1550,
"docs/specs/mouse-and-clipboard.md": 4300,
From 8f519aa9539dde67012d9930983389fcad0d4b70 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:57:42 -0700
Subject: [PATCH 09/11] Promote over hosted-signin: the subscription is built,
sign-in stays where it landed
pricing.md takes hosted-signin's front door, sign-in surface, managed
voice pointer and open question, and drops "the subscription as the
entitlement" from its Future and Status. hosted.md's Future no longer
lists the entitlement swap, and the hand-minted token routes are no
longer "the admin test path". ADMIN_EMAIL's comment names the standing
comp instead of "until billing ships".
Co-Authored-By: Claude Opus 5.5
---
docs/specs/hosted.md | 2 +-
docs/specs/pricing.md | 26 ++++++++++----------------
hosted/server/entitlement.ts | 2 +-
scripts/spec-word-budgets.json | 4 ++--
4 files changed, 14 insertions(+), 20 deletions(-)
diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md
index 4507eabb4..baf33fb49 100644
--- a/docs/specs/hosted.md
+++ b/docs/specs/hosted.md
@@ -62,7 +62,7 @@ Source of truth: `entitledSql` and `entitled` in `hosted/server/entitlement.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 |
|---|---|---|
diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md
index 4cfbcb3b9..17330c9c2 100644
--- a/docs/specs/pricing.md
+++ b/docs/specs/pricing.md
@@ -3,13 +3,13 @@
> - 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 checkout, the account pages, and the subscription entitlement are built and off until Stripe is configured ([Checkout and entitlement](#checkout-and-entitlement)); turning billing on and desktop sign-in are 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
**`/hosted` is canonical, titled "Dormouse Hosted"; `/pricing` 301-redirects to it.** The header nav and the rail label do not change: the tool is free and open source, and Hosted is the optional service with a price, so pricing is a section of the Hosted page, never a page of its own.
-**Settings is the front door.** The spoken-alarm row's managed-voice link and the playground tutorial land on `/hosted#voice`, and the plan cards sit within one screen of that anchor. `#remote-control` and `#voice` keep resolving as section ids.
+**Settings is the front door.** Its sign-in, in Notifications' managed voice and Network's Remote control, starts membership on the desktop; the playground tutorial lands on `/hosted#voice`, and the plan cards sit within one screen of that anchor. An account with no plan is linked to `#pricing` from Settings and from the baseboard alarm buttons' offers (`docs/specs/alert.md` -> "Settings dialog"). `#remote-control` and `#voice` keep resolving as section ids.
**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`.
@@ -72,6 +72,7 @@ Built on the account Worker and off until Stripe is configured; routes, bindings
- **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.
@@ -80,11 +81,10 @@ Built on the account Worker and off until Stripe is configured; routes, bindings
**Scope: hosted-sales** — what remains, in staged order:
-1. **Desktop sign-in** by device code ("Desktop sign-in").
-2. **Managed voice for members**: the disclosure, one voice per Pane.
-3. **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.
-4. **Founder avatars** proxied onto this origin.
-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).
@@ -115,16 +115,10 @@ What each plan grants once checkout can sell it; [Published prices](#published-p
- **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.
-### Desktop sign-in
-
-- **A desktop signs in from Settings by device code**, the flow Burrow enrollment already runs (`docs/specs/hosted.md` -> "Burrow enrollment"). The approval mints a desktop credential the host keeps and never hands a webview. Sign-in is the only account surface in the free client.
-
### Managed voice
-- **Dormouse operates the endpoint and holds the vendor key** (ElevenLabs). A request carries the desktop credential, a voice id, and the text; the response is audio.
-- **What leaves the machine is exactly the sanitized spoken label and the voice id** — the `toSpokenText` output in `lib/src/lib/alert-speech.ts`, never terminal content, never a notification body, never a Session id. **Disclose this in the enable flow before the first request**, honoring the promise the Hosted page makes.
-- **Cache clips by voice and text on the client** and regenerate only when the label changes; a cache hit makes no request. **Fair use is a daily request cap per member**; past it, the system voice speaks.
-- **The system voice is the fallback**, for offline, unentitled, endpoint error, or cap: same delivery rules, same cut-off on attend, never silence because the service failed. Delivery identity, queueing, and cut-off stay owned by `docs/specs/alert.md` -> "Spoken alarms".
+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".
+
- **One voice per Pane.** The member default applies everywhere; a per-Pane override is persisted with the pane's settings and follows the Session through minimize and restore. Doors and headers show nothing new.
- **Pocket speaks only in the foreground** — a web app cannot voice a background push — so the desktop is the primary voice sink. A native Pocket is out of scope here.
@@ -138,4 +132,4 @@ What each plan grants once checkout can sell it; [Published prices](#published-p
### Open questions
- A free hosted tier, no card. It is the only way a stock binary can try Pocket, since the shipped bundle reaches only `*.dormouse.sh` (`docs/specs/relay.md` -> "Relay origin").
-- The curated voice set and whether members may bring their own ElevenLabs voice id.
+- Whether members may bring their own ElevenLabs voice id beyond the curated set.
diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts
index 2f192d4f4..40166faa4 100644
--- a/hosted/server/entitlement.ts
+++ b/hosted/server/entitlement.ts
@@ -1,7 +1,7 @@
// 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.
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index a68fe607e..f82c079eb 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -12,7 +12,7 @@
"docs/specs/dor-tools-builtin.md": 1450,
"docs/specs/dor-tools-lib.md": 350,
"docs/specs/glossary.md": 3000,
- "docs/specs/hosted.md": 4700,
+ "docs/specs/hosted.md": 4800,
"docs/specs/layout.md": 7450,
"docs/specs/mobile-terminal-ui.md": 1550,
"docs/specs/mouse-and-clipboard.md": 4300,
@@ -28,7 +28,7 @@
"docs/specs/security-ci.md": 2750,
"docs/specs/security-hosted.md": 2800,
"docs/specs/security-local.md": 3750,
- "docs/specs/security-remote.md": 5700,
+ "docs/specs/security-remote.md": 5750,
"docs/specs/security-supply-chain.md": 1300,
"docs/specs/security.md": 2450,
"docs/specs/shortcuts.md": 1150,
From dbfba0637381e0968b351b40f7b76f2ab018b030 Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 01:00:06 -0700
Subject: [PATCH 10/11] Billing tests speak a voice of the curated set
hosted-signin added
Co-Authored-By: Claude Opus 5.5
---
hosted/server/tests/billing.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/hosted/server/tests/billing.test.ts b/hosted/server/tests/billing.test.ts
index e5c7fc4ec..df1b33ed4 100644
--- a/hosted/server/tests/billing.test.ts
+++ b/hosted/server/tests/billing.test.ts
@@ -4,7 +4,7 @@ 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, NOT_ENTITLED_ERROR } from "remote-lib-common";
+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";
@@ -186,7 +186,7 @@ async function fixture({ billing = true } = {}) {
call(ORIGINS.voice + "/api/voice/speak", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
- body: JSON.stringify({ text: "Build finished", voiceId: "abc" }),
+ body: JSON.stringify({ text: "Build finished", voiceId: DEFAULT_MANAGED_VOICE_ID }),
});
/** The account Worker's Cron Trigger, run now. */
const scheduled = async () => {
From cc2075af2cc6eef9a5e60beeb7dfc6c987da810a Mon Sep 17 00:00:00 2001
From: Ned Twigg
Date: Sun, 4 Oct 2026 00:53:15 -0700
Subject: [PATCH 11/11] TEMP: local pgstencil 0.3.0 tarball
Drop this commit once pgstencil 0.3.0 (diffplug/pgstencil named-plans,
packed clean from dab7292) is released to npm, then run `pnpm install`
and commit the lockfile: the code commits already declare ^0.3.0.
vendor/ holds the three archives, pnpm-workspace.yaml overrides the
three package names to them (and lets the file: override satisfy their
pgstencil peer), and the lockfile test is marked as expected to fail
while they are vendored.
Co-Authored-By: Claude Opus 5.5
---
hosted/server/tests/artifacts.test.ts | 4 +-
pnpm-lock.yaml | 65 +++++++++++++++++++++-----
pnpm-workspace.yaml | 9 ++++
vendor/pgstencil-0.3.0.tgz | Bin 0 -> 16441 bytes
vendor/pgstencil-auth-0.3.0.tgz | Bin 0 -> 37239 bytes
vendor/pgstencil-stripe-0.3.0.tgz | Bin 0 -> 15080 bytes
6 files changed, 65 insertions(+), 13 deletions(-)
create mode 100644 vendor/pgstencil-0.3.0.tgz
create mode 100644 vendor/pgstencil-auth-0.3.0.tgz
create mode 100644 vendor/pgstencil-stripe-0.3.0.tgz
diff --git a/hosted/server/tests/artifacts.test.ts b/hosted/server/tests/artifacts.test.ts
index 20a406bc4..b1f2d75da 100644
--- a/hosted/server/tests/artifacts.test.ts
+++ b/hosted/server/tests/artifacts.test.ts
@@ -21,7 +21,9 @@ test("Hosted declares every peer of the installed pgstencil packages", () => {
}
});
-test("the lockfile resolves every pgstencil package 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/pnpm-lock.yaml b/pnpm-lock.yaml
index 1d994fb5a..d7aad0cf1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -162,6 +162,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ pgstencil: file:vendor/pgstencil-0.3.0.tgz
+ '@pgstencil/auth': file:vendor/pgstencil-auth-0.3.0.tgz
+ '@pgstencil/stripe': file:vendor/pgstencil-stripe-0.3.0.tgz
+
importers:
.:
@@ -327,8 +332,11 @@ importers:
specifier: ^2.0.10
version: 2.1.1(hono@4.13.9)
'@pgstencil/auth':
- specifier: ^0.2.1
- version: 0.2.1(hono@4.13.9)(kysely@0.29.6)(pg@8.23.0)(pgstencil@0.2.1(kysely@0.29.6)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.2)
+ specifier: file:../vendor/pgstencil-auth-0.3.0.tgz
+ version: file:vendor/pgstencil-auth-0.3.0.tgz(hono@4.13.9)(kysely@0.29.6)(pg@8.23.0)(pgstencil@file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.2)
+ '@pgstencil/stripe':
+ specifier: file:../vendor/pgstencil-stripe-0.3.0.tgz
+ version: file:vendor/pgstencil-stripe-0.3.0.tgz(kysely@0.29.6)(pgstencil@file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2))(stripe@22.6.2(@types/node@24.19.0))
hono:
specifier: ^4.13.8
version: 4.13.9
@@ -336,8 +344,8 @@ importers:
specifier: ^0.29.5
version: 0.29.6
pgstencil:
- specifier: ^0.2.1
- version: 0.2.1(kysely@0.29.6)(supports-color@10.2.2)
+ specifier: file:../vendor/pgstencil-0.3.0.tgz
+ version: file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2)
react:
specifier: ^19.2.6
version: 19.3.0
@@ -347,6 +355,9 @@ importers:
remote-lib-common:
specifier: workspace:*
version: link:../remote-lib-common
+ stripe:
+ specifier: ^22.6.1
+ version: 22.6.2(@types/node@24.19.0)
devDependencies:
'@types/node':
specifier: ^24.13.6
@@ -2982,13 +2993,23 @@ packages:
cpu: [x64]
os: [win32]
- '@pgstencil/auth@0.2.1':
- resolution: {integrity: sha512-B5Wu035642KEr0V/VJf+C0MMM9PiAGvpXF9VTeKeiTnBifYQIkO9Cks+RLPiO2laxdBWLlNxo6RDVbjLaIiufA==}
+ '@pgstencil/auth@file:vendor/pgstencil-auth-0.3.0.tgz':
+ resolution: {integrity: sha512-CwcTsyt06WjMzEfPAN8ydzRpczyU8/3n14svl4QLcxjAtNVTVL8Wovn6E3Ikwycr0CsbULiweEtK8/bZOQcy3w==, tarball: file:vendor/pgstencil-auth-0.3.0.tgz}
+ version: 0.3.0
engines: {node: '>=24'}
peerDependencies:
hono: ^4.13.7
kysely: ^0.29.5
- pgstencil: ^0.2.1
+ pgstencil: ^0.3.0
+
+ '@pgstencil/stripe@file:vendor/pgstencil-stripe-0.3.0.tgz':
+ resolution: {integrity: sha512-eDRnpYGhSla5JiIKwWMan7ErC/EypzRZPNZTRaULBiIkiXQJ+1nTQAwEj3p2HLanVokEwiS0ig/vcicTu+AC3A==, tarball: file:vendor/pgstencil-stripe-0.3.0.tgz}
+ version: 0.3.0
+ engines: {node: '>=24'}
+ peerDependencies:
+ kysely: ^0.29.5
+ pgstencil: ^0.3.0
+ stripe: ^22.6.1
'@phosphor-icons/react@2.1.10':
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
@@ -6924,8 +6945,9 @@ packages:
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
- pgstencil@0.2.1:
- resolution: {integrity: sha512-Rvap6rNjtJfncsZUvM97BQwPsvOoi5lpg0R8a+r+PJOmAKh0UfwLNaoV2iONqJxSnZtSa5A+n6xPQ2ojyA1qdw==}
+ pgstencil@file:vendor/pgstencil-0.3.0.tgz:
+ resolution: {integrity: sha512-USK6qJa80zSZnbdzsx7tBqpdfLgXJbONUdoCOR21cJxJGHRcIWL6VyODGtjdN7/8ZzQivFQe23XDyLnkHPyRqQ==, tarball: file:vendor/pgstencil-0.3.0.tgz}
+ version: 0.3.0
engines: {node: '>=24'}
peerDependencies:
kysely: ^0.29.5
@@ -7475,6 +7497,15 @@ packages:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
+ stripe@22.6.2:
+ resolution: {integrity: sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
structured-source@4.0.0:
resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==}
@@ -10245,14 +10276,14 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
optional: true
- '@pgstencil/auth@0.2.1(hono@4.13.9)(kysely@0.29.6)(pg@8.23.0)(pgstencil@0.2.1(kysely@0.29.6)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.2)':
+ '@pgstencil/auth@file:vendor/pgstencil-auth-0.3.0.tgz(hono@4.13.9)(kysely@0.29.6)(pg@8.23.0)(pgstencil@file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.2)':
dependencies:
better-auth: 1.7.6(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.2)
hono: 4.13.9
jose: 6.2.12
kysely: 0.29.6
openid-client: 6.8.8
- pgstencil: 0.2.1(kysely@0.29.6)(supports-color@10.2.2)
+ pgstencil: file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2)
transitivePeerDependencies:
- '@lynx-js/react'
- '@opentelemetry/api'
@@ -10274,6 +10305,12 @@ snapshots:
- vitest
- vue
+ '@pgstencil/stripe@file:vendor/pgstencil-stripe-0.3.0.tgz(kysely@0.29.6)(pgstencil@file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2))(stripe@22.6.2(@types/node@24.19.0))':
+ dependencies:
+ kysely: 0.29.6
+ pgstencil: file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2)
+ stripe: 22.6.2(@types/node@24.19.0)
+
'@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
dependencies:
react: 19.3.0
@@ -14339,7 +14376,7 @@ snapshots:
dependencies:
split2: 4.2.0
- pgstencil@0.2.1(kysely@0.29.6)(supports-color@10.2.2):
+ pgstencil@file:vendor/pgstencil-0.3.0.tgz(kysely@0.29.6)(supports-color@10.2.2):
dependencies:
cheerio: 1.2.0
kysely: 0.29.6
@@ -15058,6 +15095,10 @@ snapshots:
strip-json-comments@2.0.1:
optional: true
+ stripe@22.6.2(@types/node@24.19.0):
+ optionalDependencies:
+ '@types/node': 24.19.0
+
structured-source@4.0.0:
dependencies:
boundary: 2.0.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index fa42bbeb1..3bb7938e3 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -43,7 +43,16 @@ peerDependencyRules:
# node-ws release widens the range; `relay/test/*.test.mjs` covers the relay
# sockets that would break first.
"@hono/node-ws>@hono/node-server": ^2.0.0
+ # TEMP (with `overrides` below): a file: override satisfies no semver peer range.
+ allowAny:
+ - pgstencil
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- pgstencil
- "@pgstencil/*"
+# TEMP: the unreleased pgstencil 0.3.0 (named plans), packed from diffplug/pgstencil
+# named-plans at dab7292. Drop this block and vendor/ once 0.3.0 is on npm.
+overrides:
+ pgstencil: file:vendor/pgstencil-0.3.0.tgz
+ '@pgstencil/auth': file:vendor/pgstencil-auth-0.3.0.tgz
+ '@pgstencil/stripe': file:vendor/pgstencil-stripe-0.3.0.tgz
diff --git a/vendor/pgstencil-0.3.0.tgz b/vendor/pgstencil-0.3.0.tgz
new file mode 100644
index 0000000000000000000000000000000000000000..8402359e8ddcb61f55fc7a8b0ccfe879a1b9807b
GIT binary patch
literal 16441
zcmV-9K*qlxiwFP!00000|LuKidm2Zwus`cpbi6(bIA}l@+gTi0FP5;)MglFMWOEcP
z8khz;8m7ryB(aMA`}#UO8A6~XKpy9RT6XJ21~M_VZ;N%N3qKS7EFmt#(c5d;1d$hSVST|iLc#|
z5;yRO8%2D~+?aaAFszX|hcM9T4ulIML{<9P);@u=%>uz*0G$|Bb+p7A6m
zA&ugYjiHSyVZqo>JQ#z@_Su}t9-unTJlcpkNg`UsaaPHkdu#Qk>Qz8Tl-lRf)%-
z=}{8XDv2NwL0E-()ObiD>iZjGzF;&0sF#K=BFpeSQs78}qcpNyyk81)DXh-*wIgzlr*4Pxf{WWhu1FFQ?VV`K2CMf%+jgCCl`7HRj%Nw52T
z`>=ILo;UlX-G5#sKePv*x~Bv3L$lXwb_PF^?g!HB{7AlQcMhwh_4kurtKTQx-bVZQ
z`tde%;;OO+Q-T6q~pAJZ;J0M5xn(UBiMwIB)lYj#NUV9@S%U>XPA&Y;&k7*xri
z+Z*VrKeYR;DrxrGeE`UZUiY}V0pRR@fFkV<)a$fFD*$R@tw^_rzfb!u-5@z^HILez
zkA0{NQ&feWjej?ydis8sZz1o0z-KkjjopzOQRnCA@$Uca`s-KQnfrfhYkO<^>HdF&
zkIeyrlUp*s^jKIWA$7eE%%@dy9kQ6hpX82ALOv&@fP1ttiE4|G&sjvH(%TJP{b$Ys
zxD~^|ozp6TT?BvGbr)_tGbLIzU=NFS@5$Y@R%&o&61pM-o&qv(i_FO4cbA75q)j|Ei4
zS}sBk^g;*MCW_sd9*V;(N6#G?~8@#AzXDd3$NELj#v?qcCHY7D;md2_9jE7xb9
z8`I)Gvj8wwXVskKj~?gozdXS)52BcO^osg?0oze6KXgU*V7Emv4aUqLdi1LFcBA(4
zC20pSorbjkw<9uhBNA{jqply%2o1aiXF>dyxB*Fm%Ya`8B&PF)@5VrfMb|Wp$Zma?
z@L)^{i-EdM77lq?+Ylq^wVH=*AX?OfnjSSh>q!zIp-gBHB_Zw0dgTha-5`Wy8zTGrd)W