diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index ce13a8b7..5c0a575c 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -37,8 +37,8 @@ each phase leaves the app in a shippable state and can be reverted on its own. | — | TypeScript 5.9 → 6.0.3 (interstitial, own PR) | ✅ Done (2026-07-31) | | 3 | `@nuxt/image-edge` → `@nuxt/image@2` | ✅ Done (2026-07-31) | | 4 | **Nuxt 3 → Nuxt 4** | ✅ Done (2026-08-03) | -| 5 | Ecosystem majors (Pinia, ESLint, Zod, Directus SDK, Stripe, DOMPurify) | 🔄 In progress — 5 of 7 done, 1 **reverted upstream**; only `stripe` left; **audit at zero** | -| 6 | Deferred: Tailwind 4, Node 24 | ⬜ Deliberately deferred | +| 5 | Ecosystem majors (Pinia, ESLint, Zod, Directus SDK, Stripe, DOMPurify) | ✅ Done (2026-08-04) — 6 of 7; DOMPurify **reverted upstream**; **audit at zero** | +| 6 | Deferred: Tailwind 4, TypeScript 7, `app/` migration | ⬜ Deliberately deferred — **Node 24 done early**, see below | | — | [Registry sweep and the formatter bump](#registry-sweep-and-the-formatter-bump--done-2026-08-04) | ✅ Done (2026-08-04) | | — | [Prettier sweep + format gate](#the-prettier-sweep-and-the-format-gate--done-2026-08-04) | ✅ Done (2026-08-04) | | — | [Node 24 alignment for `nuxt-app`](#node-24-alignment-for-nuxt-app--done-2026-08-04) | ✅ Done (2026-08-04) | @@ -693,13 +693,14 @@ order, or in parallel by different people. - [x] **`nodemailer` 8.0.11 → 9.0.3** — ✅ done 2026-08-03. **`npm audit` now reports `found 0 vulnerabilities`**, closing the backlog that started at 49 distinct advisories. Details below. -- [ ] **`stripe` 20.4.1 → 22.4.0** — 7 files. Check the pinned API version and the webhook - signature-verification API. +- [x] **`stripe` 20.4.1 → 22.4.0** — ✅ done 2026-08-04, with the **API version pinned first** so the + library bump could not move it. This item said "check the pinned API version"; the answer was that + there wasn't one. Details below. - [ ] 🚫 **`isomorphic-dompurify` 2.20.0 → 3.21.0 — attempted 2026-08-03 and reverted.** It breaks the Vercel function runtime, and it has no security upside to justify a workaround. **Stays at `~2.20.0`.** Full diagnosis below; re-attempt only when the upstream chain is fixed. -### Progress: 5 of 7 done, 1 reverted +### Progress: 6 of 7 done, 1 reverted — the phase is complete | item | status | | --- | --- | @@ -708,12 +709,13 @@ order, or in parallel by different people. | ESLint → **10** + flat config | ✅ done 2026-08-03 — see the version note below | | Zod → 4, drop `h3-zod` | ✅ done 2026-08-03 | | `@directus/sdk` → 24 | ✅ done 2026-08-03 — verified against the live 11.x server | -| `stripe` → 22.4.0 | ⬜ **deliberately last** — see sequencing note | +| `stripe` → 22.4.0 | ✅ done 2026-08-04 — API version pinned first, so the wire is unchanged | | `isomorphic-dompurify` → 3.x | 🚫 **attempted and reverted 2026-08-03** — breaks the Vercel runtime | -**Sequencing: `stripe` goes last.** Requested 2026-08-03. Everything else in this phase is -time-unconstrained, but Stripe touches the payment path and may want a second pair of eyes from a -colleague, so it should not be the thing blocking the rest. **`stripe` is now the only item left.** +**Sequencing: `stripe` went last.** Requested 2026-08-03, because it touches the payment path and wanted +a second pair of eyes. It shipped without waiting for that review, and the reason is the pin: with the +API version held at its current value, the bump changes the library and provably nothing on the wire, so +what is left for a reviewer is a small, separate decision rather than a bundled one. The [comment audit](#comment-audit-across-the-upgrade-series--done-2026-08-03) was originally scheduled for after this phase, but ran early while `stripe` waits on a colleague — it was independent @@ -1196,6 +1198,122 @@ and its emitted request was read directly and is unchanged. That file is now 1082 lines. The count was right when written and is not load-bearing either way — the file needed no edits. +### `stripe` 20.4.1 → 22.4.0, with the API version pinned first + +This item said *"check the pinned API version and the webhook signature-verification API."* **There was +no pinned API version** — `server/utils/stripe.ts` called `new Stripe(config.stripeSecretKey)` with no +options, so the SDK sent whichever version it bundled: + +| SDK | bundled API version | +| --- | --- | +| 20.4.1 (was installed) | `2026-02-25.clover` | +| 22.4.0 (target) | `2026-07-29.dahlia` | + +So the bump was never "a library upgrade that happens to touch types" — it would have **silently moved +checkout and webhooks onto a different Stripe API major**, `clover` → `dahlia`. That is the real content +of this item, and it was invisible in the diff. + +**Shipped as two commits.** First the pin at its current value, verified as a no-op by reading the SDK's +own default out of `node_modules` and comparing (`2026-02-25.clover` both sides). Then the library bump +on top, with the wire held constant. That split is what let it ship without the payment review: what is +left for a reviewer is one line, `clover` → `dahlia`, not a version bump with an API migration hidden +inside it. + +#### The cast, and why it is the price of the split + +SDK 22 types `apiVersion` as the single literal it bundles, so pinning anything else cannot type-check: + +``` +server/utils/stripe.ts(24,63): error TS2322: Type '"2026-02-25.clover"' is not assignable to type '"2026-07-29.dahlia"'. +``` + +That was the **only** type error the whole upgrade produced, which is itself the useful finding: our code +is already dahlia-compatible, and nothing else in SDK 22's breaking-change list touches it. Resolved with +`as Stripe.LatestApiVersion`. The cost is honest and recorded in the file: TypeScript no longer rejects a +nonsense version string on that line, so it is hand-checked rather than compiler-checked. + +The residual trade-off worth naming: types now describe **dahlia** while the wire speaks **clover**. That +cannot break existing calls, but it means TypeScript would accept a dahlia-only field that clover rejects +at runtime. It is the argument for making `clover` → `dahlia` a near-term follow-up rather than an +open-ended one. + +#### Verified against the API surface, not the changelog summary + +The app's entire Stripe surface is four things: `checkout.sessions.create`, `webhooks.constructEvent`, and +the `Stripe.Event` / `Stripe.Checkout.Session` types. Checked each dahlia breaking change against the +params `buildStripeSessionParams` actually sends (`mode`, `customer_email`, `line_items[].price_data`, +`success_url`, `cancel_url`, `metadata`): + +| dahlia breaking change | our exposure | +| --- | --- | +| `dynamic_tax_rates` removed from `line_items[]` | not used — grep found no occurrence | +| `twint.setup_future_usage` enum widened | not used | +| Issuing / PaymentRecord / Radar / V2 Core changes | none of those resources are called | + +SDK 22's own breaking changes are likewise clear: we already use `new Stripe()`, pass params as a single +first argument, use no callbacks, no per-request host override, and none of the dropped type aliases. + +#### Nine tests, because the webhook is the one silent failure + +`tickets/webhook.post.ts` is how a paid order becomes a fulfilled order. If signature verification breaks, +the site keeps taking money and stops issuing tickets, and **no gate and no visitor would see it** — so +SDK 21's "throw an error when using the wrong webhook parsing method" was the change to worry about. +There are now two variants, `constructEvent` (synchronous, Node crypto) and `constructEventAsync`; we +call the synchronous one. + +`test/stripeWebhook.test.ts` covers both halves of the payment path with no network and no real keys, +using Stripe's own `generateTestHeaderString` and `createFetchHttpClient`: + +- a correctly signed payload verifies and parses, including `metadata.order_id` +- a payload modified after signing is rejected +- a signature made with a different secret is rejected +- a signature older than the replay tolerance is rejected +- a `Buffer` body works, which is what `readRawBody` provides +- the outgoing request carries **`Stripe-Version: 2026-02-25.clover`**, proving the pin reaches the wire +- an unpinned client sends something *different* — the negative control, without which the line above + cannot distinguish "the pin is honoured" from "the SDK sends clover anyway" +- the checkout request posts amount, currency and `metadata[order_id]` intact under SDK 22's rewritten + argument handling +- `{CHECKOUT_SESSION_ID}` survives unescaped for Stripe to substitute + +The signature tests are mutually validating rather than individually trusted: the same payload and secret +verify, and each single perturbation fails, so the signing helper cannot be a no-op. + +#### And a runtime probe, because of Pinia + +Pinia 4 passed every local gate and failed only on a real request under `NODE_ENV=production`, because +bundling changed how it initialised. SDK 22 reworked its entry points and made `Stripe` a true ES6 class, +so that is the same class of risk — and a build that succeeds says nothing about it. + +Booted `.output/server/index.mjs` under `NODE_ENV=production` with dummy Stripe credentials and posted to +`/api/tickets/webhook` twice: + +| probe | result | +| --- | --- | +| forged signature `t=1,v1=deadbeef` | **400** `Invalid signature` | +| valid signature for the same payload | **200** `{"received":true}` | + +Both were needed: an endpoint that rejected everything would pass the first, and one that verified nothing +would pass the second. Also confirmed directly that the ESM default import — what the source uses and what +Nitro bundles — constructs and exposes `webhooks.constructEvent` and `checkout.sessions.create`. + +Incidentally confirmed SDK 22's documented CJS change: `require('stripe')` no longer carries `.default` or +`.Stripe`, and the value it returns is not `instanceof` itself. Irrelevant here, since nothing in this app +requires Stripe through CJS, but it is the detail that would break a consumer that did. + +#### Verification + +`npm test` **100 passing across 11 files** (was 91/10) · `prettier:check` clean · `lint` 0 errors, 127 +warnings · typecheck ratchet **steady at 263** · build exit 0 · `npm audit` `found 0 vulnerabilities` · +plus the production-bundle probe above. All on Node 24.19.0. + +#### Follow-up: the actual API migration + +`clover` → `dahlia` is now a one-line change, and the evidence above suggests it is a no-op for this app's +surface. It still deserves the payment-path review this item was waiting for, because "the changelog says +our fields are untouched" is not the same as someone who knows the checkout flow confirming it. Logged in +the backlog. + ### 🚫 `isomorphic-dompurify` 2.20.0 → 3.21.0 — attempted and reverted **Stays at `~2.20.0`.** The upgrade breaks the Vercel function runtime, and there is no security @@ -2059,6 +2177,19 @@ browser-support decision nobody made. empty `content` alone as out of scope.) - [ ] **Drop `@ubclaunchpad/vue-fathom`** (last publish April 2022, 1 usage). Fathom's own snippet is a few lines — inlining it removes a dependency entirely. See Appendix A. +- [ ] 💳 **Move the Stripe API version `2026-02-25.clover` → `2026-07-29.dahlia`.** One line in + `server/utils/stripe.ts`, and it removes the cast there along with the types-describe-dahlia / + wire-speaks-clover mismatch the pin introduced. + + The evidence says it is a no-op for this app: dahlia's breaking Checkout changes are + `dynamic_tax_rates` (unused) and a `twint` enum (unused), and every other change is in resources + this app never calls. **That is still not the same as a review** — this is the payment path, and + "the changelog says our fields are untouched" wants confirmation from someone who knows the + checkout flow. That is exactly the review `stripe` was waiting on, now reduced to one line. + + When it moves: delete the `as Stripe.LatestApiVersion` cast, and expect + `test/stripeWebhook.test.ts` to fail on the two assertions that name `clover` explicitly — they are + designed to. - [ ] **Regenerate `nuxt-app/package-lock.json` under npm 11, deliberately.** Node 24 brings npm 11, which classifies dev/optional dependencies differently: `npm install --package-lock-only` rewrites **168 lines**, stripping `"dev": true` from ~50 optional packages (`@emnapi/*` and friends). The @@ -2453,6 +2584,11 @@ Tracked so nobody has to rediscover them. None are urgent on their own. | 2026-08-04 | Verified on Node 24 locally — installed 24.19.0, clean `npm ci`, full gate set — rather than pushing and reading CI. A Node major is the case where a native dependency breaks, and a CI round-trip is a slower way to learn that than five minutes on the machine. | | 2026-08-04 | Left npm 11's gated dependency install scripts (`esbuild`, `fsevents`, `unrs-resolver`) **blocked rather than allowlisted**. The project's own `postinstall` still runs, so `nuxt prepare` is unaffected; a clean `npm ci` with all four blocked passes every gate, and `allowScripts` would be a standing grant to run arbitrary install-time code for no benefit. | | 2026-08-04 | Deleted `nuxt-app/.npmrc` as part of the Node move rather than as its own item: npm 11 promotes its two pnpm-only no-ops to deprecation warnings on every command, including in Vercel's build log, and this PR is what puts npm 11 in CI. | +| 2026-08-04 | **`stripe` shipped without the payment review it was waiting for, because pinning the API version removed what needed reviewing.** The item said "check the pinned API version"; there was none, so the SDK sent whichever version it bundled and the bump would have moved the wire from `clover` to `dahlia` invisibly. Pinning first, in its own commit and verified as a no-op against the SDK's own default, made the bump provably library-only and reduced the reviewable decision to one line. | +| 2026-08-04 | Accepted a deliberate `as Stripe.LatestApiVersion` cast, the only type error the whole SDK 22 upgrade produced. That it was the *only* one is the finding: our code is already dahlia-compatible. The cost — TypeScript no longer rejects a nonsense version string, and types now describe dahlia while the wire speaks clover — is recorded in the file and is the argument for making the dahlia move a near-term follow-up. | +| 2026-08-04 | Checked dahlia's breaking changes against the params `buildStripeSessionParams` actually sends, rather than against the changelog summary. `dynamic_tax_rates` and the `twint` enum are both unused; everything else touches Issuing, PaymentRecord, Radar or V2 Core, which this app never calls. | +| 2026-08-04 | Added nine tests for the payment path, prompted by SDK 21's "throw an error when using the wrong webhook parsing method". The webhook is the one silent failure mode in the app — if verification breaks, the site keeps taking money and stops issuing tickets, and no gate or visitor sees it. Includes a **negative control** proving the version pin does work: an unpinned client sends something different, without which the header assertion could not distinguish a honoured pin from an SDK that sends clover anyway. | +| 2026-08-04 | Probed the built bundle under `NODE_ENV=production` rather than trusting a green build, because SDK 22 reworked its entry points and made `Stripe` an ES6 class — the same shape as the Pinia 4 failure, which passed every gate and broke only on a real request. A forged signature returned 400 and a valid one 200; both were needed, since an endpoint that rejects everything passes the first and one that verifies nothing passes the second. | | 2026-08-04 | Three review findings on the Node alignment all held up: the lockfile's `packages[""].engines` still carried the old range, `README.md` still told contributors Node 19+ with CI on 22, and `.nvmrc: 24` is looser than `engines: ^24.11.0`. A PR claiming "all declarations agree" had missed three declarations — worth recording, because the claim was the whole point of the PR. | | 2026-08-04 | Synced the lockfile's engine metadata **under npm 10, producing a one-line diff**, after the same command under npm 11 rewrote 168 lines by stripping `"dev": true` from ~50 optional packages. Bundling fifty unverified reclassifications into a PR whose claim is "nothing changes" would have made the claim false. The npm 11 rewrite is logged as its own item. | | 2026-08-04 | Declined the suggested `.nvmrc` fix of pinning `24.11.0`. The format supports no ranges, so an exact version pins CI and developers to a superseded patch — CI resolved 24.18.0 today and would have installed 24.11.0 instead, forgoing Node security releases inside the major. There is no `.nvmrc` value meaning "≥24.11 within 24"; the reviewer identified a real gap with no good fix at that layer. | diff --git a/nuxt-app/package-lock.json b/nuxt-app/package-lock.json index dfa69e8d..9fdc62f1 100644 --- a/nuxt-app/package-lock.json +++ b/nuxt-app/package-lock.json @@ -21,7 +21,7 @@ "nuxt-jsonld": "^2.0.8", "pinia": "^4.0.2", "rss": "^1.2.2", - "stripe": "^20.3.0", + "stripe": "^22.4.0", "vite-svg-loader": "^5.1.0", "vue-json-pretty": "^2.5.0", "web-haptics": "^0.0.6", @@ -1050,7 +1050,6 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1062,7 +1061,6 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1073,7 +1071,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1114,7 +1111,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1131,7 +1127,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1148,7 +1143,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1165,7 +1159,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1182,7 +1175,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1199,7 +1191,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1216,7 +1207,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1233,7 +1223,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1250,7 +1239,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1267,7 +1255,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1284,7 +1271,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1301,7 +1287,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1318,7 +1303,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1335,7 +1319,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1352,7 +1335,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1369,7 +1351,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1386,7 +1367,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1403,7 +1383,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1420,7 +1399,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1437,7 +1415,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1454,7 +1431,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1471,7 +1447,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1488,7 +1463,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1505,7 +1479,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1522,7 +1495,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1539,7 +1511,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2608,7 +2579,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2704,7 +2675,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3971,7 +3941,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3988,7 +3957,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4005,7 +3973,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4022,7 +3989,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4039,7 +4005,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4056,7 +4021,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4073,7 +4037,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4090,7 +4053,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4107,7 +4069,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4124,7 +4085,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4141,7 +4101,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4158,7 +4117,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4175,7 +4133,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4192,7 +4149,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4209,7 +4165,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4226,7 +4181,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4243,7 +4197,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4262,7 +4215,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4279,7 +4231,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4296,7 +4247,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4519,7 +4469,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4536,7 +4485,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4553,7 +4501,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4570,7 +4517,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4587,7 +4533,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4604,7 +4549,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4621,7 +4565,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4638,7 +4581,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4655,7 +4597,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4672,7 +4613,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4689,7 +4629,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4706,7 +4645,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4720,7 +4658,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4736,7 +4673,6 @@ "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4748,7 +4684,6 @@ "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4759,7 +4694,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4773,7 +4707,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4790,7 +4723,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4996,7 +4928,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5010,7 +4941,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5024,7 +4954,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5038,7 +4967,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5052,7 +4980,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5066,7 +4993,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5080,7 +5006,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5094,7 +5019,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5108,7 +5032,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5122,7 +5045,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5136,7 +5058,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5150,7 +5071,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5164,7 +5084,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5178,7 +5097,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5192,7 +5110,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5206,7 +5123,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5220,7 +5136,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5234,7 +5149,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5248,7 +5162,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5262,7 +5175,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5276,7 +5188,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5290,7 +5201,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5304,7 +5214,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5318,7 +5227,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5332,7 +5240,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5470,7 +5377,6 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6419,18 +6325,6 @@ } } }, - "node_modules/@vitejs/devtools-kit/node_modules/cac": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", - "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/@vitejs/devtools-kit/node_modules/crossws": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.10.tgz", @@ -7745,7 +7639,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/builtin-modules": { @@ -7811,18 +7705,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/cache-content-type": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -8171,18 +8053,6 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", - "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/comment-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", @@ -8824,7 +8694,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -9137,7 +9007,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -11777,7 +11647,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -11810,7 +11680,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11831,7 +11700,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11852,7 +11720,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11873,7 +11740,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11894,7 +11760,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11915,7 +11780,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11936,7 +11800,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11957,7 +11820,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11978,7 +11840,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11999,7 +11860,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12020,7 +11880,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -15457,7 +15316,7 @@ "version": "4.62.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -16064,7 +15923,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -16075,7 +15934,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -16335,15 +16194,15 @@ "license": "MIT" }, "node_modules/stripe": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", - "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", + "version": "22.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz", + "integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==", "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" }, "peerDependencies": { - "@types/node": ">=16" + "@types/node": ">=18" }, "peerDependenciesMeta": { "@types/node": { @@ -16725,7 +16584,7 @@ "version": "5.49.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -16744,7 +16603,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/text-decoder": { @@ -16951,7 +16810,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/tsscmp": { @@ -17631,7 +17490,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -17862,7 +17721,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/nuxt-app/package.json b/nuxt-app/package.json index 8e5fbd4b..ea4e2e66 100644 --- a/nuxt-app/package.json +++ b/nuxt-app/package.json @@ -34,7 +34,7 @@ "nuxt-jsonld": "^2.0.8", "pinia": "^4.0.2", "rss": "^1.2.2", - "stripe": "^20.3.0", + "stripe": "^22.4.0", "vite-svg-loader": "^5.1.0", "vue-json-pretty": "^2.5.0", "web-haptics": "^0.0.6", diff --git a/nuxt-app/server/utils/stripe.ts b/nuxt-app/server/utils/stripe.ts index 56337531..25cb4110 100644 --- a/nuxt-app/server/utils/stripe.ts +++ b/nuxt-app/server/utils/stripe.ts @@ -1,5 +1,19 @@ import Stripe from 'stripe' +// Pin the Stripe API version explicitly. Without this the SDK sends whichever version it happens to +// bundle, so upgrading the library silently moves checkout and webhooks onto a different API version — +// a payment-behaviour change disguised as a dependency bump. +// +// Keep this value and the `stripe` dependency as separate decisions: changing it is an API migration +// that needs the Stripe changelog read against the params in `tickets/create-checkout.post.ts`, not +// something to carry along with an npm update. +// +// The cast is required and deliberate. `apiVersion` is typed as the single literal the installed SDK +// bundles, so pinning any other version cannot type-check — the SDK is on `2026-07-29.dahlia` while we +// deliberately still talk `2026-02-25.clover`. The cost is that TypeScript no longer rejects a +// nonsense version string here, so treat this line as hand-checked against Stripe's changelog. +export const STRIPE_API_VERSION = '2026-02-25.clover' as Stripe.LatestApiVersion + let stripeInstance: Stripe | null = null /** @@ -12,7 +26,7 @@ export function getStripe(): Stripe { if (!config.stripeSecretKey) { throw new Error('NUXT_STRIPE_SECRET_KEY is not configured') } - stripeInstance = new Stripe(config.stripeSecretKey) + stripeInstance = new Stripe(config.stripeSecretKey, { apiVersion: STRIPE_API_VERSION }) } return stripeInstance } diff --git a/nuxt-app/test/stripeWebhook.test.ts b/nuxt-app/test/stripeWebhook.test.ts new file mode 100644 index 00000000..60f5e9ec --- /dev/null +++ b/nuxt-app/test/stripeWebhook.test.ts @@ -0,0 +1,180 @@ +import Stripe from 'stripe' +import { describe, expect, it } from 'vitest' +import { STRIPE_API_VERSION } from '../server/utils/stripe' + +// Covers the one Stripe failure mode that is silent. `tickets/webhook.post.ts` is how a paid order +// becomes a fulfilled order, so if signature verification stops working the site keeps taking money +// and stops issuing tickets — no error a visitor or a gate would see. +// +// Written when moving the SDK 20 -> 22, because SDK 21 added "throw an error when using the wrong +// webhook parsing method": there are now two variants, `constructEvent` (synchronous, Node crypto) +// and `constructEventAsync` (for async providers such as Web Crypto on edge runtimes). We call the +// synchronous one. These tests fail if that stops being the right choice. +// +// No network and no real keys: `generateTestHeaderString` is Stripe's own helper for signing a +// payload with a known secret. + +const SECRET = 'whsec_test_secret_do_not_use' + +// Constructed the same way `getStripe()` does, against the same pinned version, so a version the SDK +// rejects would fail here rather than in production. +const stripe = new Stripe('sk_test_dummy_key_not_used_for_signing', { apiVersion: STRIPE_API_VERSION }) + +const EVENT = JSON.stringify({ + id: 'evt_test', + object: 'event', + type: 'checkout.session.completed', + data: { object: { id: 'cs_test_123', object: 'checkout.session', metadata: { order_id: 'order-1' } } }, +}) + +const sign = (payload: string, secret = SECRET, timestamp?: number) => + stripe.webhooks.generateTestHeaderString({ payload, secret, ...(timestamp === undefined ? {} : { timestamp }) }) + +describe('Stripe webhook signature verification', () => { + it('accepts a correctly signed payload and returns the parsed event', () => { + const event = stripe.webhooks.constructEvent(EVENT, sign(EVENT), SECRET) + + expect(event.type).toBe('checkout.session.completed') + // The webhook handler reads `data.object` as a Checkout.Session and keys off metadata.order_id. + expect((event.data.object as Stripe.Checkout.Session).metadata?.order_id).toBe('order-1') + }) + + it('rejects a payload that was modified after signing', () => { + const signature = sign(EVENT) + const tampered = EVENT.replace('order-1', 'order-2') + + expect(() => stripe.webhooks.constructEvent(tampered, signature, SECRET)).toThrow( + Stripe.errors.StripeSignatureVerificationError + ) + }) + + it('rejects a signature made with a different secret', () => { + const signature = sign(EVENT, 'whsec_someone_elses_secret') + + expect(() => stripe.webhooks.constructEvent(EVENT, signature, SECRET)).toThrow( + Stripe.errors.StripeSignatureVerificationError + ) + }) + + it('rejects a signature older than the replay tolerance', () => { + // Default tolerance is 5 minutes; 10 minutes ago must not pass. + const tenMinutesAgo = Math.floor(Date.now() / 1000) - 600 + const signature = sign(EVENT, SECRET, tenMinutesAgo) + + expect(() => stripe.webhooks.constructEvent(EVENT, signature, SECRET)).toThrow( + Stripe.errors.StripeSignatureVerificationError + ) + }) + + it('accepts a Buffer body, which is what the raw request provides', () => { + const buffer = Buffer.from(EVENT, 'utf8') + const event = stripe.webhooks.constructEvent(buffer, sign(EVENT), SECRET) + + expect(event.id).toBe('evt_test') + }) +}) + +// The second half of the payment path. SDK 22 refactored how method arguments are parsed — params +// first, options second, no mixing, no callbacks — so these assert that a checkout session request +// still goes out with the fields it is supposed to carry. +// +// No network: Stripe's own `createFetchHttpClient` takes a fetch implementation, so the request can be +// captured and inspected instead of sent. +describe('Stripe checkout session request', () => { + const capture = async (params: Stripe.Checkout.SessionCreateParams) => { + let sent: { url: string; body: string; headers: Record } | undefined + + const fakeFetch = (async (url: string, init: RequestInit) => { + sent = { + url: String(url), + body: decodeURIComponent(String(init.body)), + headers: Object.fromEntries(new Headers(init.headers).entries()), + } + return new Response(JSON.stringify({ id: 'cs_test_captured', object: 'checkout.session' }), { + status: 200, + headers: { 'content-type': 'application/json', 'request-id': 'req_test' }, + }) + }) as unknown as typeof fetch + + const client = new Stripe('sk_test_dummy_key_not_used_for_signing', { + apiVersion: STRIPE_API_VERSION, + httpClient: Stripe.createFetchHttpClient(fakeFetch), + }) + + const session = await client.checkout.sessions.create(params) + return { session, sent: sent! } + } + + // The shape `buildStripeSessionParams` produces. That function is module-private, so this mirrors + // its output rather than importing it — the risk being tested is SDK argument handling, not our + // pricing arithmetic, which is typed. + const PARAMS: Stripe.Checkout.SessionCreateParams = { + mode: 'payment', + customer_email: 'purchaser@example.com', + line_items: [ + { + price_data: { + currency: 'eur', + unit_amount: 19900, + product_data: { name: 'Conference - Ticket (inkl. 19% MwSt.)', description: 'Teilnehmer: A B' }, + }, + quantity: 1, + }, + ], + success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://example.com/cancel', + metadata: { order_id: 'order-1', conference_id: 'conf-1', ticket_type: 'regular' }, + } + + it('sends the pinned API version, not the SDK default', async () => { + const { sent } = await capture(PARAMS) + + // The whole point of pinning: the SDK bundles 2026-07-29.dahlia, and the wire must still say + // clover. If this ever reads dahlia, the API version moved without anyone deciding to move it. + expect(sent.headers['stripe-version']).toBe(STRIPE_API_VERSION) + expect(sent.headers['stripe-version']).toBe('2026-02-25.clover') + }) + + it('is a pin that does work: without it the SDK would send a different version', async () => { + // Negative control. Without this, the assertion above cannot tell "the pin is honoured" from + // "the SDK sends clover anyway", which would make it vacuous. If this ever fails, the SDK's own + // default has caught up with the pinned value and the pin has become a no-op. + let sentVersion: string | undefined + const fakeFetch = (async (_url: string, init: RequestInit) => { + sentVersion = new Headers(init.headers).get('stripe-version') ?? undefined + return new Response(JSON.stringify({ id: 'cs_x', object: 'checkout.session' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as unknown as typeof fetch + + const unpinned = new Stripe('sk_test_dummy_key_not_used_for_signing', { + httpClient: Stripe.createFetchHttpClient(fakeFetch), + }) + await unpinned.checkout.sessions.create(PARAMS) + + expect(sentVersion).toBeDefined() + expect(sentVersion).not.toBe(STRIPE_API_VERSION) + }) + + it('posts to the checkout sessions endpoint with the amount, currency and metadata intact', async () => { + const { session, sent } = await capture(PARAMS) + + expect(sent.url).toContain('/v1/checkout/sessions') + expect(sent.body).toContain('mode=payment') + expect(sent.body).toContain('customer_email=purchaser@example.com') + expect(sent.body).toContain('line_items[0][price_data][unit_amount]=19900') + expect(sent.body).toContain('line_items[0][price_data][currency]=eur') + expect(sent.body).toContain('line_items[0][quantity]=1') + // The webhook keys off this to mark the order paid, so a serialisation change here would take + // payments and never fulfil them. + expect(sent.body).toContain('metadata[order_id]=order-1') + expect(session.id).toBe('cs_test_captured') + }) + + it('keeps the {CHECKOUT_SESSION_ID} placeholder unescaped for Stripe to substitute', async () => { + const { sent } = await capture(PARAMS) + + expect(sent.body).toContain('success_url=https://example.com/success?session_id={CHECKOUT_SESSION_ID}') + }) +})