From 3393e49abfb28ccf7daadff99d68e96fe2344fc3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 04:57:17 -0700 Subject: [PATCH 1/2] Hosted: billing routes, webhook, refunds and disputes GET /api/billing, POST checkout, confirm, and portal on the account Worker, behind the login gate, 4 KiB bodies, and 503 while billing is closed. Checkout takes a plan and a ref only: the ref is one of HOSTED_REFS, "other", or none, sent through pgstencil's checkout ref; the owner is the login, its email only once verified, else null so Stripe Checkout asks. A checkout open for another plan is expired. The webhook verifies the raw body (1 MiB cap) before any write and answers 2xx only after commit. In its transaction, each under its own savepoint, it counts checkout.completed from completedCheckout and subscription.canceled or .refunded from a purchase's end, so a redelivery counts nothing and a failed count rolls back alone, logged. A full refund or a lost dispute ends the subscription its charge paid for, found through the PaymentIntent's invoice payments, while it is the owner's current one; a charge for an earlier subscription or none ends nothing and is logged for the operator. It runs after the commit, retried on every delivery until it has. An open dispute, read from Stripe's current status, suspends through a new dormouse_billing_disputes row the entitlement checks; closing in the account's favor restores it. checkout.started counts best effort. The admin page lists only the counted labels of a long label list. Co-Authored-By: Claude Opus 5.5 --- hosted/scripts/preview-smoke.mjs | 3 +- hosted/scripts/preview.test.mjs | 4 + hosted/server/account-app.ts | 7 +- hosted/server/account-gate.ts | 6 +- hosted/server/billing-routes.ts | 282 +++++++++++ hosted/server/billing.ts | 4 +- .../014_billing_disputes.sql | 13 + hosted/server/entitlement.ts | 13 +- hosted/server/metric-labels.ts | 15 + hosted/server/metrics.ts | 37 +- hosted/server/policy-constants.ts | 4 + hosted/server/runtime-roles.sql | 4 +- hosted/server/tests/billing-routes.test.ts | 446 ++++++++++++++++++ hosted/server/tests/boundary.test.ts | 24 + hosted/server/tests/migrations.test.ts | 2 + hosted/server/tests/runtime-roles.test.ts | 8 +- hosted/server/tests/stripe-fake.ts | 120 +++++ hosted/src/AdminMetrics.tsx | 4 + hosted/src/admin-metrics.test.ts | 18 + hosted/src/admin-metrics.ts | 20 +- hosted/src/stories/AdminMetrics.stories.tsx | 4 + 21 files changed, 1018 insertions(+), 20 deletions(-) create mode 100644 hosted/server/billing-routes.ts create mode 100644 hosted/server/dormouse-migrations/014_billing_disputes.sql create mode 100644 hosted/server/tests/billing-routes.test.ts create mode 100644 hosted/server/tests/stripe-fake.ts diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index 1aaebaea8..6df7a6305 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -164,7 +164,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 first, whether or not the deployment sells. + assert.equal((await request("/api/billing", OWN_PAGE)).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 46f2fcc3c..70ba7fec9 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -487,6 +487,10 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read ); if (pathname === "/api/auth/get-session") return signedOutRead(init); if (pathname === "/api/providers") return Response.json([]); + if (pathname === "/api/billing") + return init?.headers?.["sec-fetch-site"] === "same-origin" + ? Response.json({ message: "Sign in first." }, { status: 401 }) + : Response.json({ message: "Invalid origin." }, { status: 403 }); 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/server/account-app.ts b/hosted/server/account-app.ts index 1702e480b..00782a1ff 100644 --- a/hosted/server/account-app.ts +++ b/hosted/server/account-app.ts @@ -1,6 +1,7 @@ import type { Context, ExecutionContext, Hono } from "hono"; import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate"; import { billingSetup, resyncBilling } from "./billing"; +import { billingRoutes, type BillingHost } from "./billing-routes"; import type { AccountEnv } from "./bindings"; import { accountRules } from "./headers"; import { ADMIN_METRICS_PATH } from "./metric-labels"; @@ -14,7 +15,7 @@ import { workerApp } from "./worker-app"; /** * The account Worker (`hosted.dormouse.sh`): auth, providers, readiness, * terms acceptance, Natural Voice on or off, the Relay's account routes, the - * admin's metrics, the frontend, and billing's resync. The production and preview entries differ only in `fetchAuth`'s + * admin's metrics, the frontend, and billing. The production and preview entries differ only in `fetchAuth`'s * mail and in `bindings`. */ export function accountApp( @@ -46,15 +47,17 @@ export function accountApp( headers.delete("cookie"); return fetchAuth(new Request(c.req.raw, { headers }), 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), }); termsRoutes(app, host); voiceAccountRoutes(app, host); relayAccountRoutes(app, host); + billingRoutes(app, host); app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) => c.json(await readMetrics(c.env.HYPERDRIVE.connectionString)), ); diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts index c5dacdb9c..7edda7026 100644 --- a/hosted/server/account-gate.ts +++ b/hosted/server/account-gate.ts @@ -50,6 +50,8 @@ export interface AccountLogin { * recent login reads it, and it fails closed on a value it cannot read. */ createdAt: unknown; + /** The account's verified email, or null for a provider-only account without one. */ + email: string | null; } /** @@ -109,7 +111,7 @@ function cookieGate( ); if (!response.ok) throw new Error("Login lookup failed"); const session = (await response.json()) as { - user?: { id: string }; + user?: { id: string; email?: unknown; emailVerified?: unknown }; session?: { createdAt?: unknown }; } | null; if (!session?.user) return c.json({ message: "Sign in first." }, 401); @@ -117,6 +119,8 @@ function cookieGate( c.set("login", { userId: session.user.id, createdAt: session.session?.createdAt, + email: + session.user.emailVerified === true && typeof session.user.email === "string" ? session.user.email : null, }); await next(); }; diff --git a/hosted/server/billing-routes.ts b/hosted/server/billing-routes.ts new file mode 100644 index 000000000..8acbf4613 --- /dev/null +++ b/hosted/server/billing-routes.ts @@ -0,0 +1,282 @@ +// Rules: docs/specs/hosted.md -> "Billing" and "Metrics"; docs/specs/pricing.md +// -> "Checkout and entitlement". +import { BillingError, purchaseStanding, type Billing, type BillingDB, type Stripe } from "@pgstencil/stripe"; +import type { Context, Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { CompiledQuery, type Kysely } from "kysely"; +import { readJson } from "remote-lib-common"; +import { accountQuery, cookieLogin, type AccountHost } from "./account-gate"; +import { REFUND_DAYS, foundingCohort, withBilling, type BillingSetup, type Plan } from "./billing"; +import { CURRENT_STATUSES, entitledSql } from "./entitlement"; +import { checkoutRef, type MetricEvent } from "./metric-labels"; +import { countMetricInTransaction, recordMetric } from "./metrics"; +import { BILLING_PLANS } from "./policy-constants"; + +/** 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 closed; throws when misconfigured. */ + setup(): BillingSetup | null; +} + +/** Every billing route's answer while billing is closed. */ +export const CHECKOUT_CLOSED = "Checkout is not open yet."; + +/** Stripe's webhook endpoint. */ +export const BILLING_WEBHOOK_PATH = "/api/billing/webhook"; + +/** Stripe's events are larger than anything a page sends here. */ +export const WEBHOOK_BODY_BYTES = 1024 * 1024; + +/** The cancellation comment a subscription ended for a full refund or a final reversal carries. */ +export const ENDED_FOR_REFUND = "dormouse: full refund or final reversal"; + +const JSON_BODY_BYTES = 4096; +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"]; + +const isPlan = (plan: unknown): plan is Plan => (BILLING_PLANS as readonly unknown[]).includes(plan); + +/** + * What a dispute's current status does to its account's subscription: an + * open one suspends it, one closed in the account's favor restores it, and + * a lost one is a final reversal, which ends it. An inquiry, or a status + * this list does not know, changes nothing. + */ +export function disputeAction(status: string): "suspend" | "restore" | "end" | null { + if (status === "needs_response" || status === "under_review") return "suspend"; + if (status === "won" || status === "warning_closed" || status === "prevented") return "restore"; + if (status === "lost") return "end"; + return null; +} + +/** + * The billing routes on the account origin: the cookie routes a signed-in + * account drives, and Stripe's webhook. Call before any `/api/*` catch-all. + */ +export function billingRoutes(app: Hono, host: (c: Context) => BillingHost) { + const gate = cookieLogin(host); + const limit = (maxSize: number) => + bodyLimit({ maxSize, onError: (c) => c.json({ message: "Request too large." }, 413) }); + + /** Runs `action` on this deployment's `Billing`: 503 while closed, 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, 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, from the synchronized rows the webhook, confirm, and resync keep current. */ + const summary = async (c: Context, billing: Billing, setup: BillingSetup, userId: string) => { + const [status, [account], founding] = await Promise.all([ + billing.status(userId), + accountQuery<{ entitled: boolean; suspended: boolean }>( + host(c), + `SELECT ${entitledSql("u")} AS entitled, + EXISTS (SELECT FROM dormouse_billing_disputes d WHERE d."userId" = u.id) AS suspended + FROM "user" u WHERE u.id = $1`, + [userId], + ), + foundingCohort(billing, setup.founding), + ]); + // A subscription Stripe still holds, paid up or not: its owner manages it + // in the portal (a failed card included) and cannot check out again. + const held = !!status.subscription && !ENDED.includes(status.subscription.status); + return c.json({ + plan: held ? status.plan : null, + active: status.access, + until: held ? status.accessUntil : null, + renews: held && !status.subscription!.cancel_at_period_end, + entitled: account?.entitled === true, + suspended: account?.suspended === true, + founding, + }); + }; + + app.get("/api/billing", gate, (c) => + billed(c, (billing, setup) => summary(c, billing, setup, c.get("login").userId)), + ); + + app.post("/api/billing/checkout", limit(JSON_BODY_BYTES), gate, async (c) => { + const { userId, email } = c.get("login"); + const body = await readJson<{ plan?: unknown; ref?: unknown }>(c); + const plan = body?.plan; + if (!isPlan(plan)) return c.json({ message: "Choose monthly, yearly, or founding." }, 400); + const ref = checkoutRef(body?.ref); + return billed(c, async (billing) => { + // A checkout left open for another plan gives way to this one. + const open = await billing.db + .selectFrom("checkouts") + .select("plan") + .where("owner_id", "=", userId) + .where("status", "in", ["pending", "open"]) + .executeTakeFirst(); + if (open && open.plan !== plan) await billing.cancelCheckout(userId); + // Without an email, Stripe Checkout asks for one. + const { url } = await billing.checkout(userId, email, plan, ref === "none" ? {} : { ref }); + recordMetric(c, host(c).databaseUrl, "checkout.started", `${plan}:${ref}`); + return c.json({ url }); + }); + }); + + app.post("/api/billing/confirm", limit(JSON_BODY_BYTES), gate, async (c) => { + const { userId } = c.get("login"); + const checkout = (await readJson<{ checkout?: unknown }>(c))?.checkout; + if (typeof checkout !== "string" || !CHECKOUT_ID.test(checkout)) + return c.json({ message: "Checkout not found." }, 404); + return billed(c, async (billing, setup) => { + await billing.confirmCheckout(userId, checkout); + return summary(c, billing, setup, userId); + }); + }); + + app.post("/api/billing/portal", limit(JSON_BODY_BYTES), gate, (c) => + billed(c, async (billing) => c.json({ url: await billing.portal(c.get("login").userId) })), + ); + + // Stripe's: the raw body under its signature, 2xx only once `webhook()` has + // committed; any other failure throws to `onError`'s 503, which Stripe retries. + app.post(BILLING_WEBHOOK_PATH, limit(WEBHOOK_BODY_BYTES), async (c) => { + const signature = c.req.header("stripe-signature"); + if (!signature) return c.json({ message: "Missing signature." }, 400); + const body = await c.req.text(); + return billed(c, async (billing) => { + await processEvent(billing, body, signature); + return c.json({ received: true }); + }); + }); +} + +/** The owner of Stripe customer `customer`, or null for one pgstencil did not create. */ +async function ownerOf(billing: Billing, customer: string | Stripe.Customer | Stripe.DeletedCustomer | null) { + const id = typeof customer === "string" ? customer : (customer?.id ?? null); + if (!id) return null; + const row = await billing.db + .selectFrom("accounts") + .select("owner_id") + .where("customer_id", "=", id) + .executeTakeFirst(); + return row?.owner_id ?? null; +} + +/** + * One verified event: pgstencil's synchronization, and in its transaction + * the counts and dispute suspensions, so a redelivery repeats neither. A full + * refund or a lost dispute then ends the subscription in Stripe, after the + * commit and on every delivery until it has. + */ +async function processEvent(billing: Billing, body: string, signature: string) { + const event = await billing.verifyWebhook(body, signature); + // A dispute names no customer, and its event may be stale: read its current + // status and its charge from Stripe, before any write. + let dispute: { id: string; charge: Stripe.Charge; owner: string | null; action: ReturnType } | null = + null; + if (event.type.startsWith("charge.dispute.")) { + const current = await billing.stripe.disputes.retrieve((event.data.object as Stripe.Dispute).id, { + expand: ["charge"], + }); + const charge = current.charge as Stripe.Charge; + dispute = { id: current.id, charge, owner: await ownerOf(billing, charge.customer), action: disputeAction(current.status) }; + } + await billing.webhook(body, signature, async (verified, trx) => { + const count = (event: MetricEvent, label: string) => + countMetricInTransaction((text, values) => trx.executeQuery(CompiledQuery.raw(text, values)), event, label); + const completed = await billing.completedCheckout(verified, trx); + if (completed && isPlan(completed.plan)) + await count("checkout.completed", `${completed.plan}:${checkoutRef(completed.ref)}`); + if (verified.type === "customer.subscription.deleted") { + const subscription = verified.data.object as Stripe.Subscription; + const plan = billing.planOf(subscription.items.data[0]?.price.id); + const standing = purchaseStanding(subscription, { refundDays: REFUND_DAYS }); + if (plan && (standing === "purchased" || standing === "refund-window")) + await count( + standing === "refund-window" || subscription.cancellation_details?.comment === ENDED_FOR_REFUND + ? "subscription.refunded" + : "subscription.canceled", + plan, + ); + } + if (dispute?.owner) await recordDispute(trx, dispute.id, dispute.owner, dispute.action); + }); + const refunded = event.type === "charge.refunded" ? (event.data.object as Stripe.Charge) : null; + const reversed = refunded?.refunded ? refunded : dispute?.action === "end" ? dispute.charge : null; + if (reversed) await endForRefund(billing, reversed); +} + +/** + * The subscription `charge` paid for, through its invoice: the pinned API + * version's charge names no invoice, so the invoice payments of its + * PaymentIntent do. Null when that names no one subscription. + */ +async function subscriptionPaidBy(billing: Billing, charge: Stripe.Charge): Promise { + const intent = typeof charge.payment_intent === "string" ? charge.payment_intent : (charge.payment_intent?.id ?? null); + if (!intent) return null; + const payments = await billing.stripe.invoicePayments.list({ + payment: { type: "payment_intent", payment_intent: intent }, + expand: ["data.invoice"], + }); + const paid = new Set( + payments.data.flatMap((payment) => { + const subscription = (payment.invoice as Stripe.Invoice).parent?.subscription_details?.subscription; + return subscription ? [typeof subscription === "string" ? subscription : subscription.id] : []; + }), + ); + return paid.size === 1 ? [...paid][0]! : null; +} + +/** Opens or closes dispute `disputeId`'s suspension of `owner`, as `action` says. */ +async function recordDispute( + trx: Kysely, + disputeId: string, + owner: string, + action: ReturnType, +) { + if (action === "suspend") + await trx.executeQuery( + CompiledQuery.raw( + `INSERT INTO public.dormouse_billing_disputes ("disputeId", "userId") VALUES ($1, $2) + ON CONFLICT ("disputeId") DO NOTHING`, + [disputeId, owner], + ), + ); + else if (action) + await trx.executeQuery( + CompiledQuery.raw(`DELETE FROM public.dormouse_billing_disputes WHERE "disputeId" = $1`, [disputeId]), + ); +} + +/** + * Ends at once, without proration or a final invoice, the subscription that + * `charge` paid for, now fully refunded or finally reversed, while it is its + * owner's current one. A charge for an older subscription, one that names no + * subscription, or one already ended, as on a redelivery once it has, ends + * nothing; the first two are logged for the operator. + */ +async function endForRefund(billing: Billing, charge: Stripe.Charge) { + const owner = await ownerOf(billing, charge.customer); + if (!owner) return; + await billing.reconcile(owner); + const { subscription } = await billing.status(owner); + if (!subscription || !CURRENT_STATUSES.includes(subscription.status)) return; + const paidFor = await subscriptionPaidBy(billing, charge); + if (paidFor !== subscription.id) { + console.warn( + `Charge ${charge.id} was refunded or reversed in full, but paid for ${paidFor ?? "no one subscription"}, not ${subscription.id}; ending nothing`, + ); + return; + } + await billing.stripe.subscriptions.cancel(subscription.id, { + cancellation_details: { comment: ENDED_FOR_REFUND }, + prorate: false, + invoice_now: false, + }); + await billing.reconcile(owner); +} diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 532a5d3fb..e861c5c12 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -5,10 +5,10 @@ import { connectDatabase, queryDatabase } from "pgstencil/postgres"; import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER } from "../../website/src/lib/hosted-pricing"; import type { BillingEnv } from "./bindings"; import { grantsAccessSql } from "./entitlement"; -import { BILLING_RETURN_PATH } from "./policy-constants"; +import { BILLING_PLANS, BILLING_RETURN_PATH } from "./policy-constants"; /** The plans checkout sells, by the names `status()` and a checkout use. */ -export type Plan = "monthly" | "yearly" | "founding"; +export type Plan = (typeof BILLING_PLANS)[number]; /** A purchase that ends within this many days of starting is a refund: its seat returns to its cohort. */ export const REFUND_DAYS = 30; diff --git a/hosted/server/dormouse-migrations/014_billing_disputes.sql b/hosted/server/dormouse-migrations/014_billing_disputes.sql new file mode 100644 index 000000000..4bb241f9d --- /dev/null +++ b/hosted/server/dormouse-migrations/014_billing_disputes.sql @@ -0,0 +1,13 @@ +-- Up Migration +-- An open payment dispute suspends its account's subscription without ending +-- it (docs/specs/hosted.md -> "Billing"): one row per dispute Stripe holds +-- open, deleted when it closes. The entitlement refuses an account with any. +CREATE TABLE dormouse_billing_disputes ( + "disputeId" text PRIMARY KEY, + "userId" text NOT NULL REFERENCES "user" (id) ON DELETE CASCADE, + "openedAt" timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX dormouse_billing_disputes_user ON dormouse_billing_disputes ("userId"); + +-- Down Migration +DROP TABLE dormouse_billing_disputes; diff --git a/hosted/server/entitlement.ts b/hosted/server/entitlement.ts index be4c821bb..352e1885c 100644 --- a/hosted/server/entitlement.ts +++ b/hosted/server/entitlement.ts @@ -11,7 +11,8 @@ if (!/^[^'\\]+$/.test(ADMIN_EMAIL)) throw new Error("ADMIN_EMAIL cannot be inlin * The statuses `@pgstencil/stripe`'s `status()` counts as a current * subscription; access needs exactly one of them. */ -const CURRENT = `'trialing', 'active', 'past_due', 'unpaid', 'paused', 'incomplete'`; +export const CURRENT_STATUSES: readonly string[] = ["trialing", "active", "past_due", "unpaid", "paused", "incomplete"]; +const CURRENT = CURRENT_STATUSES.map((status) => `'${status}'`).join(", "); /** * Whether subscription row `s` grants access now, by the database's clock: @@ -33,15 +34,19 @@ function subscribedSql(owner: string): string { FROM pgstencil_billing.subscriptions entitling WHERE entitling.owner_id = ${owner}), false)`; } +/** Whether the SQL expression `owner` has a payment dispute open, which suspends its subscription. */ +const disputedSql = (owner: string) => + `EXISTS (SELECT FROM dormouse_billing_disputes disputed WHERE disputed."userId" = ${owner})`; + /** * The entitlement (docs/specs/pricing.md -> "Checkout and entitlement"): a * SQL boolean over the `"user"` row aliased by `user` (default `u`), so a * query that resolves a bearer resolves its owner's entitlement in the same - * statement. The account's subscription grants it, and the verified - * `ADMIN_EMAIL` is a standing comp. + * statement. The account's subscription grants it unless a payment dispute + * is open, and the verified `ADMIN_EMAIL` is a standing comp. */ export function entitledSql(user = "u"): string { - return `(${adminSql(user)} OR ${subscribedSql(`${user}.id`)})`; + return `(${adminSql(user)} OR (${subscribedSql(`${user}.id`)} AND NOT ${disputedSql(`${user}.id`)}))`; } /** Whether the `"user"` row aliased by `user` is the admin's: `ADMIN_EMAIL`, verified. */ diff --git a/hosted/server/metric-labels.ts b/hosted/server/metric-labels.ts index 8274ff6b8..d3fa249d6 100644 --- a/hosted/server/metric-labels.ts +++ b/hosted/server/metric-labels.ts @@ -1,7 +1,18 @@ // Rules: docs/specs/hosted.md -> "Metrics". No I/O: the admin page's bundle // imports this as well as the Workers. +import { HOSTED_REFS, isHostedRef } from "dormouse-lib/lib/hosted-links"; +import { BILLING_PLANS } from "./policy-constants"; import { providerIds } from "./providers.js"; +/** A checkout's attribution: one of `HOSTED_REFS`, `other` for any other value, `none` without one. */ +export const checkoutRef = (ref: unknown) => + ref === undefined || ref === null ? "none" : isHostedRef(ref) ? ref : "other"; + +/** Every `plan:ref` label a checkout count may carry. */ +const PLAN_REFS = BILLING_PLANS.flatMap((plan) => + [...Object.values(HOSTED_REFS), "other", "none"].map((ref) => `${plan}:${ref}`), +); + /** * Every event Hosted counts, and the only labels each may carry; `[""]` for * one counted without a label. @@ -15,6 +26,10 @@ export const METRIC_LABELS = { "voice.fallback-cap": [""], "push.sent": [""], "pocket.signin": [""], + "checkout.started": PLAN_REFS, + "checkout.completed": PLAN_REFS, + "subscription.canceled": BILLING_PLANS, + "subscription.refunded": BILLING_PLANS, } as const satisfies Record; export type MetricEvent = keyof typeof METRIC_LABELS; diff --git a/hosted/server/metrics.ts b/hosted/server/metrics.ts index ffcf3432e..094a76e01 100644 --- a/hosted/server/metrics.ts +++ b/hosted/server/metrics.ts @@ -16,8 +16,20 @@ import { * {@link recordMetric} or {@link authMetricHooks}. */ export async function countMetric(databaseUrl: string, event: MetricEvent, label = "", n = 1) { - await queryDatabase( - databaseUrl, + await countMetricWith((text, values) => queryDatabase(databaseUrl, text, values), event, label, n); +} + +/** + * {@link countMetric} through `query`, such as an open transaction's, so the + * count commits or rolls back with it. + */ +export async function countMetricWith( + query: (text: string, values: unknown[]) => Promise, + event: MetricEvent, + label = "", + n = 1, +) { + await query( `INSERT INTO dormouse_metrics_daily AS m (day, event, label, count) VALUES ((now() AT TIME ZONE 'UTC')::date, $1, $2, $3) ON CONFLICT (day, event, label) DO UPDATE SET count = m.count + EXCLUDED.count`, @@ -25,6 +37,27 @@ export async function countMetric(databaseUrl: string, event: MetricEvent, label ); } +/** + * {@link countMetricWith} inside an open transaction, under a savepoint of + * its own: the count commits with the transaction, so a redelivered event + * never counts twice, and a failed count rolls back only itself and is + * logged, never failing the transaction it rides in. + */ +export async function countMetricInTransaction( + query: (text: string, values: unknown[]) => Promise, + event: MetricEvent, + label = "", +) { + await query("SAVEPOINT dormouse_metric", []); + try { + await countMetricWith(query, event, label); + await query("RELEASE SAVEPOINT dormouse_metric", []); + } catch (error) { + await query("ROLLBACK TO SAVEPOINT dormouse_metric", []); + console.error(`Metric ${event} not recorded: ${error instanceof Error ? error.message : "unknown error"}`); + } +} + /** `write`, settled: it never rejects, and its failure is only logged. */ const loggedFailure = (what: string, write: Promise) => write.then( diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index f989913e1..4cdcb4ff6 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -37,3 +37,7 @@ export const TERMS_VERSION = "2026-10-28"; // Where Stripe returns the browser from Checkout and the customer portal: an // account page on the account origin. export const BILLING_RETURN_PATH = "/billing"; + +// The plans checkout sells, by the names a checkout request, `status()`, and +// the billing metrics use. +export const BILLING_PLANS = ["monthly", "yearly", "founding"] as const; diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql index 5eee92df9..ae8936b15 100644 --- a/hosted/server/runtime-roles.sql +++ b/hosted/server/runtime-roles.sql @@ -62,11 +62,13 @@ $$; GRANT USAGE ON SCHEMA public TO dormouse_relay, dormouse_voice; -- Both: the entitlement (hosted/server/entitlement.ts) reads the owner's --- address and its verification, and the owner's subscriptions. +-- address and its verification, the owner's subscriptions, and whether a +-- payment dispute suspends them. 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; +GRANT SELECT ("userId") ON dormouse_billing_disputes TO dormouse_relay, dormouse_voice; -- Both: their metrics' upsert-increment (hosted/server/metrics.ts), whose -- conflict needs the read. A row is an aggregate count. diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts new file mode 100644 index 000000000..100d6c87c --- /dev/null +++ b/hosted/server/tests/billing-routes.test.ts @@ -0,0 +1,446 @@ +import { test, expect, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { Hono } from "hono"; +import { createTestContext } from "pgstencil/testing"; +import { queryDatabase } from "pgstencil/postgres"; +import { FOUNDING_COHORT_SIZE } from "../../../website/src/lib/hosted-pricing"; +import type { BillingSetup } from "../billing"; +import { + BILLING_WEBHOOK_PATH, + CHECKOUT_CLOSED, + ENDED_FOR_REFUND, + WEBHOOK_BODY_BYTES, + billingRoutes, + disputeAction, +} from "../billing-routes"; +import { entitledSql } from "../entitlement"; +import { migrations } from "../migrations"; +import { ORIGINS } from "./bundle"; +import { stripeFake } from "./stripe-fake"; +import { workerDatabases } from "./worker-roles"; + +// The billing routes in Node, on real Postgres and the Stripe fake: what a +// signed-in account may ask, what Stripe's webhook does, and what each counts. + +const origin = ORIGINS.account; + +async function fixture({ closed = false } = {}) { + const context = await createTestContext({ migrations, now: new Date().toISOString() }); + const databases = await workerDatabases(context.database.url); + const stripe = await stripeFake(context.time, context.random); + const sql = >(text: string, values?: unknown[]) => + queryDatabase(context.database.url, text, values); + /** user id → its session's user, as `get-session` answers it. */ + const users = new Map(); + const app = new Hono(); + billingRoutes(app, () => ({ + databaseUrl: context.database.url, + auth: async (request) => { + const id = /(?:^|; )u=([^;]+)/.exec(request.headers.get("cookie") ?? "")?.[1]; + const user = id && users.get(id); + return Response.json(user ? { user, session: { createdAt: new Date().toISOString() } } : null); + }, + setup: (): BillingSetup | null => (closed ? null : stripe.setup), + })); + // The account Worker's `onError`. + app.onError((_error, c) => c.json({ message: "unavailable" }, 503)); + + /** A signed-in account; `email: null` is a provider-only one. */ + const account = async (email: string | null = `${randomUUID()}@example.test`, emailVerified = email !== null) => { + const id = randomUUID(); + await sql(`INSERT INTO "user" (id, name, email, "emailVerified") VALUES ($1, $1, $2, $3)`, [ + id, + email ?? `${id}@identity.pgstencil.invalid`, + emailVerified, + ]); + users.set(id, { id, email, emailVerified }); + const request = (path: string, init: { method?: string; body?: unknown; headers?: Record } = {}) => + app.request(origin + path, { + method: init.method ?? "GET", + headers: { + cookie: `u=${id}`, + "content-type": "application/json", + ...((init.method ?? "GET") === "GET" ? { "sec-fetch-site": "same-origin" } : { origin }), + ...init.headers, + }, + ...(init.body !== undefined && { body: typeof init.body === "string" ? init.body : JSON.stringify(init.body) }), + }); + const summary = async () => (await (await request("/api/billing")).json()) as Record; + return { id, request, summary }; + }; + + const webhook = (body: string, signature?: string) => + app.request(origin + BILLING_WEBHOOK_PATH, { + method: "POST", + headers: { "content-type": "application/json", ...(signature && { "stripe-signature": signature }) }, + body, + }); + /** Signs and sends `event` to the webhook. */ + const send = (event: Parameters[0]) => { + const { body, signature } = stripe.dev.signed(event); + return webhook(body, signature); + }; + /** Every event {@link deliver} sent, in order. */ + const delivered: Parameters[0][] = []; + /** Sends every pending StripeDev event, each answered 200. */ + const deliver = async () => { + while (stripe.dev.events.length) { + const response = await send(stripe.dev.events[0]!); + expect(response.status, await response.clone().text()).toBe(200); + delivered.push(stripe.dev.events.shift()!); + } + }; + /** A request with no login, from the account's own page. */ + const anonymous = (path: string, method = "GET") => + app.request(origin + path, { + method, + headers: { "content-type": "application/json", ...(method === "GET" ? { "sec-fetch-site": "same-origin" } : { origin }) }, + ...(method === "POST" && { body: "{}" }), + }); + /** Starts `plan` for `who` and completes it in StripeDev, delivering its events. */ + const buy = async (who: Awaited>, plan: string, ref?: string) => { + const started = await who.request("/api/billing/checkout", { method: "POST", body: { plan, ref } }); + expect(started.status, await started.clone().text()).toBe(200); + const { url } = (await started.json()) as { url: string }; + const session = [...stripe.dev.checkouts.values()].find((s) => s.url === url)!; + const subscription = stripe.dev.completeCheckout(session.id); + await deliver(); + return { session, subscription }; + }; + const metrics = () => + sql<{ event: string; label: string; count: string }>( + `SELECT event, label, count FROM dormouse_metrics_daily WHERE event LIKE 'checkout.%' OR event LIKE 'subscription.%' + ORDER BY event, label`, + ).then((rows) => rows.map((row) => `${row.event} ${row.label} ${row.count}`)); + /** Whether `userId` is entitled, as each Worker's own role reads it. */ + const entitledOn = async (userId: string) => { + const read = async (url: string) => + (await queryDatabase<{ is: boolean }>(url, `SELECT ${entitledSql("u")} AS is FROM "user" u WHERE u.id = $1`, [userId]))[0] + ?.is; + const all = [await read(databases.account), await read(databases.relay), await read(databases.voice)]; + expect(new Set(all).size, "every Worker's role agrees").toBe(1); + return all[0]; + }; + return { + ...context, + ...stripe, + sql, + account, + anonymous, + delivered, + webhook, + send, + deliver, + buy, + metrics, + entitledOn, + close: async () => { + await stripe.close(); + await context.close(); + }, + }; +} + +test("a dispute's status suspends, restores, or ends, and an inquiry changes nothing", () => { + expect( + ["needs_response", "under_review", "won", "warning_closed", "prevented", "lost", "warning_needs_response", "new_status"].map( + disputeAction, + ), + ).toEqual(["suspend", "suspend", "restore", "restore", "restore", "end", null, null]); +}); + +test("closed, every billing route answers 503 to a signed-in account, after the login gate", async ({ onTestFinished }) => { + const f = await fixture({ closed: true }); + onTestFinished(f.close); + const member = await f.account(); + for (const [method, path, body] of [ + ["GET", "/api/billing"], + ["POST", "/api/billing/checkout", { plan: "monthly" }], + ["POST", "/api/billing/confirm", { checkout: "op_x" }], + ["POST", "/api/billing/portal"], + ] as const) { + const response = await member.request(path, { method, body }); + expect([path, response.status, await response.json()]).toEqual([path, 503, { message: CHECKOUT_CLOSED }]); + expect((await f.anonymous(path, method)).status, path).toBe(401); + } + const { body, signature } = f.dev.signed(f.event("customer.updated", { id: "cus_x" })); + expect((await f.webhook(body, signature)).status).toBe(503); +}); + +test("checkout takes a plan and a ref, never a Price, customer, quantity, or return URL, and counts from the webhook once", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + expect(await member.summary()).toMatchObject({ plan: null, active: false, entitled: false, suspended: false }); + for (const plan of ["price_dev_monthly", "annual", "", 7, null]) { + const refused = await member.request("/api/billing/checkout", { method: "POST", body: { plan } }); + expect([refused.status, await refused.json()], String(plan)).toEqual([400, { message: "Choose monthly, yearly, or founding." }]); + } + const big = JSON.stringify({ plan: "monthly", pad: "x".repeat(4096) }); + expect((await member.request("/api/billing/checkout", { method: "POST", body: big })).status).toBe(413); + // Another origin's page never starts one. + expect( + (await member.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly" }, headers: { origin: ORIGINS.relay } })) + .status, + ).toBe(403); + + // A first checkout for monthly, then founding: the open monthly one is expired first. + const first = await member.request("/api/billing/checkout", { + method: "POST", + body: { + plan: "monthly", + ref: "settings-voice", + price: "price_x", + customer: "cus_x", + quantity: 3, + success_url: "https://attacker.example/", + owner: "someone-else", + }, + }); + expect(first.status).toBe(200); + const sessions = () => f.dev.requests.filter((r) => r.path === "/v1/checkout/sessions"); + expect(sessions()[0]!.body).toMatchObject({ + "line_items[0][price]": f.setup.monthly, + "line_items[0][quantity]": "1", + "metadata[pgstencil_ref]": "settings-voice", + client_reference_id: member.id, + success_url: expect.stringMatching(new RegExp(`^${origin}/billing\\?`)), + }); + expect(f.dev.requests.find((r) => r.path === "/v1/customers")!.body).toMatchObject({ email: expect.stringContaining("@example.test") }); + + const { session } = await f.buy(member, "founding", "settings-voice"); + expect([...f.dev.checkouts.values()][0]!.status).toBe("expired"); + expect(sessions()[1]!.body["line_items[0][price]"]).toBe(f.setup.founding[0]); + await vi.waitFor(async () => + expect(await f.metrics()).toEqual([ + "checkout.completed founding:settings-voice 1", + "checkout.started founding:settings-voice 1", + "checkout.started monthly:settings-voice 1", + ]), + ); + // A redelivery is answered, and counts nothing again. + expect((await f.send(f.delivered.find((event) => event.type === "checkout.session.completed")!)).status).toBe(200); + expect(await f.metrics()).toContain("checkout.completed founding:settings-voice 1"); + + expect(await member.summary()).toEqual({ + plan: "founding", + active: true, + until: expect.any(String), + renews: true, + entitled: true, + suspended: false, + founding: { cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE - 1 }, + }); + // The return page confirms the operation it names, and only a well-formed one. + for (const checkout of ["", "../x", "x".repeat(65), 7]) + expect((await member.request("/api/billing/confirm", { method: "POST", body: { checkout } })).status).toBe(404); + const confirmed = await member.request("/api/billing/confirm", { + method: "POST", + body: { checkout: session.metadata!.pgstencil_operation }, + }); + expect([confirmed.status, await confirmed.json()]).toEqual([200, expect.objectContaining({ plan: "founding" })]); + // A held subscription is managed in the portal, never bought twice. + const portal = await member.request("/api/billing/portal", { method: "POST" }); + expect(((await portal.json()) as { url: string }).url).toBe(`${f.dev.origin}/portal/${session.customer}`); + expect((await member.request("/api/billing/checkout", { method: "POST", body: { plan: "yearly" } })).status).toBe(409); + + // An unknown ref counts as `other`, and none as `none`, which sends Stripe no ref. + const other = await f.account(); + await f.buy(other, "yearly", "attacker-chosen"); + const none = await f.account(null); + await f.buy(none, "monthly"); + const created = sessions().slice(-2).map((s) => s.body["metadata[pgstencil_ref]"]); + expect(created).toEqual(["other", undefined]); + // A provider-only account sends Stripe no email: Checkout asks for one. + expect(f.dev.requests.filter((r) => r.path === "/v1/customers").at(-1)!.body).toEqual({ + "metadata[pgstencil_owner]": none.id, + }); + // Nor does an address the account has not verified. + const unverified = await f.account(`${randomUUID()}@example.test`, false); + expect((await unverified.request("/api/billing/checkout", { method: "POST", body: { plan: "monthly" } })).status).toBe(200); + expect(f.dev.requests.filter((r) => r.path === "/v1/customers").at(-1)!.body).toEqual({ + "metadata[pgstencil_owner]": unverified.id, + }); + await vi.waitFor(async () => + expect(await f.metrics()).toEqual([ + "checkout.completed founding:settings-voice 1", + "checkout.completed monthly:none 1", + "checkout.completed yearly:other 1", + "checkout.started founding:settings-voice 1", + "checkout.started monthly:none 2", + "checkout.started monthly:settings-voice 1", + "checkout.started yearly:other 1", + ]), + ); +}); + +test("the webhook verifies the raw body before any write, caps it, and refuses another mode, API version, or Connect account", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const event = f.event("customer.updated", { id: "cus_x" }); + const { body, signature } = f.dev.signed(event); + expect((await f.webhook(body)).status).toBe(400); + expect((await f.webhook(body.replace(event.id, "evt_forged"), signature)).status).toBe(400); + expect((await f.webhook(body, "t=1,v1=00")).status).toBe(400); + expect((await f.webhook("x".repeat(WEBHOOK_BODY_BYTES + 1), signature)).status).toBe(413); + for (const extra of [{ livemode: true }, { api_version: "2020-08-27" }, { account: "acct_connected" }]) { + const { body: other, signature: signed } = f.dev.signed(f.event("customer.updated", { id: "cus_x" }, extra)); + expect((await f.webhook(other, signed)).status, JSON.stringify(extra)).toBe(400); + } + expect(await f.sql(`SELECT id FROM pgstencil_billing.events`)).toEqual([]); + expect((await f.webhook(body, signature)).status).toBe(200); + expect(await f.sql(`SELECT id FROM pgstencil_billing.events`)).toEqual([{ id: event.id }]); + // A failure before the commit answers 503, which Stripe retries, and the event stays unprocessed. + const { body: disputed, signature: disputeSignature } = f.dev.signed( + f.event("charge.dispute.created", { id: "du_unknown", object: "dispute" }), + ); + expect((await f.webhook(disputed, disputeSignature)).status).toBe(503); + expect(await f.sql(`SELECT count(*)::int AS n FROM pgstencil_billing.events WHERE processed_at IS NOT NULL`)).toEqual([{ n: 1 }]); +}); + +test("a full refund ends the subscription it paid for at once; a partial one changes nothing", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + const { session, subscription } = await f.buy(member, "founding"); + f.paidInvoices.set("pi_1", subscription.id); + const charge = (refunded: boolean, paymentIntent = "pi_1") => + f.event("charge.refunded", { + id: `ch_${paymentIntent}`, + object: "charge", + customer: session.customer, + payment_intent: paymentIntent, + refunded, + amount: 5000, + amount_refunded: refunded ? 5000 : 1000, + }); + + expect((await f.send(charge(false))).status).toBe(200); + expect(f.cancels).toEqual([]); + expect(await f.entitledOn(member.id)).toBe(true); + + // Past the refund window, as a yearly renewal's refund is: the subscription's end says why. + f.time.advanceMilliseconds(31 * 24 * 60 * 60 * 1000); + const full = charge(true); + expect((await f.send(full)).status).toBe(200); + expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_REFUND }]); + expect(await f.entitledOn(member.id)).toBe(false); + expect(await member.summary()).toMatchObject({ plan: null, active: false, entitled: false }); + await f.deliver(); + // A redelivery ends nothing more. + expect((await f.send(full)).status).toBe(200); + expect(f.cancels).toHaveLength(1); + await vi.waitFor(async () => expect(await f.metrics()).toContain("subscription.refunded founding 1")); + expect((await f.metrics()).filter((row) => row.startsWith("subscription.canceled"))).toEqual([]); +}); + +test("an open dispute suspends without ending, closing it in the account's favor restores it, and a lost one ends it", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const member = await f.account(); + const { session, subscription } = await f.buy(member, "yearly"); + f.paidInvoices.set("pi_du_2", subscription.id); + const dispute = (id: string, status: string) => { + f.disputes.set(id, { status, customer: session.customer as string, paymentIntent: `pi_${id}` }); + return f.event(status === "needs_response" ? "charge.dispute.created" : "charge.dispute.closed", { + id, + object: "dispute", + status, + }); + }; + + const opened = dispute("du_1", "needs_response"); + expect((await f.send(opened)).status).toBe(200); + expect(await f.entitledOn(member.id)).toBe(false); + expect(await member.summary()).toMatchObject({ plan: "yearly", active: true, entitled: false, suspended: true }); + expect(f.cancels).toEqual([]); + + expect((await f.send(dispute("du_1", "won"))).status).toBe(200); + expect(await f.entitledOn(member.id)).toBe(true); + // A stale delivery of the opening is read against the dispute's current status. + expect((await f.send({ ...opened, id: "evt_stale_redelivery" })).status).toBe(200); + expect(await f.entitledOn(member.id)).toBe(true); + + expect((await f.send(dispute("du_2", "needs_response"))).status).toBe(200); + expect(await f.entitledOn(member.id)).toBe(false); + expect((await f.send(dispute("du_2", "lost"))).status).toBe(200); + expect(f.cancels).toEqual([{ subscription: subscription.id, comment: ENDED_FOR_REFUND }]); + expect(await f.sql(`SELECT "disputeId" FROM dormouse_billing_disputes`)).toEqual([]); + expect(await member.summary()).toMatchObject({ plan: null, entitled: false, suspended: false }); +}); + +test("a purchase ending otherwise counts as canceled, and one ending within the refund window as refunded", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const kept = await f.account(); + const { subscription: long } = await f.buy(kept, "monthly"); + const quick = await f.account(); + const { subscription: short } = await f.buy(quick, "yearly"); + f.dev.transition(short.id, "cancel"); + await f.deliver(); + f.time.advanceMilliseconds(31 * 24 * 60 * 60 * 1000); + f.dev.transition(long.id, "cancel"); + await f.deliver(); + const ends = (await f.metrics()).filter((row) => row.startsWith("subscription.")); + expect(ends).toEqual(["subscription.canceled monthly 1", "subscription.refunded yearly 1"]); +}); + +test("a full refund of a charge for an earlier subscription, or one naming none, ends nothing and is logged", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + onTestFinished(() => warn.mockRestore()); + const member = await f.account(); + const { session, subscription: earlier } = await f.buy(member, "monthly"); + f.dev.transition(earlier.id, "cancel"); + await f.deliver(); + const { subscription: current } = await f.buy(member, "yearly"); + f.paidInvoices.set("pi_earlier", earlier.id); + f.paidInvoices.set("pi_current", current.id); + const refund = (paymentIntent: string | null) => + f.event("charge.refunded", { + id: `ch_${paymentIntent}`, + object: "charge", + customer: session.customer, + payment_intent: paymentIntent, + refunded: true, + }); + for (const paymentIntent of ["pi_earlier", "pi_unknown", null]) { + expect((await f.send(refund(paymentIntent))).status, String(paymentIntent)).toBe(200); + expect(f.cancels, String(paymentIntent)).toEqual([]); + } + expect(warn).toHaveBeenCalledTimes(3); + expect(await f.entitledOn(member.id)).toBe(true); + // The current subscription's own charge still ends it. + expect((await f.send(refund("pi_current"))).status).toBe(200); + expect(f.cancels).toEqual([{ subscription: current.id, comment: ENDED_FOR_REFUND }]); +}); + +test("a count that fails rolls back alone and is logged: the webhook still commits and answers 200", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + onTestFinished(() => error.mockRestore()); + await f.sql(`CREATE FUNCTION refuse_metric() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'metric refused'; END $$`); + await f.sql(`CREATE TRIGGER refuse_metric BEFORE INSERT ON dormouse_metrics_daily + FOR EACH ROW WHEN (NEW.event = 'checkout.completed') EXECUTE FUNCTION refuse_metric()`); + const member = await f.account(); + await f.buy(member, "monthly"); + expect(await f.entitledOn(member.id)).toBe(true); + expect(await f.sql(`SELECT count(*)::int AS n FROM pgstencil_billing.events WHERE processed_at IS NULL`)).toEqual([{ n: 0 }]); + expect(error).toHaveBeenCalledWith(expect.stringContaining("Metric checkout.completed not recorded")); + expect((await f.metrics()).filter((row) => row.startsWith("checkout.completed"))).toEqual([]); +}); diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 2b6c68ce1..811157df9 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -33,6 +33,7 @@ import { cookieEntitled, openAuthRead } from "../account-gate"; import { createAuthApp } from "@pgstencil/auth/better-auth"; import { authPolicy } from "../policy"; import { prelaunchAuth } from "../prelaunch"; +import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED } from "../billing-routes"; import { RECENT_LOGIN_REQUIRED, relayAccountRoutes } from "../relay-account"; import type { RelayRoomRpc } from "../relay-room-contract"; import { voiceApp } from "../voice-app"; @@ -144,6 +145,15 @@ const ACCOUNT_RELAY: [string, string][] = [ ["DELETE", "/api/relay/passkeys/AAAA"], ]; +/** 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"], + ["POST", BILLING_WEBHOOK_PATH], +]; + /** A route each Worker serves, as method and path. */ const SERVED: Record = { account: [ @@ -152,6 +162,7 @@ const SERVED: Record = { ["GET", "/api/voice"], ["DELETE", "/api/voice"], ...ACCOUNT_RELAY, + ...ACCOUNT_BILLING, ["GET", "/login"], ], relay: [ @@ -231,6 +242,7 @@ const ABSENT: Record = { ["GET", "/api/voice"], ["DELETE", "/api/voice"], ...ACCOUNT_RELAY, + ...ACCOUNT_BILLING, ["POST", "/api/voice/speak"], ["DELETE", "/api/voice/token"], // The self-host installers' probe; neither a Burrow nor Pocket asks Hosted for it. @@ -253,6 +265,7 @@ const ABSENT: Record = { ["GET", "/login"], ...RELAY_API, ...ACCOUNT_RELAY, + ...ACCOUNT_BILLING, ["GET", WS_ROUTES.burrow], ["GET", WS_ROUTES.client], ], @@ -270,6 +283,17 @@ test.for(NAMES)("%s: serves only its own routes", async (name) => { expect(outbound).toEqual([]); }); +test("without Stripe bindings the account's webhook is closed before any database or Stripe request", async () => { + const response = await workers.account.dispatchFetch(ORIGINS.account + BILLING_WEBHOOK_PATH, { + method: "POST", + headers: { "stripe-signature": "t=1,v1=00", "content-type": "application/json" }, + body: "{}", + }); + expect([response.status, await response.json()]).toEqual([503, { message: CHECKOUT_CLOSED }]); + expect(response.headers.get("content-security-policy")).toBe(ACCOUNT_POLICY); + 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); diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 8670a8a38..ef51afc6c 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -39,6 +39,8 @@ const PINNED: Record = { "45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230", "013_metrics.sql": "ffb460491d0d9d3cf00b33067b6cbe8dae04ca68fc663cb005aabf15619a08eb", + "014_billing_disputes.sql": + "ddf7454ce96f389f153d76d4001ca559fb5bd8969e6fda46e68dcd10e901a502", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts index 544ae9bc7..e1308b100 100644 --- a/hosted/server/tests/runtime-roles.test.ts +++ b/hosted/server/tests/runtime-roles.test.ts @@ -72,6 +72,7 @@ const EXPECTED: Record = { relay: [ // PUBLIC's defaults. "database: CONNECT, TEMPORARY", + "dormouse_billing_disputes.userId: SELECT", "dormouse_metrics_daily.count: UPDATE", "dormouse_metrics_daily: INSERT, SELECT", "dormouse_relay_burrows.enrolledAt: UPDATE", @@ -106,6 +107,7 @@ const EXPECTED: Record = { ], voice: [ "database: CONNECT, TEMPORARY", + "dormouse_billing_disputes.userId: SELECT", "dormouse_metrics_daily.count: UPDATE", "dormouse_metrics_daily: INSERT, SELECT", "dormouse_voice_tokens.createdAt: SELECT", @@ -152,10 +154,14 @@ const BILLING_REFUSED = [ `SELECT id FROM pgstencil_billing.events`, `UPDATE pgstencil_billing.subscriptions SET status = status`, `DELETE FROM pgstencil_billing.subscriptions`, + `SELECT "disputeId" FROM dormouse_billing_disputes`, + `INSERT INTO dormouse_billing_disputes ("disputeId", "userId") VALUES ('x', 'x')`, + `DELETE FROM dormouse_billing_disputes`, ]; /** What the entitlement reads of the subscriptions, which both roles may. */ -const ENTITLEMENT_READ = `SELECT owner_id, status, period_end, trial_end FROM pgstencil_billing.subscriptions`; +const ENTITLEMENT_READ = `SELECT owner_id, status, period_end, trial_end FROM pgstencil_billing.subscriptions + WHERE NOT EXISTS (SELECT FROM dormouse_billing_disputes d WHERE d."userId" = owner_id)`; 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. diff --git a/hosted/server/tests/stripe-fake.ts b/hosted/server/tests/stripe-fake.ts new file mode 100644 index 000000000..9b3af70b3 --- /dev/null +++ b/hosted/server/tests/stripe-fake.ts @@ -0,0 +1,120 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { STRIPE_API_VERSION, Stripe } from "@pgstencil/stripe"; +import type { RandomSource, Time } from "pgstencil"; +import { stripeDevBilling } from "../billing-dev"; + +/** + * StripeDev behind a proxy that adds what billing's refund and dispute + * handling call and StripeDev does not model: a dispute's retrieval, with + * its charge expanded, and a subscription's immediate cancellation. Every + * other request reaches StripeDev unchanged. + */ +export async function stripeFake(time: Time, random: RandomSource) { + const { dev, setup: devSetup } = await stripeDevBilling(time, random); + /** Dispute id → its current status, and its charge's customer and PaymentIntent. */ + const disputes = new Map(); + /** PaymentIntent id → the subscription whose invoice it paid. */ + const paidInvoices = new Map(); + /** Each cancellation billing asked for: the subscription and its comment. */ + const cancels: { subscription: string; comment: string | null }[] = []; + const server = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const raw = Buffer.concat(chunks).toString(); + const url = new URL(req.url!, "http://127.0.0.1"); + const json = (status: number, value: unknown) => + res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value)); + const dispute = /^\/v1\/disputes\/([^/]+)$/.exec(url.pathname); + if (dispute && req.method === "GET") { + const found = disputes.get(dispute[1]!); + if (!found) return json(404, { error: { message: "No such dispute" } }); + return json(200, { + id: dispute[1], + object: "dispute", + status: found.status, + charge: { id: `ch_for_${dispute[1]}`, object: "charge", customer: found.customer, payment_intent: found.paymentIntent }, + }); + } + if (url.pathname === "/v1/invoice_payments" && req.method === "GET") { + const intent = url.searchParams.get("payment[payment_intent]"); + const subscriptionId = intent && url.searchParams.get("payment[type]") === "payment_intent" && paidInvoices.get(intent); + return json(200, { + object: "list", + has_more: false, + data: subscriptionId + ? [ + { + id: `inpay_${intent}`, + object: "invoice_payment", + invoice: { + id: `in_${intent}`, + object: "invoice", + parent: { type: "subscription_details", subscription_details: { subscription: subscriptionId, metadata: null } }, + }, + }, + ] + : [], + }); + } + const subscription = /^\/v1\/subscriptions\/([^/]+)$/.exec(url.pathname); + if (subscription && req.method === "DELETE") { + // The SDK sends a DELETE's parameters in its query. + const comment = new URLSearchParams(raw || url.search).get("cancellation_details[comment]"); + cancels.push({ subscription: subscription[1]!, comment }); + dev.transition(subscription[1]!, "cancel"); + // Stripe carries the comment on the subscription it ends. + const ended = dev.events.at(-1)!.data.object as Stripe.Subscription; + ended.cancellation_details = { comment } as Stripe.Subscription.CancellationDetails; + return json(200, ended); + } + const forwarded = await fetch(dev.origin + url.pathname + url.search, { + method: req.method, + headers: Object.fromEntries( + Object.entries(req.headers).filter(([name]) => name !== "host" && name !== "content-length"), + ) as Record, + ...(req.method === "POST" ? { body: raw } : {}), + }); + res.writeHead(forwarded.status, { "content-type": "application/json" }).end(await forwarded.text()); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${port}`; + const stripe = new Stripe(devSetup.secretKey, { + host: "127.0.0.1", + port, + protocol: "http", + maxNetworkRetries: 0, + timeout: 5000, + }); + let sequence = 0; + /** A test-mode event of `type` about `object`, as Stripe would sign and send it. */ + const event = (type: string, object: Record, extra: Record = {}) => + ({ + id: `evt_fake_${++sequence}`, + object: "event", + api_version: STRIPE_API_VERSION, + created: Math.floor(time.now().getTime() / 1000), + livemode: false, + type, + data: { object }, + pending_webhooks: 1, + request: null, + ...extra, + }) as unknown as Stripe.Event; + return { + dev, + setup: { ...devSetup, stripe }, + origin, + disputes, + paidInvoices, + cancels, + event, + close: async () => { + server.close(); + await dev.close(); + }, + }; +} diff --git a/hosted/src/AdminMetrics.tsx b/hosted/src/AdminMetrics.tsx index 0418a3342..cfb4b0fe7 100644 --- a/hosted/src/AdminMetrics.tsx +++ b/hosted/src/AdminMetrics.tsx @@ -27,6 +27,10 @@ const EVENT_TITLES = { "voice.fallback-cap": "Accounts that spent the day’s voice", "push.sent": "Push notifications delivered", "pocket.signin": "Pocket sign-ins", + "checkout.started": "Checkouts started", + "checkout.completed": "Checkouts completed", + "subscription.canceled": "Subscriptions canceled", + "subscription.refunded": "Subscriptions refunded", } satisfies Record; export type AdminMetricsState = diff --git a/hosted/src/admin-metrics.test.ts b/hosted/src/admin-metrics.test.ts index 899431f92..564c8c4bf 100644 --- a/hosted/src/admin-metrics.test.ts +++ b/hosted/src/admin-metrics.test.ts @@ -10,11 +10,14 @@ describe("summarize", () => { { day: "2026-03-01", event: "voice.speak" as const, label: "ok", count: 2 }, { day: "2026-03-01", event: "voice.speak" as const, label: "error", count: 1 }, { day: "2026-03-01", event: "push.sent" as const, label: "", count: 9 }, + { day: "2026-03-01", event: "checkout.started" as const, label: "founding:tutorial", count: 2 }, ], totals: [ { event: "voice.speak" as const, label: "ok", count: 10 }, { event: "voice.speak" as const, label: "error", count: 1 }, { event: "push.sent" as const, label: "", count: 9 }, + { event: "checkout.started" as const, label: "founding:tutorial", count: 2 }, + { event: "checkout.started" as const, label: "yearly:none", count: 1 }, ], }; @@ -49,8 +52,23 @@ describe("summarize", () => { "voice.fallback-cap", "push.sent", "pocket.signin", + "checkout.started", + "checkout.completed", + "subscription.canceled", + "subscription.refunded", ]); expect(summaries.find((summary) => summary.event === "push.sent")!.labels).toEqual([]); expect(summaries.find((summary) => summary.event === "pocket.signin")!.total).toBe(0); }); + + it("lists a long label list's counted labels alone, and a short list's every label", () => { + const summaries = summarize(body); + expect(summaries.find((summary) => summary.event === "checkout.started")!.labels).toEqual([ + { label: "yearly:none", total: 1, window: 0 }, + { label: "founding:tutorial", total: 2, window: 2 }, + ]); + expect(summaries.find((summary) => summary.event === "subscription.canceled")!.labels).toEqual( + ["monthly", "yearly", "founding"].map((label) => ({ label, total: 0, window: 0 })), + ); + }); }); diff --git a/hosted/src/admin-metrics.ts b/hosted/src/admin-metrics.ts index 381b1036d..bda1067be 100644 --- a/hosted/src/admin-metrics.ts +++ b/hosted/src/admin-metrics.ts @@ -18,12 +18,18 @@ export interface EventSummary { /** The window's sum, today included. */ window: number; today: number; - /** Every listed label, in the allowlist's order; empty for an unlabeled event. */ + /** + * Every listed label, in the allowlist's order, or for a list longer than + * {@link EVERY_LABEL_MAX} only the counted ones; empty for an unlabeled event. + */ labels: LabelSummary[]; /** One sum per day of the window, oldest first, a day with no row as 0. */ daily: { day: string; count: number }[]; } +/** The longest label list shown whole; a longer one (a checkout's `plan:ref`) shows only what was counted. */ +export const EVERY_LABEL_MAX = 8; + /** `YYYY-MM-DD`, `back` UTC days before `today`. */ function dayBefore(today: string, back: number): string { const date = new Date(`${today}T00:00:00Z`); @@ -46,11 +52,13 @@ export function summarize(body: AdminMetricsBody): EventSummary[] { today: sum(recent.filter((row) => row.day === body.today)), labels: listed.includes("") ? [] - : listed.map((label) => ({ - label, - total: sum(totals.filter((row) => row.label === label)), - window: sum(recent.filter((row) => row.label === label)), - })), + : listed + .map((label) => ({ + label, + total: sum(totals.filter((row) => row.label === label)), + window: sum(recent.filter((row) => row.label === label)), + })) + .filter(({ total }) => listed.length <= EVERY_LABEL_MAX || total > 0), daily: days.map((day) => ({ day, count: sum(recent.filter((row) => row.day === day)) })), }; }); diff --git a/hosted/src/stories/AdminMetrics.stories.tsx b/hosted/src/stories/AdminMetrics.stories.tsx index 1e758d05a..2f0ad1ec1 100644 --- a/hosted/src/stories/AdminMetrics.stories.tsx +++ b/hosted/src/stories/AdminMetrics.stories.tsx @@ -30,6 +30,10 @@ const recent: MetricRow[] = Array.from({ length: 30 }, (_, i) => 29 - i).flatMap ...(back < 6 ? [{ day: day(back), event: "voice.fallback-cap" as const, label: "", count: 1 }] : []), { day: day(back), event: "push.sent", label: "", count: grown * 31 + (back % 7 < 2 ? 0 : 40) }, { day: day(back), event: "pocket.signin", label: "", count: Math.ceil(grown / 2) }, + ...(back % 2 === 0 ? [{ day: day(back), event: "checkout.started" as const, label: "founding:settings-voice", count: 2 }] : []), + ...(back % 5 === 0 ? [{ day: day(back), event: "checkout.started" as const, label: "yearly:none", count: 1 }] : []), + ...(back % 2 === 0 ? [{ day: day(back), event: "checkout.completed" as const, label: "founding:settings-voice", count: 1 }] : []), + ...(back === 4 ? [{ day: day(back), event: "subscription.refunded" as const, label: "founding", count: 1 }] : []), ]; }); const totals = Object.values( From df85b3dd4d21b06a6380494de2869b6a4b3e789d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 04:57:17 -0700 Subject: [PATCH 2/2] Specs and Privacy: billing routes, refunds, disputes, and counts hosted.md gains the billing routes table, the webhook, refund, and dispute rules, the dispute clause of the entitlement, and four metric rows, with the webhook's counts under their own savepoint. pricing.md promotes the refund and dispute rule above the fold and narrows the paid-launch dispute gate to the notice. security-hosted.md adds S283 (route inputs), S284 (webhook), S285 (refunds and disputes) and widens S282 and S110 to the disputes table. Privacy lists the dispute state, the checkout ref, and the new counts. The Hosted audit prompt asks whether billing can grant what Stripe did not sell. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 1 + docs/specs/hosted.md | 30 ++++++++++++++++++++++++------ docs/specs/pricing.md | 6 +++--- docs/specs/security-hosted.md | 13 ++++++++----- scripts/security-rule-ids.json | 2 +- scripts/spec-word-budgets.json | 6 +++--- website/src/pages/Privacy.tsx | 4 ++-- 7 files changed, 42 insertions(+), 20 deletions(-) diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 5a6a1b3fd..716bab2fd 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -46,6 +46,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, or end what it should only suspend?** Trace checkout, confirm, the webhook, and the resync in `hosted/server/billing-routes.ts` and `hosted/server/billing.ts` against "Account boundary": a browser-chosen Price, owner, or return URL, an unsigned or replayed event, a 2xx before the commit, a count a redelivery repeats, a stale dispute event read as current, a partial refund, or a refund of an earlier charge, that ends a subscription, a failed count that fails the event, and a lapsed or disputed subscription that still admits. - **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 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 f23b243f0..de0fb83ec 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -9,7 +9,7 @@ | Worker | Origin | Serves | Holds | |---|---|---|---| -| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment"), billing's resync ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe bindings | +| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment"), billing and Stripe's webhook ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe bindings | | `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | | `dormouse-voice` | `https://voice.dormouse.sh` | speak, a token's own delete, and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | @@ -69,22 +69,36 @@ Source of truth: `termsRoutes` in `hosted/server/terms.ts`; `TERMS_VERSION` in ` **Must read the entitlement (`docs/specs/pricing.md` -> "Checkout and entitlement") on the server, per request, through one SQL predicate over the account's `"user"` row**, so a bearer and its owner's entitlement resolve in one query. -- **Must entitle an account only while it holds exactly one current subscription, `active` before its period end or `trialing` before its trial end** by the database's clock, as `@pgstencil/stripe`'s `status()` grants access; no other status grants anything. +- **Must entitle an account only while it holds exactly one current subscription, `active` before its period end or `trialing` before its trial end** by the database's clock, as `@pgstencil/stripe`'s `status()` grants access; no other status grants anything. **Never entitle it while a payment dispute is open** ("Billing"). - **Must entitle `ADMIN_EMAIL` as a standing comp** while it is the account's verified email, one of the exceptions to "never email" ("Identity and login"); nothing else may key on an address. Source of truth: `entitledSql` and `entitled` in `hosted/server/entitlement.ts`; `cookieEntitled` in `hosted/server/account-gate.ts`. ## Billing -The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> "Checkout and entitlement"); its routes are unbuilt. +The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> "Checkout and entitlement"). -- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix. +| Route | Credential | Success | +|---|---|---| +| `GET /api/billing` | login cookie | 200 `{ plan, active, until, renews, entitled, suspended, founding }`; `plan` names a subscription Stripe still holds | +| `POST /api/billing/checkout` | login cookie, exact `Origin`, JSON `{ plan, ref? }` | 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 | +| `POST /api/billing/webhook` | `Stripe-Signature` over the raw body | 200 once `webhook()` committed | + +Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 without a login, and cap a body at 4 KiB. + +- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every route past the login gate answers 503 `CHECKOUT_CLOSED` and the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix. - **Never offer a trial or Managed Payments; must enable Stripe Tax on every Checkout.** +- **Never take a Price, customer, owner, quantity, or return URL from a request**: checkout takes a plan name and a `ref`, normalized as `checkoutRef` does; the owner is the login, its email only once verified (else Checkout asks), and Stripe returns to `/billing`. A checkout open for another plan is expired first; a held subscription refuses another with 409. +- **Must verify the webhook's signature over the raw body before any write** and answer 2xx only once `webhook()` has committed, any other failure 503, which Stripe retries. +- **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator. +- **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`); closing in the account's favor restores it. - **Must offer founding at the first ladder step whose completed purchases, refunds included, are short of `FOUNDING_COHORT_SIZE`**; its seats left exclude purchases ended within `REFUND_DAYS`. Past the last step, founding checkout answers 409. - **Must resync, every 10 minutes from the account's Cron Trigger, a bounded random batch of owners whose `active` or `trialing` row no longer grants access**, so a missed renewal webhook lapses a member only until a run picks them; a failure fails the invocation after every owner was tried. - **Must grant the account's role the `pgstencil_billing` schema from `runtime-roles.sql`**, additively: the package's migration creates it. -Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`. Pinned by `hosted/server/tests/billing.test.ts`, on StripeDev. +Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev. ## Managed voice @@ -272,9 +286,13 @@ Hosted's Workers count what they serve in aggregate daily rows; no page or deskt | `voice.fallback-cap` | none | a speak spends its owner's last call of the UTC day | | `push.sent` | none | each delivery a send makes | | `pocket.signin` | none | a passkey sign-in mints a session | +| `checkout.started` | `plan:ref` | checkout answers a URL | +| `checkout.completed` | `plan:ref` | the webhook processes a completion (`completedCheckout`) | +| `subscription.canceled` | plan | the webhook processes a purchase's end | +| `subscription.refunded` | plan | that end was `ENDED_FOR_REFUND` or within `REFUND_DAYS` | - **Never count a label off its event's list** (`METRIC_LABELS`): the write throws. Row contents and readers: `docs/specs/security-hosted.md` -> "Account boundary". -- **Never fail or delay the request a metric counts**: the write is unawaited, kept alive by `waitUntil`, and only logs failure. +- **Never fail or delay the request a metric counts**: the write is unawaited, kept alive by `waitUntil`, and only logs failure. The webhook's counts commit in its transaction under a savepoint of their own, so a redelivery never counts twice and a failed count rolls back alone. - Rows are kept forever (rationale). The database's UTC clock dates every row and the account Worker's `/admin/metrics` "today". Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` and `authMetricHooks` in `hosted/server/metrics.ts`; `hosted/server/dormouse-migrations/013_metrics.sql`; `AdminMetrics` in `hosted/src/AdminMetrics.tsx`. diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 01a55b515..3b1533033 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -57,6 +57,7 @@ Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; the `/pricing` rule - **The entitlement is the account's subscription, read on the server on every voice and Relay request** (`docs/specs/hosted.md` -> "Entitlement"). No licence, no offline verification, and no grace past what the subscription grants; a lapsed member's voices fall back to the system voice and its Burrows to `not-entitled`. - **Cohorts close by count, never by date**: completed purchases at the billing provider; a purchase ending within the 30-day refund window, as a full refund or finally reversed payment ends it ("Checkout"), returns its seat to its cohort. **Never reopen the ladder at a lower step** once a cohort has closed. **When a cohort closes the price rises one step and the counter resets to 100**, and **founding closes only when the ladder reaches list**: founding means bought at launch pricing, and the Hosted Relay shipping does not close it. - **Checkout honors the price it opened at.** Concurrent checkouts may oversell a cohort by a few seats; the overage is the customer's, and the next cohort still opens at a full 100. +- **A full refund or a finally reversed payment ends the subscription it paid for**, so the next request is refused. A partial refund or billing correction never ends it, and an open dispute only suspends it until it closes. Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; `docs/specs/hosted.md` -> "Billing" owns the rest. @@ -65,7 +66,7 @@ Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; ` **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** ("Checkout"): the routes, the webhook, and the account pages that sell, then opening billing. +2. **Checkout** ("Checkout"): the account pages that sell, then opening billing. 3. **Managed voice for members**: one voice per Pane. 4. **Renewal, cancellation, and refund** paths. @@ -101,7 +102,6 @@ The rest of "Checkout and entitlement", once checkout can sell: - **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. - **Must license one individual, including work use, with no device limit and no per-device charge**; enrollment's abuse ceiling is far above what a person owns (`docs/specs/hosted.md` -> "Burrow enrollment"). **Must disclose material usage limits before purchase**, including managed voice's daily cap. -- **A full refund or a finally reversed payment ends the subscription**, so the next request is refused. A partial refund or billing correction never ends it, and an open dispute only suspends it ("Paid-launch requirements"). ### Managed voice @@ -129,7 +129,7 @@ EEA/UK representative appointment is excluded from the internal release gate by - **Must provide direct online cancellation and a support cancellation path when account access is lost.** **Must cancel future renewals as part of account closure**, explaining remaining access and refund eligibility before completing closure; verification cannot require account recovery. - **Must send jurisdiction-required renewal, annual, and price-change notices with cancellation instructions.** For California consumers, annual-term renewal notices are 15–45 days before renewal and fee-change notices 7–30 days before effectiveness; annual reminders also apply to monthly plans (rationale). - **Must route subscription notices to the maintained billing email**, including provider-only accounts without a sign-in email; use the account contact email for other notices, or show them at sign-in when none exists. **Must record notice delivery or presentation before starting a notice period**, and provide any additional legally required notice or consent process. -- **Never end a subscription solely for a partial refund or billing correction.** **Must distinguish payment-dispute suspension from termination**: notify the customer, restore remaining access and the prior founding price if suspension was mistaken or payment is restored, and preserve refund and dispute rights; final reversal may end the affected access and renewals. +- **Must notify the customer of a payment-dispute suspension** and preserve refund and dispute rights; suspension, its restoration, and final reversal are "Checkout and entitlement". - **Must refund unused prepaid service, including corresponding collected tax, on permanent discontinuation or termination unrelated to customer breach**, and offer the same remedy for a material service reduction or rejected material terms change during a prepaid term. **Must give at least 30 days' advance notice of discontinuation, material reductions, or material terms changes**, except urgent legal or security requirements; no retroactive terms changes for disputes. - **Must verify the permitted sales territories and tax setup before accepting payment**, and confirm refund, cancellation, failed-payment, and founder-lock behavior against the published offer. Stripe Billing does not transfer the seller's tax obligations. - **Must support worldwide sales only where lawful, including EEA and UK consumer rights**: disclose the statutory withdrawal right and model form before purchase and in the confirmation, accept an unambiguous notice without account recovery, and refund withdrawal payments within 14 days without a use deduction or waiver for immediate access. The voluntary guarantee is additional (rationale). diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index d9c5f4f7d..9197b424c 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -12,7 +12,7 @@ | **Each Hosted Worker answers only its own origin and runs only its own code.** No third-party script, analytics, or framing; sign-in cookies stay on the account origin, and its session never reaches browser storage. | One-time link, Hosted phone | `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts` | - **FAIL IF** `S77` a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. -- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). +- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieLogin` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). - **FAIL IF** `S79` the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth, or `WORKERS` in `hosted/scripts/workers.mjs` provisions either an auth secret; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** `S281` any bindings mapper but the production account's passes a `STRIPE_*` binding; `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is anything but an account Worker secret, a Wrangler `vars` entry or a preview config included; or the relay or voice entry's import graph reaches `@pgstencil/stripe`, `stripe`, or `hosted/server/billing.ts`. Inspect `hosted/server/bindings.ts`, `hosted/wrangler.jsonc`, `hosted/scripts/preview.mjs`, and each entry's import graph. - **FAIL IF** `S80` authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or account (Better Auth) session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. @@ -25,16 +25,19 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t | Guarantee | Feature | Pinned by | | --- | --- | --- | -| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts` | +| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. Billing takes no price or owner from the browser, and an open payment dispute suspends a subscription rather than ending it. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts` | - **FAIL IF** `S83` the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter. - **FAIL IF** `S84` an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`. - **FAIL IF** `S85` speak or a voice token's mint admits an account the entitlement (`docs/specs/hosted.md` -> "Entitlement") does not, without rechecking per request, stores a voice token other than as its SHA-256, accepts one idle past `VOICE_TOKEN_IDLE_DAYS`, lets a bearer delete any token but its own, logs or stores speak text, forwards an ElevenLabs error body or status, or lets a binding or request field choose the upstream origin, path prefix, model, or format, or a voice outside `MANAGED_VOICES`; or the relay mints a voice token without redeeming a voice-purpose approval, or remote enrollment mints voice credentials; inspect `hosted/server/entitlement.ts`, `hosted/server/voice.ts`, and `hosted/server/relay-api.ts`. -- **FAIL IF** `S282` the entitlement admits an account but by `ADMIN_EMAIL` verified or by exactly one current subscription that is `active` before its period end or `trialing` before its trial end by the database's clock, or reads that state from anywhere but the synchronized `pgstencil_billing.subscriptions` rows in the query that resolves the bearer; inspect `entitledSql` in `hosted/server/entitlement.ts`. +- **FAIL IF** `S282` the entitlement admits an account but by `ADMIN_EMAIL` verified or by exactly one current subscription that is `active` before its period end or `trialing` before its trial end by the database's clock with no `dormouse_billing_disputes` row, or reads that state from anywhere but those synchronized rows in the query that resolves the bearer; inspect `entitledSql` in `hosted/server/entitlement.ts`. +- **FAIL IF** `S283` a billing route takes a Stripe Price, customer, owner, quantity, or success or return URL from the request, acts for any owner but the cookie's login, passes Stripe an email the login has not verified, or sends a `ref` or counts a label `checkoutRef` did not produce; inspect `billingRoutes` in `hosted/server/billing-routes.ts`. +- **FAIL IF** `S284` the webhook writes before `Stripe-Signature` verifies over the raw body, reads a body past `WEBHOOK_BODY_BYTES`, answers 2xx before `webhook()` has committed, accepts an event of the other mode, another API version, or a Connect account, counts a metric outside its transaction, so a redelivery counts twice, or lets a failed count fail or roll back the event; inspect `processEvent` in `hosted/server/billing-routes.ts`. +- **FAIL IF** `S285` a partial refund or an open dispute ends a subscription, an open dispute leaves its account entitled, a dispute's suspension follows the event's status rather than the one Stripe returns, a full refund or a lost dispute leaves the subscription its charge paid for current once a delivery answered 2xx, or ends any other subscription, the owner's current one included; inspect `processEvent`, `disputeAction`, `subscriptionPaidBy`, and `endForRefund` in `hosted/server/billing-routes.ts`. - **FAIL IF** `S279` a `dormouse_metrics_daily` row can hold anything but a UTC day, an event, a label on that event's `METRIC_LABELS` list, and a count; or `GET /api/admin/metrics` answers anyone but the verified `ADMIN_EMAIL` (401 without a login, 404 to any other), skips the cookie read gate, or answers more than those rows. Inspect `hosted/server/dormouse-migrations/013_metrics.sql`, `countMetric` in `hosted/server/metrics.ts`, `metricLabel` in `hosted/server/metric-labels.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `adminSql` in `hosted/server/entitlement.ts`. - **FAIL IF** `S86` Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. The one-time rendezvous carries only handshake ciphertext and authorizes nothing; the ends' handshake and the laptop's confirmation do. -What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, `hosted/server/tests/metrics.test.ts`, and `hosted/server/tests/billing.test.ts`. +What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, `hosted/server/tests/metrics.test.ts`, `hosted/server/tests/billing.test.ts`, and `hosted/server/tests/billing-routes.test.ts`. ## Rendezvous boundary @@ -79,7 +82,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S107` a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or keeps more than 1 KiB of a response body; inspect `deliverPush` and `reasonOf`. - **FAIL IF** `S108` `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf` in `hosted/server/relay-push.ts`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`. - **FAIL IF** `S109` the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test. -- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables, `user`, and `pgstencil_billing.subscriptions` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a column of the latter two but the entitlement's; inspect the relay bundle's imports and queries. +- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables, `user`, `pgstencil_billing.subscriptions`, and `dormouse_billing_disputes` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a column of the latter three but the entitlement's; inspect the relay bundle's imports and queries. - **FAIL IF** `S111` `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or puts anything but its account id in `ctx.storage` (routing state lives in hibernation attachments); `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** `S112` a `scripts/e2e-lint.mjs` rule whose `spec` is this file names a line it does not enforce, or one outside "Rendezvous boundary" and "Relay boundary". - **FAIL IF** `S113` a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index a90602be0..a6849ea2a 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 283, + "next": 286, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 738447a0f..64ec9eeaa 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,13 +12,13 @@ "docs/specs/dor-tools-builtin.md": 1750, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 5100, + "docs/specs/hosted.md": 5450, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, "docs/specs/one-time.md": 2650, "docs/specs/pocket-app.md": 3650, - "docs/specs/pricing.md": 2600, + "docs/specs/pricing.md": 2500, "docs/specs/relay.md": 5700, "docs/specs/remote-api.md": 4450, "docs/specs/remote-network.md": 2400, @@ -26,7 +26,7 @@ "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, - "docs/specs/security-hosted.md": 3350, + "docs/specs/security-hosted.md": 3550, "docs/specs/security-local.md": 6400, "docs/specs/security-remote.md": 7200, "docs/specs/security-supply-chain.md": 1650, diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index e6f3b0550..bf5635967 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -30,7 +30,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [ }, { id: "subscription", title: "Your subscription", body: <>

You buy Hosted subscriptions from DiffPlug LLC. We use Stripe Billing, Stripe-hosted Checkout, and Stripe’s customer portal to process payments and manage subscriptions. Stripe collects payment details, billing addresses, and applicable tax information. It processes information on our behalf and for its own purposes, such as fraud prevention and legal compliance, as described in Stripe’s privacy policy. Dormouse does not receive or store your full card number.

-

We receive subscription and billing information from Stripe: customer and subscription identifiers linked to your account, the billing email, the plan and price (including a founding cohort, if any), status, billing-period dates, cancellation status, and billing-event records. Stripe’s dashboard and billing records may also make your billing address, tax information, invoices, and limited payment-method details, such as card brand and last four digits, available to us for support, accounting, refunds, and disputes.

+

We receive subscription and billing information from Stripe: customer and subscription identifiers linked to your account, the billing email, the plan and price (including a founding cohort, if any), status, billing-period dates, cancellation status, billing-event records, whether a payment dispute is open, and which Dormouse link led to a purchase (such as the voice setting), which also travels to Stripe with the checkout. Stripe’s dashboard and billing records may also make your billing address, tax information, invoices, and limited payment-method details, such as card brand and last four digits, available to us for support, accounting, refunds, and disputes.

If you buy a founding plan and tick the box to appear in the founders row, we show your account name and profile picture on the Hosted page. The box starts unticked, and you can untick it at any time. We serve those pictures from our own site, so a visitor’s browser never asks your sign-in provider for them. Everyone else counts only toward the total.

After checkout we may ask four optional questions about price. Nothing is sent unless you answer, and we use the answers only to set future prices.

}, @@ -51,7 +51,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

We use this information to create and recognize your account, verify sign-in, connect methods you explicitly select, send requested sign-in codes, check on each request that your subscription is active, deliver the features you pay for, apply fair-use limits, prevent abuse, troubleshoot failures, and answer support requests. We discard provider access, refresh, and identity tokens after identity verification rather than storing them in your account.

Every request to Hosted carries your IP address and connection information, which we and our infrastructure providers use to deliver the request, secure the service, and limit request rates. Rate-limit records we store hold a keyed hash of the address, not the address itself, and are kept only briefly.

DiffPlug does not sell Hosted information, use it for targeted advertising, or use it to train AI models. The Natural Voice section describes ElevenLabs’ separate processing. Signing in or subscribing does not subscribe you to a newsletter. The account site uses necessary authentication and security cookies and does not load marketing analytics.

-

To see whether Hosted is working, we keep aggregate daily counts on our own servers, with no per-person analytics: accounts created, account sign-ins by method (an emailed code or which provider), computers approved and enrolled, Pocket sign-ins, push notifications delivered, Natural Voice requests answered, over the daily limit, or failed, and accounts that reached the daily voice limit. A count row holds only the day, what was counted, a fixed label (such as “github” or “over the limit”), and the number; it carries no account id, email address, IP address, browser information, or text.

+

To see whether Hosted is working, we keep aggregate daily counts on our own servers, with no per-person analytics: accounts created, account sign-ins by method (an emailed code or which provider), computers approved and enrolled, Pocket sign-ins, push notifications delivered, Natural Voice requests answered, over the daily limit, or failed, accounts that reached the daily voice limit, checkouts started and completed by plan and by which link led there, and subscriptions canceled or refunded by plan. A count row holds only the day, what was counted, a fixed label (such as “github” or “over the limit”), and the number; it carries no account id, email address, IP address, browser information, or text.

This website, dormouse.sh, counts its visits with PostHog. When a page loads, your browser sends PostHog, through dormouse.sh, the page’s address and title, the page that linked you there, and your browser, device type, language, time zone, and screen and window sizes. Links to the Hosted page from Dormouse or this site carry a short label naming which link it was (such as the voice setting or the tutorial); the page removes it from the address bar and adds it to that page’s visit. Clicking a plan’s buy button sends which plan, and finishing the playground tutorial sends which tutorial. Each request also carries your IP address and browser user agent. PostHog counts unique visitors without storing anything on your device: in its words, it uses “a privacy-preserving hash, calculated on PostHog’s servers” from your IP address, user agent, and this site with a random value that changes daily and is deleted once that day’s events are processed. It removes the IP address before storing the visit and does not look up your location. Nothing is kept in cookies or browser storage: PostHog only checks that storage works, with a test value it removes at once. No profile is built, and we never tell PostHog who you are. If your browser sends Global Privacy Control or Do Not Track, the site does not load PostHog at all. The Hosted account site, the Hosted Relay and Pocket, and Dormouse itself do not use PostHog.

}, { id: "legal-bases", title: "Our legal bases", body: <>