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
server/utils/run-guard.ts33 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1/**
2 * The paywall enforcement check for the gameplay AI path. send-message and the
3 * chronicle call this before spending tokens, so a forged or unpaid run id can't
4 * draw free generation: the run must be charged AND owned by this request's
5 * device (the run id comes from the run-context middleware, the device from its
6 * cookie).
7 *
8 * Fails OPEN on a store error — a balance-store outage must not block a legit
9 * in-progress run; the spend cap (a later vertebra) is the floor for that window.
10 * A missing run id, by contrast, is a hard no: there is nothing to verify.
11 */
12import type { H3Event } from 'h3'
13import { deviceId } from '~/server/utils/device'
14import { currentRunId } from '~/server/utils/run-context'
15import { balanceStore } from '~/server/utils/balance-store'
16import { isUuid } from '~/server/utils/uuid'
17 
18export async function runIsActive(event: H3Event): Promise<boolean> {
19 const runId = currentRunId()
20 // A missing or non-UUID id can never name a real run — a hard no, and (since
21 // runs.id is a uuid column) it must NEVER reach the store: a non-UUID would
22 // make the Supabase query throw, and the fail-open below would then hand it
23 // free generation. Validate the SHAPE before the call, so fail-open covers
24 // only a genuine store error on a well-formed id.
25 if (!runId || !isUuid(runId)) return false
26 try {
27 return await balanceStore().runActiveFor(deviceId(event), runId)
28 } catch {
29 // A well-formed id whose check errored = store unreachable; don't block a
30 // legit in-progress run (a valid uuid can't be a cast error here).
31 return true
32 }

"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
server/utils/balance-store.ts136 lines · TypeScript
⋯ 65 lines hidden (lines 1–65)
1/**
2 * The run balance — the ledger that makes a run a thing you spend. A brand-new
3 * device gets FREE_RUNS for free (the doc's "first run free"); chargeRun spends
4 * one run when the player commits to an objective, once per run. Out of runs →
5 * the commit is refused (the paywall). Packs (Stripe) credit this balance later.
6 *
7 * Same two-adapter shape as the run store: in-memory by default (dev + tests need
8 * nothing), Supabase when configured. Keyed by an opaque device id (a server-set
9 * cookie) until accounts arrive, when a user id will supersede it.
10 *
11 * The Supabase charge goes through one SQL function (spend_run) so the
12 * ensure-grant, the idempotency check, and the decrement are atomic in a single
13 * round-trip — no read-modify-write race. The in-memory adapter mirrors that
14 * logic exactly.
15 */
16import { createClient, type SupabaseClient } from '@supabase/supabase-js'
17import { supabaseConfig } from './supabase'
18 
19/** Runs granted to a brand-new device — the free trial ("first run free").
20 * Mirrored by the spend_run SQL function; keep the two in sync. */
21export const FREE_RUNS = 1
22 
23export interface ChargeResult {
24 /** False only when the device is out of runs (the paywall). */
25 charged: boolean
26 runsRemaining: number
28 
29export interface BalanceStore {
30 /** Ensure a balance row for the device, granting the free trial on first
31 * sight; returns the current runs remaining. */
32 ensureDevice(deviceId: string): Promise<number>
33 /** Spend one run for the device, once per runId (idempotent — re-committing
34 * the same run never double-charges). charged=false means out of runs. */
35 chargeRun(deviceId: string, runId: string): Promise<ChargeResult>
36 /** Runs remaining for the device (0 if unknown). */
37 remaining(deviceId: string): Promise<number>
38 /** Is this run charged AND owned by this device? The gameplay AI path checks
39 * this before spending tokens, so a forged or unpaid run id can't draw free
40 * generation. */
41 runActiveFor(deviceId: string, runId: string): Promise<boolean>
43 
44/** Dev default — process-local balances, durable enough for dev. Mirrors the
45 * spend_run SQL function: free grant on first sight, one charge per run. */
46export class InMemoryBalanceStore implements BalanceStore {
47 private readonly balances = new Map<string, number>()
48 // runId → the device that charged it. Idempotency is per (device, run), so a
49 // different device can't ride another's already-charged run for free.
50 private readonly chargedRuns = new Map<string, string>()
51 
52 async ensureDevice(deviceId: string): Promise<number> {
53 if (!this.balances.has(deviceId)) this.balances.set(deviceId, FREE_RUNS)
54 return this.balances.get(deviceId) ?? 0
55 }
56 
57 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
58 const remaining = await this.ensureDevice(deviceId)
59 if (this.chargedRuns.get(runId) === deviceId) return { charged: true, runsRemaining: remaining }
60 if (remaining <= 0) return { charged: false, runsRemaining: 0 }
61 this.balances.set(deviceId, remaining - 1)
62 this.chargedRuns.set(runId, deviceId)
63 return { charged: true, runsRemaining: remaining - 1 }
64 }
65 
66 async remaining(deviceId: string): Promise<number> {
67 return this.balances.get(deviceId) ?? 0
68 }
69 
70 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
71 return this.chargedRuns.get(runId) === deviceId
72 }
⋯ 33 lines hidden (lines 74–106)
74 
75/** Service-key (RLS-bypassing) balance access; the table has no public policy. */
76export class SupabaseBalanceStore implements BalanceStore {
77 constructor(private readonly client: SupabaseClient) {}
78 
79 async ensureDevice(deviceId: string): Promise<number> {
80 const { error } = await this.client
81 .from('balances')
82 .insert({ device_id: deviceId, runs_remaining: FREE_RUNS })
83 // 23505 = unique_violation: the row already exists, which is fine.
84 if (error && error.code !== '23505') throw new Error(`balance ensure failed: ${error.message}`)
85 return this.remaining(deviceId)
86 }
87 
88 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
89 const { data, error } = await this.client.rpc('spend_run', {
90 p_device: deviceId,
91 p_run: runId
92 })
93 if (error) throw new Error(`spend_run failed: ${error.message}`)
94 const row = (Array.isArray(data) ? data[0] : data) as { charged: boolean; runs_remaining: number }
95 return { charged: row.charged, runsRemaining: row.runs_remaining }
96 }
97 
98 async remaining(deviceId: string): Promise<number> {
99 const { data, error } = await this.client
100 .from('balances')
101 .select('runs_remaining')
102 .eq('device_id', deviceId)
103 .maybeSingle()
104 if (error) throw new Error(`balance read failed: ${error.message}`)
105 return (data?.runs_remaining as number | undefined) ?? 0
106 }
107 
108 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
109 const { data, error } = await this.client
110 .from('runs')
111 .select('id')
112 .eq('id', runId)
113 .eq('device_id', deviceId)
114 .not('charged_at', 'is', null)
115 .maybeSingle()
116 if (error) throw new Error(`run check failed: ${error.message}`)
117 return !!data
118 }
120 
⋯ 16 lines hidden (lines 121–136)
121let cached: BalanceStore | null = null
122 
123/** The configured balance store (memoized). */
124export function balanceStore(): BalanceStore {
125 if (cached) return cached
126 const cfg = supabaseConfig()
127 cached = cfg
128 ? new SupabaseBalanceStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
129 : new InMemoryBalanceStore()
130 return cached
132 
133/** Test seam: clear the memoized store so the next balanceStore() re-reads config. */
134export function resetBalanceStore(): void {
135 cached = null

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
server/api/send-message.post.ts303 lines · TypeScript
⋯ 102 lines hidden (lines 1–102)
1import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
2import { toContactMoment, formatContactMoment } from '~/utils/contact-moment'
3import {
4 cleanArray,
5 cleanString,
6 clampInt,
7 MAX_ERA_CHARS,
8 MAX_FIGURE_NAME_CHARS,
9 MAX_GROUNDING_CHARS,
10 MAX_HISTORY_ENTRIES,
11 MAX_LEDGER_SWING,
12 MAX_OBJECTIVE_DESC_CHARS,
13 MAX_OBJECTIVE_TITLE_CHARS,
14 MAX_TIMELINE_ENTRIES
15} from '~/server/utils/validate'
16 
17export default defineEventHandler(async (event) => {
18 // Only allow POST requests
19 if (getMethod(event) !== 'POST') {
20 throw createError({
21 statusCode: 405,
22 statusMessage: 'Method Not Allowed'
23 })
24 }
25 
26 const body = await readBody(event)
27 
28 // The client UI bounds these, but a direct POST is untrusted and every field
29 // below feeds a gpt-5.5 prompt — so validate the boundary here, not just in the
30 // store. (Surfaced by the input-validation-gap loop.)
31 if (!body || typeof body.message !== 'string' || !body.message.trim()) {
32 throw createError({
33 statusCode: 400,
34 statusMessage: 'Bad Request: message parameter is required'
35 })
36 }
37 if (body.message.trim().length > MAX_MESSAGE_CHARS) {
38 throw createError({
39 statusCode: 400,
40 statusMessage: `Bad Request: message exceeds ${MAX_MESSAGE_CHARS} characters`
41 })
42 }
43 if (typeof body.figureName !== 'string' || !body.figureName.trim()) {
44 throw createError({
45 statusCode: 400,
46 statusMessage: 'Bad Request: figureName parameter is required'
47 })
48 }
49 
50 const { when = null, figureContext = null, objective = null } = body
51 const message = body.message.trim()
52 // A name is interpolated into every prompt (and the Wikipedia lookup upstream),
53 // so it gets a hard cap like every other prompt-feeding string.
54 const figure = cleanString(body.figureName, MAX_FIGURE_NAME_CHARS)
55 // The grounded contact year (signed: AD positive / BC negative) — anchors the
56 // causal-distance read and the character's world-brief. Optional + untrusted.
57 const figureYear = typeof body.whenSigned === 'number' && Number.isFinite(body.whenSigned)
58 ? clampInt(body.whenSigned, 12000)
59 : undefined
60 // The pinned moment (issue #32) arrives as untrusted integers and is
61 // re-validated here; the display string the prompts see is DERIVED, never
62 // the client's. Sub-year detail is prompt flavor only — every mechanic
63 // below (world-brief, liveness, the wager) still computes on figureYear.
64 const figureMoment = figureYear !== undefined ? toContactMoment(body.whenMonth, body.whenDay) : null
65 const whenLabel = figureYear !== undefined ? formatContactMoment(figureYear, figureMoment) : undefined
66 // The last-stand wager: the client offers it only on the final dispatch; the
67 // doubling is applied after amplification, and the response echoes it.
68 const staked = body.stake === true
69 
70 // Normalize the objective into the context the Timeline Engine needs (coerced +
71 // bounded — a non-string or megabyte field would otherwise reach the prompt raw).
72 const objectiveContext = {
73 title: cleanString(objective?.title, MAX_OBJECTIVE_TITLE_CHARS) || 'Alter the course of history',
74 description: cleanString(objective?.description, MAX_OBJECTIVE_DESC_CHARS) || 'Bend the chain of events toward a better world.',
75 era: cleanString(objective?.era, MAX_ERA_CHARS)
76 }
77 // The objective's far anchor year (signed: AD+/BC−) — the foothold the
78 // causal-chain dial decays toward. Optional + untrusted; absent → no-op.
79 const objectiveAnchorYear = typeof objective?.anchorYear === 'number' && Number.isFinite(objective.anchorYear)
80 ? clampInt(objective.anchorYear, 12000)
81 : undefined
82 
83 // Sanitize the client-supplied ledger + thread: cap the counts and coerce each
84 // field, so a crafted body can't crash a `.map`, forge an unbounded prior
85 // history, or amplify token cost.
86 const timeline = cleanArray(body.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
87 const e = (raw ?? {}) as Record<string, unknown>
88 return {
89 era: cleanString(e.era, MAX_ERA_CHARS),
90 headline: cleanString(e.headline, MAX_OBJECTIVE_TITLE_CHARS),
91 detail: cleanString(e.detail, MAX_OBJECTIVE_DESC_CHARS),
92 progressChange: clampInt(e.progressChange, MAX_LEDGER_SWING),
93 whenSigned: typeof e.whenSigned === 'number' && Number.isFinite(e.whenSigned)
94 ? clampInt(e.whenSigned, 12000)
95 : undefined
96 }
97 })
98 const conversationHistory = cleanArray(body.conversationHistory, MAX_HISTORY_ENTRIES)
99 .map((raw) => (raw ?? {}) as Record<string, unknown>)
100 .filter((m) => (m.sender === 'user' || m.sender === 'ai') && typeof m.text === 'string')
101 .map((m) => ({ sender: m.sender as 'user' | 'ai', text: cleanString(m.text, MAX_MESSAGE_CHARS) }))
102 
103 try {
104 // Paywall enforcement: the run must be charged and owned by this device,
105 // or a forged / unpaid run id could draw free generation. Fails open if
106 // the balance store is unreachable (see run-guard) so an outage never
107 // blocks a legit in-progress run.
108 const { runIsActive } = await import('~/server/utils/run-guard')
109 if (!(await runIsActive(event))) {
110 return {
111 success: false as const,
112 message: 'Run not active',
113 data: { userMessage: message, error: 'This run is no longer active. Start a new run to continue.' }
114 }
115 }
116 
⋯ 187 lines hidden (lines 117–303)
117 const { callCharacterAI, callTimelineAI, callJudgeAI } = await import('~/server/utils/openai')
118 const { rollD20, applyCraftModifier } = await import('~/utils/dice')
119 const { CRAFT_MODIFIER } = await import('~/utils/craft')
120 const { hardBlockCheck, moderateAction } = await import('~/server/utils/moderation')
121 
122 // A blocked turn never burns a message: success:false + data.blocked lets
123 // the store show a distinct, honest "blocked" banner (not an infra retry).
124 const blockedResponse = (reason: string) => ({
125 success: false as const,
126 message: 'Blocked by moderation',
127 data: { userMessage: message, blocked: true as const, moderationReason: reason, error: reason }
128 })
129 
130 // Moderation, stage 1 — a deterministic hard block on the raw dispatch
131 // BEFORE we spend a generation token. The unforgivable categories (sexual
132 // content, anything involving minors, self-harm facilitation) need no
133 // context; the contextual Sentinel runs post-generation over the exchange.
134 const inputBlock = await hardBlockCheck(message, 'turn', 'input')
135 if (inputBlock.blocked) return blockedResponse(inputBlock.block.reason)
136 
137 // The client supplies figureContext (it grounded the figure via /api/figure),
138 // so treat it as untrusted: coerce + bound each field before it becomes a
139 // "fact" in the character system prompt.
140 const grounding = {
141 when: whenLabel ?? (cleanString(when, MAX_ERA_CHARS) || undefined),
142 // The figure should take a pinned moment with period-appropriate
143 // granularity rather than false precision (the prompt line is
144 // conditional so year-only turns stay byte-identical).
145 momentRefined: figureMoment !== null,
146 description: cleanString(figureContext?.description, MAX_GROUNDING_CHARS) || undefined,
147 lifespan: cleanString(figureContext?.lifespan, MAX_ERA_CHARS) || undefined
148 }
149 
150 // Step 1: The Judge grades the dispatch's craft — the one place player skill
151 // directly tilts fate. A Judge outage grades 'sound' (modifier 0): an infra
152 // hiccup must never tilt the die either way.
153 const judged = await callJudgeAI({
154 objective: objectiveContext,
155 figureName: figure,
156 when: grounding.when,
157 momentRefined: figureMoment !== null,
158 userMessage: message
159 })
160 const craft = judged.success && judged.judge ? judged.judge.craft : 'sound'
161 const craftReason = judged.success && judged.judge ? judged.judge.reason : ''
162 const rollModifier = CRAFT_MODIFIER[craft]
163 
164 // Step 2: Roll the dice of fate, tilted by craft (clamped to the die's faces).
165 const natural = rollD20()
166 const diceResult = applyCraftModifier(natural.roll, rollModifier)
167 
168 // Step 3: The chosen figure replies and acts, in character, blind to the goal.
169 // Grounding (when + real facts) anchors the role-play; the world-brief hands
170 // them the altered history their own moment would already know (changes at or
171 // before their year), so a turn-4 figure doesn't role-play the unaltered world.
172 // Headlines only — `detail` narrates downstream ripples (often stamped with
173 // later eras) and would hand the figure the future outright.
174 const worldBrief = figureYear === undefined
175 ? []
176 : timeline
177 .filter(e => typeof e.whenSigned === 'number' && e.whenSigned <= figureYear && e.headline)
178 .slice(-5)
179 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
180 const character = await callCharacterAI(figure, message, conversationHistory, diceResult.outcome, grounding, worldBrief)
181 if (!character.success || !character.characterResponse) {
182 // A model-side safety refusal is a block, not an infra hiccup.
183 if (character.refused) return blockedResponse(character.error || 'This dispatch was declined by content moderation and cannot be sent.')
184 return {
185 success: false,
186 message: 'Failed to generate character response',
187 data: {
188 userMessage: message,
189 error: character.error || 'Character AI failed'
190 }
191 }
192 }
193 
194 const reply = character.characterResponse
195 
196 // Enforce the message-length constraint on the figure's reply.
197 if (reply.message && reply.message.length > MAX_MESSAGE_CHARS) {
198 console.warn(`Figure reply exceeded ${MAX_MESSAGE_CHARS} characters (${reply.message.length}); truncating.`)
199 reply.message = reply.message.substring(0, MAX_MESSAGE_CHARS - 3) + '...'
200 }
201 
202 // Step 4: The Timeline Engine judges how the action ripples toward the objective.
203 const timelineResult = await callTimelineAI({
204 objective: objectiveContext,
205 figureName: figure,
206 era: reply.era || objectiveContext.era,
207 userMessage: message,
208 characterMessage: reply.message,
209 characterAction: reply.action,
210 diceRoll: diceResult.roll,
211 diceOutcome: diceResult.outcome,
212 timeline,
213 figureYear,
214 figureMoment: figureMoment ? whenLabel : undefined,
215 objectiveAnchorYear
216 })
217 
218 if (!timelineResult.success || !timelineResult.ripple) {
219 if (timelineResult.refused) return blockedResponse(timelineResult.error || 'This dispatch was declined by content moderation and cannot be sent.')
220 return {
221 success: false,
222 message: 'Failed to generate timeline analysis',
223 data: {
224 userMessage: message,
225 diceRoll: diceResult.roll,
226 diceOutcome: diceResult.outcome,
227 characterResponse: { message: reply.message, action: reply.action },
228 error: timelineResult.error || 'Timeline AI failed'
229 }
230 }
231 }
232 
233 const ripple = timelineResult.ripple
234 
235 // Moderation, stage 2 — the contextual Sentinel reviews the WHOLE exchange
236 // (full thread + this dispatch + the figure's reply and the timeline's
237 // narration) against the verbatim Usage Policy, and the detector hard-blocks
238 // the outputs. A block here suppresses the reveal: the generated text never
239 // ships, and the turn is refunded with an honest banner.
240 const moderation = await moderateAction({
241 surface: 'turn',
242 objective: { title: objectiveContext.title, description: objectiveContext.description },
243 figureName: figure,
244 era: reply.era || objectiveContext.era,
245 conversation: conversationHistory.map(m => ({ role: m.sender === 'user' ? 'user' : 'assistant', text: m.text })),
246 userText: message,
247 outputs: [reply.message, reply.action, ripple.headline, ripple.detail].filter((t): t is string => !!t)
248 })
249 if (moderation.blocked) return blockedResponse(moderation.block.reason)
250 
251 // The last stand: a staked dispatch doubles its resolved swing, both ways,
252 // and may escape the per-turn fuse up to the full meter. The valence badge
253 // is re-asserted AFTER the doubling — the sign-guard inside callTimelineAI
254 // ran on the pre-stake value, and a doubled swing must wear its own color.
255 const { applyStake } = await import('~/utils/swing-bands')
256 const finalProgressChange = staked ? applyStake(ripple.progressChange) : ripple.progressChange
257 const finalValence = staked && Math.abs(finalProgressChange) > 3
258 ? (finalProgressChange > 0 ? 'positive' as const : 'negative' as const)
259 : ripple.valence
260 
261 return {
262 success: true,
263 message: 'Timeline updated',
264 data: {
265 userMessage: message,
266 figure: {
267 name: figure,
268 era: reply.era,
269 descriptor: reply.persona
270 },
271 // The effective (craft-tilted) roll drives the bands and the UI; the
272 // natural roll + modifier ride along so the reveal can show the tilt.
273 diceRoll: diceResult.roll,
274 diceOutcome: diceResult.outcome,
275 naturalRoll: natural.roll,
276 rollModifier,
277 craft,
278 craftReason,
279 staked,
280 characterResponse: { message: reply.message, action: reply.action },
281 timeline: {
282 headline: ripple.headline,
283 detail: ripple.detail,
284 era: reply.era || objectiveContext.era,
285 progressChange: finalProgressChange,
286 baseProgressChange: ripple.baseProgressChange,
287 valence: finalValence,
288 anachronism: ripple.anachronism,
289 causalChain: ripple.causalChain
290 },
291 error: null
292 }
293 }
294 } catch (error) {
295 // Log the detail server-side; the wire gets a clean line (internal error
296 // text — stack hints, key issues, SDK messages — is not for the client).
297 console.error('API endpoint error:', error)
298 throw createError({
299 statusCode: 500,
300 statusMessage: 'Internal Server Error'
301 })
302 }
303})

Same gate before the chronicler's large generation (lookup/research mirror it).

server/api/chronicle.post.ts · 76 lines
server/api/chronicle.post.ts76 lines · TypeScript
⋯ 55 lines hidden (lines 1–55)
1import {
2 cleanArray,
3 cleanString,
4 clampInt,
5 MAX_ERA_CHARS,
6 MAX_LEDGER_SWING,
7 MAX_OBJECTIVE_DESC_CHARS,
8 MAX_OBJECTIVE_TITLE_CHARS,
9 MAX_TIMELINE_ENTRIES
10} from '~/server/utils/validate'
11import type { ChronicleStatus } from '~/server/utils/prompt-builder'
12 
13const STATUSES: ChronicleStatus[] = ['playing', 'victory', 'defeat']
14 
15/**
16 * POST /api/chronicle — the Chronicler (Layer 3). Given the live objective, the
17 * running ledger, and the run's status, it narrates the altered timeline as it now
18 * stands. The client fires this non-blocking after every resolved turn, so the
19 * dice/progress reveal never waits on prose; the final telling is the epilogue.
20 *
21 * Like /api/send-message, every field below feeds a gpt-5.5 prompt, so the untrusted
22 * body is coerced + bounded here (the client caps live only in the store, which a raw
23 * POST bypasses). A model failure self-heals to `{ success: false }` — the caller
24 * keeps the prior chronicle rather than blanking the panel.
25 */
26export default defineEventHandler(async (event) => {
27 if (getMethod(event) !== 'POST') {
28 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
29 }
30 
31 const body = await readBody(event)
32 
33 const objectiveContext = {
34 title: cleanString(body?.objective?.title, MAX_OBJECTIVE_TITLE_CHARS) || 'Alter the course of history',
35 description: cleanString(body?.objective?.description, MAX_OBJECTIVE_DESC_CHARS) || 'Bend the chain of events toward a better world.',
36 era: cleanString(body?.objective?.era, MAX_ERA_CHARS)
37 }
38 
39 // Sanitize the client-supplied ledger: cap the count and coerce each field, so a
40 // crafted body can't crash a `.map`, forge an unbounded prior history, or amplify
41 // token cost. (Mirrors send-message.post.ts.)
42 const timeline = cleanArray(body?.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
43 const e = (raw ?? {}) as Record<string, unknown>
44 return {
45 era: cleanString(e.era, MAX_ERA_CHARS),
46 figureName: cleanString(e.figureName, MAX_ERA_CHARS),
47 headline: cleanString(e.headline, MAX_OBJECTIVE_TITLE_CHARS),
48 detail: cleanString(e.detail, MAX_OBJECTIVE_DESC_CHARS),
49 progressChange: clampInt(e.progressChange, MAX_LEDGER_SWING)
50 }
51 })
52 
53 const status: ChronicleStatus = STATUSES.includes(body?.status) ? body.status : 'playing'
54 const progress = Math.max(0, Math.min(100, clampInt(body?.progress, 100)))
55 
56 try {
57 // Paywall enforcement: don't spend the chronicler's large generation on a
58 // forged / unpaid run. Graceful — the caller keeps the prior telling.
59 const { runIsActive } = await import('~/server/utils/run-guard')
60 if (!(await runIsActive(event))) {
61 return { success: false, error: 'Run not active' }
62 }
63 
⋯ 13 lines hidden (lines 64–76)
64 const { callChroniclerAI } = await import('~/server/utils/openai')
65 const result = await callChroniclerAI({ objective: objectiveContext, timeline, status, progress })
66 
67 if (!result.success || !result.chronicle) {
68 return { success: false, error: result.error || 'Chronicle AI failed' }
69 }
70 return { success: true, chronicle: result.chronicle }
71 } catch (error) {
72 console.error('Chronicle endpoint error:', error)
73 // Log the detail server-side; the wire gets a clean line.
74 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
75 }
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. - Majorspend_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).