1// A listener's song plays in a sandbox, never in this page. 2// 3// Listeners' songs are someone else's code. Evaluated here, on the site's 4// origin, they could do anything the page can: for a signed-in visitor, 5// post comments, publish, or spend their daily Jev budget with their 6// session. So the page never evaluates them. They play in an iframe with an 7// opaque origin (`sandbox="allow-scripts"`, never `allow-same-origin`), 8// served with a strict CSP (sandboxPolicy.mjs): it has no cookies, no 9// storage of the site's, no way into this page (only postMessage), no 10// navigation of the top page, no forms or popups, and it reaches only the 11// site's own files and the sample hosts songs use. 12// 13// This module is the page's end: 14// 15// - Provenance. The editor's code is `foreign` from the moment a 16// listener's song, or code from a link (Strudel's #code or ?hash), is 17// loaded into it, until code of the site's own (a site song, a pattern, 18// an example) replaces it. The visitor's edits keep it foreign: it is 19// still that listener's code. `guardEditor` makes the editor evaluate 20// foreign code only in the sandbox: play, ctrl+enter, block evaluation 21// and the header's button all go there, and the page's own `repl` 22// refuses it outright. A trusted loader that forgets to say so only 23// leaves code in the sandbox; a foreign one is marked where it loads 24// (`showForeign`), and a listener song's object carries its code as 25// `foreignCode` (listeners.mjs), so no path that plays songs can take it. 26// - The frame. One per foreign load (a new song gets a fresh frame, so no 27// song's code outlives it), made on the first play. It posts only data 28// (sandboxProtocol.mjs rebuilds every message), which the booth, the 29// mixer, the playhead and the console show. 30// - Jev. The frame's jev() calls come here as messages and go to the relay 31// from this page. A listener's song or a link's code is asked with 32// `credentials: 'omit'`: never the visitor's session, so never their 33// budget; the relay counts them per address, as anyone signed out. Code 34// the visitor's own AI played through the hosted MCP (myTabs.mjs) is 35// asked with this page's session, so it is charged to the visitor's own 36// daily budget, as their other Jev calls are: the visitor connected that 37// app to act for them, and anonymous calls would let it spend the 38// address's shared allowance instead. The frame never holds the session 39// either way: the page makes the call, and the frame gets only the answer. 40// The relay itself refuses cross-origin calls, so the frame cannot reach 41// it directly. A song asking more than half the relay's per-visitor rate 42// is refused here, as allSongs.test.mjs refuses a site song that would. 43import { logger } from '@strudel/core'; 44import { ATTEMPT_HEADER, BUDGET_HEADER, LEAD_HEADER, noteBudgetHeader, RELAY } from './ask.mjs'; 45import { boothStore } from './boothStore.mjs'; 46import { escapeHtml, fromFrame, MAX_ANSWER_BYTES } from './sandboxProtocol.mjs'; 47 48export const SANDBOX_PATH = '/jev/sandbox/'; 49// half the relay's JEV_LIMIT (60 a minute, worker/wrangler.json), as 50// allSongs.test.mjs holds site songs to; sandbox.test.mjs checks it 51export const ASKS_PER_MINUTE = 30; 52const READY_MS = 30_000; 53// how many messages a frame may send a second before the rest are dropped 54const MESSAGES_PER_S = 200; 55 56// ── provenance ──────────────────────────────────── 57// null: the editor's code is the site's or the visitor's own. Otherwise 58// where it came from: { song } (a listener's, from listeners.mjs), 59// { link: true } (code in the address) or { ai: name } (code the visitor's 60// own AI played through the hosted MCP, myTabs.mjs; `name` is the app's 61// own, unverified, and only ever shown as text). 62let foreign = null; 63// what the page shows of it (a new object on every change, for React) 64let state = { foreign, status: 'idle', cycle: null, cps: null, at: 0, error: null, audio: null }; 65let levels = null; 66const listeners = new Set(); 67const changed = () => { 68 for (const l of listeners) l(); 69}; 70function set(fields) { 71 state = { ...state, ...fields }; 72 changed(); 73} 74 75export const sandbox = { 76 subscribe(l) { 77 listeners.add(l); 78 return () => listeners.delete(l); 79 }, 80 // for useSyncExternalStore: a new object on every change 81 get: () => state, 82 foreign: () => foreign, 83 playing: () => state.status === 'playing' || state.status === 'loading', 84}; 85 86// The cycle the sandbox is on, from its last report, or null when it is 87// not playing (the booth's and mixer's playhead). 88export function sandboxCycle() { 89 if (state.status !== 'playing' || state.cycle === null) return null; 90 const elapsed = (performance.now() - state.at) / 1000; 91 return state.cycle + (state.cps ? elapsed * state.cps : 0); 92} 93export const sandboxLevels = () => (state.status === 'playing' ? levels : null); 94 95// A recorded performance of a listener's song, armed for the sandbox: 96// { songId, jevs, changed }. Every play of that song while it is loaded 97// sends it (fromPage's play), so the frame's jev()s play it and ask Jev 98// nothing, as a site song's replay does in the page (performance.mjs). 99// Anything else loaded into the editor disarms it. 100let replay = null; 101export function armSandboxReplay(songId, performance) { 102 replay = performance ? { songId, jevs: performance.jevs, changed: Boolean(performance.changed) } : null; 103} 104const replayFor = () => 105 replay && foreign?.song?.id === replay.songId ? { jevs: replay.jevs, changed: replay.changed } : null; 106 107// Foreign code into the editor: a listener's song (its `foreignCode`), or 108// code from a link. The last sandbox goes; the next play makes a new one. 109export function showForeign(editor, code, from) { 110 destroy(); 111 if (from?.song?.id !== replay?.songId) replay = null; 112 foreign = from; 113 set({ foreign }); 114 editor?.setCode(code); 115} 116 117// The site's or the visitor's own code into the editor. 118export function setOwnCode(editor, code) { 119 markOwn(); 120 editor?.setCode(code); 121} 122export function markOwn() { 123 replay = null; 124 if (!foreign && !frame) return; 125 destroy(); 126 foreign = null; 127 set({ foreign }); 128} 129 130// ── the frame ───────────────────────────────────── 131let frame = null; // { el, ready, run, token, bucket, asks, charge } 132let runs = 0; 133// What the frame logs, raw, for whoever asked (myTabs.mjs's get_logs); the 134// page's console gets it escaped, as before. 135const logListeners = new Set(); 136export function onSandboxLog(listener) { 137 logListeners.add(listener); 138 return () => logListeners.delete(listener); 139} 140const logged = (message, kind) => { 141 for (const l of logListeners) l(message, kind); 142}; 143// Each play is numbered, and the frame's state names the play it is about, 144// so playForeign can tell its song's start from the song before. 145let plays = 0; 146let waiter = null; // { run, finish } 147 148function style(el, visible) { 149 Object.assign(el.style, visible 150 ? { position: 'fixed', right: '16px', bottom: '16px', width: '320px', height: '96px', opacity: '1', pointerEvents: 'auto', zIndex: '60', border: '1px solid currentColor', borderRadius: '6px', background: 'var(--background, #222)' } 151 : { position: 'fixed', right: '0', bottom: '0', width: '1px', height: '1px', opacity: '0', pointerEvents: 'none', zIndex: '-1', border: '0' }); 152} 153 154function create() { 155 const el = document.createElement('iframe'); 156 // allow-scripts only: an opaque origin, no forms, popups, modals or top 157 // navigation. Never add allow-same-origin: with it the frame would be 158 // this site, and could reach into this page and its cookies. 159 el.setAttribute('sandbox', 'allow-scripts'); 160 // the page's click may start the frame's audio (browsers keep audio off 161 // until a click, and let a frame use its page's only when allowed) 162 el.setAttribute('allow', 'autoplay'); 163 el.setAttribute('referrerpolicy', 'no-referrer'); 164 el.title = "the sandbox a listener's song plays in"; 165 style(el, false); 166 let resolveReady, rejectReady; 167 const ready = new Promise((resolve, reject) => { 168 resolveReady = resolve; 169 rejectReady = reject; 170 }); 171 ready.catch(() => {}); 172 // whose budget the frame's Jev calls are charged to is fixed with the 173 // frame: provenance changing destroys it (showForeign, markOwn) 174 const f = { el, ready, resolveReady, run: ++runs, token: {}, bucket: { n: MESSAGES_PER_S, at: performance.now() }, asks: [], loads: 0, charge: Boolean(foreign?.ai) }; 175 // A second load is the frame navigating itself away (listener code can: 176 // the sandbox cannot forbid it). Whatever loaded is still sandboxed, but 177 // it is no longer the player: end it. 178 el.addEventListener('load', () => { 179 f.loads += 1; 180 if (f.loads > 1 && frame === f) { 181 logger("A listener's song navigated its sandbox away, so it was stopped.", 'warning'); 182 destroy(); 183 } 184 }); 185 setTimeout(() => rejectReady(new Error('the sandbox did not start')), READY_MS); 186 el.src = SANDBOX_PATH; 187 document.body.append(el); 188 return f; 189} 190 191function destroy() { 192 if (frame) { 193 frame.el.remove(); 194 frame = null; 195 boothStore.showRemote(null); 196 } 197 levels = null; 198 if (state.status !== 'idle') set({ status: 'idle', cycle: null, cps: null, error: null, audio: null }); 199} 200 201function send(message) { 202 frame?.el.contentWindow?.postMessage(message, '*'); 203} 204 205// Plays the editor's (foreign) code in the sandbox, making it first. 206export async function playInSandbox(code) { 207 // numbered now, before any await, so playForeign knows which it is 208 const run = ++plays; 209 if (!frame) frame = create(); 210 const f = frame; 211 set({ status: 'loading', error: null }); 212 try { 213 await f.ready; 214 } catch (e) { 215 if (frame === f) { 216 set({ status: 'error', error: e.message }); 217 logger(`The sandbox for this listener's song could not start (${escapeHtml(e.message)}).`, 'error'); 218 } 219 return; 220 } 221 if (frame !== f) return; 222 send({ type: 'resume' }); 223 send({ type: 'play', code, run, replay: replayFor() }); 224} 225 226const AUDIO_HELD = "this tab's sound is held until someone clicks the page (or the ▶ in its sandbox box)"; 227 228// Plays foreign code in the sandbox and resolves once it is playing 229// ({ ok: true }) or failed ({ error }), for the hosted MCP's play_code. 230export function playForeign(code, { timeoutMs = 25_000 } = {}) { 231 waiter?.finish({ error: 'another play replaced it' }); 232 return new Promise((resolve) => { 233 const w = { 234 run: plays + 1, 235 finish(result) { 236 if (waiter !== w) return; 237 waiter = null; 238 clearTimeout(timer); 239 resolve(result); 240 }, 241 }; 242 waiter = w; 243 const timer = setTimeout( 244 () => w.finish({ error: state.audio && state.audio !== 'running' ? AUDIO_HELD : 'the sandbox did not start playing in time' }), 245 timeoutMs, 246 ); 247 playInSandbox(code).then(() => { 248 if (state.status === 'error') w.finish({ error: `the sandbox could not start (${state.error})` }); 249 }); 250 }); 251} 252 253export function stopSandbox() { 254 if (!frame) return; 255 send({ type: 'stop' }); 256 if (state.status !== 'idle') set({ status: 'stopped', cycle: null }); 257} 258 259// The page was clicked: that click may start the frame's audio. 260if (typeof window !== 'undefined') { 261 window.addEventListener('pointerdown', () => frame && send({ type: 'resume' }), true); 262 window.addEventListener('message', (e) => { 263 const f = frame; 264 // only this page's own sandbox, and only while it is opaque: a frame 265 // that somehow had this origin would send its origin, not "null" 266 if (!f || e.source !== f.el.contentWindow || e.origin !== 'null') return; 267 const now = performance.now(); 268 const b = f.bucket; 269 b.n = Math.min(MESSAGES_PER_S, b.n + ((now - b.at) / 1000) * MESSAGES_PER_S); 270 b.at = now; 271 if (b.n < 1) return; 272 b.n -= 1; 273 const m = fromFrame(e.data); 274 if (m) receive(f, m); 275 }); 276} 277 278function receive(f, m) { 279 switch (m.type) { 280 case 'ready': 281 f.resolveReady(); 282 return; 283 case 'state': 284 if (m.error) { 285 logger(`[sandbox] ${escapeHtml(m.error)}`, 'error'); 286 logged(m.error, 'error'); 287 } 288 if (waiter && m.run === waiter.run) { 289 if (m.error) waiter.finish({ error: m.error }); 290 else if (m.playing) waiter.finish({ ok: true }); 291 } 292 // a play first stops what played before: that stop leaves it loading 293 set({ 294 status: m.playing ? 'playing' : state.status === 'loading' && !m.error ? 'loading' : 'stopped', 295 cycle: m.cycle, 296 cps: m.cps, 297 at: performance.now(), 298 error: m.error, 299 }); 300 return; 301 case 'log': 302 // the console renders HTML: a listener's text is escaped 303 logger(`[sandbox] ${escapeHtml(m.message)}`, m.kind || undefined); 304 logged(m.message, m.kind); 305 return; 306 case 'booth': 307 if (f.boothRun !== m.run) { 308 f.boothRun = m.run; 309 f.token = {}; 310 } 311 boothStore.showRemote( 312 m.booth && { 313 ...m.booth, 314 // the same evaluation keeps one identity (JevBooth's identityOf) 315 views: [f.token], 316 // played in the frame: the recorder reads its song from `foreign` 317 sandboxed: true, 318 react: (segment, kind) => send({ type: 'react', segment, kind }), 319 }, 320 ); 321 return; 322 case 'levels': 323 levels = m.levels; 324 return; 325 case 'audio': 326 style(f.el, m.state !== 'running' && sandbox.playing()); 327 set({ audio: m.state }); 328 return; 329 case 'ask': 330 ask(f, m); 331 return; 332 } 333} 334 335// A Jev call for the frame, made anonymously (see the top). 336async function ask(f, { id, body, lead, attempt }) { 337 const now = Date.now(); 338 f.asks = f.asks.filter((t) => now - t < 60_000); 339 if (f.asks.length >= ASKS_PER_MINUTE) { 340 const wait = Math.ceil((60_000 - (now - f.asks[0])) / 1000); 341 send({ 342 type: 'answer', 343 id, 344 status: 429, 345 headers: { 'retry-after': String(wait) }, 346 body: `this song asks Jev more than ${ASKS_PER_MINUTE} times a minute`, 347 }); 348 return; 349 } 350 f.asks.push(now); 351 const headers = { 'Content-Type': 'application/json', [ATTEMPT_HEADER]: String(attempt) }; 352 if (lead !== null) headers[LEAD_HEADER] = lead.toFixed(2); 353 try { 354 const res = await fetch(RELAY, { method: 'POST', credentials: f.charge ? 'same-origin' : 'omit', headers, body }); 355 if (f.charge) noteBudgetHeader(res.headers.get(BUDGET_HEADER)); 356 const text = (await res.text()).slice(0, MAX_ANSWER_BYTES); 357 const pass = {}; 358 for (const name of ['retry-after', 'retry-after-ms', 'jev-cache']) { 359 const v = res.headers.get(name); 360 if (v !== null) pass[name] = v.slice(0, 200); 361 } 362 if (frame === f) send({ type: 'answer', id, status: res.status, headers: pass, body: text }); 363 } catch (e) { 364 if (frame === f) send({ type: 'answer', id, error: 'the relay could not be reached' }); 365 } 366} 367 368// ── the editor ──────────────────────────────────── 369// Foreign code is evaluated only in the sandbox; see the top. 370export function guardEditor(editor) { 371 const evaluate = editor.evaluate.bind(editor); 372 const stop = editor.stop.bind(editor); 373 const repl = editor.repl; 374 const replEvaluate = repl.evaluate; 375 const replEvaluateBlock = repl.evaluateBlock; 376 const refuse = () => { 377 const e = new Error("a listener's code plays only in the sandbox"); 378 logger(e.message, 'warning'); 379 return Promise.reject(e); 380 }; 381 editor.evaluate = async (autostart = true) => { 382 if (foreign) { 383 if (!autostart) return refuse(); 384 editor.flash(); 385 repl.stop(); 386 return playInSandbox(editor.code); 387 } 388 stopSandbox(); 389 return evaluate(autostart); 390 }; 391 editor.stop = async () => { 392 stopSandbox(); 393 return stop(); 394 }; 395 editor.toggle = async () => { 396 if (foreign) return sandbox.playing() ? stopSandbox() : editor.evaluate(); 397 if (repl.scheduler.started) return stop(); 398 return editor.evaluate(); 399 }; 400 // The page's own start that was already under way (awaiting its samples in 401 // beforeStart) when foreign code arrived must not go on to play: the 402 // scheduler's stop() does not cancel a start in flight 403 // (packages/core/cyclist.mjs), so a site song clicked a moment before a 404 // listener's song, or before the listener's AI played, would sound under it. 405 const scheduler = repl.scheduler; 406 const beforeStart = scheduler.beforeStart; 407 scheduler.beforeStart = async () => { 408 await beforeStart?.(); 409 if (foreign) throw new Error('not started: the editor now holds code that plays in the sandbox'); 410 }; 411 repl.evaluate = (code, autostart) => (foreign ? refuse() : replEvaluate(code, autostart)); 412 repl.evaluateBlock = (code, autostart, options) => 413 foreign ? editor.evaluate() : replEvaluateBlock(code, autostart, options); 414 return editor; 415}