jevstrudel.git / worker / CLAUDE.md
CLAUDE.mdpreviewCLAUDE.mdsource97 lines · 19.9 KB · raw

For agents, on top of README.md, which they read first.

Notes for agents

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.

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.

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/).

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.

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.

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.

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.

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.

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.

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):

  • 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
  • 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
  • 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.
  • No workerd issue: the crash was wrangler's Node process, not workerd.

Why 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.

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.

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.

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.

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.

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).

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.

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.

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.

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.

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.

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.

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.

Tests are outside the type check. tsconfig.json excludes *.test.ts (vitest's types are not in the check); vitest runs them.

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).

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.

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.

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.

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.

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.

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.

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.

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).

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").

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.

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.

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).

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.

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.

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).

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.

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.

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.

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 LIMITed 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.

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.