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
scout/forge:scout/11137-post-hooks-stripe-verifies-the-signature → main 3 commits fork
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 | ||
| 6 | 6 | - **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). |
| 7 | 7 | - **Audit:** every grant is a row in `entitlement_change` and a `billing.plan` event. |
| 8 | 8 | - **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. | |
| 10 | 11 | |
| 11 | 12 | ## Gift codes |
| 12 | 13 | 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"; | ||
| 14 | 14 | import { connectionOf, forget, openKey, sealKey, sealWebhook, type Connection } from "../billing/connection.ts"; |
| 15 | 15 | import { isStripeKey, stripeClient, type StripeError } from "../billing/stripe.ts"; |
| 16 | 16 | import { publicOrigin } from "../core/public_origin.ts"; |
| 17 | import { hookProbe, type HookProbe } from "../billing/reconcile.ts"; | |
| 17 | 18 | import { holdValues } from "../kernel/log.ts"; |
| 18 | 19 | import { listChildrenNow } from "./spawn.ts"; |
| 19 | 20 | import { parseSize } from "../spawn/sizes.ts"; |
| @@ -41,6 +42,8 @@ export interface BillingView { | ||
| 41 | 42 | readonly forges: readonly (Entitlement & { size: string })[]; |
| 42 | 43 | /** The Stripe connection (mode, whether events are signed), never the key. */ |
| 43 | 44 | readonly stripe: Connection | undefined; |
| 45 | /** The last webhook received and the refusals of the last day (#11137). */ | |
| 46 | readonly hook: HookProbe | undefined; | |
| 44 | 47 | } |
| 45 | 48 | |
| 46 | 49 | 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 | ||
| 51 | 54 | return ok({ |
| 52 | 55 | forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })), |
| 53 | 56 | stripe: connectionOf(app.db), |
| 57 | hook: hookProbe(app.db), | |
| 54 | 58 | }); |
| 55 | 59 | } |
| 56 | 60 |
src/billing/connection.ts +1 −1
| @@ -44,7 +44,7 @@ export function sealWebhook(db: Database, dataDir: string, secret: string, endpo | ||
| 44 | 44 | } |
| 45 | 45 | |
| 46 | 46 | /** 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 { | |
| 48 | 48 | const c = connectionOf(db); |
| 49 | 49 | if (!c) return undefined; |
| 50 | 50 | 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 { | ||
| 66 | 66 | export interface StripeClient { |
| 67 | 67 | addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>; |
| 68 | 68 | 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; | |
| 69 | 78 | } |
| 70 | 79 | |
| 71 | 80 | 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 | ||
| 80 | 89 | } |
| 81 | 90 | let res: Response; |
| 82 | 91 | 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 ?? {}) } : {}) }); | |
| 84 | 93 | } catch { |
| 85 | 94 | return err({ kind: "unreachable", message: "Stripe could not be reached." }); |
| 86 | 95 | } |
| @@ -112,5 +121,21 @@ export function stripeClient(key: string, fetchFn: typeof fetch = fetch): Stripe | ||
| 112 | 121 | const r = await call("DELETE", `/v1/webhook_endpoints/${encodeURIComponent(id)}`); |
| 113 | 122 | return r.ok ? ok(true as const) : r; |
| 114 | 123 | }, |
| 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 | }, | |
| 115 | 140 | }; |
| 116 | 141 | } |
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[] = [ | ||
| 66 | 66 | "boards", |
| 67 | 67 | "contribute", |
| 68 | 68 | "csp-report", |
| 69 | "hooks", | |
| 69 | 70 | "deploy-to", |
| 70 | 71 | "pinned", |
| 71 | 72 | "pipeline", |
src/http/server.ts +7 −1
| @@ -37,6 +37,7 @@ import { plansSet } from "../web/settings/plans.ts"; | ||
| 37 | 37 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 38 | 38 | import { measureSubmit } from "./measure.ts"; |
| 39 | 39 | import { cspReportSubmit } from "./csp_report.ts"; |
| 40 | import { stripeHook } from "./stripe_hook.ts"; | |
| 40 | 41 | import { settingsRoutes } from "./settings_routes.ts"; |
| 41 | 42 | import { pluginRoutes } from "./plugin_routes.ts"; |
| 42 | 43 | import { pinnedRoutes } from "./pinned_routes.ts"; |
| @@ -826,6 +827,8 @@ export function buildRouter(): Router<Handler> { | ||
| 826 | 827 | router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf)); |
| 827 | 828 | // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only. |
| 828 | 829 | 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); | |
| 829 | 832 | router.add("GET", "/", home); |
| 830 | 833 | // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts). |
| 831 | 834 | router.add("GET", "/claim", claimFragmentPage); |
| @@ -2895,7 +2898,10 @@ async function answerGet( | ||
| 2895 | 2898 | return noRoute(request.method, url.pathname); |
| 2896 | 2899 | } |
| 2897 | 2900 | 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); | |
| 2899 | 2905 | noteSessionTime(request, performance.now() - authStart); |
| 2900 | 2906 | if (auth.retryAfter !== undefined) return tooManyAttempts(auth.retryAfter); |
| 2901 | 2907 | 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 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { drainPending, sweepIfDue } from "./billing/reconcile.ts"; | |
| 7 | 8 | import { sweepStarts } from "./actions/start_sweep.ts"; |
| 8 | 9 | import { scanNightly } from "./checks/scan.ts"; |
| 9 | 10 | import { nightlyDue } from "./checks/scan_store.ts"; |
| @@ -694,6 +695,9 @@ const staleTimer = setInterval(() => void during("asks", tickAsks), 120_000); | ||
| 694 | 695 | const startsTimer = setInterval(() => during("starts", () => void sweepStarts(app)), 3_600_000); |
| 695 | 696 | during("starts", () => void sweepStarts(app)); |
| 696 | 697 | 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); | |
| 697 | 701 | |
| 698 | 702 | // Every start is counted, and one that follows a process that never said |
| 699 | 703 | // goodbye is a crash the log could not record (kernel/boots.ts, #3708). |
| @@ -921,6 +925,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 921 | 925 | clearInterval(syncTimer); |
| 922 | 926 | clearInterval(staleTimer); |
| 923 | 927 | clearInterval(startsTimer); |
| 928 | clearInterval(stripeTimer); | |
| 924 | 929 | clearInterval(followTimer); |
| 925 | 930 | stopClogTick(); |
| 926 | 931 | stopKeeper(); |
src/web/pages/billing.ts +10
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * Anyone else gets the not-found page. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { BillingView } from "../../actions/billing.ts"; | |
| 8 | 9 | import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts"; |
| 9 | 10 | import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts"; |
| 10 | 11 | import { publicOrigin } from "../../core/public_origin.ts"; |
| @@ -27,6 +28,14 @@ interface Shown { | ||
| 27 | 28 | readonly link: string; |
| 28 | 29 | } |
| 29 | 30 | |
| 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 | ||
| 30 | 39 | const day = (iso: string): string => iso.slice(0, 10); |
| 31 | 40 | |
| 32 | 41 | /** 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> = { | ||
| 108 | 117 | ? html`<dl class="status-rows"> |
| 109 | 118 | <div class="status-row"><dt>Stripe</dt><dd data-mode="${s.mode}">${s.mode}</dd></div> |
| 110 | 119 | <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> | |
| 111 | 121 | </dl> |
| 112 | 122 | ${s.signed |
| 113 | 123 | ? null |
test/job-slices.test.ts +1
| @@ -251,6 +251,7 @@ const UNTIMED: Record<string, string> = { | ||
| 251 | 251 | "snapshot-report": "inside a running snapshot upload: logs its progress; nightly", |
| 252 | 252 | "system-live": "reads /proc and the cgroup roots for the system page while someone watches it", |
| 253 | 253 | 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", | |
| 254 | 255 | }; |
| 255 | 256 | |
| 256 | 257 | /** 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 | }); |