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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);