jevstrudel.git / website / src / jev / sandboxProtocol.mjs
1// The wire between the page and the sandbox a listener's song plays in
2// (sandbox.mjs is the page's end, sandbox/player.mjs the frame's). The frame
3// runs someone else's code, so everything it sends is untrusted: each
4// message is rebuilt here from the fields the page reads, each checked for
5// its type and bounded in size, and anything else is dropped. Nothing from
6// the frame is ever evaluated, rendered as HTML, or passed on as it came.
7//
8// Page → frame: play { code, run, replay? } (replay: a recorded performance's
9// { jevs, changed } for the frame's jev()s to play, as the page replays a
10// site song's; performance.mjs), stop, react { segment, kind }, answer { id,
11// status, headers, body } (a Jev call the page made for the frame), and
12// resume (the page was clicked: start the frame's audio).
13// Frame → page: ready, state { playing, cycle, cps, error, run } (`run` is
14// the last play's, so the page knows which play a state is about), log { message,
15// kind }, booth { run, booth } (what Jev decided, for the booth and the
16// mixer), levels { levels } (the meter), audio { state }, ask { id, body,
17// lead, attempt } (a Jev call, which the page makes without the visitor's
18// session: see sandbox.mjs).
19
20export const MAX_CODE = 1024 * 1024;
21// the relay's own cap on a Jev request (worker/src/relay.ts MAX_BYTES)
22export const MAX_ASK_BYTES = 64 * 1024;
23export const MAX_ANSWER_BYTES = 256 * 1024;
24const MAX_TEXT = 4000;
25const MAX_NAME = 100;
26const MAX_SEGMENTS = 1024;
27const MAX_QUESTIONS = 32;
28const MAX_OPTIONS = 255; // TypeSafe's own limit for a choice
29const MAX_LEVELS = 10; // and for a score
30const MAX_PARTS = 32;
31
32const isPlain = (x) =>
33  x !== null && typeof x === 'object' && !Array.isArray(x) && Object.getPrototypeOf(x) === Object.prototype;
34const str = (x, max = MAX_TEXT) => (typeof x === 'string' && x.length <= max ? x : undefined);
35const num = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : undefined);
36const int = (x, lo, hi) => (Number.isInteger(x) && x >= lo && x <= hi ? x : undefined);
37const bool = (x) => (typeof x === 'boolean' ? x : undefined);
38// an object without the keys whose value is undefined
39const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
40
41// Plain JSON data, bounded: what Jev was told (a decision's `states`) is
42// shown as JSON text, so only its shape and size need checking. `budget`
43// counts nodes across one message.
44function plainData(x, budget, depth = 0) {
45  if (budget.n-- <= 0 || depth > 8) return undefined;
46  if (x === null || typeof x === 'boolean') return x;
47  if (typeof x === 'number') return Number.isFinite(x) ? x : null;
48  if (typeof x === 'string') return x.length <= MAX_TEXT ? x : x.slice(0, MAX_TEXT);
49  if (Array.isArray(x)) return x.slice(0, 256).map((v) => plainData(v, budget, depth + 1) ?? null);
50  if (!isPlain(x)) return undefined;
51  const out = {};
52  for (const [k, v] of Object.entries(x).slice(0, 256)) {
53    const clean = plainData(v, budget, depth + 1);
54    if (clean !== undefined && k.length <= MAX_NAME) out[k] = clean;
55  }
56  return out;
57}
58
59// How a finished section measured (meter.mjs's hear(), as Jev was told it),
60// which the booth's detail tables.
61function measuredRow(m) {
62  if (!isPlain(m) || str(m.cycles, 40) === undefined) return undefined;
63  const parts = {};
64  if (isPlain(m.parts)) {
65    for (const [name, p] of Object.entries(m.parts).slice(0, MAX_PARTS)) {
66      if (name.length > MAX_NAME || !isPlain(p)) continue;
67      parts[name] = compact({ loudnessDbfs: num(p.loudnessDbfs), peakDbfs: num(p.peakDbfs), silentShare: num(p.silentShare) });
68    }
69  }
70  return compact({
71    cycles: m.cycles,
72    loudnessDbfs: num(m.loudnessDbfs),
73    peakDbfs: num(m.peakDbfs),
74    clipped: bool(m.clipped),
75    parts,
76  });
77}
78
79function state(s, budget) {
80  const clean = plainData(s, budget);
81  if (!isPlain(clean)) return undefined;
82  if ('measuredSound' in clean) {
83    const rows = Array.isArray(s.measuredSound) ? s.measuredSound.slice(0, 64).map(measuredRow).filter(Boolean) : [];
84    if (rows.length) clean.measuredSound = rows;
85    else delete clean.measuredSound;
86  }
87  return clean;
88}
89
90function question(q) {
91  if (!isPlain(q)) return undefined;
92  const name = str(q.name, MAX_NAME);
93  const instructions = q.instructions === undefined ? undefined : str(q.instructions);
94  if (!name || (q.instructions !== undefined && instructions === undefined)) return undefined;
95  let criteria;
96  if (q.type === 'choice') {
97    if (!isPlain(q.criteria)) return undefined;
98    const entries = Object.entries(q.criteria);
99    if (entries.length > MAX_OPTIONS || entries.some(([k, v]) => k.length > MAX_NAME || str(v) === undefined)) return undefined;
100    criteria = Object.fromEntries(entries);
101  } else if (q.type === 'score') {
102    if (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > MAX_LEVELS) return undefined;
103    if (q.criteria.some((v) => str(v) === undefined)) return undefined;
104    criteria = [...q.criteria];
105  } else if (q.type === 'noul') {
106    if (q.criteria !== undefined) {
107      if (!isPlain(q.criteria) || str(q.criteria.true) === undefined || str(q.criteria.false) === undefined) return undefined;
108      criteria = { true: q.criteria.true, false: q.criteria.false };
109    }
110  } else {
111    return undefined;
112  }
113  return compact({ name, type: q.type, instructions, criteria });
114}
115
116function probabilities(p) {
117  if (!isPlain(p)) return undefined;
118  const entries = Object.entries(p).filter(([k, v]) => k.length <= MAX_NAME && num(v) !== undefined);
119  return Object.fromEntries(entries.slice(0, MAX_OPTIONS));
120}
121
122// One answer, as its question's type needs it: the booth calls toFixed on a
123// score and shows a choice as text, so a value without its field is dropped.
124function answer(a, type) {
125  if (!isPlain(a)) return undefined;
126  const common = {
127    confidence: num(a.confidence),
128    probabilities: probabilities(a.probabilities),
129    fallback: bool(a.fallback),
130    forced: bool(a.forced),
131    absent: bool(a.absent),
132  };
133  if (type === 'choice' || type === 'form') {
134    const choice = str(a.choice, MAX_NAME);
135    if (choice === undefined) return undefined;
136    return compact({ ...common, choice, sampled: num(a.sampled), top: str(a.top, MAX_NAME) });
137  }
138  if (type === 'score') {
139    const score = num(a.score);
140    return score === undefined ? undefined : compact({ ...common, score });
141  }
142  const noul = num(a.noul);
143  return noul === undefined ? undefined : compact({ ...common, noul });
144}
145
146const STATUSES = new Set(['opening', 'asking', 'answered', 'fallback']);
147
148function decision(d, types, budget) {
149  if (!isPlain(d) || !STATUSES.has(d.status)) return undefined;
150  const segment = int(d.segment, 0, MAX_SEGMENTS);
151  const cycles = str(d.cycles, 40);
152  if (segment === undefined || cycles === undefined) return undefined;
153  const values = {};
154  if (isPlain(d.values)) {
155    for (const [name, a] of Object.entries(d.values)) {
156      if (!types.has(name)) continue;
157      const clean = answer(a, types.get(name));
158      if (clean) values[name] = clean;
159    }
160  }
161  const problems = Array.isArray(d.problems) ? d.problems.slice(0, 20).map((p) => str(p)).filter(Boolean) : undefined;
162  const states = Array.isArray(d.states) ? d.states.slice(0, 8).map((s) => state(s, budget)).filter(Boolean) : undefined;
163  return compact({
164    segment,
165    cycles,
166    status: d.status,
167    values,
168    problems,
169    reason: str(d.reason),
170    ms: num(d.ms),
171    // a late answer's first cycle, and a segment the song ended before:
172    // what the recorder (performance.mjs) and the request log read
173    from: int(d.from, 0, 1e7),
174    ended: d.ended === true ? true : undefined,
175    states,
176  });
177}
178
179// The frame's jev()s one by one, in declaration order (each jev()'s own
180// decisions), which a recorded performance keeps apart
181// (performance.mjs's performanceOf): at most MAX_JEVS, on the booth's
182// cadence, each decision checked as the booth's are.
183const MAX_JEVS = 4;
184function views(list, every, questions, types, budget) {
185  if (!Array.isArray(list) || !list.length || list.length > MAX_JEVS) return undefined;
186  const out = [];
187  for (const v of list) {
188    if (!isPlain(v) || num(v.every) !== every || !Array.isArray(v.decisions)) return undefined;
189    let form = null;
190    if (v.form !== null && v.form !== undefined) {
191      const name = isPlain(v.form) ? str(v.form.name, MAX_NAME) : undefined;
192      if (!name || !questions.some((q) => q.name === name && q.type === 'choice')) return undefined;
193      form = { name };
194    }
195    const decisions = [];
196    v.decisions.slice(0, MAX_SEGMENTS).forEach((d, i) => {
197      const clean = d === undefined || d === null ? undefined : decision(d, types, budget);
198      if (clean && clean.segment === i) decisions[i] = clean;
199    });
200    out.push({ every, form, decisions });
201  }
202  return out;
203}
204
205// A recorded performance for the frame to replay (fromPage's play), as the
206// Worker stores it (worker/src/listening.ts): only what jevCore's
207// setReplay reads, bounded.
208const REPLAY_STATUSES = new Set(['opening', 'answered', 'fallback']);
209const answerType = (a) =>
210  typeof a?.choice === 'string' ? 'choice' : typeof a?.score === 'number' ? 'score' : typeof a?.noul === 'number' ? 'noul' : null;
211// as many as the site stores in one (worker/src/listening.ts)
212const MAX_TAKE_SEGMENTS = 64;
213export function cleanReplay(r) {
214  if (!isPlain(r) || !Array.isArray(r.jevs) || !r.jevs.length || r.jevs.length > MAX_JEVS) return undefined;
215  const jevs = [];
216  for (const j of r.jevs) {
217    if (!isPlain(j) || !Array.isArray(j.segments) || j.segments.length > MAX_TAKE_SEGMENTS) return undefined;
218    const segments = [];
219    for (const seg of j.segments) {
220      if (!isPlain(seg) || !REPLAY_STATUSES.has(seg.status) || !isPlain(seg.values)) return undefined;
221      const values = {};
222      for (const [name, a] of Object.entries(seg.values).slice(0, MAX_QUESTIONS)) {
223        const type = answerType(a);
224        const clean = name.length <= MAX_NAME && type ? answer(a, type) : undefined;
225        if (!clean) return undefined;
226        values[name] = clean;
227      }
228      segments.push(compact({ status: seg.status, ms: num(seg.ms), from: int(seg.from, 0, 1e7), values }));
229    }
230    jevs.push({ segments });
231  }
232  return { jevs, changed: r.changed === true };
233}
234
235// What Jev decided in the frame, as the booth, the mixer and the jev panel
236// read it (boothStore.mjs's combine()), rebuilt from checked fields; null
237// when it is not one.
238export function cleanBooth(b) {
239  if (!isPlain(b)) return null;
240  const every = num(b.every);
241  const segments = b.segments === Infinity ? Infinity : int(b.segments, 0, MAX_SEGMENTS);
242  const last = b.last === null ? null : int(b.last, 0, MAX_SEGMENTS);
243  if (!(every > 0) || every > MAX_SEGMENTS || segments === undefined || last === undefined) return null;
244  if (!Array.isArray(b.questions) || !Array.isArray(b.decisions)) return null;
245  const questions = b.questions.slice(0, MAX_QUESTIONS).map(question).filter(Boolean);
246  let form = null;
247  if (isPlain(b.form)) {
248    const name = str(b.form.name, MAX_NAME);
249    const sections = Array.isArray(b.form.sections)
250      ? b.form.sections.slice(0, MAX_SEGMENTS).filter((s) => str(s, MAX_NAME) !== undefined)
251      : [];
252    if (name && questions.some((q) => q.name === name && q.type === 'choice')) form = { name, sections };
253  }
254  const types = new Map(questions.map((q) => [q.name, q.name === form?.name ? 'form' : q.type]));
255  const budget = { n: 20000 };
256  const decisions = [];
257  b.decisions.slice(0, MAX_SEGMENTS).forEach((d, i) => {
258    const clean = d === undefined || d === null ? undefined : decision(d, types, budget);
259    if (clean && clean.segment === i) decisions[i] = clean;
260  });
261  const reactions = [];
262  if (Array.isArray(b.reactions)) {
263    b.reactions.slice(0, MAX_SEGMENTS).forEach((r, i) => {
264      const fire = isPlain(r) ? int(r.fire, 0, 1e6) : undefined;
265      const sleep = isPlain(r) ? int(r.sleep, 0, 1e6) : undefined;
266      if (fire !== undefined && sleep !== undefined) reactions[i] = { fire, sleep };
267    });
268  }
269  const about =
270    typeof b.about === 'string' ? str(b.about) ?? '' : isPlain(b.about) && str(b.about.title) ? { title: b.about.title } : '';
271  // each jev() apart, for a recorded performance; the booth works without
272  const jevs = b.jevs === undefined ? undefined : views(b.jevs, every, questions, types, budget);
273  // whether the frame is replaying a recorded performance (it is then not
274  // recorded again), and whether the song changed since
275  const replay = isPlain(b.replay) ? { changed: b.replay.changed === true } : null;
276  const model = str(b.model, MAX_NAME) ?? '';
277  return compact({
278    model,
279    about,
280    every,
281    segments,
282    questions,
283    form,
284    last,
285    decisions,
286    reactions,
287    replay,
288    jevs: jevs?.map((v) => ({ ...v, model })),
289  });
290}
291
292// The meter's newest reading (meter.mjs latest()), for the mixer.
293export function cleanLevels(l) {
294  if (l === null) return null;
295  if (!isPlain(l) || !(num(l.rms) >= 0) || !(num(l.peak) >= 0)) return undefined;
296  const parts = {};
297  if (isPlain(l.parts)) {
298    for (const [name, p] of Object.entries(l.parts).slice(0, MAX_PARTS)) {
299      if (name.length <= MAX_NAME && isPlain(p) && num(p.rms) >= 0 && num(p.peak) >= 0) parts[name] = { rms: p.rms, peak: p.peak };
300    }
301  }
302  return { rms: l.rms, peak: l.peak, parts };
303}
304
305const LOG_KINDS = new Set(['', 'error', 'warning', 'highlight']);
306
307// A message from the frame, rebuilt; null when it is not one.
308export function fromFrame(m) {
309  if (!isPlain(m) || typeof m.type !== 'string') return null;
310  switch (m.type) {
311    case 'ready':
312      return { type: 'ready' };
313    case 'state': {
314      const playing = bool(m.playing);
315      const cycle = m.cycle === null ? null : num(m.cycle);
316      const cps = m.cps === null ? null : num(m.cps);
317      const error = m.error === null ? null : str(m.error, 500);
318      // which play it reports on (the page's `run`, 0 before any)
319      const run = m.run === undefined ? 0 : int(m.run, 0, Number.MAX_SAFE_INTEGER);
320      if (playing === undefined || cycle === undefined || cps === undefined || error === undefined || run === undefined) return null;
321      return { type: 'state', playing, cycle, cps: cps > 0 ? cps : null, error, run };
322    }
323    case 'log': {
324      const message = typeof m.message === 'string' ? m.message.slice(0, 500) : null;
325      const kind = LOG_KINDS.has(m.kind) ? m.kind : '';
326      return message === null ? null : { type: 'log', message, kind };
327    }
328    case 'booth': {
329      const run = int(m.run, 0, Number.MAX_SAFE_INTEGER);
330      if (run === undefined) return null;
331      if (m.booth === null) return { type: 'booth', run, booth: null };
332      const booth = cleanBooth(m.booth);
333      return booth ? { type: 'booth', run, booth } : null;
334    }
335    case 'levels': {
336      const levels = cleanLevels(m.levels);
337      return levels === undefined ? null : { type: 'levels', levels };
338    }
339    case 'audio':
340      return ['running', 'suspended', 'closed', 'interrupted'].includes(m.state) ? { type: 'audio', state: m.state } : null;
341    case 'ask': {
342      const id = int(m.id, 0, Number.MAX_SAFE_INTEGER);
343      const body = str(m.body, MAX_ASK_BYTES);
344      const lead = m.lead === null ? null : num(m.lead);
345      const attempt = int(m.attempt, 0, 9);
346      if (id === undefined || body === undefined || lead === undefined || attempt === undefined) return null;
347      return { type: 'ask', id, body, lead, attempt };
348    }
349    default:
350      return null;
351  }
352}
353
354// A message from the page, as the frame checks it.
355export function fromPage(m) {
356  if (!isPlain(m) || typeof m.type !== 'string') return null;
357  switch (m.type) {
358    case 'play': {
359      const code = str(m.code, MAX_CODE);
360      const run = m.run === undefined ? 0 : int(m.run, 0, Number.MAX_SAFE_INTEGER);
361      const replay = m.replay === undefined || m.replay === null ? null : cleanReplay(m.replay);
362      if (code === undefined || run === undefined || replay === undefined) return null;
363      return { type: 'play', code, run, replay };
364    }
365    case 'stop':
366    case 'resume':
367      return { type: m.type };
368    case 'react': {
369      const segment = int(m.segment, 0, MAX_SEGMENTS);
370      return segment !== undefined && (m.kind === 'fire' || m.kind === 'sleep') ? { type: 'react', segment, kind: m.kind } : null;
371    }
372    case 'answer': {
373      const id = int(m.id, 0, Number.MAX_SAFE_INTEGER);
374      if (id === undefined) return null;
375      if (m.error !== undefined) return { type: 'answer', id, error: str(m.error, 500) ?? 'failed' };
376      const status = int(m.status, 200, 599);
377      const body = str(m.body, MAX_ANSWER_BYTES);
378      if (status === undefined || body === undefined || !isPlain(m.headers)) return null;
379      const headers = Object.fromEntries(
380        Object.entries(m.headers).filter(([k, v]) => k.length <= 64 && str(v, 200) !== undefined),
381      );
382      return { type: 'answer', id, status, headers, body };
383    }
384    default:
385      return null;
386  }
387}
388
389// What the frame sends of its booth (boothStore.mjs's combine()): plain
390// data only. Functions stay in the frame; each view's decisions go as data,
391// and of a replay only whether it is one and whether the song changed.
392export function boothData(booth) {
393  if (!booth) return null;
394  let data;
395  try {
396    // JSON drops functions (and turns holes into null); segments may be
397    // Infinity, which JSON cannot carry and structured cloning can
398    data = JSON.parse(
399      JSON.stringify({
400        model: booth.model,
401        about: booth.about,
402        every: booth.every,
403        questions: booth.questions,
404        form: booth.form ? { name: booth.form.name, sections: booth.form.sections } : null,
405        last: booth.last,
406        decisions: booth.decisions,
407        reactions: booth.reactions,
408        // each jev() apart (boothStore.mjs's combine() keeps them as `jevs`)
409        jevs: (booth.jevs ?? [booth]).map((v) => ({
410          every: v.every,
411          form: v.form ? { name: v.form.name } : null,
412          decisions: v.decisions,
413        })),
414        replay: booth.replay ? { changed: Boolean(booth.replay.changed) } : null,
415      }),
416    );
417  } catch {
418    return null;
419  }
420  return { ...data, segments: booth.segments };
421}
422
423// Text for the page's console, which renders its messages as HTML
424// (repl/components/panel/ConsoleTab.jsx): anything a listener wrote is
425// escaped first.
426export const escapeHtml = (s) =>
427  String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);