Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions docs/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,5 @@ 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) |
| **The app tests' stage harness does not cover a timeout while an `act()` is pending** (`mobile/src/testing/stage.ts`, #432, #439): vitest does not cancel a test that times out, so a test whose own `act()` is still open at that moment (the harness's render, a press, a settle) keeps it running; when it resolves inside the next test it overlaps that test's `act()`, and React's act queue stays broken for the rest of the file — one timeout read as a file of misleading failures, the #432 shape. The stage + `stillLive` harness stops a late continuation only at the import awaits, where the observed failure was (#432); no run has shown the act-pending shape yet | Nothing in the product. A loaded run that times out inside an act reads as a cascade; only the FIRST red test of such a file is the real one | A red run whose first failure is a timeout inside an act → an issue with its log; or a harness that can end a pending act once its test is over |
2 changes: 1 addition & 1 deletion docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion lib/checkout-session-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions lib/stripe-browser.test.ts
Original file line number Diff line number Diff line change
@@ -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 (`<date>.<train>`, 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/<train>/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<HTMLScriptElement>(
'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);
});
});
2 changes: 1 addition & 1 deletion lib/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
38 changes: 19 additions & 19 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading