|
| 1 | +/** |
| 2 | + * A real Stripe webhook, verified by Hookdeck. |
| 3 | + * |
| 4 | + * The synthetic verification suite proves Hookdeck refuses a forged signature, |
| 5 | + * using the documented single-scheme header. This one closed the remaining |
| 6 | + * gap: what Stripe actually sends in the wild. |
| 7 | + * |
| 8 | + * Measured, rather than assumed. A real delivery carries THREE schemes: |
| 9 | + * |
| 10 | + * t=1786615…,v1=e798bfb…,v0=6260368… |
| 11 | + * |
| 12 | + * The `v0` is not in the documented example a synthetic signature is built |
| 13 | + * from, and Hookdeck verified the request regardless — so its parser handles |
| 14 | + * the multi-scheme form. Worth knowing before anyone is tempted to verify a |
| 15 | + * Stripe signature by hand. |
| 16 | + * |
| 17 | + * Idempotent, and run in three passes: |
| 18 | + * |
| 19 | + * 1. `setup` creates the source and prints the URL to give Stripe. |
| 20 | + * 2. `arm` puts the signing secret on the source (needs |
| 21 | + * STRIPE_WEBHOOK_SECRET in .env.local). Verification happens at |
| 22 | + * ingest, so this must be done BEFORE the event is triggered. |
| 23 | + * 3. `watch` polls for the delivery and reports what arrived. |
| 24 | + * |
| 25 | + * `cleanup` removes the source when you are done with it. |
| 26 | + */ |
| 27 | +import { readFileSync } from "node:fs"; |
| 28 | + |
| 29 | +const REPO = process.argv[2] ?? "."; |
| 30 | +const MODE = process.argv[3] ?? "watch"; |
| 31 | +const NAME = "openclaw-e2e-stripe-live"; |
| 32 | +const env = readFileSync(`${REPO}/.env.local`, "utf8"); |
| 33 | +const KEY = /^HOOKDECK_TEST_API_KEY=(.+)$/m.exec(env)[1].trim(); |
| 34 | +const STRIPE_SECRET = /^STRIPE_WEBHOOK_SECRET=(.+)$/m.exec(env)?.[1]?.trim(); |
| 35 | + |
| 36 | +const api = async (method, path, body) => { |
| 37 | + const r = await fetch(`https://api.hookdeck.com/2025-07-01${path}`, { |
| 38 | + method, |
| 39 | + headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" }, |
| 40 | + ...(body ? { body: JSON.stringify(body) } : {}), |
| 41 | + }); |
| 42 | + const t = await r.text(); |
| 43 | + return { status: r.status, body: t ? JSON.parse(t) : {} }; |
| 44 | +}; |
| 45 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 46 | + |
| 47 | +const findSource = async () => |
| 48 | + (await api("GET", `/sources?name=${NAME}`)).body.models?.[0]; |
| 49 | + |
| 50 | +if (MODE === "cleanup") { |
| 51 | + for (const kind of ["connections", "sources", "destinations"]) { |
| 52 | + const list = (await api("GET", `/${kind}?limit=255`)).body.models ?? []; |
| 53 | + for (const m of list.filter((x) => String(x.name).startsWith("openclaw-e2e-stripe-live"))) { |
| 54 | + const d = await api("DELETE", `/${kind}/${m.id}`); |
| 55 | + console.log(`deleted ${kind}/${m.id} (${m.name}) ${d.status}`); |
| 56 | + } |
| 57 | + } |
| 58 | + process.exit(0); |
| 59 | +} |
| 60 | + |
| 61 | +// A source with no connection: nothing is delivered anywhere, so this cannot |
| 62 | +// disturb anything. Verification happens at ingest, which is all we need. |
| 63 | +let source = await findSource(); |
| 64 | +if (source === undefined) { |
| 65 | + const created = await api("PUT", "/sources", { name: NAME, type: "STRIPE" }); |
| 66 | + if (!created.body.id) { |
| 67 | + console.log("could not create the source:", JSON.stringify(created.body).slice(0, 200)); |
| 68 | + process.exit(1); |
| 69 | + } |
| 70 | + source = created.body; |
| 71 | +} |
| 72 | + |
| 73 | +if (MODE === "setup") { |
| 74 | + console.log(`\nSource ready.\n\n URL: ${source.url}\n`); |
| 75 | + console.log("Point Stripe at that URL, then put the signing secret Stripe gives you into"); |
| 76 | + console.log(".env.local as:\n\n STRIPE_WEBHOOK_SECRET=whsec_...\n"); |
| 77 | + console.log("Then run: node scripts/e2e-live-stripe.mjs . arm"); |
| 78 | + process.exit(0); |
| 79 | +} |
| 80 | + |
| 81 | +if (MODE === "arm") { |
| 82 | + if (STRIPE_SECRET === undefined) { |
| 83 | + console.log("STRIPE_WEBHOOK_SECRET is not in .env.local; nothing to arm."); |
| 84 | + process.exit(1); |
| 85 | + } |
| 86 | + const armed = await api("PUT", "/sources", { |
| 87 | + name: NAME, |
| 88 | + type: "STRIPE", |
| 89 | + config: { auth: { webhook_secret_key: STRIPE_SECRET } }, |
| 90 | + }); |
| 91 | + // A source whose TYPE is STRIPE carries `auth` with no `auth_type` — the |
| 92 | + // type names the provider. A generic WEBHOOK source uses `auth_type` instead, |
| 93 | + // which is the shape the plugin provisions. Both enable verification. |
| 94 | + const cfg = armed.body.config ?? {}; |
| 95 | + const ok = |
| 96 | + cfg.auth_type === "STRIPE" || |
| 97 | + typeof cfg.auth?.webhook_secret_key === "string"; |
| 98 | + |
| 99 | + // Never print the config: it echoes the signing secret back. |
| 100 | + console.log( |
| 101 | + ok |
| 102 | + ? `Armed. Verification is on for ${source.url}\n\nTrigger a Stripe event, then run:\n node scripts/e2e-live-stripe.mjs . watch` |
| 103 | + : `The source did not accept the secret. type=${armed.body.type} keys=[${Object.keys(cfg).join(", ")}]`, |
| 104 | + ); |
| 105 | + process.exit(ok ? 0 : 1); |
| 106 | +} |
| 107 | + |
| 108 | +// ------------------------------------------------------------------- watch |
| 109 | +const armedCfg = (await findSource())?.config ?? {}; |
| 110 | +const armedNow = |
| 111 | + armedCfg.auth_type === "STRIPE" || |
| 112 | + typeof armedCfg.auth?.webhook_secret_key === "string"; |
| 113 | +console.log(`watching ${source.url}`); |
| 114 | +console.log(`verification is ${armedNow ? "ON" : "OFF — run `arm` first"}\n`); |
| 115 | + |
| 116 | +const started = Date.now(); |
| 117 | +let seen; |
| 118 | +for (let i = 0; i < 60 && seen === undefined; i += 1) { |
| 119 | + const reqs = (await api("GET", `/requests?source_id=${source.id}&limit=5`)).body.models ?? []; |
| 120 | + seen = reqs.find((r) => r.ingested_at > new Date(started - 120_000).toISOString()); |
| 121 | + if (seen === undefined) { |
| 122 | + process.stdout.write("."); |
| 123 | + await sleep(5000); |
| 124 | + } |
| 125 | +} |
| 126 | +console.log(); |
| 127 | + |
| 128 | +if (seen === undefined) { |
| 129 | + console.log("No request arrived in five minutes. Trigger a Stripe event and run watch again."); |
| 130 | + process.exit(1); |
| 131 | +} |
| 132 | + |
| 133 | +// The list omits headers; only the detail endpoint returns them. |
| 134 | +const detail = (await api("GET", `/requests/${seen.id}`)).body; |
| 135 | +const headers = detail.data?.headers ?? {}; |
| 136 | +const parsed = typeof headers === "string" ? JSON.parse(headers) : headers; |
| 137 | +const sig = parsed["stripe-signature"] ?? parsed["Stripe-Signature"]; |
| 138 | + |
| 139 | +const results = []; |
| 140 | +const record = (name, pass, detail_) => { |
| 141 | + results.push({ name, pass }); |
| 142 | + console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail_ ? ` — ${detail_}` : ""}`); |
| 143 | +}; |
| 144 | + |
| 145 | +record("a real Stripe webhook reached the source", true, `request ${seen.id}`); |
| 146 | +record( |
| 147 | + "it carried a Stripe-Signature header", |
| 148 | + typeof sig === "string" && sig.length > 0, |
| 149 | + sig ? `${String(sig).slice(0, 60)}…` : "absent", |
| 150 | +); |
| 151 | +record( |
| 152 | + "Hookdeck verified it", |
| 153 | + seen.verified === true, |
| 154 | + `verified=${seen.verified} rejection=${seen.rejection_cause ?? "none"}`, |
| 155 | +); |
| 156 | + |
| 157 | +// What the wild actually looks like, versus the documented single scheme. |
| 158 | +const schemes = String(sig ?? "") |
| 159 | + .split(",") |
| 160 | + .map((p) => p.split("=")[0]?.trim()) |
| 161 | + .filter(Boolean); |
| 162 | +console.log(`\n signature schemes present: [${[...new Set(schemes)].join(", ")}]`); |
| 163 | +console.log(` event type: ${detail.data?.body ? JSON.parse(detail.data.body)?.type ?? "?" : "?"}`); |
| 164 | + |
| 165 | +console.log("\n============================================="); |
| 166 | +console.log(`${results.filter((r) => r.pass).length}/${results.length} checks passed`); |
| 167 | +console.log("\nWhen you are done: node scripts/e2e-live-stripe.mjs . cleanup"); |
| 168 | +process.exit(results.every((r) => r.pass) ? 0 : 1); |
0 commit comments