eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// sandbox-frame.js — the runtime INSIDE the member-app sandbox frame.
2//
3// This file is served inlined into GET /app-sandbox (memberapps.go), the page
4// the chat shell embeds as <iframe sandbox="allow-scripts"> to run a
5// member-built app. Everything in this document is untrusted-by-construction:
6// the frame has an OPAQUE origin (no cookies, storage, or parent DOM) and a
7// CSP with no network at all, so the only doorway in or out is the
8// postMessage bridge to the host page — and the host validates every message
9// (web/static/sandbox.js). This runtime is the trusted half *inside* the
10// cage: it receives the app's source + manifest + folded state over the
11// bridge, evals the app against a shim of the mini-app SDK, folds events,
12// renders into this document, and reports state + emits back out.
13//
14// Kept importable by Node (guarded boot, pure helpers exported) so
15// test/sandbox.test.mjs can pin the SDK copies against apps.js and exercise
16// the module transform. IMPORTANT: this file must never contain the literal
17// "<" + "/script" sequence — it is inlined into a script tag (see
18// TestSandboxFrameInlinable).
19//
20// Bridge protocol (all messages are plain JSON-able objects):
21// parent → frame window: { type: "lchat-sandbox-init",
22// source, manifest, threadId, me: { uid, name },
23// theme: { vars, font, dark }, seed: { v, vUpTo, pend, chat } } + [port]
24// host → frame (port): { type: "events", batch: [{ e, meta }] }
25// { type: "command", seq, cmd, args }
26// { type: "ephemeral-in", event }
27// { type: "theme", vars, font, dark }
28// { type: "rpc-reply", seq, value }
29// frame → host (port): { type: "state", state, upTo, chat? }
30// { type: "emit", event }
31// { type: "schedule", seq, event, when }
32// { type: "unschedule", seq, sid }
33// { type: "ephemeral", event }
34// { type: "command-reply", seq, spec }
35// { type: "panel-open", seq, cmdSeq, spec } (a command asking for a panel)
36// { type: "panel-size", h } (panel frame → fit my content)
37// { type: "panel-close" } (panel frame dismissing itself)
38// { type: "panel-insert", text } (panel frame → composer insert)
39// { type: "error", message }
40//
41// PANEL frames: a member app's command can open a command panel (the
42// transient surface above the composer — apps/README.md § Command panels).
43// A render closure can't cross the bridge, so the sandbox form is
44// declarative: the command calls ctx.panel({ title, data }) and the app
45// declares a top-level panel(data, el, panelCtx) hook. The host mounts a
46// FRESH sandboxed frame inside the real panel and initialises it with
47// { panel: { data } } — this runtime then calls the hook instead of starting
48// the render/fold loop. panelCtx = ctx + { close, insertText }.
49
50// The pure text helpers from ../format.js — the SAME module the trusted apps
51// use, so a member app's linkifying/formatting behaves identically. In Node
52// (tests) this import resolves normally; in the browser the /app-sandbox
53// handler BUNDLES format.js ahead of this file (stripping this line), so the
54// names below resolve to format.js's own top-level definitions in the one
55// inlined module. See memberapps.go serveAppSandbox + sandboxBundle.
56import {
57 tokenizeText,
58 richTokens,
59 blockTokens,
60 firstLink,
61 youtubeId,
62 replySnippet,
63 emojiOnlyCount,
64 typingSummary,
65 autoGroupTitle,
66 formatBytes,
67} from "./format.js";
68
69// FMT is the exact ../format.js surface a member app may import (mirrors the
70// "Allowed imports" table in docs/building-member-apps.md).
71const FMT = {
72 tokenizeText,
73 richTokens,
74 blockTokens,
75 firstLink,
76 youtubeId,
77 replySnippet,
78 emojiOnlyCount,
79 typingSummary,
80 autoGroupTitle,
81 formatBytes,
82};
83export const ALLOWED_FMT = Object.keys(FMT);
84
85// ---- SDK helper copies ----------------------------------------------------
86// Verbatim copies of the collection helpers from apps.js: the sandbox SDK
87// must behave identically to the in-page one so an app written against the
88// guide runs the same in both worlds. Drift is pinned by test/sandbox.test.mjs
89// — change these ONLY in lockstep with apps.js.
90
91export function addItem(list, item) {
92 return list.some((i) => i.id === item.id) ? list.slice() : [...list, item];
93}
94export function updateItem(list, id, patch) {
95 return list.map((i) => (i.id === id ? { ...i, ...patch } : i));
96}
97export function removeItem(list, id) {
98 return list.filter((i) => i.id !== id);
99}
100
101export const DONE_GRACE_MS = 6000;
102
103export function partitionByGrace(items, doneAtOf, now, graceMs = DONE_GRACE_MS) {
104 const active = [];
105 const grace = [];
106 const archived = [];
107 for (const it of items || []) {
108 const d = doneAtOf(it) || 0;
109 if (!d) active.push(it);
110 else if (now - d < graceMs) grace.push(it);
111 else archived.push(it);
112 }
113 return { active, grace, archived };
114}
115
116// namespace(ns) — verbatim copy of apps.js's helper (pinned by test/sandbox.test.mjs):
117// N.ev("add") → "todo.add" (event type), N.cls("item") → "todo-item" (CSS class).
118export function namespace(ns) {
119 const base = String(ns || "");
120 return {
121 ns: base,
122 ev: (name) => (name != null && String(name).includes(".") ? String(name) : base + "." + name),
123 cls: (name) => base + "-" + name,
124 };
125}
126
127export function keyedList(el, items, opts) {
128 const { key, render, rev = () => "", srev = () => "", update } = opts;
129 const existing = new Map();
130 for (const child of [...el.children]) {
131 if (child.dataset && child.dataset.k !== undefined) existing.set(child.dataset.k, child);
132 }
133 let cursor = null;
134 for (const item of items) {
135 const k = String(key(item));
136 const r = String(rev(item));
137 const sr = String(srev(item));
138 let node = existing.get(k);
139 existing.delete(k);
140 if (node && node.dataset.rev !== r) {
141 const fresh = render(item);
142 fresh.dataset.k = k;
143 fresh.dataset.rev = r;
144 fresh.dataset.srev = sr;
145 node.replaceWith(fresh);
146 node = fresh;
147 } else if (!node) {
148 node = render(item);
149 node.dataset.k = k;
150 node.dataset.rev = r;
151 node.dataset.srev = sr;
152 } else if (update && node.dataset.srev !== sr) {
153 update(node, item);
154 node.dataset.srev = sr;
155 }
156 const want = cursor ? cursor.nextSibling : el.firstChild;
157 if (node !== want) el.insertBefore(node, want);
158 cursor = node;
159 }
160 for (const gone of existing.values()) gone.remove();
161}
162
163// The exact import surface a member app may use — the sandbox mirror of the
164// "Allowed imports" table in docs/building-member-apps.md. (No format.js in
165// the sandbox yet; the runtime must stay a single self-contained script.)
166export const ALLOWED_IMPORTS = [
167 "defineApp",
168 "namespace",
169 "addItem",
170 "updateItem",
171 "removeItem",
172 "partitionByGrace",
173 "DONE_GRACE_MS",
174 "keyedList",
175];
176
177// ---- module transform -----------------------------------------------------
178// A member app is written as a plain ES module (same shape as a trusted app),
179// but the frame can't load modules from anywhere (CSP: no network, no blob:).
180// So we rewrite the module's static surface into a Function body: the one
181// legal import becomes a destructure of the injected SDK object, export
182// keywords are stripped (registration happens via the defineApp call, not the
183// export), and anything else import/export-shaped is rejected. The rewrite is
184// reliable because the authoring contract pins the surface: named imports
185// from "../apps.js" only (docs/building-member-apps.md, hard constraints).
186
187// stripCode removes comments and string/template contents so token scans
188// don't trip on prose ("never call import()"). Best-effort, scan-only — the
189// output is never executed.
190export function stripCode(code) {
191 let out = "";
192 let i = 0;
193 const n = code.length;
194 while (i < n) {
195 const c = code[i];
196 const c2 = code[i + 1];
197 if (c === "/" && c2 === "/") {
198 while (i < n && code[i] !== "\n") i++;
199 continue;
200 }
201 if (c === "/" && c2 === "*") {
202 i += 2;
203 while (i < n && !(code[i] === "*" && code[i + 1] === "/")) i++;
204 i += 2;
205 continue;
206 }
207 if (c === '"' || c === "'" || c === "`") {
208 const q = c;
209 i++;
210 while (i < n && code[i] !== q) {
211 if (code[i] === "\\") i++;
212 i++;
213 }
214 i++;
215 out += q + q;
216 continue;
217 }
218 out += c;
219 i++;
220 }
221 return out;
222}
223
224// rewriteImport turns `import { a, b as c } from "…"` into a destructure of
225// the injected object `objName`, validating each name against `allowed`.
226function rewriteImport(names, allowed, objName) {
227 const bindings = [];
228 for (const part of names.split(",")) {
229 const p = part.trim();
230 if (!p) continue;
231 const m = /^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/.exec(p);
232 if (!m) throw new Error(`unsupported import binding: "${p}"`);
233 if (!allowed.includes(m[1])) {
234 throw new Error(`"${m[1]}" is not an allowed import — allowed: ${allowed.join(", ")}`);
235 }
236 bindings.push(m[2] ? `${m[1]}: ${m[2]}` : m[1]);
237 }
238 return `const { ${bindings.join(", ")} } = ${objName};`;
239}
240
241// transformModuleSource rewrites a member-app ES module into a strict-mode
242// Function body that takes the SDK as `__sdk` and the format helpers as
243// `__fmt`. Throws with a human-readable message on anything outside the
244// authoring contract.
245export function transformModuleSource(src) {
246 if (typeof src !== "string" || !src.trim()) throw new Error("empty app source");
247 let out = src;
248
249 // The two legal import sources: named bindings from "../apps.js" (the SDK)
250 // and "../format.js" (the pure text helpers). Rename support:
251 // `addItem as add` → `addItem: add`.
252 out = out.replace(
253 /import\s*\{([^}]*)\}\s*from\s*(["'])(?:\.\.\/|\/static\/)apps\.js\2\s*;?/g,
254 (_, names) => rewriteImport(names, ALLOWED_IMPORTS, "__sdk"),
255 );
256 out = out.replace(
257 /import\s*\{([^}]*)\}\s*from\s*(["'])(?:\.\.\/|\/static\/)format\.js\2\s*;?/g,
258 (_, names) => rewriteImport(names, ALLOWED_FMT, "__fmt"),
259 );
260
261 // Exports: strip the keyword (declarations stay), neutralise `default`,
262 // drop bare re-export lists of already-declared names.
263 out = out.replace(/\bexport\s+default\s+/g, "const __defaultExport = ");
264 out = out.replace(/\bexport\s+(?=(?:const|let|var|function|class|async)\b)/g, "");
265 out = out.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, "");
266
267 // Anything import/export-shaped that survived is outside the contract.
268 const stripped = stripCode(out);
269 if (/(^|[^.\w$])import\b/.test(stripped)) {
270 throw new Error('imports are allowed only from "../apps.js" and "../format.js"');
271 }
272 if (/(^|[^.\w$])export\b/.test(stripped)) {
273 throw new Error("unsupported export form");
274 }
275 return '"use strict";\n' + out;
276}
277
278// makeReducer — same contract as apps.js: seed initialState on first fold,
279// dispatch by type, unknown types fall through unchanged.
280export function makeSandboxReducer(events, initialState) {
281 const seed = typeof initialState === "function" ? initialState : () => initialState || {};
282 return (state, event, meta) => {
283 const s = state === undefined ? seed() : state;
284 const handler = events && events[event.type];
285 return handler ? handler(s, event, meta || {}) : s;
286 };
287}
288
289// evalMemberApp runs the transformed module against a defineApp-capturing SDK
290// and returns the app spec. Pure of DOM (render isn't called), so Node tests
291// can exercise real generated apps end-to-end.
292export function evalMemberApp(source, expectedName) {
293 let captured = null;
294 const sdk = {
295 namespace,
296 addItem,
297 updateItem,
298 removeItem,
299 partitionByGrace,
300 DONE_GRACE_MS,
301 keyedList,
302 defineApp(spec) {
303 if (captured) throw new Error("defineApp called more than once");
304 if (!spec || typeof spec !== "object") throw new Error("defineApp needs a spec object");
305 captured = { ...spec };
306 // Mirror apps.js: namespace defaults to name, and app.ns is the resolver.
307 captured.namespace = captured.namespace || captured.name;
308 captured.ns = namespace(captured.namespace);
309 captured.types = Object.keys(captured.events || {});
310 captured.reduce = makeSandboxReducer(captured.events || {}, captured.initialState);
311 return captured;
312 },
313 };
314 const body = transformModuleSource(source);
315 new Function("__sdk", "__fmt", body)(sdk, FMT);
316 if (!captured) throw new Error("the app never called defineApp");
317 if (expectedName && captured.name !== expectedName) {
318 throw new Error(`app name "${captured.name}" does not match its manifest ("${expectedName}")`);
319 }
320 captured.commands = normalizeCommands(captured.commands);
321 return captured;
322}
323
324// manifestOf derives the deploy manifest from an evaluated app spec — the
325// authoritative extractor App Studio uses (it runs the real defineApp in the
326// cage, so name/title/glyph/types/commands/chat/places are exactly what the
327// app declares). Mirrors the shape validateManifest (sandbox.js) checks.
328export function manifestOf(spec) {
329 const m = {
330 v: 1,
331 name: spec.name,
332 title: spec.title,
333 glyph: spec.glyph,
334 // Optional native chrome: an SF Symbols name iOS/Mac render for the app
335 // (docs/icons.md) — declared beside the emoji glyph, exactly like a
336 // built-in. The emoji stays the universal identity; a bad name just
337 // falls back to it. (Brand `mark`s are NOT a member-app field: naming a
338 // vendored asset would wear another product's logo.)
339 ...(typeof spec.symbol === "string" ? { symbol: spec.symbol } : {}),
340 types: spec.types || [],
341 commands: Object.entries(spec.commands || {}).map(([name, c]) => ({
342 name,
343 desc: (c && c.desc) || "",
344 args: (c && c.args) || "",
345 })),
346 chat: typeof spec.chatMessage === "function",
347 // places: WHERE copies of the app can live — unlike `chat` it can't be
348 // derived from code shape; it's a trust decision the author declares
349 // explicitly in the source's defineApp. A places-first source passes
350 // through verbatim (the deploy validator holds it to the member-app
351 // rules, "you" included); a source written in the legacy vocabulary
352 // (groupCapable/spaceCapable booleans) normalizes into it — so a minted
353 // manifest always speaks the current vocabulary, whichever generation
354 // the source was written in.
355 places: Array.isArray(spec.places) ? spec.places : ["you", ...(spec.groupCapable ? ["chat"] : []), ...(spec.spaceCapable ? ["space"] : [])],
356 // listed: false — the implicitly-placed shape (declared, like places):
357 // the app stays out of the catalogues; with places ["space"] its one
358 // copy is the space's, used on first command with no add gesture (the
359 // /gif shape — docs/space-apps.md).
360 ...(spec.listed === false ? { listed: false } : {}),
361 ...(spec.placeNote ? { placeNote: String(spec.placeNote).slice(0, 120) } : {}),
362 // quiet: same idea — a behavior decision the author declares explicitly
363 // (defineApp({ quiet: true, ... })), not derivable from code shape.
364 quiet: !!spec.quiet,
365 };
366 // defaultCommand: the command plain (non-slash) text runs in the app's Chat
367 // view (like To-do's /add). Host-side routing hint — validateManifest checks it
368 // names a real command.
369 if (typeof spec.defaultCommand === "string" && spec.defaultCommand) {
370 m.defaultCommand = spec.defaultCommand;
371 }
372 return m;
373}
374
375// normalizeCommands mirrors apps.js: a bare handler function and the richer
376// { run, desc, args } form both become { run, desc, args }.
377export function normalizeCommands(cmds) {
378 const out = {};
379 for (const [name, v] of Object.entries(cmds || {})) {
380 if (typeof v === "function") out[name] = { run: v, desc: "", args: "" };
381 else if (v && typeof v.run === "function") out[name] = { run: v.run, desc: v.desc || "", args: v.args || "" };
382 }
383 return out;
384}
385
386// ---- frame boot -----------------------------------------------------------
387
388// How many companion chat lines the frame keeps + reports, and the max length
389// of each. Bounds the snapshot (like the ~100-message chat window) so a large
390// or hostile projection can't blow the server's thread_state cap; older lines
391// drop off. Kept in step with sanitizeChat's caps in sandbox.js.
392const CHAT_CAP = 200;
393const CHAT_TEXT_CAP = 400;
394
395// isSubmitTrigger — would activating this element START native form submission?
396// True for a submit button, an image input, or a TYPELESS <button> (which
397// defaults to type="submit"), when it belongs to a form. Pure + exported so
398// it's unit-tested; bootFrame's click handler uses it to catch the trigger
399// BEFORE the browser runs the sandbox-blocked, error-logging submission
400// algorithm (see the shim in bootFrame for why the trigger, not the submit
401// event, is what must be cancelled).
402export function isSubmitTrigger(tagName, typeAttr, hasForm) {
403 if (!hasForm) return false;
404 const tag = String(tagName || "").toUpperCase();
405 const t = String(typeAttr || "").toLowerCase();
406 if (tag === "INPUT") return t === "submit" || t === "image";
407 if (tag === "BUTTON") return t === "" || t === "submit"; // typeless button = submit
408 return false;
409}
410
411// isEnterSubmit — would this keydown IMPLICITLY submit a form? Enter, without
412// Shift, not mid-IME-composition, in a single-line <input> that belongs to a
413// form. (Textareas keep Enter for newlines; buttons/other targets are ignored.)
414export function isEnterSubmit(key, shiftKey, isComposing, tagName, hasForm) {
415 return key === "Enter" && !shiftKey && !isComposing && !!hasForm
416 && String(tagName || "").toUpperCase() === "INPUT";
417}
418
419function bootFrame() {
420 let port = null;
421 let spec = null;
422 let state; // undefined until first fold / non-null seed
423 let maxFolded = 0;
424 const foldedIds = new Set();
425 let cleanups = [];
426 let rpcSeq = 0;
427 const rpcWaiters = new Map();
428 let root = null;
429 // Companion: each event projected via the app's chatMessage into a chat line
430 // { mid, ts, text, action }. Seeded from the snapshot, appended on fold.
431 let chatLines = [];
432
433 const uuid = () =>
434 crypto.randomUUID
435 ? crypto.randomUUID()
436 : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
437 const r = (Math.random() * 16) | 0;
438 return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
439 });
440
441 const send = (msg) => {
442 if (!port) return;
443 try {
444 port.postMessage(msg);
445 } catch {
446 // Not structured-cloneable — report rather than die silently.
447 try {
448 port.postMessage({ type: "error", message: "app produced a non-serializable message" });
449 } catch {}
450 }
451 };
452
453 const rpc = (type, fields) => {
454 const seq = ++rpcSeq;
455 return new Promise((resolve) => {
456 rpcWaiters.set(seq, resolve);
457 send({ type, seq, ...fields });
458 // The host answers quickly or not at all (e.g. it was torn down);
459 // resolve null so an app awaiting a schedule can degrade gracefully.
460 setTimeout(() => {
461 if (rpcWaiters.delete(seq)) resolve(null);
462 }, 15000);
463 });
464 };
465
466 const showFatal = (msg) => {
467 document.body.textContent = "";
468 const p = document.createElement("p");
469 p.style.cssText = "padding:16px;font:13px/1.5 system-ui,sans-serif;color:var(--muted,#888)";
470 p.textContent = `This app hit an error: ${msg}`;
471 document.body.appendChild(p);
472 send({ type: "error", message: String(msg) });
473 };
474
475 const applyTheme = (theme) => {
476 if (!theme) return;
477 const de = document.documentElement;
478 for (const [k, v] of Object.entries(theme.vars || {})) {
479 if (/^--[\w-]+$/.test(k) && typeof v === "string") de.style.setProperty(k, v);
480 }
481 // color-scheme on <html> (like the shell) so the light-dark() in the vars
482 // resolves to the right side. Background + text colour come from the base
483 // `html, body { background: var(--panel); color: var(--ink) }` rule the frame
484 // is served with (--panel = the chat panel it's embedded in), so forwarding
485 // the vars above is all that's needed to match the surrounding surface.
486 de.style.colorScheme = theme.dark ? "dark" : "light";
487 if (typeof theme.font === "string" && theme.font) document.body.style.font = theme.font;
488 };
489
490 const ctx = {
491 threadId: null,
492 me: null,
493 meName: null,
494 id: uuid,
495 // Namespace resolvers bound to this app (see apps.js makeCtx). `spec` is
496 // assigned during init, so read it lazily; before then a bare name is
497 // returned so an early call never throws.
498 ev: (name) => (spec && spec.ns ? spec.ns.ev(name) : String(name)),
499 cls: (name) => (spec && spec.ns ? spec.ns.cls(name) : String(name)),
500 getState: () => state,
501 emit(event) {
502 send({ type: "emit", event });
503 return Promise.resolve(true);
504 },
505 schedule(event, when) {
506 return rpc("schedule", { event, when });
507 },
508 unschedule(sid) {
509 return rpc("unschedule", { sid: String(sid || "") });
510 },
511 emitEphemeral(event) {
512 send({ type: "ephemeral", event });
513 },
514 interval(fn, ms) {
515 const t = setInterval(fn, ms);
516 cleanups.push(() => clearInterval(t));
517 return t;
518 },
519 onCleanup(fn) {
520 cleanups.push(fn);
521 },
522 open() {}, // navigation is the host's business; a sandboxed app can't move you
523 };
524
525 const runCleanups = () => {
526 const fns = cleanups;
527 cleanups = [];
528 for (const fn of fns) {
529 try {
530 fn();
531 } catch {}
532 }
533 };
534
535 const draw = () => {
536 if (!spec || !root) return;
537 runCleanups();
538 if (!spec.incremental) root.textContent = "";
539 if (!spec.render) return;
540 try {
541 spec.render(state, root, ctx);
542 } catch (e) {
543 console.error("member app render failed:", e);
544 root.textContent = "";
545 const p = document.createElement("p");
546 p.style.cssText = "padding:16px;font:13px/1.5 system-ui,sans-serif;color:var(--muted,#888)";
547 p.textContent = `This app's view hit an error (${(e && e.message) || e}).`;
548 root.appendChild(p);
549 }
550 };
551
552 // Project one event into a companion chat line via the app's chatMessage,
553 // if it opted in. Pure of state; a throw is swallowed (a bad projection must
554 // not break the fold).
555 const project = (e, id, meta) => {
556 if (id == null || !spec || typeof spec.chatMessage !== "function") return;
557 let line = null;
558 try {
559 line = spec.chatMessage(e);
560 } catch {}
561 const text = typeof line === "string" ? line : line && line.text;
562 if (!text) return;
563 chatLines.push({
564 mid: id,
565 ts: (e && e.ts) || (meta && meta.ts) || 0,
566 text: String(text).slice(0, CHAT_TEXT_CAP),
567 action: typeof line === "object" && !!line.action,
568 });
569 if (chatLines.length > CHAT_CAP) chatLines = chatLines.slice(chatLines.length - CHAT_CAP);
570 };
571
572 // Fold one wire event. Reducer throws must never take the frame down — a
573 // hostile/malformed event is a legal input that folds to a no-op.
574 const fold = (e, meta) => {
575 const id = meta && meta.id;
576 if (id != null) {
577 if (foldedIds.has(id)) return;
578 foldedIds.add(id);
579 if (id > maxFolded) maxFolded = id;
580 }
581 try {
582 state = spec.reduce(state, e, meta || {});
583 } catch (err) {
584 console.error("member app reducer failed:", err);
585 }
586 project(e, id, meta);
587 };
588
589 const reportState = () => {
590 // JSON-normalize: snapshots are JSON on the host side too, so anything
591 // non-JSON in state would diverge between live and reloaded sessions.
592 let snap = null;
593 try {
594 snap = JSON.parse(JSON.stringify(state === undefined ? null : state));
595 } catch {
596 send({ type: "error", message: "app state is not serializable" });
597 return;
598 }
599 const msg = { type: "state", state: snap, upTo: maxFolded };
600 // A companion app also reports its projected chat lines; the host folds
601 // them into the thread's Chat view (idempotent by mid).
602 if (spec && typeof spec.chatMessage === "function") msg.chat = chatLines;
603 send(msg);
604 };
605
606 // Run a slash command's handler (untrusted) in here, where it belongs. Its
607 // ctx.emit/schedule bridge out exactly like the surface's; ctx.reply is
608 // captured and returned to the host, which shows it as the private "only
609 // visible to you" confirmation where the user typed. Emits happen through the
610 // normal path, so a command that fires before we reply is still ordered.
611 const runCommand = async (m) => {
612 const handler = spec && spec.commands && spec.commands[m.cmd];
613 let replySpec = null;
614 if (handler && typeof handler.run === "function") {
615 const cctx = Object.assign({}, ctx, {
616 reply: (r) => {
617 if (r && typeof r === "object") {
618 replySpec = { text: String(r.text || ""), short: r.short != null ? String(r.short) : undefined };
619 }
620 },
621 // Open a command panel (declarative — see the header). The host mounts
622 // a fresh panel frame that runs the app's panel(data, el, panelCtx)
623 // hook; everything crossing the bridge is plain JSON. Resolves whether
624 // a panel actually opened (false: no hook, no chat surface, rejected).
625 panel: (p) => {
626 if (!spec || typeof spec.panel !== "function") {
627 console.warn("ctx.panel needs a top-level panel(data, el, panelCtx) hook in the sandbox");
628 return Promise.resolve(false);
629 }
630 const c = p && typeof p === "object" ? p : {};
631 // Unset fields are OMITTED (an `undefined` property would trip the
632 // host's whole-object JSON validation).
633 const out = { data: c.data === undefined ? null : c.data };
634 if (c.title !== undefined) out.title = c.title;
635 if (c.position !== undefined) out.position = c.position;
636 if (c.animate !== undefined) out.animate = c.animate;
637 return rpc("panel-open", { cmdSeq: m.seq, spec: out }).then((v) => !!v);
638 },
639 });
640 try {
641 await handler.run(String(m.args || ""), cctx);
642 } catch (e) {
643 console.error("member command failed:", e);
644 }
645 }
646 send({ type: "command-reply", seq: m.seq, spec: replySpec });
647 };
648
649 const onPortMessage = (ev) => {
650 const m = ev.data;
651 if (!m || typeof m !== "object") return;
652 if (m.type === "events" && Array.isArray(m.batch)) {
653 for (const it of m.batch) if (it && it.e) fold(it.e, it.meta);
654 reportState();
655 draw();
656 } else if (m.type === "command") {
657 runCommand(m);
658 } else if (m.type === "ephemeral-in") {
659 if (spec && typeof spec.ephemeral === "function") {
660 try {
661 spec.ephemeral(m.event, ctx);
662 } catch {}
663 }
664 } else if (m.type === "theme") {
665 applyTheme(m);
666 } else if (m.type === "rpc-reply") {
667 const w = rpcWaiters.get(m.seq);
668 if (w) {
669 rpcWaiters.delete(m.seq);
670 w(m.value === undefined ? null : m.value);
671 }
672 }
673 };
674
675 const onInit = (ev) => {
676 // Only the embedding page may initialise us. Its origin is the real
677 // instance origin — a sibling sandbox frame (opaque origin, "null") that
678 // grabbed a window reference can't pass this check.
679 if (ev.source !== window.parent || ev.origin === "null" || ev.origin === "") return;
680 const m = ev.data;
681 if (!m || m.type !== "lchat-sandbox-init" || port) return;
682 if (!ev.ports || !ev.ports[0]) return;
683 window.removeEventListener("message", onInit);
684 port = ev.ports[0];
685 port.onmessage = onPortMessage;
686
687 // Extract mode: eval the source only to report its manifest (App Studio's
688 // "Check" — deriving name/title/glyph/types/commands/chat from the real
689 // defineApp call, in the cage). No render, no seed, no fold.
690 if (m.extract) {
691 try {
692 const s = evalMemberApp(m.source);
693 port.postMessage({ type: "extracted", manifest: manifestOf(s) });
694 } catch (e) {
695 port.postMessage({ type: "extracted", error: (e && e.message) || String(e) });
696 }
697 return;
698 }
699
700 ctx.threadId = m.threadId || null;
701 ctx.me = (m.me && m.me.uid) || null;
702 ctx.meName = (m.me && m.me.name) || "";
703 applyTheme(m.theme);
704
705 root = document.createElement("div");
706 root.id = "app-root";
707 root.style.cssText = "height:100%;overflow:auto;box-sizing:border-box";
708 document.body.appendChild(root);
709
710 try {
711 spec = evalMemberApp(m.source, m.manifest && m.manifest.name);
712 } catch (e) {
713 showFatal((e && e.message) || String(e));
714 return;
715 }
716 if (spec.styles) {
717 const st = document.createElement("style");
718 st.textContent = String(spec.styles);
719 document.head.appendChild(st);
720 }
721 // Hooks that still don't work in the sandbox — say so where the author can
722 // see it. (commands, chatMessage, and ../format.js imports ARE supported.)
723 for (const k of ["messageAction", "renderEvent", "notify", "preview", "unread", "hostEvents"]) {
724 if (spec[k]) console.warn(`member app "${spec.name}": "${k}" is not available in the sandbox — ignored`);
725 }
726
727 // Seed: the host's snapshot of our own last report, plus the queued
728 // events it accumulated while no frame was alive, plus the companion chat
729 // lines projected before the last compaction.
730 const seed = m.seed || {};
731 if (seed.v !== null && seed.v !== undefined) {
732 state = seed.v;
733 maxFolded = seed.vUpTo || 0;
734 }
735 chatLines = Array.isArray(seed.chat) ? seed.chat.slice(-CHAT_CAP) : [];
736 for (const it of seed.pend || []) if (it && it.e) fold(it.e, it.meta);
737
738 // PANEL mode: this frame IS a command panel's body. Run the app's panel
739 // hook against the seeded state and keep the host sized to the content —
740 // no render/fold loop and no state reports (the app-thread's own frame
741 // owns those; the host ignores a panel frame's "state" anyway).
742 if (m.panel) {
743 if (state === undefined) {
744 try {
745 state = spec.reduce(undefined, { type: "@@lchat/init" }, {});
746 } catch {
747 state = null;
748 }
749 }
750 if (typeof spec.panel !== "function") {
751 showFatal("this app has no panel(data, el, panelCtx) hook");
752 return;
753 }
754 const pctx = Object.assign({}, ctx, {
755 close: () => send({ type: "panel-close" }),
756 insertText: (t) => send({ type: "panel-insert", text: String(t == null ? "" : t).slice(0, 2000) }),
757 });
758 try {
759 spec.panel(m.panel.data === undefined ? null : m.panel.data, root, pctx);
760 } catch (e) {
761 showFatal((e && e.message) || String(e));
762 return;
763 }
764 const postSize = () => {
765 const h = Math.max(document.body.scrollHeight, root.scrollHeight || 0);
766 send({ type: "panel-size", h: Math.min(600, Math.max(24, h)) });
767 };
768 if (typeof ResizeObserver !== "undefined") new ResizeObserver(postSize).observe(document.body);
769 postSize();
770 return;
771 }
772 // Materialise initialState even with zero events to fold: the reducer only
773 // seeds on the first fold, so a freshly-deployed app opened before any event
774 // would otherwise render with state === undefined — and a render that reads
775 // a state field (e.g. `state.count`, as the App Studio starter does) throws.
776 // Folding a benign, unhandled event returns the seeded initialState untouched.
777 if (state === undefined) {
778 try {
779 state = spec.reduce(undefined, { type: "@@lchat/init" }, {});
780 } catch (err) {
781 console.error("member app initialState failed:", err);
782 state = null;
783 }
784 }
785 reportState();
786 draw();
787 };
788
789 // Make a plain <form onsubmit> work in this sandbox WITHOUT loosening it.
790 // Native submission has nowhere to go (no allow-forms, and the CSP forbids
791 // it), so the browser blocks it AND logs a scary "Blocked form submission …
792 // 'allow-forms' is not set". Crucially, that message is emitted while the
793 // submission ALGORITHM runs, and canceling the `submit` event does NOT
794 // suppress it (verified) — only preventing the algorithm from STARTING does.
795 // So intercept the triggers (a submit button's click, an Enter implicit
796 // submit) in capture, cancel them, and re-dispatch a SCRIPTED `submit` event:
797 // a scripted dispatch runs the app's onsubmit but performs no native
798 // submission — so no navigation and no console error, and the sandbox stays
799 // exactly as tight. Apps may use <form onsubmit> or a <button onclick>; both
800 // just work.
801 const fireSubmit = (form, submitter) => {
802 const ev = typeof SubmitEvent === "function"
803 ? new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: submitter || null })
804 : new Event("submit", { bubbles: true, cancelable: true });
805 form.dispatchEvent(ev);
806 };
807 document.addEventListener("click", (e) => {
808 const b = e.target && e.target.closest && e.target.closest("button, input[type=submit], input[type=image]");
809 if (!b || !isSubmitTrigger(b.tagName, b.getAttribute("type"), !!b.form)) return;
810 e.preventDefault(); // stop the native submission → no console error
811 // If the button handles its OWN click, don't also synthesize a submit — its
812 // onclick covers it (avoids a double-fire); otherwise re-dispatch so a pure
813 // submit button's form.onsubmit still runs.
814 if (typeof b.onclick !== "function") fireSubmit(b.form, b);
815 }, true);
816 document.addEventListener("keydown", (e) => {
817 if (!isEnterSubmit(e.key, e.shiftKey, e.isComposing, e.target && e.target.tagName, !!(e.target && e.target.form))) return;
818 e.preventDefault();
819 fireSubmit(e.target.form, null);
820 }, true);
821 // Backstop: cancel the navigation on any real submit that slips past the
822 // triggers above (e.g. a programmatic form.requestSubmit()). This does NOT
823 // suppress the console error for that path — only the trigger intercept does —
824 // so it's defense in depth, not the fix.
825 document.addEventListener("submit", (e) => e.preventDefault(), true);
826
827 // The host initialises us off our iframe's `load` event (see initMount in
828 // sandbox.js) — an unforgeable signal — rather than a postMessage from here,
829 // so hostile app code can't fake readiness to hijack the bridge. Module
830 // scripts run before the frame's load event fires, so this listener is
831 // installed in time to receive that init.
832 window.addEventListener("message", onInit);
833}
834
835// Boot only inside a real embedded frame — never under Node (tests import
836// this module for the pure helpers above).
837if (typeof window !== "undefined" && window.parent !== window) {
838 bootFrame();
839}
840