Commit

Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard

Billing made its own destination through Stripe's API and kept the signing secret in D1, so a secret rolled or a destination made in the dashboard could never reach it. Now it is the usual way: the destination is made in Stripe, and its whsec_ secret is the billing Worker's secret, like STRIPE_SECRET_KEY. - stripe_webhooks is dropped (0028), with the register flow and Stripe::delete. - sudo -> Stripe checks instead of registering: whether the secret is set, the destination at api.g1t.sh/stripe/webhook as Stripe has it, its status and missing events, and one Fix destination action. - The daily upkeep finds the destination by its address, enables it and adds missing events; it never touches the secret. - deploy/stack.jsonc lists STRIPE_WEBHOOK_SECRET, so doctor checks it. - docs/BILLING_OPERATIONS.md says how to set it up and roll it.

syntaqxcommitted Parentd5d554fBrowse files
12 files+227−2040/12 viewed
+70−62
11 import { Webhook } from "lucide-react";
2−import { Link } from "react-router";
32
43 import type { StripeStatus } from "@g1t/contracts";
54
65 import type { Route } from "./+types/stripe";
76 import { Badge, Button, EmptyState, Notice, Section, When } from "~/components/ui";
8−import { text } from "~/lib/forms";
97 import { admin } from "~/lib/services.server";
108 import { requireStaff } from "~/lib/staff";
119
1614 return { status: await admin.stripe() };
1715 }
1816
19−type ActionData = { review: true } | { status: StripeStatus; registered: true };
17+type ActionData = { status: StripeStatus; fixed: true };
2018
21−export async function action({ request, context }: Route.ActionArgs) {
19+export async function action({ context }: Route.ActionArgs) {
2220 const staff = requireStaff(context);
23− const form = await request.formData();
24− if (text(form, "intent") !== "webhook") return { review: true } satisfies ActionData;
25− if (text(form, "confirm") !== "yes") return { review: true } satisfies ActionData;
26− return { status: await admin.stripe(true, staff.email), registered: true } satisfies ActionData;
21+ return { status: await admin.stripe(true, staff.email), fixed: true } satisfies ActionData;
2722 }
2823
2924 const MODE: Record<string, { label: string; tone: "warn" | "mint" | "danger" }> = {
3227 off: { label: "Off", tone: "danger" },
3328 };
3429
30+const WEBHOOK_URL = "https://api.g1t.sh/stripe/webhook";
31+
3532 export default function Stripe({ loaderData, actionData }: Route.ComponentProps) {
3633 const result = actionData as ActionData | undefined;
37− const status = result && "status" in result ? result.status : loaderData.status;
38− const reviewing = result != null && "review" in result;
39− const registered = result != null && "registered" in result;
34+ const status = result?.status ?? loaderData.status;
35+ const fixed = result != null;
4036 const mode = MODE[status.mode] ?? { label: status.mode, tone: "warn" as const };
4137 const { webhook } = status;
42− const verb = webhook ? "Replace" : "Register";
38+ const needsFix = webhook != null && (webhook.status !== "enabled" || status.missingEvents.length > 0);
39+ const ready = status.mode !== "off" && status.secretSet && webhook != null && !needsFix;
4340
4441 return (
4542 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
4643 <div className="flex flex-wrap items-start justify-between gap-4">
4744 <div>
4845 <h1 className="text-2xl font-semibold tracking-tight">Stripe</h1>
49− <p className="mt-1 text-sm text-muted">How billing talks to Stripe: its keys' mode, the webhook Stripe calls, and what it sent lately.</p>
46+ <p className="mt-1 text-sm text-muted">How billing hears from Stripe: the destination in Stripe, its signing secret here, and what it sent lately.</p>
5047 </div>
5148 <Badge tone={mode.tone}>{mode.label}</Badge>
5249 </div>
5350
5451 <div className="mt-6 space-y-3">
5552 {status.error && <Notice tone="error">{status.error}</Notice>}
56− {registered && !status.error && <Notice tone="ok">Webhook registered. Stripe sends its events to it from now on.</Notice>}
57− {status.mode === "off" && <Notice tone="warn">Billing has no Stripe key, so nothing reaches Stripe and no webhook can be registered.</Notice>}
58− {reviewing && (
59− <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${webhook ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
60− <h2 className="font-semibold tracking-tight">{verb} the {mode.label.toLowerCase()} webhook?</h2>
61− <p className="mt-2 text-sm text-muted">
62− Billing {webhook ? "deletes the endpoint it made before, then asks" : "asks"} Stripe for a new webhook endpoint, and keeps
63− its signing secret itself; nobody sees it.
64− </p>
65− <form method="post" action="/stripe#top" className="mt-4 flex flex-wrap items-center gap-2">
66− <input type="hidden" name="intent" value="webhook" />
67− <input type="hidden" name="confirm" value="yes" />
68− <Button type="submit" variant={webhook ? "danger" : "lavender"}>
69− {verb} webhook
70− </Button>
71− <Link to="/stripe" className="px-2 text-sm text-muted hover:text-fg">
72− Cancel
73− </Link>
74− </form>
75− </section>
76− )}
53+ {fixed && !status.error && <Notice tone="ok">Destination checked: enabled, and sending every event billing handles.</Notice>}
54+ {status.mode === "off" && <Notice tone="warn">Billing has no Stripe key (STRIPE_SECRET_KEY), so nothing reaches Stripe.</Notice>}
55+ {ready && <Notice tone="ok">Ready: Stripe sends to billing, and billing can check what it sends.</Notice>}
7756 </div>
7857
7958 <Section
8059 title="Webhook"
81− description="Replacing it makes a new signing secret, kept only in billing. Do it once per mode, and again after switching to live keys."
60+ description="Made in Stripe's dashboard; its signing secret is the billing Worker's secret STRIPE_WEBHOOK_SECRET. Billing keeps the destination's events and enables it again if Stripe turns it off; it never changes the secret."
8261 className="mt-6"
8362 actions={
84− status.mode !== "off" && !reviewing ? (
85− <form method="post" action="/stripe#review">
86− <input type="hidden" name="intent" value="webhook" />
87− <Button type="submit" variant="quiet">
63+ needsFix ? (
64+ <form method="post" action="/stripe#top">
65+ <Button type="submit" variant="lavender">
8866 <Webhook size={14} />
89− {verb} webhook
67+ Fix destination
9068 </Button>
9169 </form>
9270 ) : null
9371 }
9472 >
95− {webhook ? (
96− <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
97− <dt className="text-muted">URL</dt>
98− <dd className="font-mono text-xs break-all">{webhook.url}</dd>
99− <dt className="text-muted">Endpoint id</dt>
100− <dd className="font-mono text-xs break-all text-fg-soft">{webhook.endpointId}</dd>
101− <dt className="text-muted">Events</dt>
102− <dd className="flex flex-wrap gap-1.5">
103− {webhook.events.map((event) => (
104− <span key={event} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs">
105− {event}
106− </span>
107− ))}
108− </dd>
109− <dt className="text-muted">Registered</dt>
110− <dd className="text-fg-soft">
111− <When at={webhook.createdAt} time /> by <span className="font-mono">{webhook.createdBy}</span>
112− </dd>
113− </dl>
114− ) : (
115− <p className="text-sm text-muted">Not registered. Until it is, billing does not hear about payments, disputes or invoices from Stripe.</p>
116− )}
73+ <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
74+ <dt className="text-muted">Signing secret</dt>
75+ <dd>
76+ {status.secretSet ? (
77+ <Badge tone="mint">Set</Badge>
78+ ) : (
79+ <span className="text-fg-soft">
80+ <Badge tone="danger">Not set</Badge> Every event is refused until it is. In Stripe: the destination, Signing secret, Reveal; then, in{" "}
81+ <span className="font-mono text-xs">services/billing</span>,{" "}
82+ <span className="font-mono text-xs">npx wrangler secret put STRIPE_WEBHOOK_SECRET</span>.
83+ </span>
84+ )}
85+ </dd>
86+ <dt className="text-muted">Destination</dt>
87+ <dd className="min-w-0">
88+ {webhook ? (
89+ <span className="flex flex-wrap items-center gap-2">
90+ <span className="font-mono text-xs break-all">{webhook.url}</span>
91+ <Badge tone={webhook.status === "enabled" ? "mint" : "danger"}>{webhook.status}</Badge>
92+ <span className="font-mono text-xs text-faint">{webhook.endpointId}</span>
93+ </span>
94+ ) : status.mode === "off" ? (
95+ <span className="text-muted">Not checked without a key.</span>
96+ ) : (
97+ <span className="text-fg-soft">
98+ None in {mode.label.toLowerCase()}. In Stripe: Developers, Webhooks, Add destination, endpoint URL{" "}
99+ <span className="font-mono text-xs">{WEBHOOK_URL}</span>, any events (billing adds what it needs here). Then set its secret.
100+ </span>
101+ )}
102+ </dd>
103+ {webhook && (
104+ <>
105+ <dt className="text-muted">Made</dt>
106+ <dd className="text-fg-soft">
107+ <When at={webhook.createdAt} time />
108+ </dd>
109+ <dt className="text-muted">Events</dt>
110+ <dd className="flex flex-wrap gap-1.5">
111+ {webhook.events.map((event) => (
112+ <span key={event} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs">
113+ {event}
114+ </span>
115+ ))}
116+ {status.missingEvents.map((event) => (
117+ <span key={event} className="rounded border border-danger/40 bg-danger/5 px-1.5 py-0.5 font-mono text-xs text-danger" title="Billing handles this; the destination does not send it">
118+ {event} missing
119+ </span>
120+ ))}
121+ </dd>
122+ </>
123+ )}
124+ </dl>
117125 </Section>
118126
119127 <Section title="Recent events" description="What Stripe sent, newest first, and what billing did with it." className="mt-6">
+11−5
14441444 }
14451445
14461446 /// `admin_stripe`: where billing stands with Stripe. Staff only. Returns
1447−/// `StripeStatus`. With `setup: true`, registers (or replaces) the webhook
1448−/// endpoint for the current mode first.
1447+/// `StripeStatus`. With `fix: true`, first enables the destination at
1448+/// billing's address and gives it the events billing needs.
14491449 #[derive(Debug, Default, Serialize, Deserialize)]
14501450 pub struct AdminStripeArgs {
14511451 #[serde(default)]
1452− pub setup: bool,
1452+ pub fix: bool,
14531453 #[serde(default)]
14541454 pub by: Option<String>,
14551455 }
14591459 pub struct StripeStatus {
14601460 /// `test` or `live`, from the key; `off` without one.
14611461 pub mode: String,
1462+ /// Whether `STRIPE_WEBHOOK_SECRET` is set, so events can be checked.
1463+ pub secret_set: bool,
1464+ /// The destination at billing's address in Stripe, as Stripe has it.
14621465 pub webhook: Option<StripeWebhook>,
1466+ /// Events billing handles that the destination does not send.
1467+ pub missing_events: Vec<String>,
14631468 /// The latest events handled, newest first.
14641469 pub recent_events: Vec<StripeEventSummary>,
1465− /// What went wrong setting up, if it did.
1470+ /// What went wrong reading or fixing the destination, if it did.
14661471 pub error: Option<String>,
14671472 }
14681473
14711476 pub struct StripeWebhook {
14721477 pub url: String,
14731478 pub endpoint_id: String,
1479+ /// `enabled` or `disabled`.
1480+ pub status: String,
14741481 pub events: Vec<String>,
1475− pub created_by: String,
14761482 pub created_at: String,
14771483 }
14781484
+1−1
113113 "worker": "g1t-billing",
114114 "d1": { "database": "g1t-billing", "migrations": "migrations" },
115115 "stage": "core",
116− "secrets": ["STRIPE_SECRET_KEY", "CLOUDFLARE_USAGE_TOKEN"],
116+ "secrets": ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "CLOUDFLARE_USAGE_TOKEN"],
117117 "self_host": "run"
118118 },
119119 "integrations": {
+24−12
287287 Billing keeps what it needs from Stripe so reads never wait on it, and
288288 hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`).
289289
290−**The webhook.** sudo → Stripe → **Register webhook** creates the endpoint
291−`https://api.g1t.sh/stripe/webhook` through Stripe's API. Stripe returns the
292−signing secret only in that answer; billing stores it in `stripe_webhooks`,
293−one row per key mode (test, live). Never add the endpoint in Stripe's
294−dashboard: its secret would not reach billing, and every event would fail
295−with "The signature does not match". Register again in each mode (at
296−launch, after the key changes to live). Events are claimed once each in
297−`stripe_events`; a handler that fails forgets its claim, and Stripe retries.
290+**The webhook.** A destination made in Stripe's dashboard (Developers →
291+Webhooks → Add destination) with the endpoint URL
292+`https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at
293+launch, make one in live mode and put its secret). Its signing secret
294+(`whsec_…`: the destination, Signing secret, Reveal) is the billing
295+Worker's secret:
296+
297+```sh
298+cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET
299+```
300+
301+Without it every event is refused with 400. After rolling the secret in
302+Stripe, put the new one; during the roll Stripe signs with both, so there
303+is no gap. The event list need not be exact: billing adds any event it
304+handles that the destination does not send (daily, or **Fix destination**
305+in sudo → Stripe), and enables it again if Stripe disabled it. It never
306+changes the secret. sudo → Stripe shows whether the secret is set, the
307+destination and its status, missing events, and the latest events.
308+Events are claimed once each in `stripe_events`; a handler that fails
309+forgets its claim, and Stripe retries.
298310
299311 **What is kept, and how it stays current**
300312
311323 1,000 events were listed. Claims stuck at `handling` for 10 minutes are
312324 dropped so the replay retries them. The first run reads 3 days back.
313325
314−**Daily** (`keeper::DAILY`): the endpoint is given billing's event list in
315−place (its secret stays) and enabled again if Stripe disabled it, both
316−audited as `stripe`/`webhook`; then up to 25 stale cards and 25 stale plans
317−are read again.
326+**Daily** (`keeper::DAILY`): the destination at billing's address is
327+enabled again if Stripe disabled it and given any missing event, audited as
328+`stripe`/`webhook`; then up to 25 stale cards and 25 stale plans are read
329+again.
318330
319331 **What still calls Stripe on a request**: starting a payment page, a plan
320332 or a card check; opening the billing portal; settling a page the person
+8−2
342342 export type StripeStatus = {
343343 /** `test` or `live`, from the key; `off` without one. */
344344 mode: "test" | "live" | "off" | string;
345− webhook: { url: string; endpointId: string; events: string[]; createdBy: string; createdAt: string } | null;
345+ /** Whether `STRIPE_WEBHOOK_SECRET` is set, so events can be checked. */
346+ secretSet: boolean;
347+ /** The destination at billing's address in Stripe, as Stripe has it. */
348+ webhook: { url: string; endpointId: string; status: "enabled" | "disabled" | string; events: string[]; createdAt: string } | null;
349+ /** Events billing handles that the destination does not send. */
350+ missingEvents: string[];
346351 recentEvents: { id: string; kind: string; outcome: string; receivedAt: string }[];
347352 error: string | null;
348353 };
513518 /** The workspace's Stripe billing page, to send to the customer. Logged. */
514519 billingLink(workspace: string, by: string): Promise<Result<BillingLink>>;
515520 /** Where billing stands with Stripe; with `setup`, registers the webhook first. */
516− stripe(setup?: boolean, by?: string): Promise<StripeStatus>;
521+ /** Stripe's state for billing; `fix` enables the destination and adds missing events first. */
522+ stripe(fix?: boolean, by?: string): Promise<StripeStatus>;
517523 /** Where an enterprise's invoices go; makes its Stripe customer. */
518524 enterpriseBilling(id: string, email: string, by: string): Promise<Result<PayingAccount>>;
519525 /** Sends an enterprise its invoice now, for what its workspaces owe. */
+1−1
421421 attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
422422 credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
423423 billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }),
424− stripe: (setup = false, by) => call("admin_stripe", { setup, by: by ?? null }),
424+ stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }),
425425 enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }),
426426 invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }),
427427 accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }),
+4−0
1+-- The webhook's signing secret is the Worker secret STRIPE_WEBHOOK_SECRET,
2+-- copied from the destination made in Stripe's dashboard. Billing no
3+-- longer makes destinations or keeps their secrets.
4+DROP TABLE IF EXISTS stripe_webhooks;
+9−1
167167 db: D1Database,
168168 /// Absent when no card processor is configured.
169169 stripe: Option<Stripe>,
170+ /// The destination's signing secret from Stripe (`STRIPE_WEBHOOK_SECRET`);
171+ /// without it no event is believed.
172+ webhook_secret: Option<String>,
170173 margin_percent: u32,
171174 /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`).
172175 free: bool,
958961 .map(|key| key.to_string())
959962 .filter(|key| !key.is_empty())
960963 .map(Stripe::new),
964+ webhook_secret: env
965+ .secret("STRIPE_WEBHOOK_SECRET")
966+ .ok()
967+ .map(|secret| secret.to_string().trim().to_owned())
968+ .filter(|secret| !secret.is_empty()),
961969 margin_percent: env
962970 .var("MARGIN_PERCENT")
963971 .ok()
10181026 // Once a day: Stripe's endpoint kept listening to billing's events and
10191027 // enabled, and saved cards and plans not read in a while read again.
10201028 if event.cron() == keeper::DAILY {
1021− match billing.keep_endpoint().await {
1029+ match billing.keep_endpoint("billing").await {
10221030 Ok(done) => worker::console_log!("stripe endpoint: {done}"),
10231031 Err(error) => worker::console_error!("keeping Stripe's endpoint failed: {error}"),
10241032 }
+0−4
163163 self.send(Method::Post, path, Some(form(fields)), Some(key)).await
164164 }
165165
166− pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
167− self.call(Method::Delete, path, None).await
168− }
169−
170166 async fn call<T: for<'a> Deserialize<'a>>(
171167 &self,
172168 method: Method,
+22−32
99 //! through the same once-only claim the webhook uses. A delivery that
1010 //! failed for good, or an endpoint not registered yet, costs at most one
1111 //! cron interval.
12−//! - The endpoint, kept: once a day its events are made billing's list in
13−//! place (its signing secret stays), and it is enabled again if Stripe
14−//! turned it off after failures.
12+//! - The destination, kept: once a day it is enabled again if Stripe turned
13+//! it off after failures, and given any event billing handles that it
14+//! does not send. Its signing secret, `STRIPE_WEBHOOK_SECRET`, is not
15+//! touched.
1516
1617 use crate::Billing;
1718 use crate::stripe::form;
18−use crate::webhooks::EVENTS;
19+use crate::webhooks::{EVENTS, WEBHOOK_URL, missing_events};
1920 use g1t_contracts::billing::Card;
2021 use g1t_contracts::time::rfc3339;
2122 use g1t_kit::now_ms;
225226 Ok(format!("{} listed, {handled} not seen before and handled", listed.len()))
226227 }
227228
228− /// Keeps the registered endpoint listening to billing's events and
229− /// enabled. Nothing when no endpoint is registered for this mode.
230− pub(crate) async fn keep_endpoint(&self) -> Result<String> {
231− let (Some(stripe), Some(webhook)) = (&self.stripe, self.webhook_row().await?) else {
232− return Ok("no endpoint registered".to_owned());
229+ /// Keeps the destination at billing's address enabled and sending
230+ /// every event billing handles. Its signing secret is not touched.
231+ /// Nothing when Stripe has no destination there.
232+ pub(crate) async fn keep_endpoint(&self, by: &str) -> Result<String> {
233+ let (Some(stripe), Some(destination)) = (&self.stripe, self.destination().await?) else {
234+ return Ok(format!("no destination at {WEBHOOK_URL}"));
233235 };
234− #[derive(Deserialize)]
235− struct Endpoint {
236− status: String,
237− enabled_events: Vec<String>,
238− }
239− let path = format!("/webhook_endpoints/{}", webhook.endpoint_id);
240− let endpoint: Endpoint = stripe.get(&path).await?;
236+ let path = format!("/webhook_endpoints/{}", destination.id);
241237 let mut fields: Vec<(String, String)> = Vec::new();
242238 let mut changes = Vec::new();
243− if endpoint.status != "enabled" {
239+ if destination.status != "enabled" {
244240 fields.push(("disabled".to_owned(), "false".to_owned()));
245241 changes.push("enabled again".to_owned());
246242 }
247− let mut wanted: Vec<&str> = EVENTS.to_vec();
248− let mut has: Vec<&str> = endpoint.enabled_events.iter().map(String::as_str).collect();
249− wanted.sort_unstable();
250− has.sort_unstable();
251− if wanted != has {
252− fields.extend(EVENTS.iter().enumerate().map(|(i, event)| (format!("enabled_events[{i}]"), (*event).to_owned())));
253− changes.push(format!("events set to billing's {}", EVENTS.len()));
243+ let missing = missing_events(&destination.enabled_events);
244+ if !missing.is_empty() {
245+ // Stripe replaces the list: what it sends now, plus what is missing.
246+ let all = destination.enabled_events.iter().cloned().chain(missing.iter().cloned());
247+ fields.extend(all.enumerate().map(|(i, event)| (format!("enabled_events[{i}]"), event)));
248+ changes.push(format!("added {}", missing.join(", ")));
254249 }
255250 if changes.is_empty() {
256− return Ok("endpoint as it should be".to_owned());
251+ return Ok("destination as it should be".to_owned());
257252 }
258253 let fields: Vec<(&str, String)> = fields.iter().map(|(name, value)| (name.as_str(), value.clone())).collect();
259254 let _: Value = stripe.post(&path, &fields).await?;
260− self.db
261− .prepare("UPDATE stripe_webhooks SET events = ? WHERE mode = ?")
262− .bind(&[EVENTS.join(",").into(), self.mode().into()])?
263− .run()
264− .await?;
265− let done = changes.join(", ");
266− self.audit("stripe", "webhook", &format!("Endpoint {}: {done}", webhook.endpoint_id), "billing").await?;
255+ let done = changes.join("; ");
256+ self.audit("stripe", "webhook", &format!("Destination {}: {done}", destination.id), by).await?;
267257 Ok(done)
268258 }
269259
+73−83
6464 "setup_intent.succeeded",
6565 ];
6666
67+/// Events billing handles that `has` does not include, in billing's order.
68+pub(crate) fn missing_events(has: &[String]) -> Vec<String> {
69+ // `*` is every event.
70+ if has.iter().any(|event| event == "*") {
71+ return Vec::new();
72+ }
73+ EVENTS.iter().filter(|event| !has.iter().any(|h| h == *event)).map(|event| (*event).to_owned()).collect()
74+}
75+
6776 /// How old a signed event may be, so a captured one cannot be replayed.
6877 const TOLERANCE_SECONDS: i64 = 5 * 60;
6978
94103 })
95104 }
96105
106+/// The destination at billing's address, as Stripe lists it.
97107 #[derive(Deserialize)]
98−pub(crate) struct WebhookRow {
99− pub(crate) endpoint_id: String,
100− secret: String,
108+pub(crate) struct Destination {
109+ pub(crate) id: String,
101110 url: String,
102− pub(crate) events: String,
103− created_by: String,
104− created_at: String,
111+ /// `enabled` or `disabled`.
112+ pub(crate) status: String,
113+ pub(crate) enabled_events: Vec<String>,
114+ created: i64,
105115 }
106116
107117 #[derive(Deserialize)]
137147 }
138148 }
139149
140− pub(crate) async fn webhook_row(&self) -> Result<Option<WebhookRow>> {
141− self.db
142− .prepare("SELECT * FROM stripe_webhooks WHERE mode = ?")
143− .bind(&[self.mode().into()])?
144− .first::<WebhookRow>(None)
145− .await
150+ /// The destination at billing's address in Stripe, for this key's
151+ /// mode: an enabled one first. None when there is none.
152+ pub(crate) async fn destination(&self) -> Result<Option<Destination>> {
153+ let Some(stripe) = &self.stripe else { return Ok(None) };
154+ #[derive(Deserialize)]
155+ struct List {
156+ data: Vec<Destination>,
157+ }
158+ let mut ours: Vec<Destination> =
159+ stripe.get::<List>("/webhook_endpoints?limit=100").await?.data.into_iter().filter(|d| d.url == WEBHOOK_URL).collect();
160+ ours.sort_by_key(|d| d.status != "enabled");
161+ Ok(ours.into_iter().next())
146162 }
147163
148164 // --- Staff ------------------------------------------------------------
149165
150166 pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> {
151167 let mut error = None;
152− if a.setup
153− && let Err(e) = self.register_webhook(a.by.as_deref().unwrap_or("sudo")).await {
154− error = Some(e.to_string());
168+ if a.fix
169+ && let Err(e) = self.keep_endpoint(a.by.as_deref().unwrap_or("sudo")).await
170+ {
171+ error = Some(e.to_string());
172+ }
173+ let (webhook, missing_events) = match self.destination().await {
174+ Ok(Some(d)) => {
175+ let missing = missing_events(&d.enabled_events);
176+ let webhook = StripeWebhook {
177+ url: d.url,
178+ endpoint_id: d.id,
179+ status: d.status,
180+ events: d.enabled_events,
181+ created_at: rfc3339(d.created.max(0) as u64 * 1000),
182+ };
183+ (Some(webhook), missing)
155184 }
156− let webhook = self.webhook_row().await?.map(|row| StripeWebhook {
157− url: row.url,
158− endpoint_id: row.endpoint_id,
159− events: row.events.split(',').map(str::to_owned).collect(),
160− created_by: row.created_by,
161− created_at: row.created_at,
162− });
185+ Ok(None) => (None, Vec::new()),
186+ Err(e) => {
187+ error = error.or(Some(format!("Stripe could not be read: {e}")));
188+ (None, Vec::new())
189+ }
190+ };
163191 let recent_events = self
164192 .db
165193 .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25")
169197 .into_iter()
170198 .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at })
171199 .collect();
172− Ok(StripeStatus { mode: self.mode().to_owned(), webhook, recent_events, error })
173− }
174−
175− /// Registers billing's endpoint at Stripe for the current mode,
176− /// replacing any it made before, and keeps the new signing secret.
177− async fn register_webhook(&self, by: &str) -> Result<()> {
178− let Some(stripe) = &self.stripe else {
179− return Err(worker::Error::RustError("payments are not set up".into()));
180− };
181− #[derive(Deserialize)]
182− struct Endpoint {
183− id: String,
184− url: String,
185− #[serde(default)]
186− secret: Option<String>,
187− }
188− #[derive(Deserialize)]
189− struct List {
190− data: Vec<Endpoint>,
191− }
192− // Ours from before, whose secret cannot be read again: replaced.
193− let existing: List = stripe.get("/webhook_endpoints?limit=100").await?;
194− for endpoint in existing.data.iter().filter(|e| e.url == WEBHOOK_URL) {
195− let _: Value = stripe.delete(&format!("/webhook_endpoints/{}", endpoint.id)).await?;
196− }
197− let mut fields = vec![
198− ("url", WEBHOOK_URL.to_owned()),
199− ("description", "g1t billing".to_owned()),
200− ("metadata[g1t]", "billing".to_owned()),
201− ];
202− let names: Vec<String> = (0..EVENTS.len()).map(|i| format!("enabled_events[{i}]")).collect();
203− for (name, event) in names.iter().zip(EVENTS) {
204− fields.push((name.as_str(), (*event).to_owned()));
205− }
206− let created: Endpoint = stripe.post("/webhook_endpoints", &fields).await?;
207− let Some(secret) = created.secret else {
208− return Err(worker::Error::RustError("Stripe returned no signing secret".into()));
209− };
210− self.db
211− .prepare(
212− "INSERT INTO stripe_webhooks (mode, endpoint_id, secret, url, events, created_by, created_at)
213− VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
214− ON CONFLICT (mode) DO UPDATE SET endpoint_id = ?2, secret = ?3, url = ?4, events = ?5,
215− created_by = ?6, created_at = ?7",
216− )
217− .bind(&[
218− self.mode().into(),
219− created.id.as_str().into(),
220− secret.as_str().into(),
221− created.url.as_str().into(),
222− EVENTS.join(",").into(),
223− by.into(),
224− rfc3339(now_ms()).into(),
225− ])?
226− .run()
227− .await?;
228− self.audit("stripe", "webhook", &format!("Registered {WEBHOOK_URL} ({} mode)", self.mode()), by).await?;
229− Ok(())
200+ Ok(StripeStatus {
201+ mode: self.mode().to_owned(),
202+ secret_set: self.webhook_secret.is_some(),
203+ webhook,
204+ missing_events,
205+ recent_events,
206+ error,
207+ })
230208 }
231209
232210 // --- Events -----------------------------------------------------------
233211
234212 pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> {
235− let Some(webhook) = self.webhook_row().await? else {
236− return Ok(Outcome::fail(FailureCode::Conflict, "No webhook is registered for this mode."));
213+ let Some(secret) = &self.webhook_secret else {
214+ return Ok(Outcome::fail(
215+ FailureCode::Conflict,
216+ "STRIPE_WEBHOOK_SECRET is not set, so billing cannot check that events come from Stripe.",
217+ ));
237218 };
238219 let now_seconds = (now_ms() / 1000) as i64;
239− if !verify(&a.payload, &a.signature, &webhook.secret, now_seconds) {
220+ if !verify(&a.payload, &a.signature, secret, now_seconds) {
240221 return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match."));
241222 }
242223 let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?;
890871 }
891872
892873 #[test]
874+ fn a_destination_misses_the_events_billing_handles_that_it_does_not_send() {
875+ let has: Vec<String> = EVENTS.iter().take(11).map(|e| (*e).to_owned()).collect();
876+ assert_eq!(missing_events(&has), EVENTS[11..].iter().map(|e| (*e).to_owned()).collect::<Vec<_>>());
877+ let all: Vec<String> = EVENTS.iter().map(|e| (*e).to_owned()).collect();
878+ assert!(missing_events(&all).is_empty());
879+ assert!(missing_events(&["*".to_owned()]).is_empty());
880+ }
881+
882+ #[test]
893883 fn a_signed_event_is_believed_only_as_signed_and_only_fresh() {
894884 let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#;
895885 let header = sign(payload, "whsec_test", 1_000_000);
+4−1
128128 },
129129 // Settling runs every 15 minutes; checking costs daily (keeper::DAILY).
130130 "triggers": { "crons": ["*/15 * * * *", "17 4 * * *"] },
131− // Secret: STRIPE_SECRET_KEY. Without it nothing is charged and the
131+ // Secrets: STRIPE_SECRET_KEY. Without it nothing is charged and the
132132 // runner decides who may start agents some other way.
133+ // STRIPE_WEBHOOK_SECRET: the signing secret (whsec_…) of the destination
134+ // made in Stripe's dashboard for https://api.g1t.sh/stripe/webhook.
135+ // Without it every event is refused (the replay still reads them).
133136 "observability": { "enabled": true }
134137 }