1@README.md 2 3## Notes for agents 4 5**Never give production the dev hub.** The `HUB` binding belongs in `env.dev`'s `durable_objects` only. It drives any tab remotely with no sign-in and plays in the page; a top-level binding would publish that. The top level binds `BUDGET` (the per-user daily budgets), `PARTY` (listening parties, public on purpose), `TAB_HUB` (each user's own tabs, reached only with that user's OAuth token: the hosted MCP, public on purpose) `LOBBY` (who is listening to what, public on purpose) and `PARTY_DIRECTORY` (which parties are live, for the data tab: counts and songs, never a room's name); `votes.test.ts` checks both lists. 6 7**A new Durable Object class needs a new migration tag.** Append `{ "tag": "vN", "new_sqlite_classes": [...] }`; never edit a tag that has deployed (`v1` Hub has; `v2` is `JevBudget`, which the Votes object once held before it became a D1 table without deploying; `v3` is `Party`; `v4` is `TabHub`; `v5` is `LobbyRoom`; `v6` is `PartyDirectory`; the next is `v7`). Cloudflare skips any tag it has recorded, so a reused tag silently creates nothing and the deploy fails on the missing class. The deploy applies it. A Durable Object is for live coordination only (see Storage in README.md); records go in D1. 8 9**D1 changes are migrations, never hand-run SQL.** New tables and columns go in `migrations/` (its CLAUDE.md has the rules: next number, `STRICT`, compatible with the live Worker). Test the SQL against `testD1()` (`test/`). 10 11**No resource ids in `wrangler.json`.** D1 and KV bindings name their resource and leave the id out; the deploy finds or creates it by name and writes the id into its temporary copy only (`tools/deploy/resources.mjs`). An R2 binding's `bucket_name` is a name, and the deploy creates the bucket if the account lacks it. A new D1 database, KV namespace or R2 bucket needs nothing else: add the binding (both environments) and the deploy provisions it. 12 13**Telemetry is an event, declared first.** Add the event (or a field, at the next free column) to `src/events-schema.ts`, then call `event()`; never call `writeDataPoint` directly, and never move, rename onto another column, or reuse a column that has deployed, since old rows keep its meaning for three months. What is allowed in an event is what is allowed in a log line (below: no visitor). A number that can be unknown is `null`, which the known-mask in double1 records; never a sentinel like -1. 14 15**The answer cache keys on the exact forwarded bytes, and nothing else.** Never normalise the body (parse and re-stringify, sort keys) before hashing, never drop the model from the key, and never add anything of the caller's to it: the first two would hand one request another's answer, the last would put a visitor into KV. If the relay ever forwards something besides the body that changes the answer (a header, a query), it goes into the key. Only a 200 is kept; `relay.test.ts` holds all of this. 16 17**`assets` is inherited by `env.dev`, and needs a directory.** Production's `assets: { binding: "ASSETS" }` gets its directory from the deploy's `--assets` (and preview's); `tools/dev/dev.mjs` passes an empty one for dev. Anything that runs `wrangler dev` on this config must pass `--assets`, or wrangler refuses to start. 18 19**Bindings are per environment.** `ratelimits`, `durable_objects`, `d1_databases` (and every other binding) are not inherited by `env.dev` (wrangler's schema says so), so a new one goes in both places when both need it. 20 21**`compatibility_date` is capped by the local runtime.** Each wrangler ships a workerd that supports dates up to its own build date: 4.132.0's workerd 1.20260915.1 takes dates to 2026-09-15 (4.129.0's took 2026-09-10 locally, 2026-09-24). A later date makes `wrangler dev` fail to start. Production uses the same date so local and deployed behaviour match. 22 23**wrangler comes from `nixpkgs-wrangler`, not the nixpkgs pin** (`flake.nix`, 2026-09-25). The pin's 4.129.0 killed `wrangler dev` whenever a client dropped a request mid-flight (a headless tab closing): the process exits with an empty `✘ [ERROR]`, or with `kj::getCaughtExceptionAsKj() … write(): Broken pipe`, and every later request is refused until `supervise.mjs` restarts it. Measured here: 30 POSTs aborted mid-upload (`curl -m 0.3 --limit-rate 20k`) plus 15 aborted GETs killed 4.129.0 in 2 of 2 runs and 4.132.0 in 0 of 2, which instead logs `Error inside ProxyWorker (the affected request failed; the dev server continues)` per dropped request. Sources (official repo, read 2026-09-25): 24- workers-sdk#15202, "wrangler dev workerd exits on EPIPE while writing captured stdout" (the exact broken-pipe line), fixed by #15323 "Ignore EPIPE from closed output consumers": https://github.com/cloudflare/workers-sdk/issues/15202, https://github.com/cloudflare/workers-sdk/pull/15323 25- workers-sdk#14926, "wrangler dev exits instead of recovering" on a lost forwarded request, fixed by #15252: https://github.com/cloudflare/workers-sdk/issues/14926, https://github.com/cloudflare/workers-sdk/pull/15252 26- Both shipped in wrangler 4.129.1 (2026-09-07), so every later version has them. Still open: #15447, a client abandoning a queued request (https://github.com/cloudflare/workers-sdk/issues/15447, fix PR #15448); on 4.132 it fails that one request, as measured above. 27- No workerd issue: the crash was wrangler's Node process, not workerd. 28 29Why 4.132.0 and not 4.141.0: 4.132.0 is nixpkgs' own build (NixOS/nixpkgs#563926, from source with its workerd autopatchelfed, in cache.nixos.org), taken as a whole package from a second nixpkgs, so the pin and everything else stays put. 4.141.0 is not in nixpkgs; overriding the package to it means a new workers-sdk source and pnpm-deps hash and a from-source build, for no fix 4.132.0 lacks (4.130 to 4.141's changelogs have nothing else on disconnects; 4.141 only adds debug detail to failed proxied requests). The overlay applies to all of `pkgs`, so the devshell, the `worker-types` check and the deploy run the same wrangler. It throws once the pin's wrangler is as new: then delete the input and the overlay. 30 31**`JEV_DAILY_PER_USER` is the user's number.** It is in `wrangler.json`'s `vars`, twice (top level and `env.dev`; `budget.test.ts` checks they agree and that it is a positive whole number). Don't change it to make something pass; for a small budget in a local test, pass `--var JEV_DAILY_PER_USER:3` to `wrangler dev`. 32 33**A session is a hash in D1, never a token.** `sessions.id_hash` is the SHA-256 of the cookie's token; the token exists only in the browser's `HttpOnly` cookie and the one `Set-Cookie` that sends it. Never log, store or return it, nor the user's id, in a log line or event: signed-in relay calls log exactly what anonymous ones do. 34 35**Challenges are taken, not read.** A verify gets its challenge only through `takeChallenge` (`DELETE … RETURNING`, with the expiry in the `WHERE`), before any signature is checked, and refuses one issued for another ceremony. Reading it first and deleting after would let two verifies race on one challenge. 36 37**The budget charges only what reaches TypeSafe.** The relay charges after validation and after the cache misses, just before it forwards; a refusal or a cached answer costs nothing. A budget that cannot be counted (the object failing) refuses the call rather than forwarding it uncharged. 38 39**The Worker's dependencies come through pnpm and `strudel.worker`.** `worker/` is a workspace package (`worker/package.json`, the root lockfile); `pnpm install` at the root installs them for dev, the tests and `//:preview`. The deploy and the `worker-types` check copy `strudel.worker` (`nix/worker-closure.mjs`), never `./worker`, which has no `node_modules` in the store. A new dependency must run in workerd without `nodejs_compat` (this Worker has none): check it imports no Node built-ins, then rehash the pnpm deps (`nix/CLAUDE.md`). 40 41**Keep the relay a narrow gate.** It spends the user's key for anyone who finds it. Widen `whyNot` in `relay.ts` only for a question shape `.jev()` actually sends, and extend `relay.test.ts` with it. 42 43**Never read the key with `op-env-run -- printenv`.** 1Password masks secrets in anything a command prints, so the key captured is `<concealed by 1Password>` and TypeSafe answers 401 (2026-09-24). Run the consumer inside `op-env-run` instead, as `tools/dev/worker.mjs` and the flake's deploy do. 44 45**`env.ts` is the Env, not `wrangler types`.** Generate with `--include-env=false`. The generated Env would make `HUB` required, but production has no hub, and `index.ts` must check for it; hand-writing `HUB?` keeps that check honest. 46 47**The party room's rules live in `party-room.ts`, not the Durable Object.** `party.ts` only accepts sockets and hands each event to `Room`, so the rules run in plain vitest (`cloudflare:workers` does not load in Node). Keep new rules there, with a test in `party-room.test.ts`. Its storage holds the current performance only, deleted when the room empties: never make it keep anything past the party (that is D1's job). It reports its people and song to `PartyDirectory` through `Room`'s `changed` callback; the directory is keyed by the room's object id (`ctx.id`), never the room's name, which is the party's join link. The directory publishes the id only for a party whose host chose listing, and the name comes only from the room itself (`Party.listedRoom`, while it has people and is listed): never copy a room's name into the directory, the data routes or a log, and never take `X-Jev-Party-Room` from anything but `party()`, which sets it. The one other way a room learns its name is a host's `listed` message, kept only once `ownName` proves it (`idFromName(name)` is this object, `party.ts`); never store a name without that proof. 48 49**The lobby's rules live in `lobby-room.ts`, and it keeps nothing.** As with the party room, `lobby.ts` only accepts sockets. A tab's status lives in its socket's attachment and goes with it: never give the lobby storage, and never log who listened to what. Who a tab is (its name, account id and group) is set by the route from the session, never taken from a message; a hidden tab is sent to nobody; a party's room id goes only to the tab that asked for it, and only from the tab it asked (`asked`), so no tab can send another into a party. Keep every field a page sends checked in `statusOf`, with a test in `lobby-room.test.ts`. 50 51**A party guest never trusts the host.** The room passes decisions on as sent; each guest's jevCore checks every value against its own song (`followDecisions` in `website/src/jev/jevCore.mjs`), and guests play only songs from their own build, by id, never code from the room. Keep it that way: a host is whoever made a room. 52 53**Refusing a party socket is a 101, then a close.** A browser cannot read why an upgrade was refused and would retry forever, so `Party.fetch` accepts the socket outside the room, sends `refused`, and closes with `4000 + status`; `party.mjs` does not retry those. 54 55**Tests are outside the type check.** `tsconfig.json` excludes `*.test.ts` (vitest's types are not in the check); vitest runs them. 56 57**Who played a take comes from the session, never the body.** `listening.ts` reads the player with `signedIn` (through `Listeners`, which tests stub) and `whyNotPerformance` refuses any field it does not know, so a page cannot name a player or a time. A listener song's take (`listener:<id>`) is accepted only while that song is public (`isPublicSong`). 58 59**Listening has two rate limits of its own.** `LISTEN_LIMIT` (reactions, a song's tally, reading a performance) and `RECORD_LIMIT` (storing one), sized in `listening.ts`, apart from the relay's and the votes'. `RECORD_LIMIT` times the body cap is what one visitor can write a minute: raise either only with that arithmetic redone. 60 61**A performance is two statements, whatever its length.** `listening-store.ts` inserts every segment row from one JSON parameter through `json_each`. Don't turn it into a statement per row: D1 allows 100 bound parameters a statement and, on the free plan, 50 queries a request. 62 63**Votes have their own rate limit.** `VOTE_LIMIT` (sized in `votes.ts`) is apart from `JEV_LIMIT` so voting never spends a song's Jev calls. Both limits are found by name, not position, in `ratelimits`. So is `PARTY_LIMIT` (joining a party, sized in `party-room.ts`, namespace 1006), and listeners' content's `CONTENT_LIMIT`, `COVER_LIMIT` and `BROWSE_LIMIT` (sized in `content.ts`, namespaces 1007 to 1009), and the hosted MCP's `MCP_LIMIT` (sized in `hosted-mcp.ts`, 1010), and the lobby's `LOBBY_LIMIT` (joining it, per visitor, `lobby.ts`, 1011), and the data tab's `DATA_LIMIT` (reads, per visitor, `data.ts`, 1012; the next is 1013). The MCPs' `vote` is held to `VOTE_LIMIT` too, keyed by the account. 64 65**Writes come only from this site's own pages.** `index.ts` refuses every request but GET/HEAD whose `Origin` is neither absent nor this site's (`refuseCrossOrigin`, `session.ts`), before any route runs, and `signedIn` returns null for such a request so no cookie is ever taken as a session off-origin. This is what makes the listener-song sandbox safe: its iframe has an opaque origin, so its calls arrive with `Origin: null` and are treated as anonymous and per-address, never as the signed-in visitor (`website/src/jev/sandbox.mjs`; `relay.test.ts`, `session.test.ts`). The per-route `Origin` checks that predate it stay as defence in depth. Never add a write path that trusts a cookie without this gate. 66 67**Listeners' content is never public before its verdict.** New content is inserted `pending`, and the schema refuses anything else (`*_born_pending` triggers) and refuses changing a verdict (`*_verdict_final`). Every public read filters `screen = 'fine'` (a listener song through its newest fine revision; a cover also needs its song public). Don't add a read that skips the filter, a write that sets `screen` outside `recordScreen`, or an "approve" path: a held author publishes again and is screened again. 68 69**The owed work is the schema's, not the code's.** `content_jobs` rows are made and deleted by the triggers in `0004_listener_content.sql` as items' `screen` and `art` change; code only claims and reschedules them (`content-store.ts`). Never insert or delete a job by hand, and never give up on one: a job stays until its verdict or score is written, whatever its attempts. A new kind of content gets its own `*_screen_owed`, `*_screened` and `*_gone` triggers in a new migration. 70 71**The critic's runs must not share an answer.** `content-jobs.ts` asks TypeSafe through `upstream()` in `relay.ts`, never through the relay's answer cache: three identical score requests would get the first one's answer three times. Screening skips it too, so a verdict is always fresh. 72 73**`art-rubric.mjs` is the one rubric.** The Worker scores listeners' songs with it, and `website/src/jev/critic.mjs` re-exports it for the site and `tools/critic`. It is plain JavaScript without imports because Node, Vite and the Worker all load it; `tsconfig.json`'s `allowJs` type-checks its callers against it. Changing it changes every score's meaning (bump `RUBRIC`, rescore). 74 75**The code check is a first check, not a sandbox.** `whyNotCode` in `screen.ts` must keep passing every site song (`screen.test.ts` runs them all); add a name only after checking that, and never present it as what makes a listener's song safe (README.md, "A listener's song runs in the page"). 76 77**A cron run stays inside 50 subrequests.** `SWEEP_BATCH` (6) times a job's at most six calls (content, budget, three TypeSafe runs, result) plus the claim is 37; the free plan allows 50 per invocation, D1 and Durable Object calls included. Redo that arithmetic before raising it or adding a call to a job. 78 79**The rate limit is sized, not picked.** Its value is in `wrangler.json`, written twice (`env.dev` inherits no bindings); its arithmetic is in `relay.ts`, which reads the value from the config. `website/src/jev/allSongs.test.mjs` measures every song's calls a minute and fails one over half the limit, and checks the two copies agree. A faster song or a new per-section question means redoing the arithmetic, not raising the number until the test passes. 80 81**A token reaches its own user's tabs and nothing else.** The hosted MCP names the hub from the token's props (`TAB_HUB.idFromName(props.userId)`), never from anything in the request, and a tab joins a hub only through `/jev/me/tabs` with the session cookie from this site's pages. Never take a user, hub or tab id from an MCP tool's arguments beyond picking among that user's own tabs (`pickTab`). 82 83**The AI's code never runs in the page.** A hosted MCP `play` reaches the tab as `{ type: 'play', code, app }`, and the tab plays it only as foreign code in the sandbox (`website/src/jev/myTabs.mjs`). Never give the hosted MCP a command that evaluates anything it sent in the page, or sets code it sent as the page's own (`setOwnCode`). The one path into the page is `play-song` (and `ask-song`'s play) with a site song's id, which the tab looks up in its own build and plays as a click on its card does: never widen it to take code, or a song the page did not build. What it may read is the editor, its logs, the console tab, its status, and the sounds and settings tabs as data; nothing else of the page's. The dev hub (`mcp.ts`) plays in the page because it is the repo's own Claude on localhost; keep the two apart. 84 85**A shared tool is defined once.** `shared-tools.json` is both MCPs' (with its `scope` for the hosted one), `shared-mcp.ts` runs it, `tabTools.mjs` answers it in the tab. Don't copy one into `tools.json` or `hosted-tools.ts`, and give every tab reply field a bound in `rebuildReply` (`tab-hub-core.ts`) before a tool reads it; the dev hub's replies go through it too. 86 87**`mcp-settings.mjs` is an allowlist.** A setting reaches a tab only if it is listed there, checked in the Worker and again in the tab. Add one only if it changes how the editor looks or types: never `prebakeScript` (it runs in the page), sync, evaluation, or audio routing (the file lists why each is out). 88 89**The OAuth pages keep `Referrer-Policy: same-origin`.** Under `no-referrer` Chrome sends the consent form's POST with `Origin: null`, which `refuseCrossOrigin` refuses, and nobody can connect (2026-09-26). Their CSP's `form-action` names the client's redirect origin, since Chrome checks the redirect after the form against it. 90 91**One OAuth library, its KV, no hand-rolled tokens.** Clients, grants and tokens are `@cloudflare/workers-oauth-provider`'s, in `OAUTH_KV`; revoke through `revokeGrant`, never by deleting keys. CIMD needs the `global_fetch_strictly_public` compatibility flag (top level, inherited by `env.dev`); without it the library stops advertising CIMD and Claude falls back to registering a client per connection. In vitest the library is inlined (`vitest.config.mjs`) so its `cloudflare:workers` import resolves to `test/cloudflare-workers.ts`. 92 93**`hosted-tools.ts` imports nothing at runtime but `shared-tools.json`.** The site's `/ai/` page imports it at build time to list the tools; an import of the OAuth library or a Durable Object there would break the site's build. 94 95**The data tab shows everything but secrets; keep the secrets out.** `src/data.ts` is public by design (README.md, What is public). The activity feed (`src/activity.ts`) is part of it: every query in it stays `LIMIT`ed and `WHERE t < before`, and a comment's or pitch's text is there only when screened fine. A read there never selects a session's `id_hash`, a credential's `id` or `public_key`, an `auth_challenges` value, anything from `OAUTH_KV` but key names (counted), a cache key, or a party's room name or host hash; `data.test.ts` checks every answer against each. A new store or secret column goes into both. Held content is served as text only: never add a data route that serves a cover's bytes or plays code. 96 97**Logs carry no visitor.** The relay's log line is timing, sizes and status only: never the address, the rate-limit key, the body, or a header as sent (the page's timing headers are parsed to bounded numbers first). `invocation_logs` stays `false` in `wrangler.json`, since Cloudflare's invocation logs record each request's metadata. `relay.test.ts` checks a line, and the event written beside it, never contains the caller's address or the question.