What changed, and why
The paywall #48 shipped was advisory: the browser respects the 402, but a direct caller could skip the commit and still hit the AI endpoints with a made-up run id. This makes it binding. The billable endpoints now refuse to spend model tokens unless the run id names a charged run owned by this request's device — so a forged, foreign, or unpaid run id draws no generation.
It's the highest-value of the "address the bypasses directly" fixes. An adversarial review of the first cut also caught a critical hole and two majors, all fixed here — see the notes below.
The check
One helper answers the question. runIsActive ties the run id (from the run-context middleware, i.e. the x-run-id header) to the device (from its cookie). The shape guard on line one is load-bearing: runs.id is a uuid column, so a non-UUID id is refused before the store call — otherwise it would make the query throw, and the fail-open below would hand a 7-character header free generation. Fail-open therefore covers only a genuine store error on a well-formed id (an in-progress run surviving a blip).
Shape-guard first (the bypass fix), then ask the store; fail open only on a real error.
server/utils/run-guard.ts · 33 lines
⋯ 17 lines hidden (lines 1–17)
"Active" means charged and device-owned. The in-memory adapter checks who charged the run; Supabase queries the runs row by id, device, and a non-null charged_at.
runActiveFor on both adapters — the charged-and-owned test.
server/utils/balance-store.ts · 136 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 33 lines hidden (lines 74–106)
⋯ 16 lines hidden (lines 121–136)
The gate on the endpoints that cost money
Every billable endpoint checks first, before any model call. send-message returns a clean refusal — no tokens, and the client refunds the turn as it does for a moderation block. The chronicler (a 2000-token telling) and the two in-run browse endpoints (the Archive lookup and the Archivist study) gate the same way; the pre-commit setup endpoints stay ungated on purpose, since they run before a run is charged.
The gate sits at the top of the work, before the Judge/Character/Timeline calls.
server/api/send-message.post.ts · 303 lines
⋯ 102 lines hidden (lines 1–102)
⋯ 187 lines hidden (lines 117–303)
Same gate before the chronicler's large generation (lookup/research mirror it).
server/api/chronicle.post.ts · 76 lines
⋯ 55 lines hidden (lines 1–55)
⋯ 13 lines hidden (lines 64–76)
The review's fixes, and what stays open
Three things the review surfaced, fixed here:
- Critical — the non-UUID fail-open bypass above (a malformed header threw, then fail-open allowed it). Now refused before the store. - Major — spend_run now ensures the run row before charging, so "charged" always implies a row the gate can find; a paid run can't get locked out. - Major — the client now fails closed: if begin-run or the commit can't establish a charged, server-backed run, it surfaces an error and does not start (no fabricated local id that the gate would later reject).