From 217dd0f72f113bf285f253d1024aef3619aaf002 Mon Sep 17 00:00:00 2001 From: matio Date: Fri, 9 Oct 2026 12:25:51 +0100 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20Stripe=20=E2=80=94=20stripe=2023,?= =?UTF-8?q?=20stripe-js=2010,=20react-stripe-js=207,=20API=202026-09-30.en?= =?UTF-8?q?dive=20(#433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три мажора платёжного контура одним поездом: серверный SDK закрепляет 2026-09-30.endive, stripe-js 10 грузит js.stripe.com/endive/stripe.js, react-stripe-js 7 — пир stripe-js 10. STRIPE_API_VERSION переведён на endive; lib/stripe-api-version.test.ts был красным до этого шага. Ломающие изменения Endive нас не задевают: payment_method_types мы не шлём, billing_cycle_anchor не трогаем, Payment Request Button не используем, значения ui_mode те же. Новый lib/stripe-browser.test.ts держит поезд Stripe.js в браузере равным поезду серверной версии API, чтобы раздельный бамп пакетов был красным. Мерж — только после репетиции покупки на стенде владельцем. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0184yUssUtNnEmKVaft78cbT --- CLAUDE.md | 4 ++-- README.md | 4 ++-- docs/gotchas.md | 2 ++ docs/services.md | 2 +- lib/stripe-browser.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ lib/stripe.ts | 2 +- package.json | 6 +++--- pnpm-lock.yaml | 38 +++++++++++++++++----------------- 8 files changed, 72 insertions(+), 28 deletions(-) create mode 100644 lib/stripe-browser.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1238afe9..437d64d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Netflix-inspired UX. 60-second anonymous trial per (browser session, show). - Next.js 16 App Router, TypeScript - Postgres (Neon), Drizzle ORM (`postgres-js` driver, pooled endpoint) - Clerk 7 (auth) — local `.env.local` carries the **production** instance keys (`pk_live`/`sk_live`); keyless mode is not used. Prod Clerk refuses localhost redirects, so the admin panel can't be browser-verified locally -- Stripe 22 (Checkout + Customer Portal + subscription webhooks) +- Stripe 23 (Checkout + Customer Portal + subscription webhooks) - Mux (direct upload + signed playback IDs + asset webhooks) - Tailwind v4 + shadcn (built on Base UI, not Radix) - Resend (transactional email — new-episode reminder notifications; `RESEND_API_KEY` unset = sends off, capture keeps working) @@ -26,7 +26,7 @@ Always check these before changing integrations or guessing API shapes: - [docs/architecture.md](./docs/architecture.md) — system diagram, data model, trial & playback pipelines, route protection model, *why* each decision was made - [docs/services.md](./docs/services.md) — per-service setup (Clerk / Stripe / Mux / Neon / Vercel) and required env vars - [docs/operations.md](./docs/operations.md) — pnpm scripts, migrations, deploy commands, end-to-end test recipes -- [docs/gotchas.md](./docs/gotchas.md) — **read this before touching webhooks or Mux** — version-specific traps for Next 16, Clerk 7, Stripe SDK 22 (API 2024+), Mux 15, Vercel Blob, shadcn/Base UI, Drizzle 0.45, tsx scripts +- [docs/gotchas.md](./docs/gotchas.md) — **read this before touching webhooks or Mux** — version-specific traps for Next 16, Clerk 7, Stripe SDK 23 (API 2024+), Mux 15, Vercel Blob, shadcn/Base UI, Drizzle 0.45, tsx scripts `PROJECT.md` is the original product spec — useful for build phases and product intent. Where it conflicts with this file or `docs/`, the latter wins. diff --git a/README.md b/README.md index 78acc470..4a9469eb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Production: **https://matio.tv** - Next.js 16 App Router · TypeScript · React 19 - Postgres on Neon · Drizzle ORM (`postgres-js` driver, pooled endpoint) - Clerk 7 (auth, keyless in dev) -- Stripe 22 (Checkout + Customer Portal + webhooks) +- Stripe 23 (Checkout + Customer Portal + webhooks) - Mux 15 (direct upload + RS256-signed playback) - Tailwind v4 · shadcn (built on Base UI) - Vercel hosting @@ -44,7 +44,7 @@ Read these before changing integrations: - [docs/operations.md](./docs/operations.md) — pnpm scripts, migrations, deploy commands, end-to-end test recipes - [docs/gotchas.md](./docs/gotchas.md) — version-specific traps for - Next 16, Clerk 7, Stripe SDK 22 (API 2024+), Mux 15, Tailwind v4 + + Next 16, Clerk 7, Stripe SDK 23 (API 2024+), Mux 15, Tailwind v4 + shadcn-on-Base-UI, Drizzle 0.45 [`CLAUDE.md`](./CLAUDE.md) summarises the rules and conventions agents diff --git a/docs/gotchas.md b/docs/gotchas.md index 2d9a9e18..ac79c609 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -245,6 +245,8 @@ Also: webhook URLs must point at the **apex** (`https://matio.tv/api/webhooks/*` **The API version is pinned, and a red `lib/stripe-api-version.test.ts` is a decision, not a chore (#266).** `STRIPE_API_VERSION` in `lib/stripe.ts` goes into the `Stripe` constructor; the test holds it equal to `Stripe.API_VERSION` of the installed SDK (and `tsc` to the SDK's literal `apiVersion` type), so an SDK bump that moves the version turns CI red instead of silently changing the wire contract — when it does: read the changelog of the new API version, rehearse checkout on staging (embedded, hosted fallback, guest, wallet), and only then move the literal. The webhook endpoint's version is set in the Stripe dashboard and is not governed by this constant. +**The three Stripe packages move together, as one train (#433).** Today: `stripe` 23 = `2026-09-30.endive`, `@stripe/stripe-js` 10 = `js.stripe.com/endive/stripe.js`, `@stripe/react-stripe-js` 7 (peer of stripe-js 10). Each stripe-js major is welded to one Stripe.js release train, and Stripe.js makes its own API calls on it — Stripe's advice is to keep it on the server's train, so `lib/stripe-browser.test.ts` fails when the injected script's train ≠ the suffix of `STRIPE_API_VERSION` (a split Dependabot bump). What Endive broke, and where it does not touch us: `payment_method_types` on Checkout Session create is a 400 (we send none — dynamic payment methods); `billing_cycle_anchor` on subscription update/resume is an object (we never send it); the Payment Request Button is gone (we use `ExpressCheckoutElement`); `canConfirm` is `false` while a Checkout-Sessions server update is pending (the wallet's `onConfirm` does not read it — the staging rehearsal is what proves the confirm); `ui_mode` values are unchanged. SDK 23 itself: a 3-arg `constructEvent` keeps the 300 s tolerance, a response cut off mid-body is now `StripeConnectionError` (nothing here branches on the class — `expireIfOpen` re-reads the session on ANY failure), Node ≥ 20. + These three field moves caused real bugs during build: ### `subscription.current_period_end` → `subscription.items.data[].current_period_end` diff --git a/docs/services.md b/docs/services.md index 8b2a8160..bc550703 100644 --- a/docs/services.md +++ b/docs/services.md @@ -52,7 +52,7 @@ Clerk Dashboard (production): Native applications → iOS — App ID prefix (Tea **Used for**: subscription billing, Checkout, Customer Portal. -**SDK**: `stripe@22.x` (Node, API 2024+). Be aware of moved fields — see [gotchas](./gotchas.md#stripe-api-2024-moves). +**SDK**: `stripe@23.x` (Node, API `2026-09-30.endive`, pinned in `lib/stripe.ts`). Be aware of moved fields — see [gotchas](./gotchas.md#stripe-api-2024-moves). **Env vars**: | Name | Where to get it | diff --git a/lib/stripe-browser.test.ts b/lib/stripe-browser.test.ts new file mode 100644 index 00000000..3ebe89cd --- /dev/null +++ b/lib/stripe-browser.test.ts @@ -0,0 +1,42 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { STRIPE_API_VERSION } from "./stripe"; +import { getStripeBrowser } from "./stripe-browser"; + +// The browser half of the #266 pin (#433). The server talks to Stripe on +// STRIPE_API_VERSION (`.`, held to the server SDK by +// lib/stripe-api-version.test.ts); the browser runs whichever Stripe.js +// @stripe/stripe-js loads — since v6 each major of that package is welded to +// ONE release train (`js.stripe.com//stripe.js`), and Stripe.js makes +// its own API calls on that train with no way to override it. Stripe's advice +// is to keep the two on the same train: Embedded Checkout and the wallet's +// ExpressCheckoutElement render a Checkout Session the server created, and the +// breaking changes of a train land in BOTH halves (Endive: `canConfirm`, +// address validation, the payment request button). The SDKs move in separate +// packages, so a split upgrade — `stripe` bumped, `@stripe/stripe-js` left +// behind, or the other way round — is one Dependabot group away; this is what +// turns it red. +// +// It reads the script tag loadStripe really injects (jsdom fetches nothing), +// not the package's private constant. + +describe("Stripe.js release train", () => { + it("the browser loads Stripe.js from the server API version's train", () => { + void getStripeBrowser("pk_test_dummy"); + + const script = document.querySelector( + 'script[src^="https://js.stripe.com/"]', + ); + expect(script).not.toBeNull(); + const browserTrain = script!.src.match( + /^https:\/\/js\.stripe\.com\/([a-z]+)\/stripe\.js/, + )?.[1]; + const serverTrain = STRIPE_API_VERSION.split(".")[1]; + + expect(browserTrain).toBeTruthy(); + expect(browserTrain).toBe(serverTrain); + }); +}); diff --git a/lib/stripe.ts b/lib/stripe.ts index 59e0ad82..fac2b3aa 100644 --- a/lib/stripe.ts +++ b/lib/stripe.ts @@ -15,7 +15,7 @@ declare global { // moves the version — read that version's changelog, rehearse checkout on // staging, THEN move the literal. (The webhook endpoint has its own version, // set in the Stripe dashboard; this constant does not govern it.) -export const STRIPE_API_VERSION = "2026-08-26.dahlia"; +export const STRIPE_API_VERSION = "2026-09-30.endive"; // Lazy + cached. The Stripe SDK constructor will throw if the secret is empty. export function getStripe(): Stripe { diff --git a/package.json b/package.json index 8c0af76e..db67d507 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "@mux/mux-video-react": "^0.31.5", "@mux/upchunk": "^3.5.0", "@sentry/nextjs": "^11.4.0", - "@stripe/react-stripe-js": "^6.12.0", - "@stripe/stripe-js": "^9.17.0", + "@stripe/react-stripe-js": "^7.0.0", + "@stripe/stripe-js": "^10.0.0", "@vercel/blob": "^2.8.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -64,7 +64,7 @@ "resend": "^6.32.0", "server-only": "^0.0.1", "shadcn": "^4.21.3", - "stripe": "^22.6.2", + "stripe": "^23.0.0", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23869567..10afa1f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,11 +40,11 @@ importers: specifier: ^11.4.0 version: 11.4.0(@babel/core@7.29.7)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(webpack@5.109.2(esbuild@0.28.2)) '@stripe/react-stripe-js': - specifier: ^6.12.0 - version: 6.12.0(@stripe/stripe-js@9.17.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: ^7.0.0 + version: 7.0.0(@stripe/stripe-js@10.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@stripe/stripe-js': - specifier: ^9.17.0 - version: 9.17.0 + specifier: ^10.0.0 + version: 10.0.0 '@vercel/blob': specifier: ^2.8.1 version: 2.8.1 @@ -97,8 +97,8 @@ importers: specifier: ^4.21.3 version: 4.21.3 stripe: - specifier: ^22.6.2 - version: 22.6.2(@types/node@24.19.1) + specifier: ^23.0.0 + version: 23.0.0(@types/node@24.19.1) tailwind-merge: specifier: ^3.7.0 version: 3.7.0 @@ -2205,15 +2205,15 @@ packages: typescript: optional: true - '@stripe/react-stripe-js@6.12.0': - resolution: {integrity: sha512-5RVfx92MTb66+ELuQTreUqX2oFvma5wybZ7MaifoNo0QgrjbJiLZcysGw5kIHU7p1TCd1Lx5JnasoqteSwlUsg==} + '@stripe/react-stripe-js@7.0.0': + resolution: {integrity: sha512-wKw6YM0p/IpGj7hcwdJL7UAb7s0hlO8kLJIXn0CezewYILQ8j/A7j55EHuHHjF6dV+cr4yZhbhg4QLRjovfM4A==} peerDependencies: - '@stripe/stripe-js': '>=9.16.0 <10.0.0' + '@stripe/stripe-js': '>=10.0.0 <11.0.0' react: '>=16.8.0 <20.0.0' react-dom: '>=16.8.0 <20.0.0' - '@stripe/stripe-js@9.17.0': - resolution: {integrity: sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA==} + '@stripe/stripe-js@10.0.0': + resolution: {integrity: sha512-y5LKFTtnz1ciM+IQfrDfHcpjGlsm5Ao4onaUn4scurI/B6rNhrmV+ScTPDo+3Ges5oF8ol4tBkBAIZyVXVBIXw==} engines: {node: '>=12.16'} '@swc/helpers@0.5.23': @@ -5490,11 +5490,11 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} - stripe@22.6.2: - resolution: {integrity: sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==} - engines: {node: '>=18'} + stripe@23.0.0: + resolution: {integrity: sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA==} + engines: {node: '>=20'} peerDependencies: - '@types/node': '>=18' + '@types/node': '>=20' peerDependenciesMeta: '@types/node': optional: true @@ -7735,14 +7735,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@stripe/react-stripe-js@6.12.0(@stripe/stripe-js@9.17.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@stripe/react-stripe-js@7.0.0(@stripe/stripe-js@10.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@stripe/stripe-js': 9.17.0 + '@stripe/stripe-js': 10.0.0 prop-types: 15.8.1 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@stripe/stripe-js@9.17.0': {} + '@stripe/stripe-js@10.0.0': {} '@swc/helpers@0.5.23': dependencies: @@ -11327,7 +11327,7 @@ snapshots: strip-indent@4.1.1: {} - stripe@22.6.2(@types/node@24.19.1): + stripe@23.0.0(@types/node@24.19.1): optionalDependencies: '@types/node': 24.19.1 From 1b54e6f8489ce18083fe7dc71a4ddff676ba17b1 Mon Sep 17 00:00:00 2001 From: matio Date: Fri, 9 Oct 2026 12:50:26 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(deps):=20custom=5Ftext=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20elements=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=20=D0=BD=D0=B0?= =?UTF-8?q?=20endive=20=E2=80=94=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=B8=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=20(#433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0184yUssUtNnEmKVaft78cbT --- docs/registry.md | 1 + lib/checkout-session-params.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/registry.md b/docs/registry.md index c587ee26..0812cc06 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -140,3 +140,4 @@ close"). | **No AirPlay button in the landscape player** (#375): board E drew one in the top-right capsule (⚑ on the board), but an in-chrome AirPlay route picker is a native view (`AVRoutePickerView`) react-native-video does not expose — a new dependency, the owner's decision. AirPlay still works from Control Center (`allowsExternalPlayback` stays on); before #375 a landscape show had it in the native transport's row | A viewer has to know Control Center to send a landscape episode to a TV | The owner approves a route-picker module (e.g. a small native view or an Expo module) — then a button left of PiP in the top-right capsule, as on the board | | **The episodes panel's progress lines are this session's only** (`mobile/src/watch/episode-feed.tsx` `watchedRef`, #375): the feed remembers how far each episode was watched since the player opened; an episode watched yesterday shows no line. `/v1` has no per-show progress read — `/v1/progress?episodeId=` answers one episode, `/v1/continue` one per show | A returning viewer's panel does not show what they finished on another day | A `/v1` read of the signed-in viewer's positions for a show's episodes (one query on `watch_progress`), fed into the panel — with its own spec | | **The app's players have no Magic Tap handler — what iOS does with the gesture is UNOBSERVED** (`mobile/src/components/vertical-chrome.tsx`, `landscape-chrome.tsx`, #411, #375): nothing in either chrome answers the two-finger double tap. What the source proves (React Native 0.86.2, which runs only the New Architecture): on Fabric the JS `onMagicTap` never reaches the native view — the static view config lets it through under that name (`Libraries/NativeComponent/BaseViewConfig.ios.js:383`, `onMagicTap: true`), while the C++ props read only `onAccessibilityMagicTap` (`ReactCommon/react/renderer/components/view/AccessibilityProps.cpp:193-201`, `:569`) — the flag `RCTViewComponentView`'s `accessibilityPerformMagicTap` checks (`RCTViewComponentView.mm:1611-1619`) — and a JS prop of that name is dropped as «not a valid native prop» (`ReactNativeAttributePayload.js`), because the view config does not list it. So no view of ours handles a Magic Tap. What the source does NOT prove: what iOS then does with a Magic Tap nobody on the focus path handles. Probably it falls through to the app's Now Playing session, where react-native-video 6.19.3 registers a `togglePlayPauseCommand` target that plays/pauses the `AVPlayer` directly (`ios/Video/NowPlayingInfoCenterManager.swift`) — i.e. it may toggle playback anyway; it may also do nothing. Nobody has seen either on a device. The only way to a handler of our own is a host component on RN's private, semver-free `NativeComponentRegistry`; not taken | Unknown until checked: a VoiceOver viewer's two-finger double tap may toggle playback (behind our chrome's back — the toggle would land on the `AVPlayer`, not on our `paused` state) or do nothing. The way that works either way: focus the play/pause control (vertical: the full-surface «Play/Pause»; landscape: the gold pill) and double-tap it | One VoiceOver pass on the next TestFlight build, a vertical AND a horizontal show, with focus on the picture / the play/pause control and on «‹» (iOS sends a Magic Tap to the focused element first): note what a two-finger double tap does. It toggles and the chrome's Play/Pause follows → the unknown here becomes a fact, the row goes; it toggles but the chrome shows the wrong state → its own issue; nothing happens → the row stays, renamed to a known gap, until an RN upgrade in which `BaseViewConfig.ios.js` lists `onAccessibilityMagicTap` or `AccessibilityProps.cpp` reads `onMagicTap` — then `onMagicTap` goes back on both play/pause controls, checked by the same VoiceOver pass | +| **`custom_text` under `ui_mode: 'elements'` not re-verified on API `2026-09-30.endive`** (#433): lib/checkout-session-params.ts asks to re-check, when STRIPE_API_VERSION moves, that Stripe still rejects `custom_text` for the wallet surface (verified on `2026-08-26.dahlia`, #210) | none today — the elements surface never sends `custom_text`; the check matters only if someone tries to move the waiver wording back into Stripe's UI | Re-check against the live test-mode API on the next pin move, or when the wallet's own consent checkbox is reconsidered (#215) | diff --git a/lib/checkout-session-params.ts b/lib/checkout-session-params.ts index 77b93ff2..64833771 100644 --- a/lib/checkout-session-params.ts +++ b/lib/checkout-session-params.ts @@ -60,7 +60,9 @@ export function buildCheckoutSessionParams({ // supported with `ui_mode: elements`: custom_text". So the localized // waiver wording below simply cannot be sent. (The API version is pinned // since #266 — STRIPE_API_VERSION in lib/stripe.ts; re-check this when - // that literal moves.) + // that literal moves. Verified on 2026-08-26.dahlia; NOT re-verified on + // 2026-09-30.endive (#433) — harmless, this surface never sends + // custom_text; registry row.) // * `consent_collection` IS accepted, but its only renderer is Stripe's // `TermsElement`, which the installed SDK marks "Requires beta access" // and types with zero options — it could not carry our wording even with