jevstrudel.git / website / src / jev / sandboxProtocol.mjs

The wire between the page and the sandbox a listener's song plays in (sandbox.mjs is the page's end, sandbox/player.mjs the frame's). The frame runs someone else's code, so everything it sends is untrusted: each message is rebuilt here from the fields the page reads, each checked for its type and bounded in size, and anything else is dropped. Nothing from the frame is ever evaluated, rendered as HTML, or passed on as it came.

Page → frame: play { code, run, replay? } (replay: a recorded performance's { jevs, changed } for the frame's jev()s to play, as the page replays a site song's; performance.mjs), stop, react { segment, kind }, answer { id, status, headers, body } (a Jev call the page made for the frame), and resume (the page was clicked: start the frame's audio). Frame → page: ready, state { playing, cycle, cps, error, run } (run is the last play's, so the page knows which play a state is about), log { message, kind }, booth { run, booth } (what Jev decided, for the booth and the mixer), levels { levels } (the meter), audio { state }, ask { id, body, lead, attempt } (a Jev call, which the page makes without the visitor's session: see sandbox.mjs).

20export const MAX_CODE = 1024 * 1024;

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;
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);

an object without the keys whose value is undefined

39const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));

Plain JSON data, bounded: what Jev was told (a decision's states) is shown as JSON text, so only its shape and size need checking. budget 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}

How a finished section measured (meter.mjs's hear(), as Jev was told it), 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}
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}

One answer, as its question's type needs it: the booth calls toFixed on a 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}
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}

The frame's jev()s one by one, in declaration order (each jev()'s own decisions), which a recorded performance keeps apart (performance.mjs's performanceOf): at most MAX_JEVS, on the booth's 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}

A recorded performance for the frame to replay (fromPage's play), as the Worker stores it (worker/src/listening.ts): only what jevCore's 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;

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}

What Jev decided in the frame, as the booth, the mixer and the jev panel read it (boothStore.mjs's combine()), rebuilt from checked fields; null 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}

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}
305const LOG_KINDS = new Set(['', 'error', 'warning', 'highlight']);

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}

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}

What the frame sends of its booth (boothStore.mjs's combine()): plain data only. Functions stay in the frame; each view's decisions go as data, 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}

Text for the page's console, which renders its messages as HTML (repl/components/panel/ConsoleTab.jsx): anything a listener wrote is escaped first.

426export const escapeHtml = (s) =>
427  String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);