1# worker 2 3The jevstrudel Cloudflare Worker, in TypeScript. Cloudflare serves the 4static site's files itself; the Worker answers the paths that are not files. 5 6| File | What it is | 7|---|---| 8| `src/index.ts` | The routes. | 9| `src/relay.ts` | `POST /jev/v1/systemone`: the Jev relay. TypeSafe's API refuses calls from web pages, so the page calls this (`.jev()`, the mood picker, the radio and "is it art?"), and it forwards them with the site's own key. It forwards only TypeSafe's question shapes (choice, score or noul) within TypeSafe's own limits, one pinned model, at most 64 KiB, at a per-visitor rate sized in `relay.ts` (the limit itself is in `wrangler.json`). Every call logs one line (see Logs below). A request byte for byte the same as one answered in the last hour is answered from the cache instead (`src/answer-cache.ts`). | 10| `src/votes.ts` | `POST /jev/votes` and `GET /jev/votes/summary`: the "do you agree with Jev?" votes (`website/src/jev/agree.mjs`). A vote is `{ a, b, pick }`, two of the deploy's own songs (its `/jev/songs.json`) and one of them, at a per-visitor rate of its own (`VOTE_LIMIT`, apart from the relay's). It is kept only as a count per (a, b, pick): no time, address or visitor. The summary returns the counts, for `nix run .#agreement` (`tools/agreement/`). | 11| `src/votes-store.ts` | The votes' storage: the `votes` table in the site's D1 database. | 12| `src/auth.ts` | `/jev/auth/*`: accounts, signed in with passkeys (see Accounts below). | 13| `src/accounts-store.ts` | The accounts' storage: users, credentials, sessions and challenges in D1. | 14| `src/session.ts` | The session cookie, and the random ids, tokens and challenges accounts use. | 15| `src/budget.ts`, `src/budget-counter.ts` | `JevBudget`, the Durable Object counting one signed-in user's relay calls per UTC day, and its counting (apart, so tests run it on Node's SQLite). | 16| `src/radio.ts` | `GET` and `POST /jev/me/radio`: a signed-in listener's radio history, in D1. | 17| `src/listening.ts` | Listening: `POST /jev/reactions` and `GET /jev/reactions?song=` (every listener's 🔥/😴, a count per song, section and reaction, anonymous), and `POST /jev/performances` and `GET /jev/performances/<id>` (a performance: one play of a song as Jev arranged it, per jev() per segment what played, under a random 22-character id, for a replay link; with who played it, from the session cookie and never the body, null when signed out, and when, to the millisecond). A song is the deploy's own or a listener's public one (`listener:<id>`, recorded from the page's sandbox, `website/src/jev/JevPerformance.jsx`). `GET /jev/performances?song=` lists a song's performances a page at a time, each with its path through the form, and `GET /jev/performances/moves?song=` says how Jev moves through it over its latest 500 (per section: plays, fallbacks, answer times, and the sections picked next with their shares); both for the song view's takes (`website/src/jev/JevTakes.jsx`), cached a minute. Validates shape and size (64 KB) and that the song is the deploy's or a public listener song; its own two rate limits, `LISTEN_LIMIT` (reactions and reads) and `RECORD_LIMIT` (storing), sized in the file. | 18| `src/listening-store.ts` | Listening's storage in D1: `reactions`, `performances`, and `performance_segments`, a row per segment per jev, which is also Jev's decision history (`nix run .#jev-history`, `tools/jev-history/`). A performance is written in one batch of two statements. | 19| `src/events.ts`, `src/events-schema.ts` | Telemetry: `event(env, name, { blobs, doubles })` writes one event to Analytics Engine; the schema says what each event's columns mean (see Storage). | 20| `src/answer-cache.ts` | The relay's answer cache in KV: key, lookup, and keeping a 200 for an hour. | 21| `src/party.ts`, `src/party-room.ts` | `GET /jev/party/<room>`: listening parties; and `GET /jev/parties[/<id>]`, the live ones and joining a listed one (see Listening parties). A WebSocket into the room's Durable Object (`Party`, one per room), which passes the host page's performance (its song, its start, every Jev decision) to its guests and counts everyone's reactions (see Listening parties). `party-room.ts` is the room's rules as plain code; `party.ts` the Durable Object that holds the sockets. | 22| `src/lobby.ts`, `src/lobby-room.ts` | `GET /jev/lobby`: who is listening to what. A WebSocket into the site's one `LobbyRoom` Durable Object, which tells every page what every visible tab plays and how far through it is, and brokers listening along (see The lobby). `lobby-room.ts` is its rules as plain code; `lobby.ts` the route, which says who the tab is, and the object that holds the sockets. | 23| `src/data.ts` | `GET /jev/data[/*]`: everything the site stores, for anyone to read (see What is public), for the jev panel's data tab (`website/src/jev/DataTab.jsx`); an account's page is also its profile (its public songs), and `/jev/data/activity` the activity tab's feed. | 24| `src/activity.ts` | The activity feed: songs published and revised (with Jev's verdict and score), comments, pitches and performances, newest first, merged from five bounded queries and paged by time (see What is public). | 25| `src/party-directory.ts` | Which listening parties are live: the rules of `PartyDirectory`, the one object every party room reports its people and song to (keyed by the room's object id, never its name), since rooms cannot be listed. | 26| `src/content.ts` | `/jev/listeners/*`: listeners' content (see Listeners' content): songs signed-in listeners publish, with revisions and a cover, comments on any song, and pitches. Public to read, signed in to write; only what Jev screened fine is shown. | 27| `src/content-store.ts` | Its storage in D1: `listener_songs`, `song_revisions`, `covers`, `comments`, `pitches`, and `content_jobs`, the work Jev still owes. | 28| `src/content-jobs.ts` | Running that work: screening each item, scoring each fine song revision, and rescheduling what cannot run now; the cron trigger sweeps it every minute. | 29| `src/screen.ts` | The screening question (a Jev choice: fine, spam or abuse) and `whyNotCode`, the first check on a song's code. | 30| `src/art-rubric.mjs` | The art critic's rubric, shared: the Worker scores listeners' songs with it, and the site (`website/src/jev/critic.mjs`) and `tools/critic/` score the site's own. | 31| `src/hosted-mcp.ts` | `POST /jev/mcp`: the hosted MCP for everyone's own AI, acting for one listener with an OAuth token (see The hosted MCP). | 32| `src/hosted-tools.ts` | Its scopes and tool definitions, importing only `shared-tools.json`, so the site's `/ai/` page lists exactly what it offers. | 33| `src/shared-tools.json`, `src/shared-mcp.ts` | The tools both MCPs offer (see The tools both MCPs share) and running them: a command to the tab, or no tab for the reference and a vote. | 34| `src/mcp-settings.mjs` | The settings tab's settings an MCP may read and change, and the checks; plain JavaScript, shared with the tab (`website/src/jev/tabTools.mjs`). | 35| `src/reference.ts` | The function reference for `api_reference`, Strudel's and jev's (`website/src/jev/jevCore.mjs`'s jsdoc): `/jev/reference.json`, which the build writes, searched (any of the query's words, a name before a mention) and described. | 36| `src/primer.ts` | How to write a song with Jev, which the hosted MCP's instructions end with, and the complete song it teaches with (played in `website/src/jev/reference.test.mjs`). | 37| `src/oauth.ts` | OAuth 2.1 for it (`@cloudflare/workers-oauth-provider`): discovery, token, registration, the authorize page (passkey sign-in and consent), connected apps (`/jev/me/apps`), and a tab joining its user's hub (`/jev/me/tabs`). | 38| `src/tab-hub.ts`, `src/tab-hub-core.ts` | `TabHub`, one Durable Object per signed-in user holding that user's open tabs, and its rules as plain code (which tab, what a reply may hold, sizes). | 39| `src/catalog.ts` | The site's own songs as the hosted MCP lists and reads them (`/jev/catalog.json`, `/jev/catalog/<theme>/<song>.json`, which the build writes), and `siteFile`, how the Worker reads any file the build writes. | 40| `src/mcp-rpc.ts` | MCP's JSON-RPC over HTTP, shared by both MCPs: both protocol eras (`initialize`, and 2026-07-28's per-request `_meta` with `server/discover`), stateless. | 41| `src/hub.ts` | The dev MCP hub, a Durable Object holding every open REPL tab's WebSocket (`/strudel/ws`); dev only. | 42| `src/mcp.ts` | `POST /mcp`: the dev hub's MCP; its tools become commands sent to a tab through the hub. | 43| `src/tools.json` | The dev MCP's own tool definitions; it offers `shared-tools.json`'s after them. The stdio proxy in `tools/mcp/` reads both while the Worker is down. | 44| `migrations/` | The D1 database's schema, as numbered SQL migrations (see Storage). | 45| `test/` | Test helpers: `testD1()`, the D1 database in memory with every migration applied, `memoryKV()`, `memoryR2()`, `memoryBudgets()`, `memoryTabHubs()`, a passkey `authenticator()` in software, and `cloudflare:workers` stand-ins for Node. | 46| `wrangler.json` | The config. The top level is production; `env.dev` adds the dev hub, and reads the song list from the dev server. Both bind the party rooms, the tab hubs and the lobby. | 47| `package.json` | The Worker's own dependencies (`@simplewebauthn/server`, `@cloudflare/workers-oauth-provider`); `worker/` is a pnpm workspace package, installed with the rest. | 48| `tsconfig.json` | For `tsc`: the sources against the Workers runtime types. | 49 50## Two environments 51 52Production (`nix run .#deploy`, `--env=""`) is the site plus the relay, the 53votes, accounts, listening, listening parties, listeners' content and the 54hosted MCP (`/jev/mcp`, for everyone's own AI, behind OAuth). It has no dev 55hub, so `/mcp` and `/strudel/*` answer 404 and the public page never turns 56the dev MCP on. The votes read the deploy's song list 57through the `ASSETS` binding, so a vote is only ever for a song that deploy 58has. 59 60Development (`buck2 run //:dev`, `wrangler dev --env dev` on port 4323) 61adds the dev hub, so Claude Code in this repo can play into your local tab 62without signing in (its code is the page's own); the hosted MCP runs there 63too, at `http://localhost:4322/jev/mcp`. The dev server 64(`localhost:4322`) proxies the Worker's paths to it, and wrangler reloads 65the Worker on every save, tool changes included. There the votes read the 66song list from the dev server itself (`SONGS_URL`), so a new song is 67votable on reload; `dev.mjs` gives the inherited `assets` an empty folder, 68since wrangler requires one. 69 70## Storage 71 72Each kind of data lives where its shape fits, so a new feature picks by 73the table below rather than by what the last one used. 74 75| What | Where | Binding | Why there | 76|---|---|---|---| 77| The site's records: votes, accounts (users, passkeys, sessions, sign-in challenges), radio history, listening (reactions, performances, Jev's decision history), and listeners' content (songs, revisions, covers' records, comments, pitches, and the screening and scoring Jev owes them) | D1, one database `jevstrudel` | `DB` | Relational, queried across rows, one copy for every Worker. SQLite, so tests run the real SQL (`test/d1.ts`). | 78| Live coordination: the dev MCP hub (dev only), each signed-in user's daily Jev budget, listening-party rooms, each signed-in user's open tabs for the hosted MCP, the lobby, the directory of live parties | Durable Objects | `HUB`, `BUDGET`, `PARTY`, `TAB_HUB`, `LOBBY`, `PARTY_DIRECTORY` | One instance per room or user, holding sockets or a strictly ordered counter. Not for records that outlive the room. | 79| Telemetry: what happened, how often, how fast (the relay's calls, …) | Workers Analytics Engine, dataset `jevstrudel_events` | `EVENTS` | Written without waiting, sampled under load, queried with SQL by time; kept three months. Not for anything read back by the site. | 80| Caches: Jev's answers to a request already asked, for an hour | Workers KV, namespace `jevstrudel-cache` | `CACHE` | Read far more than written, fine to be a minute stale across data centres, expiring by itself. Never the only copy of anything. | 81| The hosted MCP's OAuth: registered clients, grants, tokens | Workers KV, namespace `jevstrudel-oauth-kv` | `OAUTH_KV` | Where `@cloudflare/workers-oauth-provider` keeps them (it takes only KV): tokens, codes and client secrets as hashes, a grant's props encrypted by its token; a grant's user id, app name and redirect host in the clear, for listing and revoking a user's apps. Every record expires by itself (tokens in an hour, a grant 30 days after its last use). | 82| Files: listener songs' cover images | R2, bucket `jevstrudel-covers` | `COVERS` | Bytes, not rows: up to 2 MiB each, served whole. Each has its record in D1, which says whether it may be served. | 83 84The database's schema is `migrations/` (`0001_votes.sql`, …). Every place 85that runs the Worker applies them first: `//:dev` and `//:preview` to their 86local databases on start (`tools/dev/migrate.mjs`), and the deploy to the 87real one before it publishes (`tools/deploy/resources.mjs`, which also 88creates the database on an account that has none, and the R2 bucket). 89`wrangler.json` names the database and the bucket but holds no id: the 90deploy looks them up by name each time. 91 92To add a table, see `migrations/README.md`. To read the local data: 93 94 wrangler d1 execute jevstrudel --local --env dev --config worker/wrangler.json \ 95 --command "SELECT * FROM votes" 96 97from the repo root (`//:preview`'s is `--env "" --persist-to 98worker/.wrangler/preview`). The real one takes `--remote` in place of 99`--local`, through the deploy's wrangler, which reads the token from 1001Password. 101 102The relay's cache keys an answer by the SHA-256 of the exact body it 103forwards, under the pinned model, so only a request identical in every byte 104gets another's answer; it keeps only a 200, for an hour, and marks every 105answer `Jev-Cache: hit` or `miss` (`src/answer-cache.ts` has the reasoning). 106A hit is logged with `outcome: "cached"`, and TypeSafe is not asked. 107 108An event is declared in `src/events-schema.ts` (its fields, their columns, 109what they mean) and written with `event()` from `src/events.ts`, whose 110types allow exactly the declared fields. `nix run .#events` reads them back 111(`tools/events/`). Analytics Engine creates the dataset on the first write, 112so there is nothing to provision; `wrangler dev` keeps it local, so only 113the deployed Worker's events can be queried. 114 115Production can also run locally: `buck2 run //:preview` serves the deploy's 116own static build with the production Worker on port 4324 (`tools/dev/`). 117 118## What is public 119 120jevstrudel is a playground shared in the Jev community, and everything it 121stores is public: the jev panel's **data** tab (`website/src/jev/DataTab.jsx`) 122reads it through `GET /jev/data[/*]` (`src/data.ts`), which anyone may call. 123 124| Store | What anyone sees | What is never shown | 125|---|---|---| 126| Accounts (D1) | Every account: its id, display name and when it joined; its passkeys and sessions by date (and a passkey's transports and signature count) | A passkey's credential id and public key, a session's cookie token or `id_hash` | 127| Sign-in challenges (D1) | How many are in flight, by purpose | The challenges | 128| Radio history (D1) | Every account's plays | | 129| Listeners' content (D1) | Every song revision whole (code and spec as text), cover record, comment and pitch, public, pending or held, with Jev's verdict and confidence, the art score, and its author; every pitch vote, with who cast it and when, and the pitch each listener song answers | The image of a cover that is not public (only `/jev/listeners/covers/<id>` serves an image, and only a public one) | 130| Jev's queue (D1) | Every job: what it is for, whose budget pays, when it is due and why it waits | | 131| Listening, votes (D1) | Every reaction count and vote count; every performance, with Jev's decision history, when it was played and by which account (signed in) | | 132| Budgets (`BUDGET`) | Each account's Jev calls today, on its page | | 133| Tabs (`TAB_HUB`) | How many tabs each account's AI can reach, on its page | The tabs' ids | 134| The lobby, parties (`LOBBY`, `PARTY_DIRECTORY`) | Pages open, tabs listed and playing, and a signed-in tab's account id (its profile); each live party's song, people, whether its host is there, since when, and a listed party's object id | A party's room name (its join link) and its host key's hash; a listed party's name goes only to someone joining it (Listening parties) | 135| Answer cache (`CACHE`) | How many answers are kept | A key (a request's hash) or an answer | 136| OAuth (`OAUTH_KV`) | How many apps each account connected | A token, code, client secret or a grant's props; an app's name shows only to its own account, signed in | 137| Covers (R2) | How many images, and their bytes | A cover that is not public | 138 139An account's page (`/jev/data/accounts/<id>`) is also its profile on the 140jev panel: it adds `publicSongs`, the account's songs as the listeners' 141list shows them, and `takes`, its newest 50 performances (a listener 142song's with its public title), which the you tab's "recently played" and 143the profile's "played lately" merge with its radio plays. `/jev/data/activity?before=<ms>&limit=` is the activity 144tab's feed (`src/activity.ts`): what happened, newest first, at most 50 an 145answer, with `next` for the page before. Songs published and revised come 146with Jev's verdict and, once scored, a `scored` item with the art; comments 147and pitches with their verdict and their text only when public; and 148every performance as a `take` item: who played it (null signed out), the 149song (a listener song's public title with it), how many sections and how it 150ended, and its id for the replay link. It is cached for 15110 s, since everyone polls the same first page. 152 153The lists are paged (`offset`, `limit` up to 100) and `no-store`; reads 154are `DATA_LIMIT` (120 a minute per visitor, namespace 1012). The KV and R2 155counts are list operations, of which the free plan allows 1000 a day, so 156each isolate counts at most every ten minutes. Sign-up says it all plainly, 157on the site and on the OAuth page. 158 159## Accounts 160 161Listeners can sign in with a passkey (WebAuthn): no password, no email, no 162third party. Everything works as before without an account; signed in, a 163listener's Jev calls are charged to a daily budget of their own, and the 164radio remembers what it played for them across sessions and devices. 165 166| Route | What it does | 167|---|---| 168| `POST /jev/auth/register/options` | `{ displayName }`: options for `navigator.credentials.create()`, for a new account. Signed in and with an empty body: another passkey for your own account. | 169| `POST /jev/auth/register/verify` | The browser's registration response. A new account is created and signed in. | 170| `POST /jev/auth/login/options` | Options for `navigator.credentials.get()`, for any passkey this site has (discoverable credentials: no username). | 171| `POST /jev/auth/login/verify` | The browser's authentication response; signs in. | 172| `POST /jev/auth/logout` | Ends this browser's session. | 173| `GET /jev/auth/me` | `{ user, budget }`: who is signed in (or null), and today's `{ used, limit, resetsAt }`. | 174| `GET`, `POST /jev/me/radio` | The signed-in listener's radio history: the newest plays, or `{ song }` just started. | 175 176The ceremonies are verified by `@simplewebauthn/server`, which uses only 177Web Crypto and runs in workerd; passkeys must verify the user (PIN, face 178or fingerprint), since they are the only factor. The relying party is the 179host the request came to: RP ID `localhost` in dev, the workers.dev host in 180production. A challenge is issued into D1 and taken back by its verify with 181`DELETE … RETURNING`, so it is used at most once, and only within five 182minutes. A session is an `HttpOnly`, `Secure`, `SameSite=Lax` cookie 183holding a random token; D1 keeps only its SHA-256, for 30 days. The auth 184routes have their own per-visitor rate (`AUTH_LIMIT`). 185 186**The daily budget.** Each signed-in user has a `JevBudget` Durable Object 187(`BUDGET.idFromName(userId)`) counting the relay calls charged to them per 188UTC day. A call is charged when the relay is about to forward it to 189TypeSafe; an answer from the cache costs nothing and is not charged. The 190check and the charge are one SQL statement in an object that runs one call 191at a time, so a user's tabs and devices racing each other are counted 192exactly. Once the day's budget is spent, the relay answers `429` with 193`Retry-After` until 00:00 UTC and `Jev-Budget: spent`, so the page backs off 194and songs play their fallbacks; each charged answer says 195`Jev-Budget: <used>/<limit>`, which the header shows. The call that spends 196the last of it writes a `jev.budget.exhausted` event. 197 198The budget is `JEV_DAILY_PER_USER` in `wrangler.json` (`vars`, in both 199environments), 1000 calls: **the user's to set**. A song asks about one 200call a section, a couple a minute, so 1000 is several hours of listening. 201It must be a positive whole number; anything else and signed-in calls are 202refused rather than charged against a guessed number. 203 204Anonymous callers keep the per-address `JEV_LIMIT`, and are never charged. 205Signed-in callers are held to `JEV_LIMIT` too, keyed by their account 206rather than their address. 207 208## Listening parties 209 210A host's page starts a party from a song's view ("listen together") and 211shares `/songs/<theme>/<song>/?party=<room>`; the room id is 128 random bits 212the host's page makes. Every page in the party holds one WebSocket to 213`/jev/party/<room>`, which the Worker hands to that room's `Party` Durable 214Object (`idFromName(room)`). The page's side is 215`website/src/jev/party.mjs`. 216 217The protocol, JSON text frames: 218 219| From | Message | What it does | 220|---|---|---| 221| host | `run { song, code }` | A new performance: the song's id and a hash of its code. The room numbers it (`run`, echoed to the host) and forgets the last one's decisions, clock and reactions. | 222| host | `clock { run, startedAt, cps }` | When the host's scheduler started, in the room's clock (ms). | 223| host | `decision { run, view, segment, d }` | One decision a jev() settled: `view` is the jev()'s place in the song (declaration order), `d` jevCore's `shareDecision` (values, status, a late answer's cut, where the song ends). Guests check every value before playing it. | 224| host | `end { run }` | The host stopped. | 225| host | `listed { listed, room? }` | "Show me here" changed: the party leaves or joins the live list now (below); `room` is the room's own name, checked, when listing. | 226| anyone | `react { segment, kind }` | 🔥 (`fire`) or 😴 (`sleep`) for a section. The room counts it and sends everyone `reactions { segment, fire, sleep }`, the totals. | 227| anyone | `ping { c }` | Answered `pong { c, s }` with the room's clock, for the page's offset estimate. | 228| room | `welcome` | On joining: role, how many are here, whether the host is, and the current `run`, `clock`, `decisions` and `reactions`, so a guest joining mid-song has it all. | 229| room | `people { n, host }` | Someone joined or left. | 230| room | `refused { status, why }` | Joining was refused (no party here, full, another host's key); the socket then closes with `4000 + status`, which the page does not retry. | 231 232The first host connection claims the room with a key (kept in its tab's 233`sessionStorage`, so the host's reload rejoins as host); the room keeps only 234its SHA-256. A guest cannot join a room with nobody in it (`4404`: the party 235has ended). Limits: 50 people a room, 16 KiB a message, a burst of 30 236messages per connection refilled at 5 a second, sections below 64 and 237jev()s below 8, and `PARTY_LIMIT` (20 joins a minute per visitor) at the 238route. 239 240The room uses the Hibernation API, so a party between messages costs no 241duration. Its storage holds the current performance only, and is deleted 242when the last person leaves; a room found with storage and nobody in it is 243cleared by whoever arrives. Nothing about a party is logged or kept. 244 245Rooms cannot be listed, so each tells the site's one `PartyDirectory` 246(`src/party-directory.ts`) when its people or song change, and the data 247tab lists the live ones: song, people, whether the host is there, since 248when. The directory is keyed by the room's Durable Object id, a hash that 249cannot be turned back into the room's name, so a party stays unlisted to 250join; an entry goes when its room empties, or six hours after its last 251report (a room lost in a crash). 252 253**A listed party can be joined from the list.** The host decides, with the 254lobby's "show me here": a host whose tab is listed connects with 255`listed=1`, and the room then keeps its own name (the Worker's route hands 256the object its name in `X-Jev-Party-Room`, a header set by the Worker and 257never by the page) and reports `listed` to the directory. The directory 258gives a listed entry its object id, and `GET /jev/parties` lists them; 259`GET /jev/parties/<id>` asks that room (`PARTY.idFromString`) for its name, 260which it answers only while it has people and its host chose listing, and 261the page joins as a guest, exactly as with a link. The object id is the 262token rather than a new secret: it cannot be turned back into the name, the 263room answers for itself (so an ended or unlisted party gives nothing, with 264no second copy of the name to go stale), and an unlisted party's room keeps 265no name at all. A host's choice is read when it connects, and again the 266moment it changes: the host's page sends `listed { listed, room? }`, and the 267room lists or unlists itself and reports to the directory at once, guests 268staying in. To list itself mid-party the room needs its name, which it takes 269from that message only after proving it its own 270(`PARTY.idFromName(room)` equals the object's id), so the page still cannot 271give a room a name that is not its. Both routes are GET 272only, under `PARTY_LIMIT`. 273 274## The lobby 275 276Every open page holds one WebSocket to `/jev/lobby`, the site's one 277`LobbyRoom` Durable Object (`idFromName('lobby')`), and says what its tab is 278doing; the jev panel lists everyone else's tabs, grouped by who, each with its 279song, whether it plays, and how far through it is, and "🎧 listen along" 280(`website/src/jev/lobby.mjs`, `Lobby.jsx`). The header's 👥 is how many 281pages are open. 282 283**Who a tab is comes from the Worker.** The route reads the session 284(`signedIn`) and hands the object the account's display name, its id (for 285its profile; accounts are public, What is public) and a group key (the 286first 8 bytes of SHA-256 of `lobby:` and the account's id); signed out, no 287name or id, and the tab is its own group, which pages show as "a 288listener". The page never says 289who it is. The user's call (2026-09-27) was a more social site, so a tab is 290**listed by default**; "show me here" on the jev panel hides it (kept per 291browser), and a hidden tab is sent to nobody, only counted in the total. 292 293The protocol, JSON text frames: 294 295| From | Message | What it does | 296|---|---|---| 297| room | `welcome { id, now, tabs, n }` | On joining: this tab's id, the room's clock, the visible tabs (the 200 most recently changed) and how many pages are open. | 298| page | `status { visible, song, edited, listener, title, playing, cycle, cps, length, max, section }` | What this tab is doing, when something changes (and every 30 s while playing). Each field is checked (`statusOf`): a site song's id, or a listener song's id and title (80 characters), the playhead and tempo only while playing, the song's end in cycles when known, and the section's name (40). The room stamps it with its clock. | 299| room | `tab { now, tab }`, `gone { id }`, `count { n }` | Another tab changed, left or was hidden, or the number of pages changed. | 300| page | `listen { to }` | Listen along to a tab: it must be listed and playing a site song. The room asks that tab (`listen { from }`) and remembers who asked. | 301| page | `invite { to, room }` | That tab's answer, once it is in a listening party on its song (one it started for this, or the one it is in): the party's room id, passed as `join { from, room }` to the asker only, and only to one that asked this tab. | 302| page | `ping { c }` | Answered `pong { c, s }`, for the page's estimate of the room's clock. | 303| room | `refused { why }` | The lobby is full (1000); the socket then closes with 4503, which the page does not retry. | 304 305Listening along is an ordinary listening party (above): the asker joins its 306room as a guest, from its own build, with the host's Jev, and party rooms 307stay unlisted, since only the asker learns one. Limits: 2 KiB a message, a 308burst of 20 per connection refilled at 1 a second, and `LOBBY_LIMIT` (20 309joins a minute per visitor, namespace 1011) at the route. Each tab's status 310lives in its socket's attachment, so the room keeps no storage, and nothing 311about who listened to what is logged or kept. 312 313## Listeners' content 314 315Signed-in listeners publish songs from the editor, revise them, give them a 316cover, comment on any song (the site's or a listener's) and pitch song ideas 317for Jev; anyone reads them. The user's decision (2026-09-25): **Jev screens, 318then public**. Nothing a listener writes is shown to anyone else until Jev 319has screened it fine. 320 321| Route | What it does | 322|---|---| 323| `GET /jev/listeners/songs` | Public listener songs, the most art first (unscored last), as the site's own are listed. | 324| `POST /jev/listeners/songs` | `{ title, description, spec, code }`: a new song → `201 { id, rev, status }`. | 325| `GET /jev/listeners/songs/<id>` | One public song: its newest fine revision whole (code, spec), its fine revisions, author, art and cover. | 326| `POST /jev/listeners/songs/<id>/revisions` | The next revision, by the song's author only. | 327| `PUT /jev/listeners/songs/<id>/cover` | Multipart `image` and `alt`, by the song's author only; replaces the song's cover. | 328| `GET /jev/listeners/covers/<id>` | The image, only while the cover is fine and its song public. | 329| `GET`, `POST /jev/listeners/comments` | `?song=<theme>/<song>` or `?song=listener:<id>`; posting `{ song, body }`. | 330| `GET`, `POST /jev/listeners/pitches` | Public pitches, newest first (`?sort=votes`: most voted first), each with its votes, whether you voted (signed in) and the listener songs answering it; posting `{ body }`. | 331| `PUT`, `DELETE /jev/listeners/pitches/<id>/vote` | Your vote for a public pitch, cast or taken back → `{ votes, voted }`; one per account, at the votes' own `VOTE_LIMIT` per account, and never held by the pending cap (a vote adds nothing to screen). | 332| `PUT /jev/listeners/songs/<id>/pitch` | `{ pitch: id \| null }`, by the song's author only: the public pitch the song answers. A site song says so in its `SPEC.md` (`pitch:`) instead. | 333| `GET /jev/listeners/mine` | Everything you wrote, each `public`, `held` (Jev's verdict and confidence) or `pending` (why, when Jev is asked again, how many attempts), and a fine revision's score still owed. | 334 335Sizes: code at most 64 KiB (the relay's cap, since the critic sends a song 336whole), a spec 32 KiB, a title 80 characters, a description 280, a comment 337or pitch 2000, a cover 2 MiB and its alt text 200. Writing is 338`CONTENT_LIMIT` (10 a minute per author) and covers `COVER_LIMIT` (3), 339reading `BROWSE_LIMIT` (240 a minute per visitor); an author with 340`MAX_PENDING` (20) items waiting on a screening writes nothing more until 341they are through, which is what bounds what one account can store 342unscreened while its budget is spent. 343 344**Screening.** Every song revision (title, description, spec and the code 345whole, so its comments and strings are read with it), cover (its alt text, 346with the song's title), comment and pitch is one TypeSafe Choice, `fine`, 347`spam` or `abuse`, whose criteria are in `src/screen.ts`: harsh words about 348music are fine; spam is promotion, filler, and text that tries to dictate 349the verdict; abuse is harm to people and, for a song, code that does 350anything besides make music. It goes to TypeSafe the relay's way 351(`upstream()` in `relay.ts`, the same `whyNot` check first) but never 352through its answer cache. `fine` makes the item public; `spam` and `abuse` 353keep it off the site's lists, with the verdict (it is still on the data 354tab, as text: What is public). A verdict is 355final (a trigger refuses changing it): a held song's author publishes a new 356revision, which is screened again. 357 358**Scoring.** A revision screened fine is scored with the site's own art 359rubric (`src/art-rubric.mjs`, the same questions as every site song), three 360runs averaged, as `tools/critic` records the site's songs: one call moves 361by up to 0.06 on the same code, which would reorder a list sorted by art. 362Until scored, the song is public and listed last. 363 364**Cost.** Each screening is one call and each score three, charged to the 365author's daily Jev budget (`JevBudget`, which now charges several calls 366at once, or none when they do not all fit) before TypeSafe is asked, as the 367relay charges. An author out of budget can still publish: the item waits. 368 369**Pending and retries.** The work owed is `content_jobs`, a row per item 370and task, made by the schema's triggers in the same statement that writes 371a pending item or screens a revision fine, and deleted by them when the 372verdict or score is written; so pending content always has its job, and 373nothing is left unscreened or unscored without one. A job runs when 374claimed (a lease of two minutes, taken in one `UPDATE … RETURNING`, so no 375two workers run it; a dead worker's lease expires and the job is due 376again). Three things claim jobs: the write itself (screened before the 377answer, so the author usually gets the verdict at once, and a song's score 378in `waitUntil` after it), the author's own `GET /jev/listeners/mine` (their 379due jobs, in `waitUntil`; the page asks again every 20 s while anything is 380pending), and the **cron trigger** every minute (`triggers.crons`), six 381jobs a run so a run stays within the free plan's 50 subrequests. A job 382that cannot run now is rescheduled with why: `budget` until the budget 383resets at 00:00 UTC; `unreachable`, `upstream`, `unanswered` and 384`budget-unavailable` after a backoff from a minute, doubling, to an hour 385(or TypeSafe's own `Retry-After`, if longer), for as long as it takes. 386Nothing is dropped as failed. `wrangler dev` fires no crons: dev runs with 387`--test-scheduled`, so `curl localhost:4323/__scheduled` runs a sweep. 388 389Each attempt writes a `jev.screen` or `jev.score` event and log line: the 390kind, the outcome (the verdict, `scored`, or `pending` with why), TypeSafe's 391status and time, the attempt, and the confidence or art. Never the item, 392its text or its author. 393 394**Covers.** The image's type is read from its bytes (PNG, JPEG, WebP or 395GIF; never SVG, never what the upload says), stored under a new id per 396upload in R2, and served with that type, `nosniff`, a sandboxing CSP and a 397day's caching. Jev reads text only, so it screens a cover's alt text, not 398its pixels. What guards the pixels instead: a cover is served only while 399its alt text is fine **and** its song has a public revision, so an account 400whose songs are all held shows no image to anyone; uploads are signed in 401and limited to 3 a minute; a cover is replaced, not added to, so each song 402has one. The gap stays: an image whose description is fine can show 403something that is not. Taking one down today is by hand: `UPDATE covers` 404cannot un-screen it (the verdict is final), so delete its row (`DELETE FROM 405covers WHERE id = …` through `wrangler d1 execute --remote`) and its object 406(`wrangler r2 object delete jevstrudel-covers/covers/<id> --remote`). 407 408**A listener's song runs in a sandbox, never in the page.** Playing a 409listener's song evaluates someone else's code. On the site's own origin that 410code could do whatever the page can — for a signed-in visitor, post comments, 411publish, or spend their daily Jev budget with their session. So the page 412never evaluates it. It plays in an iframe with an **opaque origin** 413(`sandbox="allow-scripts"`, never `allow-same-origin`) served with a strict 414CSP; the parent sends the code in by `postMessage` and receives back only 415playback state, logs and Jev decisions, each rebuilt from typed, bounded 416fields before anything reads it (`website/src/jev/sandbox.mjs`, 417`sandboxProtocol.mjs`, `sandboxPolicy.mjs`, `sandbox/player.mjs`). The 418boundary is the origin, so: 419 420- The frame has no cookies and no storage of the site's: `document.cookie` 421 and `localStorage` throw, and it cannot reach the parent page or `top`. 422 Proven headless: a published song that builds `fetch`/`document`/`parent` 423 at runtime (so the name check below never sees them) reads no cookie, 424 touches no parent, and posts no comment. 425- Its `fetch`es carry `Origin: null` and no credentials. The **Worker 426 refuses every write (any method but GET/HEAD) whose `Origin` is not this 427 site's**, before any route runs (`refuseCrossOrigin` in `session.ts`), and 428 `signedIn` returns null for such a request, so a sandbox call is never 429 charged to or acted on as the visitor — it is an anonymous, per-address 430 call, as signing out would be. The CSP's `connect-src` also limits it to 431 the site's relay and the sample hosts site songs use. 432- The frame's own scripts and the sample pack are cross-origin to it, so 433 they are served with `Access-Control-Allow-Origin: *` (public, no 434 credentials): `_headers` on the deploy, the dev middleware in dev 435 (`sandboxPolicy.mjs`, `astro.config.mjs`). 436 437**The name check and the screen are now defence in depth, not the boundary.** 438`whyNotCode` still refuses, before Jev is asked, code whose identifiers reach 439the page's powers (network, storage, cookies, the document, evaluating 440strings, the ways back to the global object; every site song passes it, 441`screen.test.ts`), and the screening's `abuse` still covers code that does 442anything but make music. They give an author fast feedback and keep obvious 443abuse from ever going public, but the isolation no longer depends on them: 444code that slips past both still cannot escape the opaque origin. 445 446**Only untrusted code is sandboxed.** The site's own songs are first-party, 447curated code and keep running in the page, where they can drive the visuals, 448listening parties and performance recording the sandbox deliberately lacks. 449The sandbox is for what the page did not write: a listener's song, and code 450arriving in a link (`#hash`/`?hash`). The editor tracks this **provenance** 451(`sandbox.mjs`): foreign code stays foreign through the visitor's edits and 452plays only in the sandbox; loading a site song, pattern or example makes the 453editor the page's own again. A listener song's link (`/?listener=<id>`) loads 454it without playing; a card plays it on the listener's click. 455 456The residual gap is availability, not integrity: a song can still make its 457own sandboxed browser fetch a sample from an allowed host. It cannot read or 458act as the visitor. 459 460## The hosted MCP 461 462Anyone with an account can connect their own AI (Claude Code, Claude's 463custom connectors, any MCP client) and make songs with it: play code in 464their own open tabs, read the site's and listeners' songs, publish and 465revise their own. The site's page for it is `/ai/` 466(`website/src/pages/ai.astro`). The user's decisions (2026-09-24): OAuth 4672.1 per MCP's authorization spec with the site's passkeys as the sign-in; 468only the signed-in user's own tabs; the code plays in the sandbox; 469publishing through the listener pipeline; per-user limits. 470 471| Route | What it does | 472|---|---| 473| `POST /jev/mcp` | MCP (Streamable HTTP, JSON answers, stateless), with `Authorization: Bearer`. Without a token: `401` with `WWW-Authenticate` naming the resource's metadata. | 474| `GET /.well-known/oauth-protected-resource/jev/mcp` | RFC 9728: the resource, its issuer, its scopes. | 475| `GET /.well-known/oauth-authorization-server` | RFC 8414: the endpoints below, PKCE (S256 only), CIMD support. | 476| `GET`, `POST /jev/oauth/authorize` | The authorize page: sign in with a passkey (or make an account) if needed, then the consent form; its answer redirects to the client with a code (and `iss`), or `access_denied`. | 477| `POST /jev/oauth/token` | Code and refresh-token exchange, and revocation (the library's). | 478| `POST /jev/oauth/register` | Dynamic client registration, for clients without Client ID Metadata Documents. | 479| `GET /jev/me/apps`, `DELETE /jev/me/apps/<id>` | The signed-in user's connected apps (grants), and disconnecting one: its tokens stop working. | 480| `GET /jev/me/tabs?session_id=` | A signed-in tab's WebSocket into its user's `TabHub`. | 481 482**Who a token acts for.** `@cloudflare/workers-oauth-provider` (1.1.0) is 483both the authorization server and the resource server here, one pair per 484origin (the issuer and resource are the request's own origin, as the 485passkey relying party is). The authorize page is this site's own origin, so 486the session cookie and passkeys are the site's: a visitor not signed in 487signs in or makes an account on that page, then allows or denies. The 488consent page shows the app's name (escaped; unverified unless it is a CIMD 489client, whose domain it shows), where access goes (a warning for 490localhost), and the scopes: `play` (the tab tools) and `publish` (writing, 491and reading one's own content statuses); public songs need only a valid 492token. The grant stores `{ userId, app }` as its props. A tool without its 493scope answers the MCP scope challenge (`403`, `insufficient_scope`). 494Clients register by Client ID Metadata Document (preferred by the 2026 495spec and by Claude; needs the `global_fetch_strictly_public` flag, which is 496set) or by dynamic registration. Access tokens last an hour; a grant lasts 49730 days past its last refresh; connecting the same app again replaces its 498grant. 499 500**Only their own tabs.** A tab joins `TAB_HUB.idFromName(userId)` only 501through `/jev/me/tabs`, which takes the session cookie from this site's own 502pages (a browser's WebSocket always sends its page's `Origin`, and 503`signedIn` refuses any other), and the MCP handler reaches only the hub its 504token's user names. There is no path from one user's token to another's 505hub; `hosted-mcp.test.ts` and `tools/mcp-e2e/` hold it. A hub keeps at 506most 8 tabs (the oldest goes), and uses the Hibernation API, so an open 507tab between commands costs nothing. 508 509**The AI's code plays in the sandbox.** The tab puts it in the editor as 510foreign code (`{ ai: app }`, `website/src/jev/myTabs.mjs`) and plays it only 511in the opaque-origin iframe listeners' songs play in (A listener's song 512runs in a sandbox, above), never in the page: code a prompt-injected agent 513was talked into writing cannot read the page, post, publish or reach the 514session. Its logs and state come back through the sandbox's protocol, 515rebuilt and bounded, then again by the hub (`tab-hub-core.ts`). 516 517**Whose budget.** Jev's calls from the AI's code are charged to the 518listener's own daily budget: the page makes them for the frame with its 519own session (`credentials: 'same-origin'`), exactly as the listener's own 520Jev calls. The frame never holds the session, so nothing it could 521exfiltrate is worth anything; the worst it can do is spend the budget the 522listener gave this app to spend, at most `ASKS_PER_MINUTE` (30) a minute. 523Anonymous calls were the alternative, and wrong: they would let a 524connected app spend the address's shared allowance instead of its own 525user's, and one listener's agent would starve everyone behind that 526address. A listener's song, which the visitor did not ask anyone to write, 527stays anonymous as before. Screening and scoring what the app publishes 528are charged to the author as for any listener content. 529 530**Limits.** `MCP_LIMIT` (namespace 1010): 120 requests a minute per user, 531every method counted; an agent iterating plays, reads logs and plays again 532a few times a minute. A request body is at most 384 KiB (a publish's code 533and spec, JSON-escaped: content.ts's own cap), code to play 64 KiB, a tab's 534reply 256 KiB and 500 log lines of 500 characters. Publishing also meets 535content.ts's `CONTENT_LIMIT` and pending cap, since it is the same 536`writeSong`. Registering, authorizing and token swaps (`/jev/oauth/*`) 537share the passkey ceremonies' per-visitor `AUTH_LIMIT`, since each writes 538to KV. Browser pages of other origins are refused before any route 539(`refuseCrossOrigin`), which is also what MCP's transport asks of a server 540against DNS rebinding; MCP clients that are not browsers send no `Origin`. 541 542**The tools both MCPs share.** Nine tools are the dev hub's and the hosted 543MCP's alike (`shared-tools.json`, run by `shared-mcp.ts`), so the proxy's 544tool list has them in either environment. Most are a command to the tab 545(`tab-hub-core.ts`'s `SharedCommand`), which the tab answers from what its 546panel shows (`website/src/jev/tabTools.mjs`), rebuilt from typed, bounded 547fields before it is read, in the dev hub as in the hosted one: 548 549| Tool | Scope | What it does | 550|---|---|---| 551| `list_sounds` | play | The sounds tab: each sub-tab (samples, drum-machines, synths, wavetables, user, imported), a filter, pages of at most 200, each sound's variants and how to play it. Read from the tab, since imported sounds live only in that browser. | 552| `ask_jev_sound` | play | Jev picks a sound for a description: a Choice over the tab's sound names, asked by the page through the relay (charged as the page's own Jev calls, to the signed-in listener). Past 255 candidates it asks in banks of at most 255, several to a request, then among each bank's three likeliest, as Krug's JevLM does (`website/src/jev/soundPick.mjs`); the answer says its odds are among those finalists. At most 16 banks; more must be narrowed. | 553| `api_reference` | none | The reference tab's functions (`/jev/reference.json`, built from jsdoc's `doc.json`): search, or one whole. No tab. | 554| `get_settings`, `set_settings` | play | The settings tab's settings `mcp-settings.mjs` allows: theme, font, keybindings, the panel, editing options. Never the prebake script (code the page runs), sync, when or how code is evaluated, or where audio goes. Checked in the Worker and again in the tab. | 555| `play_song` | play | A song by id, as its card plays it: a site song in the page, looked up in the page's own build; a listener's fetched and played in the sandbox. | 556| `ask_jev_song` | play | The mood picker (`picker.mjs`'s `pickByMood`), one Jev call from the page; with `play`, its pick plays. | 557| `react` | play | The booth's 🔥/😴 for the section playing (`press.mjs`): Jev hears it; a site song as written counts it in its tally. | 558| `vote` | none | "Do you agree with Jev?" through `votes.ts`'s own rules (`castVote`), at `VOTE_LIMIT` per account (`local` in dev), and whether the pick agrees with the catalog's scores. No tab. | 559 560The hosted MCP adds `comment` (`publish`: `content.ts`'s `commentAs`, the 561page's rate and pending cap, screened by Jev) and `get_logs` with `source: 562"console"`, the page's console tab: what a site song played in the page 563logged, as the listener sees it (the dev hub's `get_logs` already is the 564page's console). Settings are `play`, not a scope of their own: only how 565the editor looks and types can be changed, each undone in the settings tab, 566and an app that may replace the editor's code and play sound in the tab is 567trusted with more than its font (`hosted-tools.ts`). A tab has 60 s for 568a command that asks Jev, 30 s for a play, 15 s for the rest (`replyMs`). 569 570**KV on the free plan** allows 1,000 writes and 1,000 lists a day. An 571authorization writes a handful of records, a refresh two, a dynamic 572registration one (Claude registers again on every fresh connection, which 573CIMD avoids); listing a user's apps is one list. Workable at today's scale; 574the answer cache's writes share the same allowance. 575 576## Logs 577 578The relay logs one structured line per call (`event: "jev.relay"`): 579`outcome` (`refused` by the relay, `forwarded` to TypeSafe, `cached` from 580the answer cache, or `unreachable`), `status`, `upstreamMs` (until TypeSafe's response headers; null when the 581relay refused the call itself), `requestBytes`, `colo` (the Cloudflare data 582centre that ran it), and the page's own timing, which `ask.mjs` sends as 583headers the relay never forwards: `sectionInS`, how many seconds before its 584section began the call went out (negative once it had begun; null before a 585song starts), and `attempt` (0, then its retries). So a late answer shows 586whether the call went out late (small `sectionInS`) or TypeSafe was slow 587(large `upstreamMs`). 588 589Workers Logs (`observability` in `wrangler.json`) keeps them, queryable in 590the Cloudflare dashboard under the Worker's Observability tab; `wrangler 591tail jevstrudel --format json` (through the deploy's 1Password-fed 592wrangler) streams them live. Cloudflare's own per-request invocation logs 593are off: they record the request's metadata, which is the visitor's. 594 595The same fields go to Analytics Engine as a `jev.relay` event, where they 596can be counted and charted over time: `nix run .#events -- jev.relay`. The 597account is never in either: a signed-in call's line and event are the same 598as an anonymous one's. 599 600## Type checking 601 602wrangler bundles `src/` with esbuild, which strips the types without 603checking them, so a type error deploys. `nix flake check` catches it: its 604`worker-types` check generates the Workers runtime types with 605`wrangler types` and runs `tsc -p worker`. To run it in the devshell, from 606`worker/`: `wrangler types --include-env=false` once (it writes the 607gitignored `worker-configuration.d.ts`), then `tsc -p .`. The check and the 608deploy both use `strudel.worker` (`nix/strudel.nix`): `worker/` with a 609`node_modules` of exactly its dependencies, so a copy of it bundles alone. 610 611The key comes from 1Password (`JEVSTRUDEL_TYPESAFE_API_KEY`): as a Worker 612secret on deploy, and through the process environment in dev 613(`tools/dev/worker.mjs`). It is never written to a file.