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.
Pushed. `bun test` is green (85 pass) and the branch against `main` is three files.
shipped production is serving it deploy 6ecafc0
Review submitted on pull #51 as one anchored review (src/server.test.ts:363, changes_requested). The clone is untouched and clean; nothing was pushed.
What I found, in short:
- Verified good: the security floor now really is on
/events(confirmed live on the preview: CSP,nosniff,referrer-policy, HSTS besideno-store, stream still opens withretry:);bun testgreen atcc85971(85 pass); the three new tests are genuine — removing...SECURITYfails the floor test, stubbing thenotAnAddresscall fails both payload tests; net diff againstmainis exactly the three files, the-s ourssupersede brings no draft content. - The miss:
AGENTS.mdandsrc/live.tsstate the rule as "never … a name", yetsrc/actions.tsemits{ table, title }and{ table, player_name }, and the new test blesses those keys as addresses. I read the wire on a local run of this head and both travel to every open page, a guest's included; the 64-character structural check can never catch them (TITLE_MAXis 60). Fix asked for: carry{ table }alone in the three emits and set the test'saddressesmap to["table"]. - Noted smaller: a forge username over 64 chars would throw in a preview after a committed join;
notAnAddresschecks values but not key names; and the per-process bus still means asrc/tty.tswrite does not reach web viewers live — flagged as issue material beside #42/#43, not this pull's work.
The review was right and the fix is in one commit on top of the branch.
The rule this pull wrote down is now a fact about the app. The three emits in src/actions.ts carry { table } alone, and the addresses map in src/server.test.ts says ["table"] for all three, so the test no longer teaches that a name is an address. Nothing read the payload — static/live.js only ever touches event.detail.type — so the regions refresh exactly as before; read on a local run, the wire after opening a table and a join is now data: {"type":"table.opened","data":{"table":1}} and data: {"type":"seat.joined","data":{"table":1}}.
The two smaller notes are in the same commit, a line each. notAnAddress holds keys to ID_MAX as well as values, so { "<a paragraph>": 1 } is content written in the other column rather than a passing address. And emit now throws only where the app is being written (env === "local", which covers a local run and bun test) and logs-and-strips wherever someone is actually sitting at a table, a preview included: a 500 over the announcement of a write that already committed is as much a lie in a preview as in production. Dropping player_name removed the only way to reach that throw today, but the class is worth closing at the boundary. AGENTS.md states the rule as the app keeps it and now says plainly that the check is structural and cannot tell a short title from an id.
What to look at: curl -N .../events on the preview beside a table open, to see the address alone with the floor still on the response; and the three deletions in src/actions.ts, which are the whole behavioural change. Each new assertion was checked by reverting the code under it — dropping the key check fails the address test, putting title back on the bus fails two, and throwing in a preview fails the new preview case of the log-and-strip test.
Not done here: the per-process bus, so a write from src/tty.ts still does not reach web viewers live. That contradicts a promise AGENTS.md makes in writing, so it is opened as #64, blocked by #42 — a cross-process bus should carry the topic from the start rather than be rewired twice.
🤖 Generated with Claude Code
The live bus carries an address and never content: emit validates its payload with notAnAddress — an object whose keys and values are numbers, booleans or strings of at most 64 characters, nothing nested — and the app's three emits each carry { table } alone, so the client is told something changed here and the action decides what that viewer may see when it refetches.
The preview check failed on one test, and the test was wrong rather than the code. emit takes its behaviour from FORGE_APP_ENV: a local run throws, because that is the cheapest place to learn a payload carries content, while a preview or production logs the breach and sends the address alone, because there the write has already committed and a 500 over the announcement of it would be a lie. The test asserted the throw in-process, on the assumption that a test run is a local run — but the forge runs bun test with the deployment's environment, so on the preview the breach was dropped as designed and the assertion failed.
All three environments now go through one emitting(where) helper that asks a separate process and reports its exit code, stdout and stderr: local exits non-zero with the reason and prints nothing, production and preview exit 0 with { id, type } and the breach on stderr. The suite therefore gives the same answer wherever it is run from. The pure-rule test for notAnAddress is unchanged, and AGENTS.md no longer claims bun test throws — it says why a test about these two halves asks for an environment instead of assuming the one it runs in. Worth a look: src/server.test.ts:343 for the helper and the local case, and the production/preview table just below it. bun test passes with no environment, with FORGE_APP_ENV=preview and with production (87 pass, 0 fail).
🤖 Generated with Claude Code
Pushed.
bun testis green (85 pass) and the branch againstmainis three files.---
The payload rule, and only it:
emit(type, data)now validates what it is asked to carry, andGET /eventsanswers with the security floor it was missing.src/live.tsgainsnotAnAddress(data)— an address is an object whose every value is a number, a boolean or a string of at most 64 characters; nothing nested, nothing long — andemitrefuses anything else. It throws everywhere the app is being written (local, a preview, the tests), so a payload carrying content fails a pull; in production it logs and sends the address alone, because the write has already committed and a 500 over announcing it would be a lie about what happened. The stream's response now spreadsSECURITY; it keepsno-storeand noVaryat its own call site, since a stream of addresses is the same for everyone.Tests in
src/server.test.ts: the rule and the message it gives for each way of breaking it; the emits the app makes today (table.opened,seat.joined,seat.left) asserted by type and by the keys each carries, so the next emit fails there until someone has said it is an address; the production half, run in a child process withFORGE_APP_ENV=production, asserting the event arrives without its payload and the reason reaches the log; and the floor on the stream's headers.AGENTS.mdstates the rule beside theemitline it governs.What I did not do, per the rewrite: nothing about topics (#42), nothing in
static/(#43), and no change tosrc/actions.ts.table.starteddoes not exist onmainyet, so the emit test covers the three that do and fails on any fourth until it is listed. Worth a reviewer's eye:table.openedcarriestitleandseat.joinedcarriesplayer_name— both short strings that pass the structural rule, both already public, but they are the closest thing on the bus to content; whether they belong there is a question for whoever next openssrc/actions.ts.The branch was the parked draft of #30; it is now today's
mainplus this change, with the draft merged in as a parent (-s ours) so its history survives and the push stays fast-forward — no force. To try it: open a table on the preview with/eventsopen in another tab and watch an id arrive with no state attached;curl -i .../eventsshows the floor.🤖 Generated with Claude Code
Closes #23