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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/parley:mason/23-the-live-channel-carries-no-secret-and-n → main 9 commits fork 1 thread · 1 unresolved

  • Merged. mason/23-the-live-channel-carries-no-secret-and-n was merged into main as e933965.

Review

Sign in to review this change.
4 files changed +206 −9
AGENTS.md +16 −1
@@ -84,7 +84,22 @@ To add a concept:
8484 - **A terminal command** — a key in `src/tty.ts` that calls the action and
8585 reports on the status line.
8686 - **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.
88103
89104 ## The bar
90105
src/actions.ts +4 −4
@@ -233,8 +233,8 @@ export function openTable(
233233 return game;
234234 })();
235235 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 });
238238 return ok(state);
239239 }
240240
@@ -261,7 +261,7 @@ export function joinTable(
261261 return fail("conflict", "This table is full.");
262262 sit(db, game, who);
263263 const state = view(db, game, who);
264 emit("seat.joined", { table: game.id, player_name: who.name });
264 emit("seat.joined", { table: game.id });
265265 return ok(state);
266266 }
267267
@@ -285,7 +285,7 @@ export function leaveTable(
285285 return fail("not_found", "You do not hold a seat at this table.");
286286 db.query("DELETE FROM seat WHERE id = ?1").run(seat.id);
287287 const state = view(db, game, who);
288 emit("seat.left", { table: game.id, player_name: who.name });
288 emit("seat.left", { table: game.id });
289289 return ok(state);
290290 }
291291
src/live.ts +71 −1
@@ -5,8 +5,19 @@
55 * `?since=`) hears what it missed. `static/live.js` turns the stream into
66 * DOM events. The bus is this process's: the terminal is another process,
77 * 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.
816 */
917
18 import { env } from "./env.ts";
19 import { SECURITY } from "./http.ts";
20
1021 export interface LiveEvent {
1122 readonly id: number;
1223 readonly type: string;
@@ -15,12 +26,64 @@ export interface LiveEvent {
1526
1627 const KEEP = 100;
1728 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;
1831 const kept: LiveEvent[] = [];
1932 const listeners = new Set<(event: LiveEvent) => void>();
2033 let last = 0;
2134
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 */
2268 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 };
2487 kept.push(event);
2588 if (kept.length > KEEP) kept.shift();
2689 for (const listen of listeners) listen(event);
@@ -74,8 +137,15 @@ export function events(request: Request): Response {
74137 return new Response(stream, {
75138 headers: {
76139 "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.
77146 "cache-control": "no-store",
78147 "x-accel-buffering": "no",
148 ...SECURITY,
79149 },
80150 });
81151 }
src/server.test.ts +115 −3
@@ -4,6 +4,7 @@ import { mkdtempSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { text } from "./http.ts";
7 import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts";
78 import { layout } from "./pages/layout.ts";
89 import { serve } from "./server.ts";
910 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",
304305 const heard = await next();
305306 expect(heard.type).toBe("table.opened");
306307 // 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"]);
309311 await reader.cancel();
310312 const late = await fetch(base + "/events", {
311313 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",
317319 expect(replay).toContain("table.opened");
318320 });
319321
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 {
outdated

The floor on /events is a real fix and it works on the preview: curl -i .../events comes back with the CSP, nosniff, referrer-policy and HSTS beside no-store, and the stream still opens with retry:. bun test is green at cc85971 (85 pass), and the three new tests are real — I re-ran them with ...SECURITY dropped from the stream headers (the floor test fails) and with the notAnAddress call in emit stubbed out (both payload tests fail). The net diff against main is exactly the three files; the -s ours supersede 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.md and src/live.ts both say an event's data is "never an accord, a charge, a claim or a name", and src/actions.ts emits { 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_MAX is 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.ts carry the address alone — emit("table.opened", { table: opened.id }), emit("seat.joined", { table: game.id }), emit("seat.left", { table: game.id }) — and set the addresses map in the new test to ["table"] for all three. Nothing reads the payload: static/live.js refetches on type and never touches the detail fields, so the regions refresh exactly as they do now, and the rule in AGENTS.md becomes a fact about the app rather than an aspiration the test contradicts.

Two smaller things, a line each rather than another round:

  • player_name also makes emit a 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 for local and the tests and log-and-strip in preview too, since a preview is a table people and agents actually sit at.
  • notAnAddress checks values but not keys, so { "<a paragraph>": 1 } passes. One key.length <= ID_MAX condition closes it.

Not this pull's to fix, but noted while reading: the bus is per process, so a write from src/tty.ts still does not reach web viewers live — the one place the terminal is not the equal door AGENTS.md promises. Worth an issue beside #42 and #43.

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
320423 test("the page region carries the live types it is refreshed by", async () => {
321424 const page = await (await fetch(base + "/")).text();
322425 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", () => {
496599 expect(shared.headers.get("x-content-type-options")).toBe("nosniff");
497600 });
498601
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 () => {
500603 const stream = await fetch(base + "/events");
501604 expect(stream.headers.get("content-type")).toBe("text/event-stream");
502605 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 );
503615 const reader = stream.body!.getReader();
504616 const first = new TextDecoder().decode((await reader.read()).value);
505617 expect(first).toContain("retry:");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs