// Tests for the member-app sandbox (web/static/sandbox.js — the host side — // and web/static/sandbox-frame.js — the in-frame runtime). // node test/sandbox.test.mjs import test from "node:test"; import assert from "node:assert/strict"; import { addItem as hostAddItem, updateItem as hostUpdateItem, removeItem as hostRemoveItem, partitionByGrace as hostPartition, DONE_GRACE_MS as HOST_GRACE, namespace as hostNamespace, defineApp, foldEvent, appStateFor, seedThread, takeSnapshot, resetThread, installMemberAppState, isAppType, appByName, groupCapableApps, appIsQuiet, commandTakenBy, } from "../web/static/apps.js"; import { addItem, updateItem, removeItem, partitionByGrace, DONE_GRACE_MS, namespace, ALLOWED_IMPORTS, ALLOWED_FMT, stripCode, transformModuleSource, evalMemberApp, normalizeCommands, manifestOf, isSubmitTrigger, isEnterSubmit, } from "../web/static/sandbox-frame.js"; import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, sanitizePanelSpec, registerMemberApp, resolveDark, manifestIsLegacy } from "../web/static/sandbox.js"; import { tokenizeText as realTokenize, formatBytes as realBytes } from "../web/static/format.js"; // ---- SDK drift pins: the sandbox copies must behave exactly like apps.js ---- test("sandbox SDK helpers match apps.js behaviour", () => { const list = [{ id: "a", n: 1 }]; for (const [host, sand] of [ [hostAddItem, addItem], [hostUpdateItem, updateItem], [hostRemoveItem, removeItem], ]) { assert.equal(typeof host, "function"); assert.equal(typeof sand, "function"); } assert.deepEqual(addItem(list, { id: "b" }), hostAddItem(list, { id: "b" })); assert.deepEqual(addItem(list, { id: "a" }), hostAddItem(list, { id: "a" })); // dedupe assert.deepEqual(updateItem(list, "a", { n: 2 }), hostUpdateItem(list, "a", { n: 2 })); assert.deepEqual(removeItem(list, "a"), hostRemoveItem(list, "a")); assert.equal(DONE_GRACE_MS, HOST_GRACE); const items = [{ id: 1, d: 0 }, { id: 2, d: 900 }, { id: 3, d: 100 }]; assert.deepEqual( partitionByGrace(items, (i) => i.d, 1000, 500), hostPartition(items, (i) => i.d, 1000, 500), ); // namespace() must behave identically to the in-page one (same wire types + classes). const s = namespace("todo"); const h = hostNamespace("todo"); assert.deepEqual([s.ns, s.ev("add"), s.cls("item"), s.ev("chat.msg")], [h.ns, h.ev("add"), h.cls("item"), h.ev("chat.msg")]); }); test("namespace is an allowed import and works inside a member app", () => { assert.ok(ALLOWED_IMPORTS.includes("namespace"), "namespace must be importable by a member app"); // A member app declaring its types via namespace() gets the same owned types as // one that spells them out — the wire strings are identical either way. const src = ` import { defineApp, namespace, addItem } from "../apps.js"; const N = namespace("habit"); const app = defineApp({ name: "habit", title: "Habit", glyph: "H", initialState: () => ({ items: [] }), events: { [N.ev("add")]: (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }), [N.ev("tick")]: (s) => s, }, render() {}, }); `; const spec = evalMemberApp(src, "habit"); assert.deepEqual(spec.types.sort(), ["habit.add", "habit.tick"].sort()); assert.equal(spec.ns.ev("add"), "habit.add"); assert.equal(spec.ns.cls("row"), "habit-row"); // The derived manifest still validates — its types are name-prefixed, as the // sandbox requires (namespace defaults to name). assert.equal(validateManifest(manifestOf(spec)), null); }); // ---- module transform ------------------------------------------------------- test("transformModuleSource rewrites the legal import and strips exports", () => { const out = transformModuleSource( `import { defineApp, addItem as add } from "../apps.js";\nexport const app = defineApp({});\nexport default app;\n`, ); assert.match(out, /const \{ defineApp, addItem: add \} = __sdk;/); assert.match(out, /const app = defineApp/); assert.match(out, /const __defaultExport = app/); assert.doesNotMatch(out, /(^|[^\w$])export\s/); }); test("transformModuleSource rejects contract violations", () => { assert.throws(() => transformModuleSource(`import { foldEvent } from "../apps.js";`), /not an allowed import/); assert.throws(() => transformModuleSource(`import { defineApp } from "../format.js";`), /not an allowed import/); assert.throws(() => transformModuleSource(`import { nameList } from "../format.js";`), /not an allowed import/); assert.throws(() => transformModuleSource(`import x from "https://evil.example/x.js";`), /apps\.js/); assert.throws(() => transformModuleSource(`const m = await import("x");`), /apps\.js/); assert.throws(() => transformModuleSource(""), /empty/); // Prose mentioning import in comments/strings is fine. transformModuleSource(`// never use import() here\nconst s = "import nothing";\nconst app = 1;`); }); test("transformModuleSource accepts format.js helper imports", () => { const out = transformModuleSource( `import { defineApp } from "../apps.js";\nimport { tokenizeText, formatBytes as fb } from "../format.js";\nconst app = 1;`, ); assert.match(out, /const \{ defineApp \} = __sdk;/); assert.match(out, /const \{ tokenizeText, formatBytes: fb \} = __fmt;/); }); test("stripCode drops comments and string contents", () => { const out = stripCode(`const a = "import x"; // import y\n/* import z */ real();`); assert.doesNotMatch(out, /import/); assert.match(out, /real\(\)/); }); // ---- evalMemberApp: real generated-app shape runs against the shim SDK ------ const NOTES_APP = ` import { defineApp, addItem, removeItem } from "../apps.js"; export const app = defineApp({ name: "notes", title: "Notes", glyph: "📝", initialState: () => ({ items: [] }), events: { "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), }, render(state, el, ctx) {}, }); `; test("evalMemberApp captures the spec and synthesises a working reducer", () => { const spec = evalMemberApp(NOTES_APP, "notes"); assert.equal(spec.name, "notes"); assert.deepEqual(spec.types, ["notes.add", "notes.remove"]); let s = spec.reduce(undefined, { type: "notes.add", id: "a", text: "hi" }); s = spec.reduce(s, { type: "notes.add", id: "a", text: "hi" }); // idempotent assert.equal(s.items.length, 1); s = spec.reduce(s, { type: "unrelated.event" }); assert.equal(s.items.length, 1); s = spec.reduce(s, { type: "notes.remove", id: "a" }); assert.equal(s.items.length, 0); }); test("evalMemberApp enforces the manifest name and single registration", () => { assert.throws(() => evalMemberApp(NOTES_APP, "other"), /does not match/); assert.throws(() => evalMemberApp(`const x = 1;`, "notes"), /never called defineApp/); assert.throws( () => evalMemberApp(`import { defineApp } from "../apps.js";\ndefineApp({name:"a"});\ndefineApp({name:"a"});`, "a"), /more than once/, ); }); // ---- format helpers in the sandbox ----------------------------------------- test("format helpers are wired into the sandbox and behave like format.js", () => { assert.deepEqual(ALLOWED_FMT.sort(), [ "autoGroupTitle", "blockTokens", "emojiOnlyCount", "firstLink", "formatBytes", "replySnippet", "richTokens", "tokenizeText", "typingSummary", "youtubeId", ].sort()); // An app that imports and uses a format helper runs, and the helper produces // the same result as the real format.js module. const src = ` import { defineApp } from "../apps.js"; import { tokenizeText, formatBytes } from "../format.js"; globalThis.__fmtProbe = { toks: tokenizeText("see http://x.com"), size: formatBytes(2048) }; const app = defineApp({ name: "fx", title: "FX", glyph: "F", initialState: () => ({}), events: { "fx.e": (s) => s }, render() {} }); `; evalMemberApp(src, "fx"); assert.deepEqual(globalThis.__fmtProbe.toks, realTokenize("see http://x.com")); assert.equal(globalThis.__fmtProbe.size, realBytes(2048)); delete globalThis.__fmtProbe; }); // ---- commands in the sandbox ----------------------------------------------- test("evalMemberApp normalizes commands and the handler runs against a ctx", () => { const src = ` import { defineApp } from "../apps.js"; const app = defineApp({ name: "cmd", title: "Cmd", glyph: "C", initialState: () => ({ items: [] }), events: { "cmd.add": (s, e) => ({ items: [...s.items, e.text] }) }, commands: { "/cmd": { desc: "add", args: "", run: (args, ctx) => { if (!args) return ctx.reply({ text: "usage" }); ctx.emit({ type: "cmd.add", id: ctx.id(), text: args }); ctx.reply({ text: "added " + args, short: "added" }); } }, }, render() {}, }); `; const spec = evalMemberApp(src, "cmd"); assert.equal(typeof spec.commands["/cmd"].run, "function"); assert.equal(spec.commands["/cmd"].desc, "add"); const emits = []; let replied = null; const ctx = { emit: (e) => emits.push(e), id: () => "id1", reply: (r) => (replied = r) }; spec.commands["/cmd"].run("buy milk", ctx); assert.deepEqual(emits, [{ type: "cmd.add", id: "id1", text: "buy milk" }]); assert.deepEqual(replied, { text: "added buy milk", short: "added" }); // Bad input → usage reply, no emit. emits.length = 0; spec.commands["/cmd"].run("", ctx); assert.equal(emits.length, 0); assert.deepEqual(replied, { text: "usage" }); }); test("manifestOf derives a valid manifest from an evaluated spec", () => { const spec = evalMemberApp( `import { defineApp } from "../apps.js"; const app = defineApp({ name: "mx", title: "MX", glyph: "🧩", initialState: () => ({}), events: { "mx.a": (s) => s, "mx.b": (s) => s }, commands: { "/mx": { run: () => {}, desc: "do", args: "" } }, chatMessage: () => null, render() {}, });`, "mx", ); const m = manifestOf(spec); assert.equal(validateManifest(m), null); // the derived manifest is valid assert.deepEqual(m.places, ["you"], "nothing declared → the personal place, in the current vocabulary"); assert.deepEqual(m.types.sort(), ["mx.a", "mx.b"]); assert.deepEqual(m.commands, [{ name: "/mx", desc: "do", args: "" }]); assert.equal(m.chat, true); assert.equal(m.defaultCommand, undefined); // none declared // defaultCommand is derived and must name a declared command. const dc = manifestOf( evalMemberApp( `import { defineApp } from "../apps.js"; const app = defineApp({ name: "dc", title: "DC", glyph: "D", initialState: () => ({}), events: { "dc.add": (s) => s }, commands: { "/add": (a, ctx) => {} }, defaultCommand: "/add", render() {}, });`, "dc", ), ); assert.equal(dc.defaultCommand, "/add"); assert.equal(validateManifest(dc), null); // A defaultCommand that isn't a declared command is rejected. assert.match(validateManifest({ ...dc, defaultCommand: "/nope" }), /defaultCommand/); // No chatMessage → chat false. const plain = evalMemberApp( `import { defineApp } from "../apps.js"; const app = defineApp({ name: "px", title: "PX", glyph: "P", initialState: () => ({}), events: { "px.a": (s) => s }, render() {} });`, "px", ); assert.equal(manifestOf(plain).chat, false); assert.deepEqual(manifestOf(plain).commands, []); // Placement is read from what the source declares — it can't be derived // from code shape the way chat is, since it's a trust decision. A legacy // groupCapable source normalizes into places; a places-first source passes // through — either way the minted manifest speaks the current vocabulary. const grouped = evalMemberApp( `import { defineApp } from "../apps.js"; const app = defineApp({ name: "gx", title: "GX", glyph: "G", groupCapable: true, initialState: () => ({}), events: { "gx.a": (s) => s }, render() {}, });`, "gx", ); assert.deepEqual(manifestOf(grouped).places, ["you", "chat"]); const placed = evalMemberApp( `import { defineApp } from "../apps.js"; const app = defineApp({ name: "plx", title: "PLX", glyph: "P", places: ["you", "chat"], initialState: () => ({}), events: { "plx.a": (s) => s }, render() {}, });`, "plx", ); assert.deepEqual(manifestOf(placed).places, ["you", "chat"]); // quiet is read verbatim too (issue #227), same as groupCapable — a trust/ // behavior decision the author declares, not derivable from code shape. const quiet = evalMemberApp( `import { defineApp } from "../apps.js"; const app = defineApp({ name: "qx", title: "QX", glyph: "Q", quiet: true, initialState: () => ({}), events: { "qx.a": (s) => s }, render() {}, });`, "qx", ); assert.equal(manifestOf(quiet).quiet, true); assert.equal(manifestOf(grouped).quiet, false); // not declared → false, not just absent assert.equal(validateManifest(manifestOf(quiet)), null); }); test("normalizeCommands accepts a bare function or a {run} object", () => { const fn = () => {}; const out = normalizeCommands({ "/a": fn, "/b": { run: fn, desc: "d", args: "x" }, "/c": { desc: "no run" } }); assert.deepEqual(Object.keys(out), ["/a", "/b"]); // "/c" has no run → dropped assert.equal(out["/a"].run, fn); assert.equal(out["/b"].desc, "d"); }); // ---- companion projection -------------------------------------------------- test("a companion app's chatMessage projects events into chat lines", () => { const src = ` import { defineApp, addItem } from "../apps.js"; const app = defineApp({ name: "todo", title: "To-do", glyph: "T", chat: true, initialState: () => ({ items: [] }), events: { "todo.add": (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }), "todo.done": (s) => s }, chatMessage: (e) => { if (e.type === "todo.add") return { text: "added “" + e.text + "”", action: true }; return null; // done is silent }, render() {}, }); `; const spec = evalMemberApp(src, "todo"); assert.equal(typeof spec.chatMessage, "function"); const add = spec.chatMessage({ type: "todo.add", text: "milk" }); assert.deepEqual(add, { text: "added “milk”", action: true }); assert.equal(spec.chatMessage({ type: "todo.done", id: "x" }), null); }); test("resolveDark: explicit wins, 'light dark' follows the OS", () => { // Forced light/dark ignore the OS. assert.equal(resolveDark("light", true), false); assert.equal(resolveDark("dark", false), true); // "light dark" (auto) follows the OS — the bug was reading this as always dark, // which forced every sandbox app onto a black UA canvas in light mode. assert.equal(resolveDark("light dark", false), false); assert.equal(resolveDark("light dark", true), true); // Unset/normal also follow the OS. assert.equal(resolveDark("", false), false); assert.equal(resolveDark("normal", true), true); }); test("sanitizeChat clamps and drops malformed companion lines", () => { const clean = sanitizeChat([ { mid: 5, ts: 100, text: "hi", action: true }, { mid: 6, text: "no ts" }, // ts defaults to 0 { text: "no mid" }, // dropped { mid: 7, text: "" }, // empty text dropped "nope", // dropped { mid: 8, ts: 1, text: "x".repeat(5000), action: "truthy" }, ]); assert.equal(clean.length, 3); // "no mid", empty text, and "nope" dropped assert.deepEqual(clean[0], { mid: 5, ts: 100, text: "hi", action: true }); assert.equal(clean[1].ts, 0); assert.equal(clean[2].text.length, 400); // clamped assert.equal(clean[2].action, true); }); // ---- manifest + event validation --------------------------------------------- const MANIFEST = { v: 1, name: "notes", title: "Notes", glyph: "📝", types: ["notes.add", "notes.remove"] }; test("validateManifest accepts the good and names the bad", () => { assert.equal(validateManifest(MANIFEST), null); assert.match(validateManifest(null), /missing/); assert.match(validateManifest({ ...MANIFEST, v: 2 }), /version/); assert.match(validateManifest({ ...MANIFEST, name: "Bad Name" }), /name/); assert.match(validateManifest({ ...MANIFEST, title: "" }), /title/); assert.match(validateManifest({ ...MANIFEST, glyph: "" }), /glyph/); assert.match(validateManifest({ ...MANIFEST, scope: "shared" }), /personal/); assert.match(validateManifest({ ...MANIFEST, types: [] }), /types/); assert.match(validateManifest({ ...MANIFEST, types: ["other.add"] }), /prefixed/); assert.match(validateManifest({ ...MANIFEST, types: ["notes."] }), /bad event type/); // Optional commands + chat. assert.equal(validateManifest({ ...MANIFEST, commands: [{ name: "/note", desc: "d", args: "" }] }), null); assert.equal(validateManifest({ ...MANIFEST, chat: true }), null); assert.match(validateManifest({ ...MANIFEST, commands: "no" }), /commands list/); assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "note" }] }), /command name/); // no leading slash assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/UP" }] }), /command name/); assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/n", desc: "x".repeat(90) }] }), /command desc/); assert.match(validateManifest({ ...MANIFEST, chat: "yes" }), /chat flag/); // Optional groupCapable — orthogonal to (and doesn't loosen) scope: personal. assert.equal(validateManifest({ ...MANIFEST, groupCapable: true }), null); assert.equal(validateManifest({ ...MANIFEST, groupCapable: false }), null); assert.match(validateManifest({ ...MANIFEST, groupCapable: "yes" }), /groupCapable flag/); assert.equal(validateManifest({ ...MANIFEST, groupCapable: true, scope: "personal" }), null); }); test("cleanJSON admits plain data and rejects the hostile", () => { assert.deepEqual(cleanJSON({ a: [1, "x", true, null] }), { a: [1, "x", true, null] }); assert.equal(cleanJSON({ fn: () => {} }), undefined); assert.equal(cleanJSON({ n: NaN }), undefined); assert.equal(cleanJSON(JSON.parse('{"__proto__": {"polluted": 1}}')), undefined); assert.equal(cleanJSON({ constructor: 1 }), undefined); let deep = 1; for (let i = 0; i < 12; i++) deep = { deep }; assert.equal(cleanJSON(deep), undefined); }); test("sanitizeAppEvent enforces ownership, strips transport fields, clamps notices", () => { const types = MANIFEST.types; assert.equal(sanitizeAppEvent("notes", types, { type: "chat.msg", text: "spoof" }), null); assert.equal(sanitizeAppEvent("notes", types, { type: "notes.unknown" }), null); assert.equal(sanitizeAppEvent("notes", types, "nope"), null); const e = sanitizeAppEvent("notes", types, { type: "notes.add", id: "a", text: "hi", name: "Forged", // transport injects these — a frame must not choose them ts: 1, uid: "someone-else", notice: { title: "T".repeat(300), body: "B", actions: [{ type: "set_theme" }] }, }); assert.equal(e.type, "notes.add"); assert.equal(e.name, undefined); assert.equal(e.ts, undefined); assert.equal(e.uid, undefined); assert.equal(e.notice.title.length, 120); assert.equal(e.notice.actions, undefined); // Oversized events are refused outright. assert.equal(sanitizeAppEvent("notes", types, { type: "notes.add", big: "x".repeat(40000) }), null); }); // ---- host proxy: queue fold + frame-state install + snapshot round-trip ------- test("registerMemberApp registers a queueing proxy; frame state installs and trims", () => { const res = registerMemberApp({ id: "app-t1", manifest: MANIFEST, source: NOTES_APP, author: "Ana" }); assert.equal(res.ok, true, res.reason); assert.equal(isAppType("notes.add"), true); const tid = "thread-sandbox-test"; resetThread(tid); foldEvent(tid, { type: "notes.add", id: "a", text: "one", ts: 5 }, { id: 101, ts: 5 }); foldEvent(tid, { type: "notes.add", id: "b", text: "two", ts: 6 }, { id: 102, ts: 6 }); let st = appStateFor(tid, "notes"); assert.equal(st.pend.length, 2); assert.equal(st.v, null); // Same server message id folds once (framework dedupe). foldEvent(tid, { type: "notes.add", id: "b", text: "two", ts: 6 }, { id: 102, ts: 6 }); assert.equal(appStateFor(tid, "notes").pend.length, 2); // The frame reports its fold through id 101 → v swaps in, pend trims. installMemberAppState(tid, "notes", { items: [{ id: "a", text: "one" }] }, 101); st = appStateFor(tid, "notes"); assert.deepEqual(st.v.items, [{ id: "a", text: "one" }]); assert.equal(st.vUpTo, 101); assert.equal(st.pend.length, 1); assert.equal(st.pend[0].meta.id, 102); // Snapshot → seed round-trips the {v, vUpTo, pend} wrapper unchanged. const snap = takeSnapshot(tid); assert.equal(snap.upTo, 102); resetThread(tid); seedThread(tid, snap.states, snap.upTo); const seeded = appStateFor(tid, "notes"); assert.deepEqual(seeded, st); // installMemberAppState is gated on the memberApp marker: a non-sandbox app // can't have its state swapped through this doorway. defineApp({ name: "plainapp-sbtest", title: "x", glyph: "x", scope: "personal", initialState: () => ({ n: 1 }), events: { "plainapp-sbtest.e": (s) => s } }); foldEvent(tid, { type: "plainapp-sbtest.e" }, { id: 103, ts: 1 }); installMemberAppState(tid, "plainapp-sbtest", { hacked: true }, 103); assert.equal(appStateFor(tid, "plainapp-sbtest").hacked, undefined); }); test("a groupCapable member app registers as group-capable; a personal one doesn't", () => { const grouped = { v: 1, name: "gscore", title: "Scoreboard", glyph: "🏆", types: ["gscore.set"], groupCapable: true }; const res = registerMemberApp({ id: "app-group1", manifest: grouped, source: NOTES_APP.replace(/notes/g, "gscore"), author: "Ana" }); assert.equal(res.ok, true, res.reason); const app = appByName("gscore"); // The stored manifest's legacy groupCapable boolean normalizes into places // — "you" is fixed (member apps are always personal, so it still gets its // own personal thread too), "chat" is the author's opt-in. assert.deepEqual(app.places, ["you", "chat"]); assert.ok(groupCapableApps().some((a) => a.name === "gscore")); const personal = { v: 1, name: "gplain", title: "Plain", glyph: "P", types: ["gplain.set"] }; const res2 = registerMemberApp({ id: "app-group2", manifest: personal, source: NOTES_APP.replace(/notes/g, "gplain") }); assert.equal(res2.ok, true, res2.reason); assert.deepEqual(appByName("gplain").places, ["you"], "a plain member app stays personal-only"); assert.ok(!groupCapableApps().some((a) => a.name === "gplain")); }); // Members can build gif-shaped apps: one shared copy for the whole space and // no personal thread at all. Which places an app lives in is the author's // call — the only rule is that it lives somewhere. test("a space-only member app is legal — places pass through, no personal copy forced", () => { const spacey = { v: 1, name: "gspace", title: "Space Lib", glyph: "🎞️", types: ["gspace.set"], places: ["space"] }; assert.equal(validateManifest(spacey), null); const res = registerMemberApp({ id: "app-space1", manifest: spacey, source: NOTES_APP.replace(/notes/g, "gspace"), author: "Ana" }); assert.equal(res.ok, true, res.reason); assert.deepEqual(appByName("gspace").places, ["space"]); assert.ok(!groupCapableApps().some((a) => a.name === "gspace")); // Somewhere is still required: an empty places list has nowhere to live. assert.equal(validateManifest({ ...spacey, places: [] }), "places must name at least one place"); }); // The command-collision gate: a member app must not shadow an existing slash // command — the built-ins register first, member apps later, so without this // a crafted blob claiming "/todo" (or "/help") would overwrite commandOwner // on every client and route members' typed arguments into a log the // hijacker can read. LOAD-time security, mirroring the event-type check. test("registerMemberApp refuses a command name an existing app or the host already owns", () => { const mk = (name, cmd) => ({ v: 1, name, title: name, glyph: "×", types: [`${name}.set`], places: ["you"], commands: [{ name: cmd, desc: "", args: "" }] }); // First app claims /clash — succeeds and registers the command. const first = registerMemberApp({ id: "app-cmd0", manifest: mk("cmdown", "/clash"), source: NOTES_APP.replace(/notes/g, "cmdown") }); assert.equal(first.ok, true, first.reason); // A second app claiming the SAME command is refused (app collision) — and // the ORIGINAL owner is untouched (the reject returns before defineApp). const hijack = registerMemberApp({ id: "app-cmd1", manifest: mk("cmdhij", "/clash"), source: NOTES_APP.replace(/notes/g, "cmdhij") }); assert.equal(hijack.ok, false); assert.match(hijack.reason, /already taken.*cmdown/); assert.equal(commandTakenBy("/clash"), "cmdown", "the hijack never mutated commandOwner"); // A host command (/help) can't be shadowed either. const host = registerMemberApp({ id: "app-cmd2", manifest: mk("cmdhij2", "/help"), source: NOTES_APP.replace(/notes/g, "cmdhij2") }); assert.equal(host.ok, false); assert.match(host.reason, /already taken.*built-in/); // A free command name registers fine. const fine = registerMemberApp({ id: "app-cmd3", manifest: mk("cmdok", "/cmdok"), source: NOTES_APP.replace(/notes/g, "cmdok") }); assert.equal(fine.ok, true, fine.reason); }); // manifestIsLegacy powers App Studio's "older format" flag + Upgrade offer // (docs/member-apps.md § Legacy formats): legacy = the stored manifest has no // places array, so loading falls back to the old scope/flag fields. test("manifestIsLegacy: no places array = legacy; a places manifest is current", () => { assert.equal(manifestIsLegacy({ v: 1, name: "x", scope: "personal" }), true); assert.equal(manifestIsLegacy({ v: 1, name: "x", scope: "personal", groupCapable: true }), true); assert.equal(manifestIsLegacy({ v: 1, name: "x", places: ["you"] }), false); // Leftover legacy fields BESIDE places don't count — places wins at load. assert.equal(manifestIsLegacy({ v: 1, name: "x", places: ["you"], groupCapable: true }), false); assert.equal(manifestIsLegacy(null), false); }); // The full /gif shape: places ["space"] + listed:false — implicitly placed, // out of the catalogues, the one shared copy used on first command with no // add gesture (docs/space-apps.md). A member app can declare it too. test("an unlisted space-only member app registers implicitly placed", () => { const implicit = { v: 1, name: "gimp", title: "Implicit", glyph: "✨", types: ["gimp.set"], places: ["space"], listed: false }; assert.equal(validateManifest(implicit), null); assert.equal(validateManifest({ ...implicit, listed: "no" }), "bad listed flag"); const res = registerMemberApp({ id: "app-space2", manifest: implicit, source: NOTES_APP.replace(/notes/g, "gimp"), author: "Ana" }); assert.equal(res.ok, true, res.reason); const app = appByName("gimp"); assert.deepEqual(app.places, ["space"]); assert.equal(app.listed, false, "stays out of the Apps catalogue"); }); // A member app opts into `quiet` (issue #227) exactly like groupCapable — a // boolean the author declares, read straight off the manifest, no different // from a built-in app's `quiet`. Same mechanism (appIsQuiet), same enforcement // (shell.js), for both origins. test("a member app can declare quiet:true; validateManifest rejects a non-boolean", () => { const quiet = { v: 1, name: "qmember", title: "Quiet", glyph: "Q", types: ["qmember.set"], quiet: true }; const res = registerMemberApp({ id: "app-quiet1", manifest: quiet, source: NOTES_APP.replace(/notes/g, "qmember"), author: "Ana" }); assert.equal(res.ok, true, res.reason); assert.equal(appByName("qmember").quiet, true); assert.equal(appIsQuiet("qmember"), true); const notQuiet = { v: 1, name: "notqmember", title: "Loud", glyph: "L", types: ["notqmember.set"] }; const res2 = registerMemberApp({ id: "app-quiet2", manifest: notQuiet, source: NOTES_APP.replace(/notes/g, "notqmember") }); assert.equal(res2.ok, true, res2.reason); assert.equal(appByName("notqmember").quiet, undefined); assert.equal(appIsQuiet("notqmember"), false); assert.equal(validateManifest({ ...quiet, quiet: "yes" }), "bad quiet flag"); }); // quiet and groupCapable are orthogonal, same as for built-in apps: a member // app can declare both, and quiet only ever governs the PERSONAL thread's // visibility — it carries no meaning for the shared group instance. test("a member app can be both groupCapable and quiet at once", () => { const both = { v: 1, name: "qgroup", title: "QG", glyph: "Q", types: ["qgroup.set"], groupCapable: true, quiet: true }; const res = registerMemberApp({ id: "app-quiet3", manifest: both, source: NOTES_APP.replace(/notes/g, "qgroup") }); assert.equal(res.ok, true, res.reason); const app = appByName("qgroup"); assert.deepEqual(app.places, ["you", "chat"]); assert.equal(app.quiet, true); assert.ok(groupCapableApps().some((a) => a.name === "qgroup")); }); test("installMemberAppState preserves the `lost` overflow flag", () => { const manifest = { v: 1, name: "lossyapp", title: "L", glyph: "L", types: ["lossyapp.e"] }; const res = registerMemberApp({ id: "app-lost1", manifest, source: NOTES_APP.replace(/notes/g, "lossyapp") }); assert.equal(res.ok, true, res.reason); const tid = "thread-lost"; resetThread(tid); // Simulate an overflow having set lost on the stored state. seedThread(tid, { lossyapp: { v: null, vUpTo: 0, pend: [{ e: { type: "lossyapp.e" }, meta: { id: 5 } }], lost: true } }, 0); // A frame state report must NOT wipe the flag. installMemberAppState(tid, "lossyapp", { done: true }, 5); assert.equal(appStateFor(tid, "lossyapp").lost, true, "lost flag erased by state install"); }); test("installMemberAppState ignores a stale (lower-upTo) report", () => { const manifest = { v: 1, name: "monoapp", title: "M", glyph: "M", types: ["monoapp.e"] }; const res = registerMemberApp({ id: "app-mono", manifest, source: NOTES_APP.replace(/notes/g, "monoapp") }); assert.equal(res.ok, true, res.reason); const tid = "thread-mono"; resetThread(tid); seedThread(tid, { monoapp: { v: null, vUpTo: 0, pend: [] } }, 0); installMemberAppState(tid, "monoapp", { at: 10 }, 10); assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 10 }); // A second frame reports an OLDER horizon — must be ignored. installMemberAppState(tid, "monoapp", { at: 5 }, 5); assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 10 }, "stale report regressed state"); assert.equal(appStateFor(tid, "monoapp").vUpTo, 10); // A newer horizon still advances. installMemberAppState(tid, "monoapp", { at: 12 }, 12); assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 12 }); }); test("registerMemberApp refuses collisions and double registration", () => { assert.equal(registerMemberApp({ id: "app-t1", manifest: MANIFEST, source: NOTES_APP }).reason, "already registered"); assert.match(registerMemberApp({ id: "app-t2", manifest: MANIFEST, source: NOTES_APP }).reason, /already exists/); // A manifest claiming another app's event types is refused even under a // fresh name. assert.match( registerMemberApp({ id: "app-t4", manifest: { ...MANIFEST, name: "notes2", types: ["notes2.ok", "notes.add"] }, source: NOTES_APP, }).reason, /prefixed/, ); }); // ---- command panels (the declarative sandbox form) -------------------------- test("sanitizePanelSpec clamps the title, whitelists position/animate, caps data", () => { const sp = sanitizePanelSpec({ title: "Hi", position: "top", animate: false, data: { a: 1 } }); assert.deepEqual(sp, { title: "Hi", data: { a: 1 }, position: "top", animate: false }); assert.equal(sanitizePanelSpec({ title: "x".repeat(200) }).title.length, 80); // A hostile position/animate never passes through; missing data → null. const loose = sanitizePanelSpec({ position: "overlay", animate: "yes" }); assert.equal(loose.position, undefined); assert.equal(loose.animate, undefined); assert.equal(loose.data, null); // Un-cleanable or oversize data rejects the whole request. assert.equal(sanitizePanelSpec({ data: { f: () => {} } }), null); assert.equal(sanitizePanelSpec({ data: "y".repeat(70 * 1024) }), null); assert.equal(sanitizePanelSpec("nope"), null); }); test("evalMemberApp captures a panel hook; the mood example evaluates whole", async () => { const { readFileSync } = await import("node:fs"); const src = readFileSync(new URL("../web/static/apps-disabled/mood.js", import.meta.url), "utf8"); const spec = evalMemberApp(src, "mood"); assert.equal(typeof spec.panel, "function", "the panel hook survives the sandbox transform"); assert.ok(spec.commands["/mood"], "the /mood command registers"); // The reducer folds a check-in and validates hostile input to a no-op. const s1 = spec.reduce(undefined, { type: "mood.set", id: "a", mood: "🙂", note: "ok", name: "Ana", ts: 5 }); assert.equal(s1.items.length, 1); assert.equal(spec.reduce(s1, { type: "mood.set", id: "b", mood: "