jevstrudel.git / worker / README.md
README.mdpreviewREADME.mdsource613 lines · 48.6 KB · raw
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.