eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for the member-app sandbox (web/static/sandbox.js — the host side —
2// and web/static/sandbox-frame.js — the in-frame runtime).
3// node test/sandbox.test.mjs
4import test from "node:test";
5import assert from "node:assert/strict";
6
7import {
8 addItem as hostAddItem,
9 updateItem as hostUpdateItem,
10 removeItem as hostRemoveItem,
11 partitionByGrace as hostPartition,
12 DONE_GRACE_MS as HOST_GRACE,
13 namespace as hostNamespace,
14 defineApp,
15 foldEvent,
16 appStateFor,
17 seedThread,
18 takeSnapshot,
19 resetThread,
20 installMemberAppState,
21 isAppType,
22 appByName,
23 groupCapableApps,
24 appIsQuiet,
25 commandTakenBy,
26} from "../web/static/apps.js";
27import {
28 addItem,
29 updateItem,
30 removeItem,
31 partitionByGrace,
32 DONE_GRACE_MS,
33 namespace,
34 ALLOWED_IMPORTS,
35 ALLOWED_FMT,
36 stripCode,
37 transformModuleSource,
38 evalMemberApp,
39 normalizeCommands,
40 manifestOf,
41 isSubmitTrigger,
42 isEnterSubmit,
43} from "../web/static/sandbox-frame.js";
44import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, sanitizePanelSpec, registerMemberApp, resolveDark, manifestIsLegacy } from "../web/static/sandbox.js";
45import { tokenizeText as realTokenize, formatBytes as realBytes } from "../web/static/format.js";
46
47// ---- SDK drift pins: the sandbox copies must behave exactly like apps.js ----
48
49test("sandbox SDK helpers match apps.js behaviour", () => {
50 const list = [{ id: "a", n: 1 }];
51 for (const [host, sand] of [
52 [hostAddItem, addItem],
53 [hostUpdateItem, updateItem],
54 [hostRemoveItem, removeItem],
55 ]) {
56 assert.equal(typeof host, "function");
57 assert.equal(typeof sand, "function");
58 }
59 assert.deepEqual(addItem(list, { id: "b" }), hostAddItem(list, { id: "b" }));
60 assert.deepEqual(addItem(list, { id: "a" }), hostAddItem(list, { id: "a" })); // dedupe
61 assert.deepEqual(updateItem(list, "a", { n: 2 }), hostUpdateItem(list, "a", { n: 2 }));
62 assert.deepEqual(removeItem(list, "a"), hostRemoveItem(list, "a"));
63 assert.equal(DONE_GRACE_MS, HOST_GRACE);
64 const items = [{ id: 1, d: 0 }, { id: 2, d: 900 }, { id: 3, d: 100 }];
65 assert.deepEqual(
66 partitionByGrace(items, (i) => i.d, 1000, 500),
67 hostPartition(items, (i) => i.d, 1000, 500),
68 );
69 // namespace() must behave identically to the in-page one (same wire types + classes).
70 const s = namespace("todo");
71 const h = hostNamespace("todo");
72 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")]);
73});
74
75test("namespace is an allowed import and works inside a member app", () => {
76 assert.ok(ALLOWED_IMPORTS.includes("namespace"), "namespace must be importable by a member app");
77 // A member app declaring its types via namespace() gets the same owned types as
78 // one that spells them out — the wire strings are identical either way.
79 const src = `
80 import { defineApp, namespace, addItem } from "../apps.js";
81 const N = namespace("habit");
82 const app = defineApp({
83 name: "habit", title: "Habit", glyph: "H",
84 initialState: () => ({ items: [] }),
85 events: {
86 [N.ev("add")]: (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }),
87 [N.ev("tick")]: (s) => s,
88 },
89 render() {},
90 });
91 `;
92 const spec = evalMemberApp(src, "habit");
93 assert.deepEqual(spec.types.sort(), ["habit.add", "habit.tick"].sort());
94 assert.equal(spec.ns.ev("add"), "habit.add");
95 assert.equal(spec.ns.cls("row"), "habit-row");
96 // The derived manifest still validates — its types are name-prefixed, as the
97 // sandbox requires (namespace defaults to name).
98 assert.equal(validateManifest(manifestOf(spec)), null);
99});
100
101// ---- module transform -------------------------------------------------------
102
103test("transformModuleSource rewrites the legal import and strips exports", () => {
104 const out = transformModuleSource(
105 `import { defineApp, addItem as add } from "../apps.js";\nexport const app = defineApp({});\nexport default app;\n`,
106 );
107 assert.match(out, /const \{ defineApp, addItem: add \} = __sdk;/);
108 assert.match(out, /const app = defineApp/);
109 assert.match(out, /const __defaultExport = app/);
110 assert.doesNotMatch(out, /(^|[^\w$])export\s/);
111});
112
113test("transformModuleSource rejects contract violations", () => {
114 assert.throws(() => transformModuleSource(`import { foldEvent } from "../apps.js";`), /not an allowed import/);
115 assert.throws(() => transformModuleSource(`import { defineApp } from "../format.js";`), /not an allowed import/);
116 assert.throws(() => transformModuleSource(`import { nameList } from "../format.js";`), /not an allowed import/);
117 assert.throws(() => transformModuleSource(`import x from "https://evil.example/x.js";`), /apps\.js/);
118 assert.throws(() => transformModuleSource(`const m = await import("x");`), /apps\.js/);
119 assert.throws(() => transformModuleSource(""), /empty/);
120 // Prose mentioning import in comments/strings is fine.
121 transformModuleSource(`// never use import() here\nconst s = "import nothing";\nconst app = 1;`);
122});
123
124test("transformModuleSource accepts format.js helper imports", () => {
125 const out = transformModuleSource(
126 `import { defineApp } from "../apps.js";\nimport { tokenizeText, formatBytes as fb } from "../format.js";\nconst app = 1;`,
127 );
128 assert.match(out, /const \{ defineApp \} = __sdk;/);
129 assert.match(out, /const \{ tokenizeText, formatBytes: fb \} = __fmt;/);
130});
131
132test("stripCode drops comments and string contents", () => {
133 const out = stripCode(`const a = "import x"; // import y\n/* import z */ real();`);
134 assert.doesNotMatch(out, /import/);
135 assert.match(out, /real\(\)/);
136});
137
138// ---- evalMemberApp: real generated-app shape runs against the shim SDK ------
139
140const NOTES_APP = `
141import { defineApp, addItem, removeItem } from "../apps.js";
142export const app = defineApp({
143 name: "notes",
144 title: "Notes",
145 glyph: "📝",
146 initialState: () => ({ items: [] }),
147 events: {
148 "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s),
149 "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
150 },
151 render(state, el, ctx) {},
152});
153`;
154
155test("evalMemberApp captures the spec and synthesises a working reducer", () => {
156 const spec = evalMemberApp(NOTES_APP, "notes");
157 assert.equal(spec.name, "notes");
158 assert.deepEqual(spec.types, ["notes.add", "notes.remove"]);
159 let s = spec.reduce(undefined, { type: "notes.add", id: "a", text: "hi" });
160 s = spec.reduce(s, { type: "notes.add", id: "a", text: "hi" }); // idempotent
161 assert.equal(s.items.length, 1);
162 s = spec.reduce(s, { type: "unrelated.event" });
163 assert.equal(s.items.length, 1);
164 s = spec.reduce(s, { type: "notes.remove", id: "a" });
165 assert.equal(s.items.length, 0);
166});
167
168test("evalMemberApp enforces the manifest name and single registration", () => {
169 assert.throws(() => evalMemberApp(NOTES_APP, "other"), /does not match/);
170 assert.throws(() => evalMemberApp(`const x = 1;`, "notes"), /never called defineApp/);
171 assert.throws(
172 () => evalMemberApp(`import { defineApp } from "../apps.js";\ndefineApp({name:"a"});\ndefineApp({name:"a"});`, "a"),
173 /more than once/,
174 );
175});
176
177// ---- format helpers in the sandbox -----------------------------------------
178
179test("format helpers are wired into the sandbox and behave like format.js", () => {
180 assert.deepEqual(ALLOWED_FMT.sort(), [
181 "autoGroupTitle", "blockTokens", "emojiOnlyCount", "firstLink", "formatBytes",
182 "replySnippet", "richTokens", "tokenizeText", "typingSummary", "youtubeId",
183 ].sort());
184 // An app that imports and uses a format helper runs, and the helper produces
185 // the same result as the real format.js module.
186 const src = `
187 import { defineApp } from "../apps.js";
188 import { tokenizeText, formatBytes } from "../format.js";
189 globalThis.__fmtProbe = { toks: tokenizeText("see http://x.com"), size: formatBytes(2048) };
190 const app = defineApp({ name: "fx", title: "FX", glyph: "F", initialState: () => ({}), events: { "fx.e": (s) => s }, render() {} });
191 `;
192 evalMemberApp(src, "fx");
193 assert.deepEqual(globalThis.__fmtProbe.toks, realTokenize("see http://x.com"));
194 assert.equal(globalThis.__fmtProbe.size, realBytes(2048));
195 delete globalThis.__fmtProbe;
196});
197
198// ---- commands in the sandbox -----------------------------------------------
199
200test("evalMemberApp normalizes commands and the handler runs against a ctx", () => {
201 const src = `
202 import { defineApp } from "../apps.js";
203 const app = defineApp({
204 name: "cmd", title: "Cmd", glyph: "C",
205 initialState: () => ({ items: [] }),
206 events: { "cmd.add": (s, e) => ({ items: [...s.items, e.text] }) },
207 commands: {
208 "/cmd": { desc: "add", args: "<t>", run: (args, ctx) => {
209 if (!args) return ctx.reply({ text: "usage" });
210 ctx.emit({ type: "cmd.add", id: ctx.id(), text: args });
211 ctx.reply({ text: "added " + args, short: "added" });
212 } },
213 },
214 render() {},
215 });
216 `;
217 const spec = evalMemberApp(src, "cmd");
218 assert.equal(typeof spec.commands["/cmd"].run, "function");
219 assert.equal(spec.commands["/cmd"].desc, "add");
220 const emits = [];
221 let replied = null;
222 const ctx = { emit: (e) => emits.push(e), id: () => "id1", reply: (r) => (replied = r) };
223 spec.commands["/cmd"].run("buy milk", ctx);
224 assert.deepEqual(emits, [{ type: "cmd.add", id: "id1", text: "buy milk" }]);
225 assert.deepEqual(replied, { text: "added buy milk", short: "added" });
226 // Bad input → usage reply, no emit.
227 emits.length = 0;
228 spec.commands["/cmd"].run("", ctx);
229 assert.equal(emits.length, 0);
230 assert.deepEqual(replied, { text: "usage" });
231});
232
233test("manifestOf derives a valid manifest from an evaluated spec", () => {
234 const spec = evalMemberApp(
235 `import { defineApp } from "../apps.js";
236 const app = defineApp({
237 name: "mx", title: "MX", glyph: "🧩",
238 initialState: () => ({}),
239 events: { "mx.a": (s) => s, "mx.b": (s) => s },
240 commands: { "/mx": { run: () => {}, desc: "do", args: "<x>" } },
241 chatMessage: () => null,
242 render() {},
243 });`,
244 "mx",
245 );
246 const m = manifestOf(spec);
247 assert.equal(validateManifest(m), null); // the derived manifest is valid
248 assert.deepEqual(m.places, ["you"], "nothing declared → the personal place, in the current vocabulary");
249 assert.deepEqual(m.types.sort(), ["mx.a", "mx.b"]);
250 assert.deepEqual(m.commands, [{ name: "/mx", desc: "do", args: "<x>" }]);
251 assert.equal(m.chat, true);
252 assert.equal(m.defaultCommand, undefined); // none declared
253
254 // defaultCommand is derived and must name a declared command.
255 const dc = manifestOf(
256 evalMemberApp(
257 `import { defineApp } from "../apps.js";
258 const app = defineApp({
259 name: "dc", title: "DC", glyph: "D", initialState: () => ({}),
260 events: { "dc.add": (s) => s },
261 commands: { "/add": (a, ctx) => {} }, defaultCommand: "/add",
262 render() {},
263 });`,
264 "dc",
265 ),
266 );
267 assert.equal(dc.defaultCommand, "/add");
268 assert.equal(validateManifest(dc), null);
269 // A defaultCommand that isn't a declared command is rejected.
270 assert.match(validateManifest({ ...dc, defaultCommand: "/nope" }), /defaultCommand/);
271 // No chatMessage → chat false.
272 const plain = evalMemberApp(
273 `import { defineApp } from "../apps.js";
274 const app = defineApp({ name: "px", title: "PX", glyph: "P", initialState: () => ({}), events: { "px.a": (s) => s }, render() {} });`,
275 "px",
276 );
277 assert.equal(manifestOf(plain).chat, false);
278 assert.deepEqual(manifestOf(plain).commands, []);
279
280 // Placement is read from what the source declares — it can't be derived
281 // from code shape the way chat is, since it's a trust decision. A legacy
282 // groupCapable source normalizes into places; a places-first source passes
283 // through — either way the minted manifest speaks the current vocabulary.
284 const grouped = evalMemberApp(
285 `import { defineApp } from "../apps.js";
286 const app = defineApp({
287 name: "gx", title: "GX", glyph: "G", groupCapable: true,
288 initialState: () => ({}), events: { "gx.a": (s) => s }, render() {},
289 });`,
290 "gx",
291 );
292 assert.deepEqual(manifestOf(grouped).places, ["you", "chat"]);
293 const placed = evalMemberApp(
294 `import { defineApp } from "../apps.js";
295 const app = defineApp({
296 name: "plx", title: "PLX", glyph: "P", places: ["you", "chat"],
297 initialState: () => ({}), events: { "plx.a": (s) => s }, render() {},
298 });`,
299 "plx",
300 );
301 assert.deepEqual(manifestOf(placed).places, ["you", "chat"]);
302
303 // quiet is read verbatim too (issue #227), same as groupCapable — a trust/
304 // behavior decision the author declares, not derivable from code shape.
305 const quiet = evalMemberApp(
306 `import { defineApp } from "../apps.js";
307 const app = defineApp({
308 name: "qx", title: "QX", glyph: "Q", quiet: true,
309 initialState: () => ({}), events: { "qx.a": (s) => s }, render() {},
310 });`,
311 "qx",
312 );
313 assert.equal(manifestOf(quiet).quiet, true);
314 assert.equal(manifestOf(grouped).quiet, false); // not declared → false, not just absent
315 assert.equal(validateManifest(manifestOf(quiet)), null);
316});
317
318test("normalizeCommands accepts a bare function or a {run} object", () => {
319 const fn = () => {};
320 const out = normalizeCommands({ "/a": fn, "/b": { run: fn, desc: "d", args: "x" }, "/c": { desc: "no run" } });
321 assert.deepEqual(Object.keys(out), ["/a", "/b"]); // "/c" has no run → dropped
322 assert.equal(out["/a"].run, fn);
323 assert.equal(out["/b"].desc, "d");
324});
325
326// ---- companion projection --------------------------------------------------
327
328test("a companion app's chatMessage projects events into chat lines", () => {
329 const src = `
330 import { defineApp, addItem } from "../apps.js";
331 const app = defineApp({
332 name: "todo", title: "To-do", glyph: "T", chat: true,
333 initialState: () => ({ items: [] }),
334 events: { "todo.add": (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }),
335 "todo.done": (s) => s },
336 chatMessage: (e) => {
337 if (e.type === "todo.add") return { text: "added “" + e.text + "”", action: true };
338 return null; // done is silent
339 },
340 render() {},
341 });
342 `;
343 const spec = evalMemberApp(src, "todo");
344 assert.equal(typeof spec.chatMessage, "function");
345 const add = spec.chatMessage({ type: "todo.add", text: "milk" });
346 assert.deepEqual(add, { text: "added “milk”", action: true });
347 assert.equal(spec.chatMessage({ type: "todo.done", id: "x" }), null);
348});
349
350test("resolveDark: explicit wins, 'light dark' follows the OS", () => {
351 // Forced light/dark ignore the OS.
352 assert.equal(resolveDark("light", true), false);
353 assert.equal(resolveDark("dark", false), true);
354 // "light dark" (auto) follows the OS — the bug was reading this as always dark,
355 // which forced every sandbox app onto a black UA canvas in light mode.
356 assert.equal(resolveDark("light dark", false), false);
357 assert.equal(resolveDark("light dark", true), true);
358 // Unset/normal also follow the OS.
359 assert.equal(resolveDark("", false), false);
360 assert.equal(resolveDark("normal", true), true);
361});
362
363test("sanitizeChat clamps and drops malformed companion lines", () => {
364 const clean = sanitizeChat([
365 { mid: 5, ts: 100, text: "hi", action: true },
366 { mid: 6, text: "no ts" }, // ts defaults to 0
367 { text: "no mid" }, // dropped
368 { mid: 7, text: "" }, // empty text dropped
369 "nope", // dropped
370 { mid: 8, ts: 1, text: "x".repeat(5000), action: "truthy" },
371 ]);
372 assert.equal(clean.length, 3); // "no mid", empty text, and "nope" dropped
373 assert.deepEqual(clean[0], { mid: 5, ts: 100, text: "hi", action: true });
374 assert.equal(clean[1].ts, 0);
375 assert.equal(clean[2].text.length, 400); // clamped
376 assert.equal(clean[2].action, true);
377});
378
379// ---- manifest + event validation ---------------------------------------------
380
381const MANIFEST = { v: 1, name: "notes", title: "Notes", glyph: "📝", types: ["notes.add", "notes.remove"] };
382
383test("validateManifest accepts the good and names the bad", () => {
384 assert.equal(validateManifest(MANIFEST), null);
385 assert.match(validateManifest(null), /missing/);
386 assert.match(validateManifest({ ...MANIFEST, v: 2 }), /version/);
387 assert.match(validateManifest({ ...MANIFEST, name: "Bad Name" }), /name/);
388 assert.match(validateManifest({ ...MANIFEST, title: "" }), /title/);
389 assert.match(validateManifest({ ...MANIFEST, glyph: "" }), /glyph/);
390 assert.match(validateManifest({ ...MANIFEST, scope: "shared" }), /personal/);
391 assert.match(validateManifest({ ...MANIFEST, types: [] }), /types/);
392 assert.match(validateManifest({ ...MANIFEST, types: ["other.add"] }), /prefixed/);
393 assert.match(validateManifest({ ...MANIFEST, types: ["notes.<x>"] }), /bad event type/);
394 // Optional commands + chat.
395 assert.equal(validateManifest({ ...MANIFEST, commands: [{ name: "/note", desc: "d", args: "<t>" }] }), null);
396 assert.equal(validateManifest({ ...MANIFEST, chat: true }), null);
397 assert.match(validateManifest({ ...MANIFEST, commands: "no" }), /commands list/);
398 assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "note" }] }), /command name/); // no leading slash
399 assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/UP" }] }), /command name/);
400 assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/n", desc: "x".repeat(90) }] }), /command desc/);
401 assert.match(validateManifest({ ...MANIFEST, chat: "yes" }), /chat flag/);
402 // Optional groupCapable — orthogonal to (and doesn't loosen) scope: personal.
403 assert.equal(validateManifest({ ...MANIFEST, groupCapable: true }), null);
404 assert.equal(validateManifest({ ...MANIFEST, groupCapable: false }), null);
405 assert.match(validateManifest({ ...MANIFEST, groupCapable: "yes" }), /groupCapable flag/);
406 assert.equal(validateManifest({ ...MANIFEST, groupCapable: true, scope: "personal" }), null);
407});
408
409test("cleanJSON admits plain data and rejects the hostile", () => {
410 assert.deepEqual(cleanJSON({ a: [1, "x", true, null] }), { a: [1, "x", true, null] });
411 assert.equal(cleanJSON({ fn: () => {} }), undefined);
412 assert.equal(cleanJSON({ n: NaN }), undefined);
413 assert.equal(cleanJSON(JSON.parse('{"__proto__": {"polluted": 1}}')), undefined);
414 assert.equal(cleanJSON({ constructor: 1 }), undefined);
415 let deep = 1;
416 for (let i = 0; i < 12; i++) deep = { deep };
417 assert.equal(cleanJSON(deep), undefined);
418});
419
420test("sanitizeAppEvent enforces ownership, strips transport fields, clamps notices", () => {
421 const types = MANIFEST.types;
422 assert.equal(sanitizeAppEvent("notes", types, { type: "chat.msg", text: "spoof" }), null);
423 assert.equal(sanitizeAppEvent("notes", types, { type: "notes.unknown" }), null);
424 assert.equal(sanitizeAppEvent("notes", types, "nope"), null);
425 const e = sanitizeAppEvent("notes", types, {
426 type: "notes.add",
427 id: "a",
428 text: "hi",
429 name: "Forged", // transport injects these — a frame must not choose them
430 ts: 1,
431 uid: "someone-else",
432 notice: { title: "T".repeat(300), body: "B", actions: [{ type: "set_theme" }] },
433 });
434 assert.equal(e.type, "notes.add");
435 assert.equal(e.name, undefined);
436 assert.equal(e.ts, undefined);
437 assert.equal(e.uid, undefined);
438 assert.equal(e.notice.title.length, 120);
439 assert.equal(e.notice.actions, undefined);
440 // Oversized events are refused outright.
441 assert.equal(sanitizeAppEvent("notes", types, { type: "notes.add", big: "x".repeat(40000) }), null);
442});
443
444// ---- host proxy: queue fold + frame-state install + snapshot round-trip -------
445
446test("registerMemberApp registers a queueing proxy; frame state installs and trims", () => {
447 const res = registerMemberApp({ id: "app-t1", manifest: MANIFEST, source: NOTES_APP, author: "Ana" });
448 assert.equal(res.ok, true, res.reason);
449 assert.equal(isAppType("notes.add"), true);
450
451 const tid = "thread-sandbox-test";
452 resetThread(tid);
453 foldEvent(tid, { type: "notes.add", id: "a", text: "one", ts: 5 }, { id: 101, ts: 5 });
454 foldEvent(tid, { type: "notes.add", id: "b", text: "two", ts: 6 }, { id: 102, ts: 6 });
455 let st = appStateFor(tid, "notes");
456 assert.equal(st.pend.length, 2);
457 assert.equal(st.v, null);
458 // Same server message id folds once (framework dedupe).
459 foldEvent(tid, { type: "notes.add", id: "b", text: "two", ts: 6 }, { id: 102, ts: 6 });
460 assert.equal(appStateFor(tid, "notes").pend.length, 2);
461
462 // The frame reports its fold through id 101 → v swaps in, pend trims.
463 installMemberAppState(tid, "notes", { items: [{ id: "a", text: "one" }] }, 101);
464 st = appStateFor(tid, "notes");
465 assert.deepEqual(st.v.items, [{ id: "a", text: "one" }]);
466 assert.equal(st.vUpTo, 101);
467 assert.equal(st.pend.length, 1);
468 assert.equal(st.pend[0].meta.id, 102);
469
470 // Snapshot → seed round-trips the {v, vUpTo, pend} wrapper unchanged.
471 const snap = takeSnapshot(tid);
472 assert.equal(snap.upTo, 102);
473 resetThread(tid);
474 seedThread(tid, snap.states, snap.upTo);
475 const seeded = appStateFor(tid, "notes");
476 assert.deepEqual(seeded, st);
477
478 // installMemberAppState is gated on the memberApp marker: a non-sandbox app
479 // can't have its state swapped through this doorway.
480 defineApp({ name: "plainapp-sbtest", title: "x", glyph: "x", scope: "personal", initialState: () => ({ n: 1 }), events: { "plainapp-sbtest.e": (s) => s } });
481 foldEvent(tid, { type: "plainapp-sbtest.e" }, { id: 103, ts: 1 });
482 installMemberAppState(tid, "plainapp-sbtest", { hacked: true }, 103);
483 assert.equal(appStateFor(tid, "plainapp-sbtest").hacked, undefined);
484});
485
486test("a groupCapable member app registers as group-capable; a personal one doesn't", () => {
487 const grouped = { v: 1, name: "gscore", title: "Scoreboard", glyph: "🏆", types: ["gscore.set"], groupCapable: true };
488 const res = registerMemberApp({ id: "app-group1", manifest: grouped, source: NOTES_APP.replace(/notes/g, "gscore"), author: "Ana" });
489 assert.equal(res.ok, true, res.reason);
490 const app = appByName("gscore");
491 // The stored manifest's legacy groupCapable boolean normalizes into places
492 // — "you" is fixed (member apps are always personal, so it still gets its
493 // own personal thread too), "chat" is the author's opt-in.
494 assert.deepEqual(app.places, ["you", "chat"]);
495 assert.ok(groupCapableApps().some((a) => a.name === "gscore"));
496
497 const personal = { v: 1, name: "gplain", title: "Plain", glyph: "P", types: ["gplain.set"] };
498 const res2 = registerMemberApp({ id: "app-group2", manifest: personal, source: NOTES_APP.replace(/notes/g, "gplain") });
499 assert.equal(res2.ok, true, res2.reason);
500 assert.deepEqual(appByName("gplain").places, ["you"], "a plain member app stays personal-only");
501 assert.ok(!groupCapableApps().some((a) => a.name === "gplain"));
502});
503
504// Members can build gif-shaped apps: one shared copy for the whole space and
505// no personal thread at all. Which places an app lives in is the author's
506// call — the only rule is that it lives somewhere.
507test("a space-only member app is legal — places pass through, no personal copy forced", () => {
508 const spacey = { v: 1, name: "gspace", title: "Space Lib", glyph: "🎞️", types: ["gspace.set"], places: ["space"] };
509 assert.equal(validateManifest(spacey), null);
510 const res = registerMemberApp({ id: "app-space1", manifest: spacey, source: NOTES_APP.replace(/notes/g, "gspace"), author: "Ana" });
511 assert.equal(res.ok, true, res.reason);
512 assert.deepEqual(appByName("gspace").places, ["space"]);
513 assert.ok(!groupCapableApps().some((a) => a.name === "gspace"));
514 // Somewhere is still required: an empty places list has nowhere to live.
515 assert.equal(validateManifest({ ...spacey, places: [] }), "places must name at least one place");
516});
517
518// The command-collision gate: a member app must not shadow an existing slash
519// command — the built-ins register first, member apps later, so without this
520// a crafted blob claiming "/todo" (or "/help") would overwrite commandOwner
521// on every client and route members' typed arguments into a log the
522// hijacker can read. LOAD-time security, mirroring the event-type check.
523test("registerMemberApp refuses a command name an existing app or the host already owns", () => {
524 const mk = (name, cmd) => ({ v: 1, name, title: name, glyph: "×", types: [`${name}.set`], places: ["you"], commands: [{ name: cmd, desc: "", args: "" }] });
525 // First app claims /clash — succeeds and registers the command.
526 const first = registerMemberApp({ id: "app-cmd0", manifest: mk("cmdown", "/clash"), source: NOTES_APP.replace(/notes/g, "cmdown") });
527 assert.equal(first.ok, true, first.reason);
528 // A second app claiming the SAME command is refused (app collision) — and
529 // the ORIGINAL owner is untouched (the reject returns before defineApp).
530 const hijack = registerMemberApp({ id: "app-cmd1", manifest: mk("cmdhij", "/clash"), source: NOTES_APP.replace(/notes/g, "cmdhij") });
531 assert.equal(hijack.ok, false);
532 assert.match(hijack.reason, /already taken.*cmdown/);
533 assert.equal(commandTakenBy("/clash"), "cmdown", "the hijack never mutated commandOwner");
534 // A host command (/help) can't be shadowed either.
535 const host = registerMemberApp({ id: "app-cmd2", manifest: mk("cmdhij2", "/help"), source: NOTES_APP.replace(/notes/g, "cmdhij2") });
536 assert.equal(host.ok, false);
537 assert.match(host.reason, /already taken.*built-in/);
538 // A free command name registers fine.
539 const fine = registerMemberApp({ id: "app-cmd3", manifest: mk("cmdok", "/cmdok"), source: NOTES_APP.replace(/notes/g, "cmdok") });
540 assert.equal(fine.ok, true, fine.reason);
541});
542
543// manifestIsLegacy powers App Studio's "older format" flag + Upgrade offer
544// (docs/member-apps.md § Legacy formats): legacy = the stored manifest has no
545// places array, so loading falls back to the old scope/flag fields.
546test("manifestIsLegacy: no places array = legacy; a places manifest is current", () => {
547 assert.equal(manifestIsLegacy({ v: 1, name: "x", scope: "personal" }), true);
548 assert.equal(manifestIsLegacy({ v: 1, name: "x", scope: "personal", groupCapable: true }), true);
549 assert.equal(manifestIsLegacy({ v: 1, name: "x", places: ["you"] }), false);
550 // Leftover legacy fields BESIDE places don't count — places wins at load.
551 assert.equal(manifestIsLegacy({ v: 1, name: "x", places: ["you"], groupCapable: true }), false);
552 assert.equal(manifestIsLegacy(null), false);
553});
554
555// The full /gif shape: places ["space"] + listed:false — implicitly placed,
556// out of the catalogues, the one shared copy used on first command with no
557// add gesture (docs/space-apps.md). A member app can declare it too.
558test("an unlisted space-only member app registers implicitly placed", () => {
559 const implicit = { v: 1, name: "gimp", title: "Implicit", glyph: "✨", types: ["gimp.set"], places: ["space"], listed: false };
560 assert.equal(validateManifest(implicit), null);
561 assert.equal(validateManifest({ ...implicit, listed: "no" }), "bad listed flag");
562 const res = registerMemberApp({ id: "app-space2", manifest: implicit, source: NOTES_APP.replace(/notes/g, "gimp"), author: "Ana" });
563 assert.equal(res.ok, true, res.reason);
564 const app = appByName("gimp");
565 assert.deepEqual(app.places, ["space"]);
566 assert.equal(app.listed, false, "stays out of the Apps catalogue");
567});
568
569// A member app opts into `quiet` (issue #227) exactly like groupCapable — a
570// boolean the author declares, read straight off the manifest, no different
571// from a built-in app's `quiet`. Same mechanism (appIsQuiet), same enforcement
572// (shell.js), for both origins.
573test("a member app can declare quiet:true; validateManifest rejects a non-boolean", () => {
574 const quiet = { v: 1, name: "qmember", title: "Quiet", glyph: "Q", types: ["qmember.set"], quiet: true };
575 const res = registerMemberApp({ id: "app-quiet1", manifest: quiet, source: NOTES_APP.replace(/notes/g, "qmember"), author: "Ana" });
576 assert.equal(res.ok, true, res.reason);
577 assert.equal(appByName("qmember").quiet, true);
578 assert.equal(appIsQuiet("qmember"), true);
579
580 const notQuiet = { v: 1, name: "notqmember", title: "Loud", glyph: "L", types: ["notqmember.set"] };
581 const res2 = registerMemberApp({ id: "app-quiet2", manifest: notQuiet, source: NOTES_APP.replace(/notes/g, "notqmember") });
582 assert.equal(res2.ok, true, res2.reason);
583 assert.equal(appByName("notqmember").quiet, undefined);
584 assert.equal(appIsQuiet("notqmember"), false);
585
586 assert.equal(validateManifest({ ...quiet, quiet: "yes" }), "bad quiet flag");
587});
588
589// quiet and groupCapable are orthogonal, same as for built-in apps: a member
590// app can declare both, and quiet only ever governs the PERSONAL thread's
591// visibility — it carries no meaning for the shared group instance.
592test("a member app can be both groupCapable and quiet at once", () => {
593 const both = { v: 1, name: "qgroup", title: "QG", glyph: "Q", types: ["qgroup.set"], groupCapable: true, quiet: true };
594 const res = registerMemberApp({ id: "app-quiet3", manifest: both, source: NOTES_APP.replace(/notes/g, "qgroup") });
595 assert.equal(res.ok, true, res.reason);
596 const app = appByName("qgroup");
597 assert.deepEqual(app.places, ["you", "chat"]);
598 assert.equal(app.quiet, true);
599 assert.ok(groupCapableApps().some((a) => a.name === "qgroup"));
600});
601
602test("installMemberAppState preserves the `lost` overflow flag", () => {
603 const manifest = { v: 1, name: "lossyapp", title: "L", glyph: "L", types: ["lossyapp.e"] };
604 const res = registerMemberApp({ id: "app-lost1", manifest, source: NOTES_APP.replace(/notes/g, "lossyapp") });
605 assert.equal(res.ok, true, res.reason);
606 const tid = "thread-lost";
607 resetThread(tid);
608 // Simulate an overflow having set lost on the stored state.
609 seedThread(tid, { lossyapp: { v: null, vUpTo: 0, pend: [{ e: { type: "lossyapp.e" }, meta: { id: 5 } }], lost: true } }, 0);
610 // A frame state report must NOT wipe the flag.
611 installMemberAppState(tid, "lossyapp", { done: true }, 5);
612 assert.equal(appStateFor(tid, "lossyapp").lost, true, "lost flag erased by state install");
613});
614
615test("installMemberAppState ignores a stale (lower-upTo) report", () => {
616 const manifest = { v: 1, name: "monoapp", title: "M", glyph: "M", types: ["monoapp.e"] };
617 const res = registerMemberApp({ id: "app-mono", manifest, source: NOTES_APP.replace(/notes/g, "monoapp") });
618 assert.equal(res.ok, true, res.reason);
619 const tid = "thread-mono";
620 resetThread(tid);
621 seedThread(tid, { monoapp: { v: null, vUpTo: 0, pend: [] } }, 0);
622 installMemberAppState(tid, "monoapp", { at: 10 }, 10);
623 assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 10 });
624 // A second frame reports an OLDER horizon — must be ignored.
625 installMemberAppState(tid, "monoapp", { at: 5 }, 5);
626 assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 10 }, "stale report regressed state");
627 assert.equal(appStateFor(tid, "monoapp").vUpTo, 10);
628 // A newer horizon still advances.
629 installMemberAppState(tid, "monoapp", { at: 12 }, 12);
630 assert.deepEqual(appStateFor(tid, "monoapp").v, { at: 12 });
631});
632
633test("registerMemberApp refuses collisions and double registration", () => {
634 assert.equal(registerMemberApp({ id: "app-t1", manifest: MANIFEST, source: NOTES_APP }).reason, "already registered");
635 assert.match(registerMemberApp({ id: "app-t2", manifest: MANIFEST, source: NOTES_APP }).reason, /already exists/);
636 // A manifest claiming another app's event types is refused even under a
637 // fresh name.
638 assert.match(
639 registerMemberApp({
640 id: "app-t4",
641 manifest: { ...MANIFEST, name: "notes2", types: ["notes2.ok", "notes.add"] },
642 source: NOTES_APP,
643 }).reason,
644 /prefixed/,
645 );
646});
647
648// ---- command panels (the declarative sandbox form) --------------------------
649
650test("sanitizePanelSpec clamps the title, whitelists position/animate, caps data", () => {
651 const sp = sanitizePanelSpec({ title: "Hi", position: "top", animate: false, data: { a: 1 } });
652 assert.deepEqual(sp, { title: "Hi", data: { a: 1 }, position: "top", animate: false });
653 assert.equal(sanitizePanelSpec({ title: "x".repeat(200) }).title.length, 80);
654 // A hostile position/animate never passes through; missing data → null.
655 const loose = sanitizePanelSpec({ position: "overlay", animate: "yes" });
656 assert.equal(loose.position, undefined);
657 assert.equal(loose.animate, undefined);
658 assert.equal(loose.data, null);
659 // Un-cleanable or oversize data rejects the whole request.
660 assert.equal(sanitizePanelSpec({ data: { f: () => {} } }), null);
661 assert.equal(sanitizePanelSpec({ data: "y".repeat(70 * 1024) }), null);
662 assert.equal(sanitizePanelSpec("nope"), null);
663});
664
665test("evalMemberApp captures a panel hook; the mood example evaluates whole", async () => {
666 const { readFileSync } = await import("node:fs");
667 const src = readFileSync(new URL("../web/static/apps-disabled/mood.js", import.meta.url), "utf8");
668 const spec = evalMemberApp(src, "mood");
669 assert.equal(typeof spec.panel, "function", "the panel hook survives the sandbox transform");
670 assert.ok(spec.commands["/mood"], "the /mood command registers");
671 // The reducer folds a check-in and validates hostile input to a no-op.
672 const s1 = spec.reduce(undefined, { type: "mood.set", id: "a", mood: "🙂", note: "ok", name: "Ana", ts: 5 });
673 assert.equal(s1.items.length, 1);
674 assert.equal(spec.reduce(s1, { type: "mood.set", id: "b", mood: "<script>" }).items.length, 1, "unknown mood folds to a no-op");
675 // Its manifest extracts cleanly and validates for deploy.
676 const m = manifestOf(spec);
677 assert.equal(validateManifest(m), null);
678 assert.equal(m.chat, true);
679});
680
681// The form-submission shim (bootFrame): a member app can use a plain
682// <form onsubmit> because the runtime intercepts the submission TRIGGER (a
683// submit button's click, an Enter implicit-submit) and re-dispatches a scripted
684// submit — no native submission, so no "Blocked form submission" console error
685// and no navigation, with the sandbox unchanged (still no allow-forms). These
686// pin the pure trigger predicates the shim keys on.
687test("isSubmitTrigger: submit buttons / image inputs / typeless buttons in a form", () => {
688 assert.equal(isSubmitTrigger("BUTTON", null, true), true, "typeless <button> defaults to submit");
689 assert.equal(isSubmitTrigger("BUTTON", "submit", true), true);
690 assert.equal(isSubmitTrigger("INPUT", "submit", true), true);
691 assert.equal(isSubmitTrigger("INPUT", "image", true), true);
692});
693test("isSubmitTrigger: NOT a trigger for type=button, plain inputs, or no form", () => {
694 assert.equal(isSubmitTrigger("BUTTON", "button", true), false, "type=button never submits");
695 assert.equal(isSubmitTrigger("INPUT", "text", true), false);
696 assert.equal(isSubmitTrigger("BUTTON", "submit", false), false, "no associated form");
697 assert.equal(isSubmitTrigger("A", null, true), false);
698});
699test("isEnterSubmit: Enter in a single-line input in a form, respecting Shift/IME", () => {
700 assert.equal(isEnterSubmit("Enter", false, false, "INPUT", true), true);
701 assert.equal(isEnterSubmit("Enter", true, false, "INPUT", true), false, "Shift+Enter = newline");
702 assert.equal(isEnterSubmit("Enter", false, true, "INPUT", true), false, "mid IME composition");
703 assert.equal(isEnterSubmit("Enter", false, false, "TEXTAREA", true), false, "textarea keeps Enter");
704 assert.equal(isEnterSubmit("Enter", false, false, "INPUT", false), false, "no associated form");
705 assert.equal(isEnterSubmit("a", false, false, "INPUT", true), false);
706});
707