Public: anyone can read this; the crew can change it.
The live channel carries no secret and no waste: events name their table, and a phone that slept catches up #51
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/23-the-live-channel-carries-no-secret-and-n → main 9 commits fork 1 thread · 1 unresolved
4 files
changed +206
−9
AGENTS.md +16 −1
| @@ -84,7 +84,22 @@ To add a concept: | ||
| 84 | 84 | - **A terminal command** — a key in `src/tty.ts` that calls the action and |
| 85 | 85 | reports on the status line. |
| 86 | 86 | - **Something live** — `emit(type, data)` in the action, and |
| 87 | `data-live="<types>"` on the region of the page that shows it. | |
| 87 | `data-live="<types>"` on the region of the page that shows it. An event's | |
| 88 | `data` is an identifier, never content — `{ table, act }`, never an | |
| 89 | accord, a charge, a claim or a name, and not a title either: the stream | |
| 90 | reaches every open page, a guest's included, so what a viewer may see | |
| 91 | stays a decision the action makes when they refetch. Every emit the app | |
| 92 | makes today carries `{ table }` and nothing more, and the client refetches | |
| 93 | on the type alone; a payload is not a shortcut for what the page could | |
| 94 | read for itself. `emit` holds you to it, refusing a key or a string longer | |
| 95 | than an id and anything nested: it throws where the app is being written | |
| 96 | (a local run, which is `bun test` on your machine) and logs and sends the | |
| 97 | address alone wherever someone is sitting at a table (a preview, | |
| 98 | production), because there the write has already committed. The forge runs | |
| 99 | the check with the deployment's `FORGE_APP_ENV`, so a test about which of | |
| 100 | the two happens asks for an environment in its own process rather than | |
| 101 | assuming the one it runs in. The check is structural, so it cannot tell a | |
| 102 | short title from an id — the rule above is yours to keep. | |
| 88 | 103 | |
| 89 | 104 | ## The bar |
| 90 | 105 |
src/actions.ts +4 −4
| @@ -233,8 +233,8 @@ export function openTable( | ||
| 233 | 233 | return game; |
| 234 | 234 | })(); |
| 235 | 235 | const state = view(db, opened, who); |
| 236 | emit("table.opened", { table: opened.id, title: opened.title }); | |
| 237 | emit("seat.joined", { table: opened.id, player_name: who.name }); | |
| 236 | emit("table.opened", { table: opened.id }); | |
| 237 | emit("seat.joined", { table: opened.id }); | |
| 238 | 238 | return ok(state); |
| 239 | 239 | } |
| 240 | 240 | |
| @@ -261,7 +261,7 @@ export function joinTable( | ||
| 261 | 261 | return fail("conflict", "This table is full."); |
| 262 | 262 | sit(db, game, who); |
| 263 | 263 | const state = view(db, game, who); |
| 264 | emit("seat.joined", { table: game.id, player_name: who.name }); | |
| 264 | emit("seat.joined", { table: game.id }); | |
| 265 | 265 | return ok(state); |
| 266 | 266 | } |
| 267 | 267 | |
| @@ -285,7 +285,7 @@ export function leaveTable( | ||
| 285 | 285 | return fail("not_found", "You do not hold a seat at this table."); |
| 286 | 286 | db.query("DELETE FROM seat WHERE id = ?1").run(seat.id); |
| 287 | 287 | const state = view(db, game, who); |
| 288 | emit("seat.left", { table: game.id, player_name: who.name }); | |
| 288 | emit("seat.left", { table: game.id }); | |
| 289 | 289 | return ok(state); |
| 290 | 290 | } |
| 291 | 291 |
src/live.ts +71 −1
| @@ -5,8 +5,19 @@ | ||
| 5 | 5 | * `?since=`) hears what it missed. `static/live.js` turns the stream into |
| 6 | 6 | * DOM events. The bus is this process's: the terminal is another process, |
| 7 | 7 | * and its writes show on the next load rather than live. |
| 8 | * | |
| 9 | * One rule holds it together, and this module enforces it: **an event's | |
| 10 | * `data` is an identifier, never content.** `{ table, act }`, never an | |
| 11 | * accord, a charge, a claim or a name. The stream reaches | |
| 12 | * every open page, a guest's included, and it has no viewer to redact for; | |
| 13 | * the client is told *something changed here* and the action decides what | |
| 14 | * that viewer may see when they refetch, so the redaction lives in one | |
| 15 | * place and cannot leak down the wire. | |
| 8 | 16 | */ |
| 9 | 17 | |
| 18 | import { env } from "./env.ts"; | |
| 19 | import { SECURITY } from "./http.ts"; | |
| 20 | ||
| 10 | 21 | export interface LiveEvent { |
| 11 | 22 | readonly id: number; |
| 12 | 23 | readonly type: string; |
| @@ -15,12 +26,64 @@ export interface LiveEvent { | ||
| 15 | 26 | |
| 16 | 27 | const KEEP = 100; |
| 17 | 28 | const HEARTBEAT_MS = 25_000; |
| 29 | /** An id on the bus is short: long enough for a uuid, too short for prose. */ | |
| 30 | export const ID_MAX = 64; | |
| 18 | 31 | const kept: LiveEvent[] = []; |
| 19 | 32 | const listeners = new Set<(event: LiveEvent) => void>(); |
| 20 | 33 | let last = 0; |
| 21 | 34 | |
| 35 | const shape = (value: unknown): string => | |
| 36 | value === null ? "null" : Array.isArray(value) ? "an array" : typeof value; | |
| 37 | ||
| 38 | /** | |
| 39 | * Why `data` is not an address, or `undefined` when it is one: an object | |
| 40 | * whose every key and every value is a number, a boolean or a string of at | |
| 41 | * most {@link ID_MAX} characters. Nothing nested, nothing long — those are | |
| 42 | * content, and content does not travel on the bus. Keys are held to the | |
| 43 | * same length as values, because `{ "<a paragraph>": 1 }` is content | |
| 44 | * written in the other column. Exported so a test can hold an emit to the | |
| 45 | * rule and say why it broke it. | |
| 46 | */ | |
| 47 | export function notAnAddress(data: unknown): string | undefined { | |
| 48 | if (data === undefined || data === null) return undefined; | |
| 49 | if (typeof data !== "object" || Array.isArray(data)) | |
| 50 | return `data is ${shape(data)}; a live payload is an object of identifiers`; | |
| 51 | for (const [key, value] of Object.entries(data)) { | |
| 52 | if (key.length > ID_MAX) | |
| 53 | return `a key is a ${key.length}-character string; a live payload names identifiers, not content (${ID_MAX} characters at most)`; | |
| 54 | if (typeof value === "number" || typeof value === "boolean") continue; | |
| 55 | if (typeof value === "string") { | |
| 56 | if (value.length <= ID_MAX) continue; | |
| 57 | return `data.${key} is a ${value.length}-character string; a live payload carries identifiers, not content (${ID_MAX} characters at most)`; | |
| 58 | } | |
| 59 | return `data.${key} is ${shape(value)}; a live payload carries only numbers, booleans and identifiers`; | |
| 60 | } | |
| 61 | return undefined; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * Announce that something changed. `data` names it and says nothing about | |
| 66 | * it; a payload that breaks the rule never travels as written. | |
| 67 | */ | |
| 22 | 68 | export function emit(type: string, data: unknown): LiveEvent { |
| 23 | const event: LiveEvent = { id: ++last, type, data }; | |
| 69 | const wrong = notAnAddress(data); | |
| 70 | let carried = data; | |
| 71 | if (wrong !== undefined) { | |
| 72 | // A payload that carries content is the expensive kind of bug: it is on | |
| 73 | // every open page before anyone reviews it. So it is loud where the app | |
| 74 | // is being written — a local run throws, tests included, and that is | |
| 75 | // before a pull exists — and quiet wherever someone is actually sitting | |
| 76 | // at a table, a preview included, because there the write has already | |
| 77 | // committed and a 500 over the announcement of it would be a lie about | |
| 78 | // what happened. There the address alone goes down the wire, with the | |
| 79 | // breach in the log: the client refetches by type, and the action decides | |
| 80 | // what it may see. | |
| 81 | const said = `emit(${JSON.stringify(type)}): ${wrong}`; | |
| 82 | if (env === "local") throw new TypeError(said); | |
| 83 | console.error(`${said} — sent as an address alone`); | |
| 84 | carried = undefined; | |
| 85 | } | |
| 86 | const event: LiveEvent = { id: ++last, type, data: carried }; | |
| 24 | 87 | kept.push(event); |
| 25 | 88 | if (kept.length > KEEP) kept.shift(); |
| 26 | 89 | for (const listen of listeners) listen(event); |
| @@ -74,8 +137,15 @@ export function events(request: Request): Response { | ||
| 74 | 137 | return new Response(stream, { |
| 75 | 138 | headers: { |
| 76 | 139 | "content-type": "text/event-stream", |
| 140 | // The one answer that is a connection rather than a document: it says | |
| 141 | // its own `cache-control` at this call site, and `no-store` is the | |
| 142 | // whole of it — the stream is the same for everyone (it carries | |
| 143 | // addresses, never a viewer's state), so it needs no `Vary`, and | |
| 144 | // nothing may hold a copy of a connection anyway. The rest of the | |
| 145 | // floor applies here as to any other response. | |
| 77 | 146 | "cache-control": "no-store", |
| 78 | 147 | "x-accel-buffering": "no", |
| 148 | ...SECURITY, | |
| 79 | 149 | }, |
| 80 | 150 | }); |
| 81 | 151 | } |
src/server.test.ts +115 −3
| @@ -4,6 +4,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { text } from "./http.ts"; |
| 7 | import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts"; | |
| 7 | 8 | import { layout } from "./pages/layout.ts"; |
| 8 | 9 | import { serve } from "./server.ts"; |
| 9 | 10 | import { sign } from "./who.ts"; |
| @@ -304,8 +305,9 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 304 | 305 | const heard = await next(); |
| 305 | 306 | expect(heard.type).toBe("table.opened"); |
| 306 | 307 | // Every open page reads this stream, and it has no viewer to redact for: |
| 307 | // an event says what happened and to which table, and carries no state. | |
| 308 | expect(Object.keys(heard.data).sort()).toEqual(["table", "title"]); | |
| 308 | // an event says what happened and to which table, and nothing else — not | |
| 309 | // even the title, which is content that happens to be short. | |
| 310 | expect(Object.keys(heard.data).sort()).toEqual(["table"]); | |
| 309 | 311 | await reader.cancel(); |
| 310 | 312 | const late = await fetch(base + "/events", { |
| 311 | 313 | headers: { "last-event-id": String(Number(heard.id) - 1) }, |
| @@ -317,6 +319,107 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 317 | 319 | expect(replay).toContain("table.opened"); |
| 318 | 320 | }); |
| 319 | 321 | |
| 322 | test("an event's data is an identifier, never content", () => { | |
| 323 | // What an address looks like: ids, counts, flags, and nothing said. | |
| 324 | expect(notAnAddress({ table: 7, act: 12 })).toBeUndefined(); | |
| 325 | expect(notAnAddress({ table: "7", started: true })).toBeUndefined(); | |
| 326 | expect(notAnAddress({ id: "x".repeat(ID_MAX) })).toBeUndefined(); | |
| 327 | expect(notAnAddress(undefined)).toBeUndefined(); | |
| 328 | expect(notAnAddress({})).toBeUndefined(); | |
| 329 | // What content looks like, and the message that says which rule it broke. | |
| 330 | expect(notAnAddress({ table: 7, accord: { clause: 2, swear: true } })).toMatch( | |
| 331 | /data\.accord is object/, | |
| 332 | ); | |
| 333 | expect(notAnAddress({ seats: [1, 2] })).toMatch(/data\.seats is an array/); | |
| 334 | expect(notAnAddress({ table: 7, claim: "x".repeat(ID_MAX + 1) })).toMatch( | |
| 335 | /data\.claim is a 65-character string/, | |
| 336 | ); | |
| 337 | // Content written in the other column is still content. | |
| 338 | expect(notAnAddress({ ["<p>".repeat(ID_MAX)]: 1 })).toMatch( | |
| 339 | /a key is a 192-character string/, | |
| 340 | ); | |
| 341 | expect(notAnAddress("the whole table")).toMatch(/data is string/); | |
| 342 | expect(notAnAddress([{ table: 7 }])).toMatch(/data is an array/); | |
| 343 | }); | |
| 344 | ||
| 345 | /** | |
| 346 | * What a breach does is the environment's answer, and an environment is read | |
| 347 | * once at start-up — so each one is asked in its own process, and the suite | |
| 348 | * says the same thing wherever it is run from (the forge runs it with the | |
| 349 | * preview's environment, a laptop with none). | |
| 350 | */ | |
| 351 | const emitting = ( | |
| 352 | where: string, | |
| 353 | ): { code: number; out: string; err: string } => { | |
| 354 | const ran = Bun.spawnSync({ | |
| 355 | cmd: [ | |
| 356 | process.execPath, | |
| 357 | "-e", | |
| 358 | `const { emit } = await import(${JSON.stringify(join(import.meta.dir, "live.ts"))}); | |
| 359 | console.log(JSON.stringify(emit("table.opened", { table: 7, charter: { clause: 1 } })));`, | |
| 360 | ], | |
| 361 | env: { ...process.env, FORGE_APP_ENV: where }, | |
| 362 | }); | |
| 363 | return { | |
| 364 | code: ran.exitCode, | |
| 365 | out: ran.stdout.toString(), | |
| 366 | err: ran.stderr.toString(), | |
| 367 | }; | |
| 368 | }; | |
| 369 | ||
| 370 | test("where the app is written a bad payload is thrown, loudly (local)", () => { | |
| 371 | // Nobody is sitting at a local table, so the cheapest place to learn that a | |
| 372 | // payload carries content is here, before a pull exists: emit refuses. | |
| 373 | const ran = emitting("local"); | |
| 374 | expect(ran.code).not.toBe(0); | |
| 375 | expect(ran.err).toMatch(/emit\("table\.opened"\)/); | |
| 376 | expect(ran.err).toMatch(/data\.charter is object/); | |
| 377 | expect(ran.out).toBe(""); | |
| 378 | }); | |
| 379 | ||
| 380 | test("every emit the app makes today passes the rule", async () => { | |
| 381 | const heard: LiveEvent[] = []; | |
| 382 | const stop = subscribe((event) => heard.push(event)); | |
| 383 | const id = await openTable(alice, "The Addressed Table"); | |
| 384 | await api(`/api/tables/${id}/join`, { method: "POST" }, bob); | |
| 385 | await api(`/api/tables/${id}/leave`, { method: "POST" }, bob); | |
| 386 | stop(); | |
| 387 | // The emits that exist today, and the ids each one carries. A new type, | |
| 388 | // or a new key, fails here until someone has said it is an address — | |
| 389 | // which is the point: a bug fails a pull instead of a game. | |
| 390 | const addresses: Record<string, string[]> = { | |
| 391 | "table.opened": ["table"], | |
| 392 | "seat.joined": ["table"], | |
| 393 | "seat.left": ["table"], | |
| 394 | }; | |
| 395 | expect(heard.map((event) => event.type)).toEqual([ | |
| 396 | "table.opened", | |
| 397 | "seat.joined", | |
| 398 | "seat.joined", | |
| 399 | "seat.left", | |
| 400 | ]); | |
| 401 | for (const event of heard) { | |
| 402 | expect(notAnAddress(event.data)).toBeUndefined(); | |
| 403 | expect(Object.keys(event.data as object).sort()).toEqual( | |
| 404 | addresses[event.type]!, | |
| 405 | ); | |
| 406 | } | |
| 407 | }); | |
| 408 | ||
| 409 | test.each(["production", "preview"])( | |
| 410 | "in front of a player a bad payload is dropped, not thrown (%s)", | |
| 411 | (where) => { | |
| 412 | // The other half of the rule, where the write has already committed and a | |
| 413 | // 500 over the announcement would be a lie about what happened. A preview | |
| 414 | // is a table people and agents actually sit at, so it behaves like | |
| 415 | // production here; only a local run throws. | |
| 416 | const ran = emitting(where); | |
| 417 | expect(ran.code).toBe(0); | |
| 418 | expect(JSON.parse(ran.out)).toEqual({ id: 1, type: "table.opened" }); | |
| 419 | expect(ran.err).toContain("sent as an address alone"); | |
| 420 | }, | |
| 421 | ); | |
| 422 | ||
| 320 | 423 | test("the page region carries the live types it is refreshed by", async () => { |
| 321 | 424 | const page = await (await fetch(base + "/")).text(); |
| 322 | 425 | const region = /<section id="tables" data-live="([^"]*)"/.exec(page)?.[1]; |
| @@ -496,10 +599,19 @@ test("a call site can hand out a public cache on purpose", () => { | ||
| 496 | 599 | expect(shared.headers.get("x-content-type-options")).toBe("nosniff"); |
| 497 | 600 | }); |
| 498 | 601 | |
| 499 | test("the event stream keeps its own caching and still streams", async () => { | |
| 602 | test("the event stream stands on the floor, keeps its own caching, and streams", async () => { | |
| 500 | 603 | const stream = await fetch(base + "/events"); |
| 501 | 604 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
| 502 | 605 | expect(stream.headers.get("cache-control")).toBe("no-store"); |
| 606 | // A connection is a response like any other: the floor is not optional | |
| 607 | // because the body never ends. | |
| 608 | expect(stream.headers.get("content-security-policy")).toContain( | |
| 609 | "default-src 'self'", | |
| 610 | ); | |
| 611 | expect(stream.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 612 | expect(stream.headers.get("referrer-policy")).toBe( | |
| 613 | "strict-origin-when-cross-origin", | |
| 614 | ); | |
| 503 | 615 | const reader = stream.body!.getReader(); |
| 504 | 616 | const first = new TextDecoder().decode((await reader.read()).value); |
| 505 | 617 | expect(first).toContain("retry:"); |
new above
offline
The floor on
/eventsis a real fix and it works on the preview:curl -i .../eventscomes back with the CSP,nosniff,referrer-policyand HSTS besideno-store, and the stream still opens withretry:.bun testis green atcc85971(85 pass), and the three new tests are real — I re-ran them with...SECURITYdropped from the stream headers (the floor test fails) and with thenotAnAddresscall inemitstubbed out (both payload tests fail). The net diff againstmainis exactly the three files; the-s ourssupersede carries nothing from the parked draft.The miss is that the rule this pull writes down is not true of the app's own emits.
AGENTS.mdandsrc/live.tsboth say an event's data is "never an accord, a charge, a claim or a name", andsrc/actions.tsemits{ table, title }and{ table, player_name }— and the new test codifies those keys as approved addresses. Running this head locally and reading the wire after one table open:id: 1 data: {"type":"table.opened","data":{"table":1,"title":"A table with a rather long title here"}} id: 2 data: {"type":"seat.joined","data":{"table":1,"player_name":"Alice With A Very Long Name…"}}
That reaches every open page, a guest's included. None of it is secret today — the lobby shows titles and seat names to anon — but the structural check cannot catch it (
TITLE_MAXis 60, comfortably inside the 64-character cap), so the guard ships passing content by construction and the test teaches the next author that a name is an address. The next emit that copies the pattern is an offer or a claim.Fix first, and it is small: make the three emits in
src/actions.tscarry the address alone —emit("table.opened", { table: opened.id }),emit("seat.joined", { table: game.id }),emit("seat.left", { table: game.id })— and set theaddressesmap in the new test to["table"]for all three. Nothing reads the payload:static/live.jsrefetches ontypeand never touches thedetailfields, so the regions refresh exactly as they do now, and the rule inAGENTS.mdbecomes a fact about the app rather than an aspiration the test contradicts.Two smaller things, a line each rather than another round:
player_namealso makesemita hazard in a preview: a forge username longer than 64 characters throws (env !== "production") after the seat has already committed, so the player gets a 500 for a join that happened and no page updates. Dropping the key removes the class; while content is on the bus at all, consider throwing only forlocaland the tests and log-and-strip inpreviewtoo, since a preview is a table people and agents actually sit at.notAnAddresschecks values but not keys, so{ "<a paragraph>": 1 }passes. Onekey.length <= ID_MAXcondition closes it.Not this pull's to fix, but noted while reading: the bus is per process, so a write from
src/tty.tsstill does not reach web viewers live — the one place the terminal is not the equal doorAGENTS.mdpromises. Worth an issue beside #42 and #43.