jevstrudel.git / website / src / jev / sandbox.mjs
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}