Public: anyone can read this; the crew can change it.

POST /hooks/stripe verifies the signature on the raw body and turns each event into an entitlement #11195

Parked opened this in answer to
Closed without merging

scout/forge:scout/11137-post-hooks-stripe-verifies-the-signature → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
17 files changed +682 −4
docs/features/billing.md +2 −1
@@ -6,7 +6,8 @@ Billing records the plan each forge is on (`src/billing/`): a store of entitleme
66 - **Grant by hand (admin, a person):** the Billing page, the terminal screen, `PUT /api/v1/forges/{name}/plan` with `{ "plan": "large" }` (`GET` reads it), or the MCP `forges` tool with `{ forge, plan }` (planned first; `dryRun: false` grants).
77 - **Audit:** every grant is a row in `entitlement_change` and a `billing.plan` event.
88 - **Terminal:** the palette's *Billing* (admins), `src/tui/screens/billing.ts`: `j`/`k` pick a forge, `s`, `m` or `l` grants that plan after a yes/no; `c` connects Stripe (key shown as dots), `w` saves a signing secret, `x` disconnects after a yes/no.
9 - **Stripe (#11136):** Settings → Billing → Provider takes one key (`src/billing/stripe.ts`, `connection.ts`); how to connect it is in `docs/PROFIT.md`. Not yet: the `/hooks/stripe` receiver, and checkout, portal and reconcile, which come with the pull that calls them.
9 - **Stripe (#11136):** Settings → Billing → Provider takes one key (`src/billing/stripe.ts`, `connection.ts`); how to connect it is in `docs/PROFIT.md`. Not yet: checkout and the portal, which come with the pull that calls them.
10 - **The webhook (#11137):** `POST /hooks/stripe` (`src/http/stripe_hook.ts`, `src/billing/hook.ts`) is public, rate-limited and reads no cookie. The signature (`Stripe-Signature: t=…,v1=…`, HMAC-SHA256 over `${t}.${raw bytes}`, any `v1` may match, 300 s tolerance; `src/billing/signature.ts`) is checked on the raw body; a bad one answers 400 and counts per hour (ten in an hour is a `billing.hook.alarm` event). Each event id is kept once; a repeat answers 200 and does nothing. A subscription or invoice event only *names* a subscription: it is queued and re-read from Stripe after the answer (`src/billing/reconcile.ts`), so order does not matter. `checkout.session.completed` binds its order (`client_reference_id` is the order id) to customer and subscription and marks it paid. A sweep reads every paid entitlement once a day (checked every five minutes). Settings → Billing → Provider shows the last event and when, and refusals in the last day.
1011
1112 ## Gift codes
1213 A code gives a plan for free, with or without a payment provider (`src/billing/codes.ts`, `src/actions/codes.ts`). No provider is asked: a redeemed code writes the entitlement itself, source `code:<id>`.
src/actions/billing.ts +4
@@ -14,6 +14,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
1414 import { connectionOf, forget, openKey, sealKey, sealWebhook, type Connection } from "../billing/connection.ts";
1515 import { isStripeKey, stripeClient, type StripeError } from "../billing/stripe.ts";
1616 import { publicOrigin } from "../core/public_origin.ts";
17 import { hookProbe, type HookProbe } from "../billing/reconcile.ts";
1718 import { holdValues } from "../kernel/log.ts";
1819 import { listChildrenNow } from "./spawn.ts";
1920 import { parseSize } from "../spawn/sizes.ts";
@@ -41,6 +42,8 @@ export interface BillingView {
4142 readonly forges: readonly (Entitlement & { size: string })[];
4243 /** The Stripe connection (mode, whether events are signed), never the key. */
4344 readonly stripe: Connection | undefined;
45 /** The last webhook received and the refusals of the last day (#11137). */
46 readonly hook: HookProbe | undefined;
4447 }
4548
4649 const admin = (a: User | undefined): Result<User, "not_found"> => (a?.isAdmin === true && a.kind !== "agent" ? ok(a) : err("not_found"));
@@ -51,6 +54,7 @@ export function readBilling(app: App, actor: User | undefined): Result<BillingVi
5154 return ok({
5255 forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })),
5356 stripe: connectionOf(app.db),
57 hook: hookProbe(app.db),
5458 });
5559 }
5660
src/billing/connection.ts +1 −1
@@ -44,7 +44,7 @@ export function sealWebhook(db: Database, dataDir: string, secret: string, endpo
4444 }
4545
4646 /** The sealed key and the endpoint id, opened (audited as `secret.read`). */
47 export function openKey(db: Database, dataDir: string, bus: EventBus, actor: string): { key: string; endpointId: string } | undefined {
47 export function openKey(db: Database, dataDir: string, bus: EventBus, actor: string | undefined): { key: string; endpointId: string } | undefined {
4848 const c = connectionOf(db);
4949 if (!c) return undefined;
5050 const got = resolveSecrets(db, dataDir, bus, [KEY_NAME[c.mode], ENDPOINT], { actor });
src/billing/hook.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * POST /hooks/stripe, the receiving end (#11137): a verified delivery turns
3 * into an entitlement; a forged one into a 400 and a count. Plain data in
4 * (the raw bytes and the header), a status out. Nothing here waits on Stripe:
5 * the event is stored, an order bound if it is a checkout, a subscription
6 * queued for `drainPending` (billing/reconcile.ts), and the delivery answered.
7 */
8
9 import type { App } from "../app.ts";
10 import { resolveSecrets } from "../core/secret_store.ts";
11 import { connectionOf } from "./connection.ts";
12 import { holdValues } from "../kernel/log.ts";
13 import { getOrder, payOrder } from "./orders.ts";
14 import { queueReconcile, setState } from "./reconcile.ts";
15 import { verifySignature } from "./signature.ts";
16 import { putEntitlement } from "./store.ts";
17 import { listChildrenNow } from "../actions/spawn.ts";
18
19 const KEEP_MS = 35 * 86_400_000;
20 const ALARM_AT = 10;
21 const ID = /^[A-Za-z0-9_]{1,80}$/;
22
23 export type Received = "unsigned" | "refused" | "malformed" | "duplicate" | "taken";
24
25 const refuse = (app: App, now: number): void => {
26 const hour = new Date(now).toISOString().slice(0, 13);
27 app.db.query("INSERT INTO stripe_bad (hour, n) VALUES (?1, 1) ON CONFLICT (hour) DO UPDATE SET n = n + 1").run(hour);
28 app.db.query("DELETE FROM stripe_bad WHERE hour < ?1").run(new Date(now - 7 * 86_400_000).toISOString().slice(0, 13));
29 const n = app.db.query<{ n: number }, [string]>("SELECT n FROM stripe_bad WHERE hour = ?1").get(hour)?.n ?? 0;
30 // the alarm: once an hour, when refusals pile up
31 if (n === ALARM_AT) app.bus.append({ kind: "billing.hook.alarm", payload: { refused: n, hour } });
32 };
33
34 const obj = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : {});
35 const text = (v: unknown): string => (typeof v === "string" ? v : typeof v === "object" && v !== null ? text(obj(v)["id"]) : "");
36
37 /** The subscription an event is about: its own id, or the one an invoice names (either API shape). */
38 function subscriptionOf(type: string, o: Record<string, unknown>): string {
39 if (type.startsWith("customer.subscription.")) return text(o["id"]);
40 const parent = obj(obj(o["parent"])["subscription_details"]);
41 return text(o["subscription"]) || text(parent["subscription"]);
42 }
43
44 export async function receive(app: App, raw: Uint8Array, header: string | null, now = Date.now()): Promise<Received> {
45 if (connectionOf(app.db)?.signed !== true) return "unsigned";
46 // a delivery with no signature at all is refused before the secret is opened (opening is audited)
47 if (header === null) {
48 refuse(app, now);
49 return "refused";
50 }
51 const secret = resolveSecrets(app.db, app.config.dataDir, app.bus, ["STRIPE_WEBHOOK_SECRET"])["STRIPE_WEBHOOK_SECRET"];
52 if (secret === undefined) return "unsigned";
53 holdValues("stripe", [secret]);
54 if ((await verifySignature(secret, header, raw, Math.floor(now / 1000))) !== "ok") {
55 refuse(app, now);
56 return "refused";
57 }
58 let event: Record<string, unknown>;
59 try {
60 event = obj(JSON.parse(new TextDecoder().decode(raw)));
61 } catch {
62 return "malformed";
63 }
64 const id = event["id"], type = event["type"];
65 if (typeof id !== "string" || !ID.test(id) || typeof type !== "string" || type.length > 100) return "malformed";
66 const o = obj(obj(event["data"])["object"]);
67 // one transaction: the id is kept with what the event did, or neither
68 let first = true;
69 app.db.transaction(() => {
70 if (app.db.query("INSERT OR IGNORE INTO stripe_event (id, type, received_at) VALUES (?1, ?2, ?3)").run(id, type, now).changes === 0) {
71 first = false;
72 return;
73 }
74 app.db.query("DELETE FROM stripe_event WHERE received_at < ?1").run(now - KEEP_MS);
75 setState(app.db, "last_type", type);
76 setState(app.db, "last_at", String(now));
77 if (type === "checkout.session.completed") bind(app, o);
78 else if (type.startsWith("customer.subscription.") || type.startsWith("invoice.")) queueReconcile(app.db, subscriptionOf(type, o));
79 })();
80 return first ? "taken" : "duplicate";
81 }
82
83 /** A completed checkout: the order learns its customer and subscription and is paid, the forge's entitlement is bound. */
84 function bind(app: App, session: Record<string, unknown>): void {
85 const order = getOrder(app.db, text(session["client_reference_id"]));
86 if (!order) return;
87 const customer = text(session["customer"]), subscription = text(session["subscription"]);
88 payOrder(app.db, order.id, customer, subscription);
89 const paid = session["payment_status"] === "paid" || session["payment_status"] === "no_payment_required";
90 const size = listChildrenNow(app).find((c) => c.name === order.forge)?.size ?? order.plan;
91 putEntitlement(app.db, {
92 forge: order.forge, plan: order.plan, status: paid ? "active" : "none", source: `stripe:${order.id}`, provider: "stripe",
93 payer: order.payer, customerId: customer, subscriptionId: subscription,
94 }, size, "stripe");
95 app.bus.append({ kind: "billing.plan", payload: { forge: order.forge, to: order.plan, source: "stripe", order: order.id } });
96 // what Stripe says now settles the status and the period
97 queueReconcile(app.db, subscription);
98 }
src/billing/orders.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Orders (#11137): what a checkout asks for. The order's id is what travels
3 * through Stripe as `client_reference_id`, so `checkout.session.completed`
4 * can say which forge's order was paid. Plain data over one table.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export interface Order {
10 readonly id: string;
11 readonly forge: string;
12 readonly plan: string;
13 readonly payer: string;
14 readonly status: "open" | "paid";
15 readonly customerId: string;
16 readonly subscriptionId: string;
17 }
18
19 interface Row { id: string; forge: string; plan: string; payer: string; status: "open" | "paid"; customer_id: string; subscription_id: string }
20
21 export function createOrder(db: Database, o: { forge: string; plan: string; payer?: string }): Order {
22 const id = `ord_${crypto.randomUUID().replace(/-/g, "")}`;
23 db.query("INSERT INTO billing_order (id, forge, plan, payer, created_at) VALUES (?1, ?2, ?3, ?4, ?5)").run(id, o.forge, o.plan, o.payer ?? "", new Date().toISOString());
24 return { id, forge: o.forge, plan: o.plan, payer: o.payer ?? "", status: "open", customerId: "", subscriptionId: "" };
25 }
26
27 export function getOrder(db: Database, id: string): Order | undefined {
28 const r = db.query<Row, [string]>("SELECT * FROM billing_order WHERE id = ?1").get(id);
29 return r === null ? undefined : { id: r.id, forge: r.forge, plan: r.plan, payer: r.payer, status: r.status, customerId: r.customer_id, subscriptionId: r.subscription_id };
30 }
31
32 /** Bind an order to Stripe's customer and subscription and mark it paid. */
33 export function payOrder(db: Database, id: string, customerId: string, subscriptionId: string): void {
34 db.query("UPDATE billing_order SET status = 'paid', customer_id = ?2, subscription_id = ?3 WHERE id = ?1").run(id, customerId, subscriptionId);
35 }
src/billing/reconcile.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * Reconcile (#11137): events arrive late, twice and out of order, so no
3 * event's body is trusted for what a subscription is. An event only names a
4 * subscription; this reads it from Stripe now and writes what Stripe says.
5 * Names are queued durably (`stripe_pending`) and read after the delivery was
6 * answered, so no webhook waits on Stripe; a failed read stays queued. A
7 * daily sweep reconciles every paid entitlement, so a missed webhook heals.
8 * One read-and-write at a time, so two cannot land in the wrong order.
9 */
10
11 import type { Database } from "bun:sqlite";
12 import type { App } from "../app.ts";
13 import { listChildrenNow } from "../actions/spawn.ts";
14 import { connectionOf, openKey } from "./connection.ts";
15 import { getStored, putEntitlement } from "./store.ts";
16 import { stripeClient } from "./stripe.ts";
17 import type { EntitlementStatus } from "./types.ts";
18
19 const ACTOR = "stripe";
20 const DAY_MS = 86_400_000;
21
22 /** Stripe's word for a subscription, as the forge's entitlement status. */
23 export function statusOf(stripe: string): EntitlementStatus {
24 if (stripe === "active" || stripe === "trialing") return "active";
25 if (stripe === "past_due" || stripe === "unpaid") return "grace";
26 return "none";
27 }
28
29 export function queueReconcile(db: Database, subscriptionId: string): void {
30 if (subscriptionId !== "") db.query("INSERT OR REPLACE INTO stripe_pending (subscription_id) VALUES (?1)").run(subscriptionId);
31 }
32
33 const lanes = new WeakMap<App, Promise<unknown>>();
34 /** Run `job` after whatever this app has in flight. */
35 function alone<T>(app: App, job: () => Promise<T>): Promise<T> {
36 const next = (lanes.get(app) ?? Promise.resolve()).then(job, job);
37 lanes.set(app, next.catch(() => undefined));
38 return next;
39 }
40
41 /** Read one subscription and write its entitlement. False when Stripe could not be asked. */
42 async function reconcileOne(app: App, client: ReturnType<typeof stripeClient>, subscriptionId: string): Promise<boolean> {
43 const got = await client.subscription(subscriptionId);
44 if (!got.ok) return false;
45 const row = app.db.query<{ forge: string }, [string]>("SELECT forge FROM entitlement WHERE subscription_id = ?1").get(subscriptionId);
46 if (!row) return true; // bound to no forge here: nothing to write
47 const before = getStored(app.db, row.forge);
48 if (!before) return true;
49 const status = statusOf(got.value.status);
50 if (before.status === status && before.periodEnd === got.value.periodEnd) return true;
51 const size = listChildrenNow(app).find((c) => c.name === row.forge)?.size ?? before.plan;
52 putEntitlement(app.db, {
53 forge: row.forge, plan: before.plan, status, source: before.source, provider: "stripe",
54 ...(got.value.periodEnd === undefined ? {} : { periodEnd: got.value.periodEnd }),
55 }, size, ACTOR);
56 app.bus.append({ kind: "billing.plan", payload: { forge: row.forge, from: before.plan, to: before.plan, source: "stripe", status } });
57 return true;
58 }
59
60 const clientFor = (app: App, fetchFn: typeof fetch) => {
61 if (!connectionOf(app.db)) return undefined;
62 const k = openKey(app.db, app.config.dataDir, app.bus, undefined);
63 return k && stripeClient(k.key, fetchFn);
64 };
65
66 /** Read every queued subscription; what could not be read stays queued. */
67 export function drainPending(app: App, fetchFn: typeof fetch = fetch): Promise<number> {
68 return alone(app, async () => {
69 const queued = app.db.query<{ seq: number; subscription_id: string }, []>("SELECT seq, subscription_id FROM stripe_pending ORDER BY seq LIMIT 50").all();
70 if (queued.length === 0) return 0;
71 const client = clientFor(app, fetchFn);
72 if (!client) return 0;
73 let done = 0;
74 for (const q of queued) {
75 if (!(await reconcileOne(app, client, q.subscription_id))) continue;
76 // only the entry just read: one queued again meanwhile has a new seq and stays
77 app.db.query("DELETE FROM stripe_pending WHERE seq = ?1").run(q.seq);
78 done++;
79 }
80 return done;
81 });
82 }
83
84 const state = (db: Database, k: string): string | undefined => db.query<{ v: string }, [string]>("SELECT v FROM stripe_state WHERE k = ?1").get(k)?.v;
85 export const setState = (db: Database, k: string, v: string): void => {
86 db.query("INSERT INTO stripe_state (k, v) VALUES (?1, ?2) ON CONFLICT (k) DO UPDATE SET v = excluded.v").run(k, v);
87 };
88
89 /** Reconcile every paid entitlement (active or in grace). Returns how many were read. */
90 export function sweepEntitlements(app: App, fetchFn: typeof fetch = fetch): Promise<number> {
91 return alone(app, async () => {
92 const subs = app.db.query<{ subscription_id: string }, []>(
93 "SELECT subscription_id FROM entitlement WHERE provider = 'stripe' AND subscription_id != '' AND status != 'none'",
94 ).all();
95 const client = subs.length === 0 ? undefined : clientFor(app, fetchFn);
96 let read = 0;
97 if (client) for (const s of subs) if (await reconcileOne(app, client, s.subscription_id)) read++;
98 setState(app.db, "swept_at", String(Date.now()));
99 return read;
100 });
101 }
102
103 /** The sweep, once a day: called often, it runs when a day has passed. */
104 export async function sweepIfDue(app: App, fetchFn: typeof fetch = fetch, now = Date.now()): Promise<boolean> {
105 const last = Number(state(app.db, "swept_at") ?? 0);
106 if (now - last < DAY_MS) return false;
107 await sweepEntitlements(app, fetchFn);
108 return true;
109 }
110
111 export interface HookProbe {
112 readonly lastType: string;
113 readonly lastAt: number;
114 readonly refused: number;
115 readonly sweptAt?: number;
116 }
117
118 /** What the Billing page shows: the last event received and when, and the refusals of the last day. */
119 export function hookProbe(db: Database, now = Date.now()): HookProbe | undefined {
120 const type = state(db, "last_type"), at = state(db, "last_at");
121 const from = new Date(now - DAY_MS).toISOString().slice(0, 13);
122 const refused = db.query<{ n: number | null }, [string]>("SELECT SUM(n) AS n FROM stripe_bad WHERE hour >= ?1").get(from)?.n ?? 0;
123 const swept = state(db, "swept_at");
124 if (type === undefined && refused === 0 && swept === undefined) return undefined;
125 return { lastType: type ?? "", lastAt: Number(at ?? 0), refused, ...(swept === undefined ? {} : { sweptAt: Number(swept) }) };
126 }
src/billing/signature.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * Stripe's webhook signature (#11137), checked on the raw bytes. The header
3 * is `t=<unix>,v1=<hex>[,v1=…]` (other keys ignored); the signed payload is
4 * `${t}.${rawBody}`, HMAC-SHA256 with the `whsec_…` string as UTF-8 key bytes.
5 * Every `v1` is tried (a rotating secret signs twice) with Web Crypto's
6 * `verify`, which compares in constant time. A timestamp more than 300 s from
7 * now, either way, is refused, so a captured delivery cannot be replayed later.
8 */
9
10 export const TOLERANCE_S = 300;
11 export type Verdict = "ok" | "malformed" | "stale" | "mismatch";
12
13 const HEX = /^[0-9a-f]{64}$/;
14 const enc = new TextEncoder();
15
16 const unhex = (h: string): Uint8Array<ArrayBuffer> => {
17 const out = new Uint8Array(new ArrayBuffer(h.length / 2));
18 for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
19 return out;
20 };
21
22 export async function verifySignature(secret: string, header: string | null, raw: Uint8Array, nowSeconds: number): Promise<Verdict> {
23 if (header === null || header.length > 1024) return "malformed";
24 let t = "";
25 const sigs: string[] = [];
26 for (const part of header.split(",")) {
27 const i = part.indexOf("=");
28 if (i < 0) continue;
29 const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
30 if (k === "t" && t === "") t = v;
31 else if (k === "v1" && HEX.test(v) && sigs.length < 8) sigs.push(v);
32 }
33 if (!/^\d{1,12}$/.test(t) || sigs.length === 0) return "malformed";
34 if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_S) return "stale";
35 const prefix = enc.encode(`${t}.`);
36 const payload = new Uint8Array(prefix.length + raw.length);
37 payload.set(prefix);
38 payload.set(raw, prefix.length);
39 const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
40 let good = false;
41 for (const s of sigs) if (await crypto.subtle.verify("HMAC", key, unhex(s), payload)) good = true;
42 return good ? "ok" : "mismatch";
43 }
src/billing/stripe.ts +26 −1
@@ -66,6 +66,15 @@ export function formBody(data: { [k: string]: Value }): string {
6666 export interface StripeClient {
6767 addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>;
6868 removeWebhook(id: string): Promise<Result<true, StripeError>>;
69 /** What Stripe says a subscription is now; a subscription Stripe no longer has is `canceled`. */
70 subscription(id: string): Promise<Result<Subscription, StripeError>>;
71 }
72
73 export interface Subscription {
74 readonly status: string;
75 /** Unix seconds the paid period ends, when Stripe says. */
76 readonly periodEnd?: number;
77 readonly customer: string;
6978 }
7079
7180 const PERMISSION = "This key may not create webhook endpoints. Paste a signing secret from Stripe's webhook page instead.";
@@ -80,7 +89,7 @@ export function stripeClient(key: string, fetchFn: typeof fetch = fetch): Stripe
8089 }
8190 let res: Response;
8291 try {
83 res = await fetchFn(`${STRIPE_API}${path}`, { method, headers, ...(method === "POST" ? { body: formBody(data ?? {}) } : {}) });
92 res = await fetchFn(`${STRIPE_API}${path}`, { method, headers, signal: AbortSignal.timeout(10_000), ...(method === "POST" ? { body: formBody(data ?? {}) } : {}) });
8493 } catch {
8594 return err({ kind: "unreachable", message: "Stripe could not be reached." });
8695 }
@@ -112,5 +121,21 @@ export function stripeClient(key: string, fetchFn: typeof fetch = fetch): Stripe
112121 const r = await call("DELETE", `/v1/webhook_endpoints/${encodeURIComponent(id)}`);
113122 return r.ok ? ok(true as const) : r;
114123 },
124 async subscription(id) {
125 const r = await call("GET", `/v1/subscriptions/${encodeURIComponent(id)}`);
126 if (!r.ok) {
127 if (r.error.kind === "rejected" && r.error.status === 404) return ok({ status: "canceled", customer: "" });
128 return r;
129 }
130 const items = ((r.value["items"] ?? {}) as { data?: unknown })["data"];
131 const first = Array.isArray(items) ? (items[0] as Record<string, unknown> | undefined) : undefined;
132 const end = first?.["current_period_end"] ?? r.value["current_period_end"];
133 const customer = r.value["customer"];
134 return ok({
135 status: str(r.value, "status"),
136 ...(typeof end === "number" ? { periodEnd: end } : {}),
137 customer: typeof customer === "string" ? customer : str((customer ?? {}) as Record<string, unknown>, "id"),
138 });
139 },
115140 };
116141 }
src/core/migration-pins/20261111100000-stripe-events.pin added +1
@@ -0,0 +1,1 @@
1 5db6fe1fb24e2e5d96800981fe126d6c9b3d291f6d42531a2ec9a19b4d5a1bc0
src/core/migrations/20261111100000-stripe-events.ts added +37
@@ -0,0 +1,37 @@
1 // Stripe's webhook (#11137). Additive. `billing_order`: what a checkout will
2 // bind (the order id travels as Stripe's client_reference_id). `stripe_event`:
3 // each event id once, so a replay does nothing. `stripe_pending`: the
4 // subscriptions to re-read, kept so no delivery waits on Stripe. `stripe_bad`:
5 // refused deliveries per UTC hour (the alarm). `stripe_state`: the last event
6 // and the last sweep, for the Billing page's probe.
7 export default `
8 CREATE TABLE billing_order (
9 id TEXT PRIMARY KEY,
10 forge TEXT NOT NULL,
11 plan TEXT NOT NULL,
12 payer TEXT NOT NULL DEFAULT '',
13 status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','paid')),
14 customer_id TEXT NOT NULL DEFAULT '',
15 subscription_id TEXT NOT NULL DEFAULT '',
16 created_at TEXT NOT NULL
17 );
18 CREATE TABLE stripe_event (
19 id TEXT PRIMARY KEY,
20 type TEXT NOT NULL,
21 received_at INTEGER NOT NULL
22 );
23 CREATE INDEX stripe_event_received ON stripe_event (received_at);
24 CREATE TABLE stripe_pending (
25 seq INTEGER PRIMARY KEY AUTOINCREMENT,
26 subscription_id TEXT NOT NULL UNIQUE
27 );
28 CREATE TABLE stripe_bad (
29 hour TEXT PRIMARY KEY,
30 n INTEGER NOT NULL
31 );
32 CREATE TABLE stripe_state (
33 k TEXT PRIMARY KEY,
34 v TEXT NOT NULL
35 );
36 CREATE INDEX entitlement_subscription ON entitlement (subscription_id);
37 `;
src/core/reserved.ts +1
@@ -66,6 +66,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6666 "boards",
6767 "contribute",
6868 "csp-report",
69 "hooks",
6970 "deploy-to",
7071 "pinned",
7172 "pipeline",
src/http/server.ts +7 −1
@@ -37,6 +37,7 @@ import { plansSet } from "../web/settings/plans.ts";
3737 import { allFlowsPage } from "../web/pages/flows-all.ts";
3838 import { measureSubmit } from "./measure.ts";
3939 import { cspReportSubmit } from "./csp_report.ts";
40 import { stripeHook } from "./stripe_hook.ts";
4041 import { settingsRoutes } from "./settings_routes.ts";
4142 import { pluginRoutes } from "./plugin_routes.ts";
4243 import { pinnedRoutes } from "./pinned_routes.ts";
@@ -826,6 +827,8 @@ export function buildRouter(): Router<Handler> {
826827 router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf));
827828 // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only.
828829 router.add("POST", "/csp-report", cspReportSubmit);
830 // Stripe's deliveries (http/stripe_hook.ts): signed, not signed in.
831 router.add("POST", "/hooks/stripe", stripeHook);
829832 router.add("GET", "/", home);
830833 // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts).
831834 router.add("GET", "/claim", claimFragmentPage);
@@ -2895,7 +2898,10 @@ async function answerGet(
28952898 return noRoute(request.method, url.pathname);
28962899 }
28972900 const authStart = performance.now();
2898 const auth = await resolveAuth(app, request);
2901 // The Stripe webhook takes no cookie and no token: its signature is its only credential.
2902 const auth: Auth = matched.pattern === "/hooks/stripe"
2903 ? NO_AUTH
2904 : await resolveAuth(app, request);
28992905 noteSessionTime(request, performance.now() - authStart);
29002906 if (auth.retryAfter !== undefined) return tooManyAttempts(auth.retryAfter);
29012907 noteUsage(request, { pattern: matched.pattern, role: roleOf(auth) });
src/http/stripe_hook.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * POST /hooks/stripe (#11137): public, rate-limited, no cookie read. The raw
3 * body is read once, as bytes, before anything parses it, and handed to
4 * `billing/hook.ts` with the `Stripe-Signature` header. It answers inside the
5 * request: Stripe is only asked afterwards, from the queue
6 * (`billing/reconcile.ts`). 200 for a taken or repeated event, 400 for a
7 * refused or unreadable one, 404 when no signing secret is connected.
8 */
9
10 import type { App } from "../app.ts";
11 import { receive } from "../billing/hook.ts";
12 import { drainPending } from "../billing/reconcile.ts";
13 import { RateLimiter } from "../core/ratelimit.ts";
14 import { crossOrigin, type Ctx } from "./context.ts";
15 import { readBounded } from "./limits.ts";
16 import { clientBucket } from "./peer.ts";
17
18 export const STRIPE_HOOK_PATH = "/hooks/stripe";
19 const BODY_MAX = 256 * 1024;
20 const PER_CLIENT = 60;
21 const PER_FORGE = 600;
22
23 const limiter = (perMinute: number) => new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
24 /** What the queue is read with: Stripe's API; a test swaps it. */
25 let reader: typeof fetch = fetch;
26 export const readWith = (f: typeof fetch): void => {
27 reader = f;
28 };
29 const limits = new WeakMap<App, { client: RateLimiter; forge: RateLimiter }>();
30
31 const answer = (status: number, headers?: Record<string, string>): Response =>
32 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
33
34 export async function stripeHook(ctx: Ctx): Promise<Response> {
35 const { request, app } = ctx;
36 // Stripe is a server and sends no Origin; a browser that did is not Stripe.
37 if (crossOrigin(request, ctx.url, app.log)) return answer(403);
38 let pair = limits.get(app);
39 if (pair === undefined) {
40 pair = { client: limiter(PER_CLIENT), forge: limiter(PER_FORGE) };
41 limits.set(app, pair);
42 }
43 for (const v of [pair.client.take(clientBucket(request), "ro"), pair.forge.take("forge", "ro")]) {
44 if (!v.allowed) return answer(429, { "retry-after": String(Math.max(1, v.retryAfter)) });
45 }
46 const raw = await readBounded(request, BODY_MAX);
47 if (raw === "too_large") return answer(413);
48 const got = await receive(app, raw, request.headers.get("stripe-signature"));
49 if (got === "unsigned") return answer(404);
50 if (got === "refused" || got === "malformed") return answer(400);
51 // after the answer is on its way: what Stripe says now, never on the delivery's time
52 if (got === "taken") setTimeout(() => void drainPending(app, reader).catch((e) => app.log.error("stripe reconcile failed", { error: String(e) })), 0);
53 return answer(200);
54 }
src/main.ts +5
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { drainPending, sweepIfDue } from "./billing/reconcile.ts";
78 import { sweepStarts } from "./actions/start_sweep.ts";
89 import { scanNightly } from "./checks/scan.ts";
910 import { nightlyDue } from "./checks/scan_store.ts";
@@ -694,6 +695,9 @@ const staleTimer = setInterval(() => void during("asks", tickAsks), 120_000);
694695 const startsTimer = setInterval(() => during("starts", () => void sweepStarts(app)), 3_600_000);
695696 during("starts", () => void sweepStarts(app));
696697 void during("asks", tickAsks);
698 // Stripe: what a webhook queued is read at once; every paid entitlement once a
699 // day (`billing/reconcile.ts`, #11137), so a webhook that never came heals.
700 const stripeTimer = setInterval(() => during("stripe", async () => { await drainPending(app); await sweepIfDue(app); }), 300_000);
697701
698702 // Every start is counted, and one that follows a process that never said
699703 // goodbye is a crash the log could not record (kernel/boots.ts, #3708).
@@ -921,6 +925,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
921925 clearInterval(syncTimer);
922926 clearInterval(staleTimer);
923927 clearInterval(startsTimer);
928 clearInterval(stripeTimer);
924929 clearInterval(followTimer);
925930 stopClogTick();
926931 stopKeeper();
src/web/pages/billing.ts +10
@@ -5,6 +5,7 @@
55 * Anyone else gets the not-found page.
66 */
77
8 import type { BillingView } from "../../actions/billing.ts";
89 import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts";
910 import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
1011 import { publicOrigin } from "../../core/public_origin.ts";
@@ -27,6 +28,14 @@ interface Shown {
2728 readonly link: string;
2829 }
2930
31 /** "invoice.paid, 3 min ago", and what was refused in the last day. */
32 function hookLine(h: BillingView["hook"]): string {
33 if (h === undefined) return "none yet";
34 const ago = (ms: number): string => { const m = Math.max(0, Math.round((Date.now() - ms) / 60_000)); return m < 1 ? "just now" : m < 120 ? `${m} min ago` : m < 2880 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} days ago`; };
35 const last = h.lastType === "" ? "none yet" : `${h.lastType}, ${ago(h.lastAt)}`;
36 return h.refused === 0 ? last : `${last} · ${h.refused} refused in the last day`;
37 }
38
3039 const day = (iso: string): string => iso.slice(0, 10);
3140
3241 /** The Codes section: make one, copy it once, then one line per code with its one action. */
@@ -108,6 +117,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
108117 ? html`<dl class="status-rows">
109118 <div class="status-row"><dt>Stripe</dt><dd data-mode="${s.mode}">${s.mode}</dd></div>
110119 <div class="status-row"><dt>Events</dt><dd>${s.signed ? "signed" : "no signing secret yet"}</dd></div>
120 <div class="status-row" data-probe="stripe-events"><dt>Last event</dt><dd>${hookLine(b.hook)}</dd></div>
111121 </dl>
112122 ${s.signed
113123 ? null
test/job-slices.test.ts +1
@@ -251,6 +251,7 @@ const UNTIMED: Record<string, string> = {
251251 "snapshot-report": "inside a running snapshot upload: logs its progress; nightly",
252252 "system-live": "reads /proc and the cgroup roots for the system page while someone watches it",
253253 traffic: "a closure in createTrafficWriter over its in-memory minute ledger; follow-up",
254 stripe: "drains queued Stripe events and sweeps paid entitlements over fetch: network, every five minutes; the fixture has no Stripe connection",
254255 };
255256
256257 /** A job held past this in one slice fails the test: five times the issue's 50 ms, so no runner flakes it. */
test/stripe-hook.test.ts added +231
@@ -0,0 +1,231 @@
1 /**
2 * POST /hooks/stripe (#11137): the signature on the raw bytes, a replay that
3 * does nothing, events in any order settling on what Stripe says now, and the
4 * probe on the Billing page.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { readBilling } from "../src/actions/billing.ts";
12 import { sealKey, sealWebhook } from "../src/billing/connection.ts";
13 import { createOrder, getOrder } from "../src/billing/orders.ts";
14 import { drainPending, hookProbe, sweepIfDue } from "../src/billing/reconcile.ts";
15 import { verifySignature } from "../src/billing/signature.ts";
16 import { getStored, putEntitlement } from "../src/billing/store.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { readWith } from "../src/http/stripe_hook.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 const SECRET = "whsec_abcdefgh12345678";
26 const OLD = "whsec_oldoldold1234567";
27 const enc = new TextEncoder();
28
29 async function sig(secret: string, raw: string | Uint8Array, t: number): Promise<string> {
30 const body = typeof raw === "string" ? enc.encode(raw) : raw;
31 const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
32 const payload = new Uint8Array([...enc.encode(`${t}.`), ...body]);
33 const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, payload));
34 return Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
35 }
36 const now = () => Math.floor(Date.now() / 1000);
37
38 describe("the signature", () => {
39 const raw = enc.encode('{"id":"evt_1"}');
40 test("good, bad, stale, malformed, and two v1 values", async () => {
41 const t = now();
42 const good = await sig(SECRET, raw, t);
43 expect(await verifySignature(SECRET, `t=${t},v1=${good}`, raw, t)).toBe("ok");
44 expect(await verifySignature(SECRET, `t=${t},v1=${"0".repeat(64)}`, raw, t)).toBe("mismatch");
45 expect(await verifySignature("whsec_other1234567", `t=${t},v1=${good}`, raw, t)).toBe("mismatch");
46 expect(await verifySignature(SECRET, `t=${t},v1=${good}`, enc.encode('{"id":"evt_2"}'), t)).toBe("mismatch");
47 // a stale timestamp, even correctly signed, either direction
48 const old = t - 301;
49 expect(await verifySignature(SECRET, `t=${old},v1=${await sig(SECRET, raw, old)}`, raw, t)).toBe("stale");
50 const future = t + 301;
51 expect(await verifySignature(SECRET, `t=${future},v1=${await sig(SECRET, raw, future)}`, raw, t)).toBe("stale");
52 expect(await verifySignature(SECRET, `t=${t - 300},v1=${await sig(SECRET, raw, t - 300)}`, raw, t)).toBe("ok");
53 for (const h of [null, "", "v1=abc", `t=${t}`, `t=x,v1=${good}`, `t=${t},v1=zz`]) expect(await verifySignature(SECRET, h, raw, t)).toBe("malformed");
54 // rotation: one of two v1 values matches; unknown keys are ignored
55 const rotated = `t=${t},v1=${await sig(OLD, raw, t)},v0=junk,v1=${good}`;
56 expect(await verifySignature(SECRET, rotated, raw, t)).toBe("ok");
57 expect(await verifySignature(SECRET, `t=${t},v1=${await sig(OLD, raw, t)},v1=${"1".repeat(64)}`, raw, t)).toBe("mismatch");
58 });
59
60 test("the bytes are signed, not the JSON: the same value spelled differently does not verify", async () => {
61 const t = now();
62 const spaced = '{ "id": "evt_1", "n": 1 }';
63 const compact = JSON.stringify(JSON.parse(spaced));
64 expect(compact).not.toBe(spaced);
65 const header = `t=${t},v1=${await sig(SECRET, spaced, t)}`;
66 expect(await verifySignature(SECRET, header, enc.encode(spaced), t)).toBe("ok");
67 expect(await verifySignature(SECRET, header, enc.encode(compact), t)).toBe("mismatch");
68 });
69 });
70
71 describe("the webhook", () => {
72 let dataDir: string;
73 let app: App;
74 let servers: Servers;
75 let base = "";
76 let admin: User;
77 let stripeSays: Record<string, { status: string; end?: number }> = {};
78 const asked: string[] = [];
79 const fetchFn = (async (url: string) => {
80 const id = decodeURIComponent(String(url).split("/").pop()!);
81 asked.push(id);
82 const s = stripeSays[id];
83 if (!s) return new Response("{}", { status: 404 });
84 return new Response(JSON.stringify({ id, status: s.status, customer: "cus_1", items: { data: [{ current_period_end: s.end ?? 2_000_000_000 }] } }));
85 }) as unknown as typeof fetch;
86
87 beforeAll(async () => {
88 dataDir = await mkdtemp(join(tmpdir(), "forge-stripe-hook-"));
89 app = createApp({ dataDir, port: 0, url: "https://f.test" });
90 servers = await serve(app);
91 base = `http://127.0.0.1:${servers.port ?? app.config.port}`;
92 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
93 if (!a.ok) throw new Error("user");
94 readWith(fetchFn);
95 admin = a.value;
96 sealKey(app.db, dataDir, "sk_test_AbCdEf1234567890");
97 sealWebhook(app.db, dataDir, SECRET);
98 }, 30_000);
99 afterAll(async () => {
100 servers.stop();
101 app.close();
102 await rm(dataDir, { recursive: true, force: true });
103 });
104
105 async function post(body: string, header?: string, extra: Record<string, string> = {}): Promise<Response> {
106 const h = header ?? `t=${now()},v1=${await sig(SECRET, body, now())}`;
107 return fetch(`${base}/hooks/stripe`, { method: "POST", body, headers: { "stripe-signature": h, "content-type": "application/json", ...extra } });
108 }
109 const event = (id: string, type: string, object: unknown) => JSON.stringify({ id, type, data: { object } });
110 const paidOrder = async (forge: string, sub: string) => {
111 const o = createOrder(app.db, { forge, plan: "medium", payer: "u1" });
112 const r = await post(event(`evt_co_${sub}`, "checkout.session.completed", { client_reference_id: o.id, customer: "cus_1", subscription: sub, payment_status: "paid" }));
113 expect(r.status).toBe(200);
114 return o;
115 };
116
117 test("a good signature binds the order to its customer and subscription and marks it paid", async () => {
118 stripeSays["sub_1"] = { status: "active", end: 1_900_000_000 };
119 const o = await paidOrder("acme", "sub_1");
120 expect(getOrder(app.db, o.id)).toMatchObject({ status: "paid", customerId: "cus_1", subscriptionId: "sub_1" });
121 expect(getStored(app.db, "acme")).toMatchObject({ plan: "medium", status: "active", provider: "stripe", subscriptionId: "sub_1", customerId: "cus_1" });
122 // the period comes from Stripe's own answer, read after the delivery
123 await drainPending(app, fetchFn);
124 expect(getStored(app.db, "acme")?.periodEnd).toBe(1_900_000_000);
125 });
126
127 test("a bad signature, a stale one and no header answer 400, change nothing, and are counted", async () => {
128 const before = hookProbe(app.db)?.refused ?? 0;
129 const body = event("evt_forged", "checkout.session.completed", { client_reference_id: createOrder(app.db, { forge: "evil", plan: "large" }).id, subscription: "sub_x", payment_status: "paid" });
130 expect((await post(body, `t=${now()},v1=${"a".repeat(64)}`)).status).toBe(400);
131 const t = now() - 400;
132 expect((await post(body, `t=${t},v1=${await sig(SECRET, body, t)}`)).status).toBe(400);
133 expect((await fetch(`${base}/hooks/stripe`, { method: "POST", body })).status).toBe(400);
134 expect(getStored(app.db, "evil")).toBeUndefined();
135 expect(app.db.query("SELECT 1 FROM stripe_event WHERE id = 'evt_forged'").get()).toBeNull();
136 expect(hookProbe(app.db)?.refused).toBe(before + 3);
137 });
138
139 test("a body whose bytes differ from its re-serialized JSON is checked as sent", async () => {
140 const spaced = `{ "id": "evt_bytes", "type": "ping.x", "data": { "object": {} } }`;
141 const t = now();
142 const header = `t=${t},v1=${await sig(SECRET, spaced, t)}`;
143 expect((await post(JSON.stringify(JSON.parse(spaced)), header)).status).toBe(400);
144 expect((await post(spaced, header)).status).toBe(200);
145 });
146
147 test("a replayed event answers 200 and does nothing", async () => {
148 stripeSays["sub_r"] = { status: "active" };
149 const o = createOrder(app.db, { forge: "replay", plan: "small" });
150 const body = event("evt_replay", "checkout.session.completed", { client_reference_id: o.id, customer: "cus_r", subscription: "sub_r", payment_status: "paid" });
151 expect((await post(body)).status).toBe(200);
152 await drainPending(app, fetchFn); // behind the one the delivery started
153 const changes = () => app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM entitlement_change WHERE forge = 'replay'").get()!.n;
154 const n = changes();
155 putEntitlement(app.db, { forge: "replay", plan: "large", status: "active", source: "granted" }, "small", "admin");
156 expect((await post(body)).status).toBe(200);
157 // the replay did not bind again: the admin's later grant stands
158 expect(getStored(app.db, "replay")?.plan).toBe("large");
159 expect(changes()).toBe(n + 1);
160 });
161
162 test("events out of order settle on what Stripe says now", async () => {
163 stripeSays["sub_o"] = { status: "active" };
164 await paidOrder("late", "sub_o");
165 await drainPending(app, fetchFn);
166 expect(getStored(app.db, "late")?.status).toBe("active");
167 // Stripe canceled it; the deleted event arrives first, then a stale "updated" that claims active
168 stripeSays["sub_o"] = { status: "canceled" };
169 expect((await post(event("evt_del", "customer.subscription.deleted", { id: "sub_o", status: "canceled" }))).status).toBe(200);
170 expect((await post(event("evt_upd", "customer.subscription.updated", { id: "sub_o", status: "active" }))).status).toBe(200);
171 await drainPending(app, fetchFn);
172 expect(getStored(app.db, "late")?.status).toBe("none");
173 // an invoice event, in either API shape, names the subscription too
174 stripeSays["sub_o"] = { status: "past_due" };
175 expect((await post(event("evt_inv", "invoice.payment_failed", { parent: { subscription_details: { subscription: "sub_o" } } }))).status).toBe(200);
176 expect((await post(event("evt_inv2", "invoice.paid", { subscription: "sub_o" }))).status).toBe(200);
177 await drainPending(app, fetchFn);
178 expect(getStored(app.db, "late")?.status).toBe("grace");
179 });
180
181 test("a failed read stays queued and is read again", async () => {
182 putEntitlement(app.db, { forge: "flaky", plan: "small", status: "active", source: "stripe:x", provider: "stripe", subscriptionId: "sub_f" }, "small", "t");
183 expect((await post(event("evt_f", "invoice.paid", { subscription: "sub_f" }))).status).toBe(200);
184 const down = (async () => { throw new Error("down"); }) as unknown as typeof fetch;
185 await drainPending(app, down);
186 expect(app.db.query("SELECT 1 FROM stripe_pending WHERE subscription_id = 'sub_f'").get()).not.toBeNull();
187 stripeSays["sub_f"] = { status: "unpaid" };
188 await drainPending(app, fetchFn);
189 expect(getStored(app.db, "flaky")?.status).toBe("grace");
190 expect(app.db.query("SELECT 1 FROM stripe_pending WHERE subscription_id = 'sub_f'").get()).toBeNull();
191 });
192
193 test("the daily sweep heals a missed webhook, once a day", async () => {
194 putEntitlement(app.db, { forge: "missed", plan: "small", status: "active", source: "stripe:y", provider: "stripe", subscriptionId: "sub_m" }, "small", "t");
195 stripeSays["sub_m"] = { status: "canceled" };
196 const day = Date.now() + 2 * 86_400_000;
197 expect(await sweepIfDue(app, fetchFn, day)).toBe(true);
198 expect(getStored(app.db, "missed")?.status).toBe("none");
199 // swept_at is now; a second call inside the day does nothing
200 expect(await sweepIfDue(app, fetchFn, Date.now() + 3600_000)).toBe(false);
201 });
202
203 test("no cookie is read, a cross-site Origin is refused, and an unconnected forge answers 404", async () => {
204 const body = event("evt_cookie", "ping.x", {});
205 expect((await post(body, undefined, { cookie: "forge_session=garbage" })).status).toBe(200);
206 expect((await post(body, undefined, { origin: "https://evil.test" })).status).toBe(403);
207 const bare = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-stripe-bare-")), port: 0, url: "https://f.test" });
208 const s2 = await serve(bare);
209 try {
210 const r = await fetch(`http://127.0.0.1:${s2.port ?? bare.config.port}/hooks/stripe`, { method: "POST", body: "{}", headers: { "stripe-signature": "t=1,v1=00" } });
211 expect(r.status).toBe(404);
212 } finally {
213 s2.stop();
214 bare.close();
215 }
216 });
217
218 test("the Billing page's probe says the last event and when", async () => {
219 await post(event("evt_probe", "invoice.paid", { subscription: "sub_none" }));
220 const probe = hookProbe(app.db)!;
221 expect(probe.lastType).toBe("invoice.paid");
222 expect(Date.now() - probe.lastAt).toBeLessThan(60_000);
223 const read = readBilling(app, admin);
224 expect(read.ok && read.value.hook?.lastType).toBe("invoice.paid");
225 // the page says it
226 const token = createToken(app.db, admin.id, "admin", "admin").token;
227 const page = await (await fetch(`${base}/settings/billing`, { headers: { authorization: `Bearer ${token}` } })).text();
228 expect(page).toContain("refused in the last day");
229 expect(page).toContain("invoice.paid, ");
230 });
231 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs