eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// sandbox.js — the HOST side of member-built apps (docs/member-apps.md).
2//
3// A member app is user content, not admin-vetted code, so the page never runs
4// it. Instead each member app registers a PROXY through the ordinary mini-app
5// framework — so it appears in New chat's Apps list, gets a personal thread,
6// folds events, and snapshots like any app — while the untrusted code itself
7// runs only inside an opaque-origin <iframe sandbox="allow-scripts"> pointed
8// at /app-sandbox (no cookies, no storage, no parent DOM, no network; see
9// memberapps.go for the frame's CSP).
10//
11// The proxy's fold is a trusted, generic queue: every wire event of the app's
12// types appends to `pend`. The frame folds those events with the app's real
13// (untrusted) reducer and reports folded state back; the host then swaps the
14// report in as `v` and trims `pend` (installMemberAppState in apps.js). At
15// every moment `v + pend` IS the fold at the thread's snapshot horizon, so
16// the framework's snapshot machinery persists sandboxed state unchanged.
17//
18// Trust rules enforced here, on every message out of a frame:
19// - an app may only emit/schedule events of its OWN manifest types (all
20// prefixed "<name>." — it can't spoof chat.*, another app, or a bare
21// text message);
22// - events are deep-sanitized: plain JSON data only, no __proto__ keys, no
23// injected name/ts/uid, notices clamped to plain strings with no action
24// chips, hard size caps, a rate limit;
25// - state reports are size-capped and JSON-normalized.
26// Nothing else crosses: no keys, no tokens, no other threads' data, no DOM.
27
28import { defineApp, appByName, isAppType, commandTakenBy, installMemberAppState, memberCtx, foldHostEvent } from "./apps.js";
29import { decrypt, encrypt } from "./crypto.js";
30
31// Injected by shell.js: seed a member app's snapshot state before an off-thread
32// command runs its (transient) logic frame, and re-render the active surfaces
33// after companion chat projections fold in. Both no-ops until set.
34let memberSeeder = null;
35export function setMemberSeeder(fn) {
36 memberSeeder = fn;
37}
38let surfaceRefresh = null;
39export function setSurfaceRefresh(fn) {
40 surfaceRefresh = fn;
41}
42
43// Injected by shell.js: the privileged context App Studio needs to list /
44// deploy / retire member apps — an accessor returning
45// { token, instanceKey, uid, name, openApp(name) }. An accessor (not a value)
46// because the instance key arrives after boot. No-op until set.
47let memberAdmin = null;
48export function setMemberAdmin(fn) {
49 memberAdmin = fn;
50}
51
52// How many queued-but-unfolded events a proxy retains while no frame is alive
53// to fold them (the frame drains the queue whenever the app is open). Beyond
54// the cap the oldest are dropped and the state flagged, so a pathological
55// backlog degrades visibly instead of growing without bound.
56const MAX_PEND = 500;
57
58// Caps on what a frame may hand us (the server's blob cap is 64 KB and the
59// thread-state cap 256 KB — stay comfortably inside both).
60const MAX_EVENT_JSON = 32 * 1024;
61// The whole proxy snapshot (state + pend + projected chat) is encrypted then
62// base64'd (~1.37×) into one thread_state blob capped at 256 KB server-side, and
63// several apps may share that slot — so keep the plaintext budget well under it.
64const MAX_STATE_JSON = 150 * 1024;
65const EMIT_BURST = 30; // emits allowed per window…
66const EMIT_WINDOW_MS = 10_000; // …per this window
67
68// The theme custom properties forwarded into frames — the same table
69// docs/building-member-apps.md promises authors.
70const THEME_VARS = [
71 "--bg", "--panel", "--sidebar", "--ink", "--muted", "--border",
72 "--accent", "--accent-ink", "--link", "--bubble-me", "--bubble-me-ink",
73 "--bubble-them", "--danger", "--warn", "--radius", "--shadow",
74 "--font-display", "--ui-cursor",
75];
76
77// ---- manifest + event validation (pure; unit-tested in Node) --------------
78
79const NAME_RE = /^[a-z][a-z0-9-]{1,23}$/;
80const TYPE_TAIL_RE = /^[a-z0-9_-]{1,40}(\.[a-z0-9_-]{1,40})*$/;
81const CMD_RE = /^\/[a-z][a-z0-9-]{0,30}$/;
82
83// validateManifest checks a decrypted member-app manifest and returns an
84// error string, or null when it's sound. The manifest is untrusted (any
85// member can deploy a blob), so nothing here is advisory: the proxy registers
86// exactly and only what validates.
87export function validateManifest(m) {
88 if (!m || typeof m !== "object") return "manifest missing";
89 if (m.v !== 1) return "unsupported manifest version";
90 if (typeof m.name !== "string" || !NAME_RE.test(m.name)) return "bad app name";
91 if (typeof m.title !== "string" || !m.title.trim() || m.title.length > 40) return "bad title";
92 if (typeof m.glyph !== "string" || !m.glyph || m.glyph.length > 8) return "bad glyph";
93 if (m.scope !== undefined && m.scope !== "personal") return "only personal member apps are supported";
94 if (!Array.isArray(m.types) || m.types.length < 1 || m.types.length > 64) return "bad types list";
95 for (const t of m.types) {
96 if (typeof t !== "string" || !t.startsWith(m.name + ".")) return `event type "${t}" must be prefixed "${m.name}."`;
97 if (!TYPE_TAIL_RE.test(t.slice(m.name.length + 1))) return `bad event type "${t}"`;
98 }
99 // Optional command METADATA (names + typeahead text); the handlers live in
100 // the app source and run in the frame. Only the shapes are checked here.
101 if (m.commands !== undefined) {
102 if (!Array.isArray(m.commands) || m.commands.length > 16) return "bad commands list";
103 for (const c of m.commands) {
104 if (!c || typeof c !== "object") return "bad command";
105 if (typeof c.name !== "string" || !CMD_RE.test(c.name)) return `bad command name "${c && c.name}"`;
106 if (c.desc !== undefined && (typeof c.desc !== "string" || c.desc.length > 80)) return "bad command desc";
107 if (c.args !== undefined && (typeof c.args !== "string" || c.args.length > 40)) return "bad command args";
108 }
109 }
110 // Optional native chrome (docs/icons.md): an SF Symbols name — dotted
111 // lowercase words, bounded. The emoji glyph stays the identity; a name
112 // native can't resolve falls back to it. `mark` is deliberately NOT a
113 // member-app field (vendored brand assets — impersonation).
114 if (m.symbol !== undefined && (typeof m.symbol !== "string" || m.symbol.length > 60 || !/^[a-z0-9.]+$/.test(m.symbol))) return "bad symbol";
115 // Optional companion opt-in: the app projects each event into a Chat view.
116 if (m.chat !== undefined && typeof m.chat !== "boolean") return "bad chat flag";
117 // Optional group opt-in: the app may ALSO be added to a group chat, where
118 // one shared instance runs for everyone (same meaning as a built-in app's
119 // `groupCapable` — see apps.js). Orthogonal to `scope`, which stays
120 // "personal": the app still gets its own personal thread too.
121 if (m.groupCapable !== undefined && typeof m.groupCapable !== "boolean") return "bad groupCapable flag";
122 // Optional space opt-in: the app may be installed FOR THE WHOLE SPACE — one
123 // shared instance in the hidden space thread, its commands available in
124 // every chat (same meaning as a built-in app's `spaceCapable` — see apps.js
125 // and docs/space-apps.md).
126 if (m.spaceCapable !== undefined && typeof m.spaceCapable !== "boolean") return "bad spaceCapable flag";
127 // Optional places-first declaration (docs/space-apps.md): any NON-EMPTY
128 // subset of ["you","chat","space"] — a member app needs somewhere to live,
129 // but which places is the author's call: personal-only is the common shape,
130 // and a space-only app (one shared copy, gif-style) is just as legal.
131 // Overrides the boolean flags above. (Legacy manifests without places imply
132 // "you" — that generation was personal by definition.)
133 if (m.places !== undefined) {
134 if (!Array.isArray(m.places) || !m.places.every((x) => x === "you" || x === "chat" || x === "space")) return "bad places list";
135 if (!m.places.length) return "places must name at least one place";
136 }
137 // Optional listed:false — the implicitly-placed shape (the /gif model,
138 // docs/space-apps.md): the app stays out of the catalogues, and a
139 // space-only unlisted app's one copy is used on first command, no add
140 // gesture. Anything but a boolean is a malformed manifest.
141 if (m.listed !== undefined && typeof m.listed !== "boolean") return "bad listed flag";
142 // Optional one-line reason a place is off ("why can't I add this here?"),
143 // shown muted in the Apps sheet on unsupported places.
144 if (m.placeNote !== undefined && (typeof m.placeNote !== "string" || m.placeNote.length > 120)) return "bad placeNote";
145 // Optional quiet opt-in: a command answering in place must not force this
146 // app's personal thread into view (same meaning as a built-in app's `quiet`
147 // — see apps.js, issue #227).
148 if (m.quiet !== undefined && typeof m.quiet !== "boolean") return "bad quiet flag";
149 // Optional defaultCommand: plain text in the Chat view runs it. Must name a
150 // command the app declares (so the host has a handler to route text to).
151 if (m.defaultCommand !== undefined) {
152 if (typeof m.defaultCommand !== "string" || !CMD_RE.test(m.defaultCommand)) return "bad defaultCommand";
153 if (!(m.commands || []).some((c) => c.name === m.defaultCommand)) {
154 return `defaultCommand "${m.defaultCommand}" isn't one of the app's commands`;
155 }
156 }
157 return null;
158}
159
160const BAD_KEYS = new Set(["__proto__", "constructor", "prototype"]);
161
162// cleanJSON deep-copies untrusted structured data into plain JSON values:
163// objects/arrays/strings/finite numbers/booleans/null only, hostile keys
164// rejected, depth-bounded. Returns undefined when the value can't be accepted.
165export function cleanJSON(v, depth = 0) {
166 if (depth > 8) return undefined;
167 if (v === null) return null;
168 const t = typeof v;
169 if (t === "string") return v.length > 8192 ? undefined : v;
170 if (t === "number") return Number.isFinite(v) ? v : undefined;
171 if (t === "boolean") return v;
172 if (Array.isArray(v)) {
173 if (v.length > 1024) return undefined;
174 const out = [];
175 for (const it of v) {
176 const c = cleanJSON(it, depth + 1);
177 if (c === undefined) return undefined;
178 out.push(c);
179 }
180 return out;
181 }
182 if (t === "object") {
183 const out = {};
184 for (const k of Object.keys(v)) {
185 if (BAD_KEYS.has(k) || k.length > 64) return undefined;
186 const c = cleanJSON(v[k], depth + 1);
187 if (c === undefined) return undefined;
188 out[k] = c;
189 }
190 return out;
191 }
192 return undefined; // functions, symbols, bigints, undefined
193}
194
195// sanitizeAppEvent admits an event a frame wants on the wire: its own types
196// only, clean JSON, no transport-injected fields, notice reduced to plain
197// clamped strings (never action chips), and a total size cap. Returns the
198// cleaned event or null.
199export function sanitizeAppEvent(appName, ownedTypes, event) {
200 const e = cleanJSON(event);
201 if (!e || typeof e !== "object" || Array.isArray(e)) return null;
202 if (typeof e.type !== "string" || !e.type.startsWith(appName + ".")) return null;
203 if (ownedTypes && !ownedTypes.includes(e.type)) return null;
204 delete e.name; // the transport injects these; a frame must not choose them
205 delete e.ts;
206 delete e.uid;
207 if (e.notice !== undefined) {
208 const n = e.notice;
209 if (!n || typeof n !== "object" || Array.isArray(n)) return null;
210 e.notice = {
211 title: String(n.title || "").slice(0, 120),
212 body: String(n.body || "").slice(0, 400),
213 };
214 }
215 try {
216 if (JSON.stringify(e).length > MAX_EVENT_JSON) return null;
217 } catch {
218 return null;
219 }
220 return e;
221}
222
223// sanitizePanelSpec admits a command panel request from a frame (the
224// declarative ctx.panel form — see sandbox-frame.js's header): clamped title,
225// whitelisted position/animate, and `data` (the JSON handed to the app's
226// panel hook) cleaned and size-capped. Returns the cleaned spec or null.
227export function sanitizePanelSpec(spec) {
228 if (!spec || typeof spec !== "object" || Array.isArray(spec)) return null;
229 // Field-wise, not cleanJSON on the whole object: an app that leaves
230 // position/animate unset ships them as `undefined` properties, which
231 // cleanJSON (rightly) rejects wholesale — only `data` is arbitrary JSON.
232 const data = spec.data === undefined || spec.data === null ? null : cleanJSON(spec.data);
233 if (data === undefined) return null;
234 const out = { title: String(spec.title || "").slice(0, 80), data };
235 if (spec.position === "top") out.position = "top";
236 if (spec.animate === false) out.animate = false;
237 try {
238 if (JSON.stringify(data).length > 64 * 1024) return null;
239 } catch {
240 return null;
241 }
242 return out;
243}
244
245// ---- the proxy app ---------------------------------------------------------
246
247// One shared stylesheet for every sandbox mount (injected once).
248function injectSandboxStyles() {
249 if (typeof document === "undefined" || document.getElementById("sandbox-style")) return;
250 const st = document.createElement("style");
251 st.id = "sandbox-style";
252 st.textContent = `
253.sandbox-pane { display: flex; flex-direction: column; height: 100%; }
254/* Sandboxed apps own their full surface edge-to-edge — drop the app-pane's
255 16px inset (apps.js), which is meant for built-in apps rendering DOM
256 directly into the pane. The app adds its own padding inside the frame.
257 (render() puts .sandbox-pane on the pane element itself.) */
258.thread-view.app-mode .app-pane.sandbox-pane { padding: 0; gap: 0; }
259.sandbox-note { padding: 8px 12px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--border); }
260.sandbox-note.err { color: var(--danger); }
261.sandbox-frame { flex: 1 1 auto; width: 100%; border: 0; display: block; }
262`;
263 document.head.appendChild(st);
264}
265
266// Live mounts: iframe element → mount record. One global listener routes each
267// frame's "ready" (sent on load AND on reload — a parked pane's iframe reloads
268// when it re-enters the DOM) to its record and (re-)initialises the bridge.
269const mounts = new Map();
270
271// resolveDark turns a computed `color-scheme` value into a concrete dark/light
272// decision for the frame. The app writes "light", "dark", or "light dark" (the
273// last means "follow the OS" — colorScheme() in ui.js), so a plain
274// .includes("dark") reads "light dark" as dark and forces every app dark even in
275// light mode. Explicit wins; the ambiguous "light dark"/unset case follows the OS.
276export function resolveDark(colorScheme, osPrefersDark) {
277 const s = (colorScheme || "").trim();
278 const hasDark = /\bdark\b/.test(s);
279 const hasLight = /\blight\b/.test(s);
280 if (hasDark && !hasLight) return true;
281 if (hasLight && !hasDark) return false;
282 return !!osPrefersDark;
283}
284
285function themeSnapshot() {
286 const cs = getComputedStyle(document.documentElement);
287 const vars = {};
288 for (const k of THEME_VARS) {
289 const v = cs.getPropertyValue(k);
290 if (v) vars[k] = v.trim();
291 }
292 return {
293 vars,
294 font: getComputedStyle(document.body).font || "",
295 dark: resolveDark(cs.colorScheme, matchMedia("(prefers-color-scheme: dark)").matches),
296 };
297}
298
299// pushTheme forwards the CURRENT theme to every live sandbox frame, so member
300// apps re-tint the moment the member switches theme/appearance — the shell calls
301// it from its own applyTheme. (Without this a frame keeps its init-time theme
302// until it's re-mounted, e.g. by revisiting the thread.)
303export function pushTheme() {
304 const msg = { type: "theme", ...themeSnapshot() };
305 for (const rec of mounts.values()) {
306 if (rec.port) {
307 try {
308 rec.port.postMessage(msg);
309 } catch {}
310 }
311 }
312}
313
314function seedFrom(state) {
315 const s = state || {};
316 return { v: s.v === undefined ? null : s.v, vUpTo: s.vUpTo || 0, pend: s.pend || [], chat: s.chat || [] };
317}
318
319// initMount is driven by the iframe's `load` event (see registerMemberApp),
320// NOT by a postMessage from the frame. That distinction is load-bearing: a
321// `load` event fires only on a genuine document (re)load and cannot be forged
322// by the code running inside the frame, whereas a `postMessage` "ready" can —
323// so a hostile app could otherwise spam a fake ready to make the host swap to
324// a fresh port the live frame never adopts, freezing event delivery. By the
325// time the parent's `load` fires, the frame's module script has already run
326// and installed its init listener, so posting the port here always lands.
327function initMount(rec) {
328 const chan = new MessageChannel();
329 rec.port = chan.port1;
330 rec.sent = new Set();
331 rec.emits = []; // rate-limit timestamps
332 rec.port.onmessage = (ev) => onFrameMessage(rec, ev);
333 const state = rec.ctx.getState();
334 const seed = seedFrom(state);
335 for (const it of seed.pend) if (it && it.meta && it.meta.id != null) rec.sent.add(it.meta.id);
336 const init = {
337 type: "lchat-sandbox-init",
338 source: rec.app.memberApp.source,
339 manifest: rec.app.memberApp.manifest,
340 threadId: rec.ctx.threadId,
341 me: { uid: rec.ctx.me, name: rec.ctx.meName },
342 theme: themeSnapshot(),
343 seed,
344 };
345 // A panel frame boots straight into the app's panel hook (sandbox-frame.js).
346 if (rec.panelFrame) init.panel = { data: rec.panelData === undefined ? null : rec.panelData };
347 rec.iframe.contentWindow.postMessage(
348 init,
349 // The frame's origin is opaque, so "*" is the only addressable target;
350 // the transferred port scopes the channel to exactly this frame.
351 "*",
352 [chan.port2],
353 );
354}
355
356function note(rec, text, isErr) {
357 let n = rec.el.querySelector(".sandbox-note");
358 if (!text) {
359 if (n) n.remove();
360 return;
361 }
362 if (!n) {
363 n = document.createElement("div");
364 n.className = "sandbox-note";
365 rec.el.prepend(n);
366 }
367 n.classList.toggle("err", !!isErr);
368 n.textContent = text;
369}
370
371function onFrameMessage(rec, ev) {
372 const m = ev.data;
373 if (!m || typeof m !== "object") return;
374 const app = rec.app;
375 const ctx = rec.ctx;
376 switch (m.type) {
377 case "state": {
378 if (rec.panelFrame) return; // the app-thread's own frame owns state
379 const upTo = m.upTo;
380 if (typeof upTo !== "number" || !Number.isFinite(upTo) || upTo < 0) return;
381 const state = cleanJSON(m.state);
382 if (state === undefined) return;
383 const chat = app.memberApp.manifest.chat ? sanitizeChat(m.chat) : undefined;
384 try {
385 // Guard the WHOLE persisted object (state + projected chat), not just
386 // state: the snapshot carries both, and together they must fit the
387 // server's thread_state cap.
388 if (JSON.stringify(state).length + (chat ? JSON.stringify(chat).length : 0) > MAX_STATE_JSON) {
389 note(rec, "This app's state grew too large to save — recent changes may not persist.", true);
390 return;
391 }
392 } catch {
393 return;
394 }
395 installMemberAppState(ctx.threadId, app.name, state, upTo, chat);
396 if (chat && chat.length) foldCompanionChat(ctx.threadId, app, chat);
397 break;
398 }
399 case "command-reply": {
400 const w = rec.cmdWaiters && rec.cmdWaiters.get(m.seq);
401 if (w) {
402 rec.cmdWaiters.delete(m.seq);
403 w(m.spec && typeof m.spec === "object" ? m.spec : null);
404 }
405 break;
406 }
407 case "emit":
408 case "ephemeral": {
409 if (!allowSend(rec)) return;
410 const e = sanitizeAppEvent(app.name, app.memberApp.manifest.types, m.event);
411 if (!e) return;
412 if (m.type === "emit") ctx.emit(e);
413 else ctx.emitEphemeral(e);
414 break;
415 }
416 case "schedule": {
417 const seq = m.seq;
418 if (!allowSend(rec)) {
419 reply(rec, seq, null);
420 return;
421 }
422 const e = sanitizeAppEvent(app.name, app.memberApp.manifest.types, m.event);
423 const when = m.when;
424 const sane =
425 e && typeof when === "number" && Number.isFinite(when) &&
426 when > Date.now() - 120_000 && when < Date.now() + 2 * 366 * 24 * 3600 * 1000;
427 if (!sane) {
428 reply(rec, seq, null);
429 return;
430 }
431 ctx.schedule(e, when).then((sid) => reply(rec, seq, sid || null));
432 break;
433 }
434 case "unschedule": {
435 const sid = typeof m.sid === "string" && m.sid.length <= 64 ? m.sid : "";
436 if (!sid || !allowSend(rec)) {
437 reply(rec, m.seq, false);
438 return;
439 }
440 Promise.resolve(ctx.unschedule(sid)).then((ok) => reply(rec, m.seq, !!ok));
441 break;
442 }
443 case "panel-open": {
444 // A COMMAND asking for a panel (declarative ctx.panel). Gated to a
445 // still-live command invocation — the host ctx (with its panel opener,
446 // bound to where the member typed) is looked up by the command's seq,
447 // so a frame can't pop panels outside a command it was just asked to
448 // run ("a response, not an ambush", for member apps too).
449 const hostCtx = rec.cmdCtx && rec.cmdCtx.get(m.cmdSeq);
450 const spec = hostCtx && typeof hostCtx.panel === "function" ? sanitizePanelSpec(m.spec) : null;
451 if (!spec) {
452 reply(rec, m.seq, false);
453 return;
454 }
455 const opened = hostCtx.panel({
456 title: spec.title,
457 position: spec.position,
458 animate: spec.animate,
459 render: (el, handle) => mountPanelFrame(app, hostCtx.threadId, spec.data, el, handle),
460 onClose: () => teardownPanelMount(app.name),
461 });
462 reply(rec, m.seq, !!opened);
463 break;
464 }
465 case "panel-size": {
466 // The panel frame fitting its content: clamp and size the iframe; the
467 // host panel's own body scrolls beyond the cap.
468 if (!rec.panelFrame) return;
469 const h = typeof m.h === "number" && Number.isFinite(m.h) ? Math.min(600, Math.max(24, m.h)) : 0;
470 if (h) rec.iframe.style.height = h + "px";
471 break;
472 }
473 case "panel-close": {
474 if (rec.panelFrame && rec.panelHandle) rec.panelHandle.close();
475 break;
476 }
477 case "panel-insert": {
478 // A panel acting on the composer it sits above (clamped; the same
479 // doorway a trusted panel's insertText uses). Should follow a user tap.
480 if (rec.panelFrame && rec.panelHandle && typeof m.text === "string") {
481 rec.panelHandle.insertText(m.text.slice(0, 2000));
482 }
483 break;
484 }
485 case "error":
486 note(rec, `App error: ${String(m.message || "unknown").slice(0, 200)}`, true);
487 break;
488 }
489}
490
491// ---- command panels for member apps ----------------------------------------
492
493// One live panel frame per app (opening another replaces it, like the host
494// panel itself). The frame is just another mount: its emits/schedules ride the
495// same sanitized, rate-limited bridge; only `state` reports are ignored.
496const panelMounts = new Map(); // app name → mount record
497
498function mountPanelFrame(app, threadId, data, el, handle) {
499 teardownPanelMount(app.name);
500 injectSandboxStyles();
501 const iframe = document.createElement("iframe");
502 iframe.className = "sandbox-frame sandbox-panel-frame";
503 iframe.setAttribute("sandbox", "allow-scripts");
504 iframe.setAttribute("allow", "");
505 iframe.referrerPolicy = "no-referrer";
506 iframe.src = "/app-sandbox";
507 iframe.style.cssText = "width:100%;border:0;display:block;height:96px";
508 const rec = {
509 iframe,
510 el,
511 app,
512 ctx: memberCtx(threadId, app),
513 port: null,
514 sent: new Set(),
515 emits: [],
516 panelFrame: true,
517 panelHandle: handle,
518 panelData: data,
519 };
520 mounts.set(iframe, rec);
521 panelMounts.set(app.name, rec);
522 iframe.addEventListener("load", () => {
523 rec.port = null;
524 initMount(rec);
525 });
526 el.appendChild(iframe);
527}
528
529function teardownPanelMount(name) {
530 const rec = panelMounts.get(name);
531 if (!rec) return;
532 panelMounts.delete(name);
533 // Small grace so a click's in-flight emit still crosses before removal.
534 setTimeout(() => {
535 mounts.delete(rec.iframe);
536 if (rec.iframe.parentNode) rec.iframe.remove();
537 }, 2000);
538}
539
540function reply(rec, seq, value) {
541 if (rec.port && typeof seq === "number") rec.port.postMessage({ type: "rpc-reply", seq, value });
542}
543
544// allowSend is the shared rate limit on everything a frame may push to the
545// wire (emit / ephemeral / schedule / unschedule) — a runaway or hostile app
546// can't flood the server with the member's credentials.
547function allowSend(rec) {
548 const now = Date.now();
549 rec.emits = rec.emits.filter((t) => now - t < EMIT_WINDOW_MS);
550 if (rec.emits.length >= EMIT_BURST) {
551 note(rec, "This app is sending too fast — some of its updates were dropped.", true);
552 return false;
553 }
554 rec.emits.push(now);
555 return true;
556}
557
558// ---- companion chat ---------------------------------------------------------
559
560// sanitizeChat cleans a frame's reported companion chat lines to plain data:
561// each { mid, ts, text, action } with clamped strings. Untrusted, so bounded.
562export function sanitizeChat(lines) {
563 if (!Array.isArray(lines)) return [];
564 const out = [];
565 for (const l of lines.slice(-CHAT_CAP)) {
566 if (!l || typeof l !== "object") continue;
567 const mid = l.mid;
568 const text = typeof l.text === "string" ? l.text : "";
569 if (mid == null || !text) continue;
570 out.push({
571 mid,
572 ts: Number.isFinite(l.ts) ? l.ts : 0,
573 text: text.slice(0, 400),
574 action: !!l.action,
575 });
576 }
577 return out;
578}
579const CHAT_CAP = 200;
580
581// foldCompanionChat folds the frame's projected lines into the thread's Chat
582// view — the SAME path a trusted companion uses (foldAppEventBubble), a
583// chat.msg attributed to the app, deduped by message id. Then refresh the
584// surfaces so the Chat pane repaints.
585//
586// The dedupe id is NAMESPACED (`c:<mid>`), not the raw frame-reported mid: a
587// hostile app (which controls its own frame's port) could otherwise report a
588// mid equal to a not-yet-folded real message id in its own thread and, via the
589// chat app's first-writer-wins dedupe, suppress the installer's own later note.
590// Prefixing keeps projected ids in their own space, so they can never collide
591// with a real chat.msg id — folding stays idempotent, ordering is by ts.
592function foldCompanionChat(threadId, app, lines) {
593 for (const l of lines) {
594 foldHostEvent(
595 threadId,
596 "chat",
597 { type: "chat.msg", name: app.title || app.name, text: l.text, uid: "app:" + app.name, ts: l.ts || Date.now(), action: l.action },
598 { id: "c:" + l.mid, ts: l.ts },
599 );
600 }
601 if (surfaceRefresh) {
602 try {
603 surfaceRefresh(threadId);
604 } catch {}
605 }
606}
607
608// ---- logic frames + commands ------------------------------------------------
609
610// A member app's command handler is untrusted, so it runs in a frame. When the
611// app's thread is open its live frame handles it; otherwise a transient hidden
612// frame is mounted (seeded from the snapshot) just to execute the command, then
613// torn down. Hidden frames live in one offscreen container.
614function hiddenContainer() {
615 let el = document.getElementById("sandbox-hidden");
616 if (!el) {
617 el = document.createElement("div");
618 el.id = "sandbox-hidden";
619 el.setAttribute("aria-hidden", "true");
620 el.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;border:0;left:-9999px;top:0";
621 document.body.appendChild(el);
622 }
623 return el;
624}
625
626// ensureLogicFrame returns a live frame (with a ready port) bound to
627// (app, threadId): a mounted visible/companion frame if one exists, else a
628// freshly-mounted transient hidden frame seeded from the snapshot.
629async function ensureLogicFrame(app, threadId) {
630 for (const rec of mounts.values()) {
631 if (rec.app === app && rec.ctx && rec.ctx.threadId === threadId && rec.port) return rec;
632 }
633 // Load the app-thread's snapshot so the frame's getState is accurate for a
634 // state-reading command (a bare emit doesn't need it, but correctness first).
635 if (memberSeeder) {
636 try {
637 await memberSeeder(threadId);
638 } catch {}
639 }
640 return mountHiddenFrame(app, threadId);
641}
642
643function mountHiddenFrame(app, threadId) {
644 const iframe = document.createElement("iframe");
645 iframe.className = "sandbox-frame";
646 iframe.setAttribute("sandbox", "allow-scripts");
647 iframe.setAttribute("allow", "");
648 iframe.referrerPolicy = "no-referrer";
649 iframe.src = "/app-sandbox";
650 const rec = {
651 iframe,
652 el: hiddenContainer(),
653 app,
654 ctx: memberCtx(threadId, app),
655 port: null,
656 sent: new Set(),
657 emits: [],
658 transient: true,
659 };
660 mounts.set(iframe, rec);
661 return new Promise((resolve) => {
662 rec.onReady = () => resolve(rec);
663 iframe.addEventListener("load", () => {
664 rec.port = null;
665 initMount(rec);
666 if (rec.onReady) {
667 const r = rec.onReady;
668 rec.onReady = null;
669 r();
670 }
671 });
672 hiddenContainer().appendChild(iframe);
673 // Never hang a command on a frame that fails to load.
674 setTimeout(() => {
675 if (rec.onReady) {
676 rec.onReady = null;
677 resolve(rec);
678 }
679 }, 6000);
680 });
681}
682
683function teardownTransient(rec) {
684 if (!rec || !rec.transient || rec.tornDown) return;
685 rec.tornDown = true;
686 // Small grace so a late state report / reply still lands before removal.
687 setTimeout(() => {
688 mounts.delete(rec.iframe);
689 if (rec.iframe.parentNode) rec.iframe.remove();
690 }, 4000);
691}
692
693// runMemberCommand executes one slash command in a frame and shows its reply
694// where the user typed. Invoked by the proxy's command wrapper; `hostCtx` is
695// the apps.js ctx bound to the app's thread (with ctx.reply set by runCommand).
696async function runMemberCommand(app, hostCtx, cmdName, args) {
697 const rec = await ensureLogicFrame(app, hostCtx.threadId);
698 if (!rec) return;
699 // finally: a transient frame that never got a port (a load-event timeout)
700 // must still be torn down, or it leaks a stranded hidden iframe.
701 try {
702 if (!rec.port) return;
703 const spec = await sendCommand(rec, cmdName, args, hostCtx);
704 if (spec && hostCtx.reply) hostCtx.reply(sanitizeReply(spec, app, hostCtx));
705 } finally {
706 teardownTransient(rec);
707 }
708}
709
710let cmdSeq = 0;
711function sendCommand(rec, cmd, args, hostCtx) {
712 if (!rec.cmdWaiters) rec.cmdWaiters = new Map();
713 if (!rec.cmdCtx) rec.cmdCtx = new Map();
714 const seq = ++cmdSeq;
715 // The host ctx stays addressable past the command's reply: a handler may
716 // reply first and open its panel after an await. The window is bounded.
717 rec.cmdCtx.set(seq, hostCtx);
718 setTimeout(() => rec.cmdCtx.delete(seq), 20000);
719 return new Promise((resolve) => {
720 rec.cmdWaiters.set(seq, resolve);
721 rec.port.postMessage({ type: "command", seq, cmd, args: String(args || "") });
722 setTimeout(() => {
723 if (rec.cmdWaiters.delete(seq)) resolve(null);
724 }, 15000);
725 });
726}
727
728// sanitizeReply reduces a frame's command reply to a private confirmation:
729// clamped text/short, and a host-owned "Open <app>" chip so the member can
730// jump to the app after a quick capture (the ONLY action — the app can't ship
731// arbitrary clickable code into the host).
732function sanitizeReply(spec, app, hostCtx) {
733 const text = String(spec.text || "").slice(0, 500);
734 if (!text) return { text: "" };
735 const out = { text, short: spec.short != null ? String(spec.short).slice(0, 200) : undefined };
736 if (hostCtx && typeof hostCtx.open === "function") {
737 out.actions = [{ label: `Open ${app.title || app.name}`, run: hostCtx.open }];
738 }
739 return out;
740}
741
742// The queue fold every proxy type shares: append, bounded. Dropping the
743// oldest breaks v-continuity, so mark the state `lost` — the app still works
744// forward, and the flag is visible for debugging. (The frame drains the
745// queue whenever the app is open, so hitting the cap means hundreds of events
746// arrived while this app was never once opened.)
747function queueEvent(s, e, meta) {
748 const pend = [...(s.pend || []), { e, meta: { id: meta && meta.id, ts: meta && meta.ts } }];
749 if (pend.length > MAX_PEND) {
750 return { v: s.v, vUpTo: s.vUpTo || 0, pend: pend.slice(pend.length - MAX_PEND), lost: true };
751 }
752 return { v: s.v, vUpTo: s.vUpTo || 0, pend, lost: s.lost };
753}
754
755// registerMemberApp validates and registers one decrypted member app as a
756// sandbox proxy. Returns { ok: true } or { ok: false, reason }.
757const registeredIds = new Set();
758export function registerMemberApp({ id, manifest, source, author }) {
759 const err = validateManifest(manifest);
760 if (err) return { ok: false, reason: err };
761 if (typeof source !== "string" || !source.trim() || source.length > 512 * 1024) {
762 return { ok: false, reason: "bad source" };
763 }
764 if (registeredIds.has(id)) return { ok: false, reason: "already registered" };
765 if (appByName(manifest.name)) return { ok: false, reason: `an app named "${manifest.name}" already exists` };
766 for (const t of manifest.types) {
767 if (isAppType(t)) return { ok: false, reason: `event type "${t}" is already owned by another app` };
768 }
769 // The command twin of the type check above, and it is LOAD-TIME security,
770 // not deploy UX: member apps register after the built-ins, so without it a
771 // crafted blob claiming "/todo" (or "/help") would overwrite commandOwner
772 // on every member's client and route their typed arguments into the
773 // hijacker-readable log — worse still for a places:["space"] app, whose
774 // log every member's hijacked commands would share.
775 for (const c of manifest.commands || []) {
776 const taken = commandTakenBy(c.name);
777 if (taken) return { ok: false, reason: `command ${c.name} is already taken (${taken === "host" ? "built-in" : `by ${taken}`})` };
778 }
779
780 const events = {};
781 for (const t of manifest.types) events[t] = queueEvent;
782
783 // Command METADATA → proxy handlers. The handler forwards to the app-thread's
784 // frame (runMemberCommand); the untrusted logic never runs in the page.
785 const commands = {};
786 for (const c of manifest.commands || []) {
787 commands[c.name] = {
788 desc: c.desc || "",
789 args: c.args || "",
790 run: (args, ctx) => runMemberCommand(app, ctx, c.name, args),
791 };
792 }
793
794 const app = defineApp({
795 name: manifest.name,
796 title: manifest.title,
797 glyph: manifest.glyph,
798 symbol: manifest.symbol,
799 blurb: author ? `Built by ${author}` : "A member-built app",
800 incremental: true, // the iframe must survive re-renders; we pump instead
801 initialState: () => ({ v: null, vUpTo: 0, pend: [], chat: [] }),
802 events,
803 commands,
804 // A companion app opts in with manifest.chat: give the proxy a chatMessage
805 // so appWantsChatView() sees it as a companion (Chat + Interface views).
806 // It returns null — the real projection runs in the frame and folds into
807 // the Chat view via foldCompanionChat, so foldAppEventBubble adds nothing.
808 ...(manifest.chat ? { chatMessage: () => null } : {}),
809 // defaultCommand: plain text in the Chat view runs this command (validated
810 // above to be one of `commands`). defaultCommandFor(threadId) reads it, so a
811 // member app gets the same "type to add" behaviour trusted apps have.
812 ...(manifest.defaultCommand && commands[manifest.defaultCommand] ? { defaultCommand: manifest.defaultCommand } : {}),
813 // The app's PLACES (the one-field declaration, docs/space-apps.md): a
814 // places-first manifest passes straight through — ANY non-empty subset,
815 // "you" included only if declared (a space-only member app is legal).
816 // The legacy groupCapable/spaceCapable booleans — stored member apps are
817 // user content, never migrated — normalize into it, with "you" fixed for
818 // that generation only (it was personal by definition). Either way this
819 // is the author's explicit trust decision (declared in the source's
820 // defineApp, never derived from code shape); the chat/space fold
821 // machinery (groupapps.*, spaceapps.*) keys off the result.
822 places: manifest.places || ["you", ...(manifest.groupCapable ? ["chat"] : []), ...(manifest.spaceCapable ? ["space"] : [])],
823 // listed: false — the implicitly-placed shape (validated above): the app
824 // stays out of the catalogues; a space-only unlisted app routes to the
825 // space's one copy on first command, no add gesture (the /gif model).
826 ...(manifest.listed === false ? { listed: false } : {}),
827 ...(typeof manifest.placeNote === "string" && manifest.placeNote ? { placeNote: manifest.placeNote } : {}),
828 // quiet: the author opted this app into never forcing its personal thread
829 // into view on mere activity (manifest.quiet — same meaning and same
830 // enforcement path as a built-in app's `quiet`, see apps.js + issue #227).
831 ...(manifest.quiet ? { quiet: true } : {}),
832 // memberApp marks the proxy (apps.js gates installMemberAppState on it)
833 // and carries what the frame needs at init.
834 memberApp: { id, source, manifest, author: author || "" },
835 render(state, el, ctx) {
836 injectSandboxStyles();
837 el.classList.add("sandbox-pane");
838 let iframe = el.querySelector("iframe.sandbox-frame");
839 const fresh = !iframe;
840 if (fresh) {
841 iframe = document.createElement("iframe");
842 iframe.className = "sandbox-frame";
843 iframe.setAttribute("sandbox", "allow-scripts");
844 iframe.setAttribute("allow", "");
845 iframe.referrerPolicy = "no-referrer";
846 iframe.src = "/app-sandbox";
847 }
848 let rec = mounts.get(iframe);
849 if (!rec) {
850 rec = { iframe, el, app, ctx, port: null, sent: new Set(), emits: [] };
851 mounts.set(iframe, rec);
852 // Drop records whose iframe left the DOM for good (thread switched,
853 // pane rebuilt) so mounts can't grow unboundedly.
854 for (const [f, r] of [...mounts]) {
855 if (f !== iframe && !f.isConnected && r.el !== el) mounts.delete(f);
856 }
857 }
858 if (fresh) {
859 // Init on the (unforgeable) load event — fires on the first load AND
860 // whenever the pane is re-inserted (which reloads the iframe with a
861 // fresh document + a fresh port). Reset the port between loads so a
862 // stale one is never used before re-init.
863 iframe.addEventListener("load", () => {
864 rec.port = null;
865 initMount(rec);
866 });
867 el.appendChild(iframe);
868 }
869 rec.ctx = ctx; // renders hand us a fresh ctx; keep the newest
870 rec.el = el;
871 if (state && state.lost) {
872 note(rec, "Some older activity couldn't be kept — this app may be missing items.", false);
873 }
874 // Pump: forward queued events the live frame hasn't seen. (No port yet
875 // means the frame is still loading; init will carry them in the seed.)
876 if (rec.port) {
877 const batch = (state && state.pend ? state.pend : []).filter(
878 (it) => it && it.meta && it.meta.id != null && !rec.sent.has(it.meta.id),
879 );
880 if (batch.length) {
881 for (const it of batch) rec.sent.add(it.meta.id);
882 rec.port.postMessage({ type: "events", batch });
883 }
884 }
885 },
886 ephemeral(event, ctx) {
887 // Relay-only traffic of this app's types, forwarded into a live frame.
888 for (const rec of mounts.values()) {
889 if (rec.app === app && rec.port && rec.ctx.threadId === ctx.threadId) {
890 rec.port.postMessage({ type: "ephemeral-in", event });
891 }
892 }
893 },
894 });
895 registeredIds.add(id);
896 return { ok: true, app };
897}
898
899// ---- boot-time loading ------------------------------------------------------
900
901let loadInflight = null;
902
903// loadMemberApps fetches, decrypts, and registers every deployed member app.
904// Needs the instance key (member-app blobs are sealed under it); when the key
905// hasn't arrived yet the call resolves and the shell retries from
906// onInstanceKeyReady. authorName maps a user id to a display name for the
907// "Built by …" byline (best-effort; may return "").
908export async function loadMemberApps({ token, instanceKey, authorName }) {
909 if (!instanceKey || loadInflight) return loadInflight;
910 loadInflight = (async () => {
911 try {
912 const r = await fetch(`/api/member-apps?token=${encodeURIComponent(token)}`);
913 if (!r.ok) return;
914 const { apps } = await r.json();
915 // Decrypt every blob concurrently (they're independent), then register
916 // in listing order so registration stays deterministic.
917 const opened = await Promise.all(
918 (apps || []).map(async (entry) => {
919 if (!entry || typeof entry.id !== "string" || registeredIds.has(entry.id)) return null;
920 try {
921 return { entry, doc: await decrypt(instanceKey, entry.blob) };
922 } catch (e) {
923 console.warn(`member app ${entry.id} failed to load:`, e);
924 return null;
925 }
926 }),
927 );
928 for (const item of opened) {
929 if (!item) continue;
930 const { entry, doc } = item;
931 const res = registerMemberApp({
932 id: entry.id,
933 manifest: doc && doc.manifest,
934 source: doc && doc.source,
935 author: (authorName && authorName(entry.author_id)) || "",
936 });
937 if (!res.ok) console.warn(`member app ${entry.id} skipped: ${res.reason}`);
938 }
939 } catch (e) {
940 console.error("member apps skipped:", e);
941 } finally {
942 loadInflight = null;
943 }
944 })();
945 return loadInflight;
946}
947
948// ---- App Studio: member-facing list / deploy / retire -----------------------
949// These power web/static/apps/appstudio.js — a member pasting their own app
950// code and deploying it directly, no bot. Privileged (they touch the token +
951// instance key), so App Studio is a TRUSTED compiled-in app that imports these;
952// sandboxed member apps never can.
953
954// manifestIsLegacy: does a STORED manifest still speak the legacy placement
955// vocabulary — no `places` array, so loading falls back to the old
956// scope/groupCapable/spaceCapable fields? Loading such an app keeps working
957// (the one compat funnel — docs/member-apps.md § Legacy formats), but App
958// Studio flags it and offers a one-tap upgrade: redeploying the SAME source
959// re-mints a current manifest (manifestOf emits places-first), a new version
960// with the old kept in History. Pure, exported for tests.
961export function manifestIsLegacy(m) {
962 return !!m && !Array.isArray(m.places);
963}
964
965// recentLocalDeploy lets shell.js skip a full page reload on the apps_changed
966// broadcast this client just caused — it registers the new app live instead.
967let lastLocalDeployAt = 0;
968export function recentLocalDeploy() {
969 return Date.now() - lastLocalDeployAt < 5000;
970}
971
972function mintAppId() {
973 const b = crypto.getRandomValues(new Uint8Array(6));
974 return "app-" + [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
975}
976
977// extractManifest runs pasted source in a throwaway sandbox frame purely to
978// read back its manifest (the real defineApp, evaluated in the cage) — never in
979// the page. Returns { manifest } or { error }.
980export function extractManifest(source) {
981 if (typeof source !== "string" || !source.trim()) return Promise.resolve({ error: "paste some app code first" });
982 if (source.length > 512 * 1024) return Promise.resolve({ error: "that code is too large" });
983 return new Promise((resolve) => {
984 const iframe = document.createElement("iframe");
985 iframe.setAttribute("sandbox", "allow-scripts");
986 iframe.setAttribute("allow", "");
987 iframe.referrerPolicy = "no-referrer";
988 iframe.src = "/app-sandbox";
989 iframe.style.cssText = "position:absolute;width:0;height:0;border:0;left:-9999px;top:0";
990 let done = false;
991 const finish = (v) => {
992 if (done) return;
993 done = true;
994 iframe.remove();
995 resolve(v);
996 };
997 iframe.addEventListener("load", () => {
998 const chan = new MessageChannel();
999 chan.port1.onmessage = (ev) => {
1000 const m = ev.data;
1001 if (m && m.type === "extracted") finish(m.error ? { error: m.error } : { manifest: m.manifest });
1002 };
1003 iframe.contentWindow.postMessage({ type: "lchat-sandbox-init", extract: true, source }, "*", [chan.port2]);
1004 });
1005 hiddenContainer().appendChild(iframe);
1006 setTimeout(() => finish({ error: "the app didn't load in time" }), 6000);
1007 });
1008}
1009
1010// listDeployedApps returns every deployed member app (decrypted), each flagged
1011// `mine` when the current member owns it — whether they deployed it themselves or
1012// a bot built it for them (owner_id), so their Claude-built apps are theirs to
1013// edit/remove too. For App Studio's list.
1014export async function listDeployedApps() {
1015 const admin = memberAdmin && memberAdmin();
1016 if (!admin || !admin.instanceKey) return { error: "your space key hasn't loaded yet — try again in a moment" };
1017 let apps;
1018 try {
1019 const r = await fetch(`/api/member-apps?token=${encodeURIComponent(admin.token)}`);
1020 if (!r.ok) return { error: `couldn't list apps (${r.status})` };
1021 ({ apps } = await r.json());
1022 } catch (e) {
1023 return { error: "couldn't reach the server" };
1024 }
1025 const out = [];
1026 for (const entry of apps || []) {
1027 if (!entry || typeof entry.id !== "string") continue;
1028 let doc = null;
1029 try {
1030 doc = await decrypt(admin.instanceKey, entry.blob);
1031 } catch {
1032 // A blob we can't open (shouldn't happen — one instance key) still lists
1033 // by id so an admin could retire it.
1034 }
1035 out.push({
1036 id: entry.id,
1037 manifest: (doc && doc.manifest) || null,
1038 source: (doc && doc.source) || "",
1039 mine: entry.owner_id === admin.uid || entry.author_id === admin.uid,
1040 // AI-generated: a bot deployed it for its owner (deployer ≠ owner). A
1041 // hand-deployed app has author === owner. (Only meaningful once owner_id is
1042 // recorded, so legacy owner-less apps read as not-bot.)
1043 byBot: !!(entry.owner_id && entry.author_id && entry.author_id !== entry.owner_id),
1044 version: entry.version,
1045 updatedAt: entry.updated_at,
1046 });
1047 }
1048 return { apps: out };
1049}
1050
1051// listAppVersions returns an app's version history (decrypted), newest first,
1052// for App Studio's History view. Each entry carries the source so "Restore" can
1053// re-deploy it (see deployMemberApp) — which records it as a new version.
1054export async function listAppVersions(id) {
1055 const admin = memberAdmin && memberAdmin();
1056 if (!admin || !admin.instanceKey) return { error: "your space key hasn't loaded yet — try again in a moment" };
1057 let data;
1058 try {
1059 const r = await fetch(`/api/member-apps/${encodeURIComponent(id)}/versions?token=${encodeURIComponent(admin.token)}`);
1060 if (!r.ok) return { error: `couldn't load history (${r.status})` };
1061 data = await r.json();
1062 } catch {
1063 return { error: "couldn't reach the server" };
1064 }
1065 const out = [];
1066 for (const v of data.versions || []) {
1067 let doc = null;
1068 try {
1069 doc = await decrypt(admin.instanceKey, v.blob);
1070 } catch {}
1071 out.push({
1072 version: v.version,
1073 current: v.version === data.current,
1074 source: (doc && doc.source) || "",
1075 byMe: v.author_id === admin.uid,
1076 at: v.created_at,
1077 based_on: v.based_on || 0, // the version this one was reverted from (0 = none)
1078 });
1079 }
1080 return { current: data.current, versions: out };
1081}
1082
1083// deployMemberApp validates + deploys pasted source directly (App Studio's
1084// Deploy). Pass { id } to UPDATE an app you already own (same thread + state);
1085// omit it to create a new one. Pass { basedOn: N } on a revert to mark the new
1086// version as "based on version N". Returns { ok, id, updated } or { error }.
1087export async function deployMemberApp(source, opts = {}) {
1088 const admin = memberAdmin && memberAdmin();
1089 if (!admin || !admin.instanceKey) return { error: "your space key hasn't loaded yet — try again in a moment" };
1090 const ext = await extractManifest(source);
1091 if (ext.error) return { error: ext.error };
1092 const manifest = ext.manifest;
1093 const verr = validateManifest(manifest);
1094 if (verr) return { error: verr };
1095
1096 const updating = !!opts.id;
1097 if (!updating) {
1098 // A NEW app can't collide with an already-registered name/event type.
1099 if (appByName(manifest.name)) return { error: `an app named “${manifest.name}” already exists — rename yours` };
1100 for (const t of manifest.types) {
1101 if (isAppType(t)) return { error: `event type “${t}” is already used by another app` };
1102 }
1103 }
1104 // Command names can't collide either — with a built-in verb, the host set
1105 // (/help, /me, …), or another app's. An UPDATE may of course keep the
1106 // commands this app itself already owns. registerMemberApp enforces the
1107 // same rule at load time on every client (that one is the security gate —
1108 // a blob can be crafted straight to the API); this check is the friendly
1109 // deploy-time error.
1110 for (const c of manifest.commands || []) {
1111 const taken = commandTakenBy(c.name);
1112 if (taken && taken !== manifest.name) return { error: `command ${c.name} is already taken (${taken === "host" ? "built-in" : `by ${taken}`}) — pick another name` };
1113 }
1114 const id = opts.id || mintAppId();
1115 const doc = { source, manifest: { ...manifest, owner: admin.uid, by: admin.name || "" } };
1116 let blob;
1117 try {
1118 blob = await encrypt(admin.instanceKey, doc);
1119 } catch {
1120 return { error: "couldn't encrypt the app" };
1121 }
1122 let res;
1123 try {
1124 const put = { token: admin.token, blob };
1125 if (updating && opts.basedOn > 0) put.based_on = opts.basedOn;
1126 res = await fetch(`/api/member-apps/${encodeURIComponent(id)}`, {
1127 method: "PUT",
1128 headers: { "Content-Type": "application/json" },
1129 body: JSON.stringify(put),
1130 });
1131 } catch {
1132 return { error: "couldn't reach the server" };
1133 }
1134 if (!res.ok) {
1135 // The server says why in httpError's {error} body — the feature gates
1136 // ("making your own apps is turned off for this space"), the app caps,
1137 // author-only updates. Show its words rather than guessing from the
1138 // status (a guessed "app limit" on a gated 403 misleads).
1139 const msg = await res.json().then((j) => (j && j.error) || "").catch(() => "");
1140 return { error: msg || `deploy was rejected (${res.status})` };
1141 }
1142 // A brand-new app registers live so it's usable without a reload (and we
1143 // suppress this client's own apps_changed reload). An UPDATE changes code the
1144 // running proxy already loaded, so it needs a fresh load — let the
1145 // apps_changed broadcast reload every client, this one included.
1146 lastLocalDeployAt = updating ? 0 : Date.now();
1147 if (!updating && !registeredIds.has(id)) {
1148 registerMemberApp({ id, manifest: doc.manifest, source, author: admin.name || "" });
1149 }
1150 return { ok: true, id, updated: updating };
1151}
1152
1153// openMemberApp launches a member app's personal thread (App Studio's "Open"),
1154// via the host opener shell.js injects.
1155export function openMemberApp(name) {
1156 const admin = memberAdmin && memberAdmin();
1157 if (admin && admin.openApp) return admin.openApp(name);
1158}
1159
1160// The rest of the place-aware "Open" seam (appOpenPlan in ui.js): the group
1161// chats an app could be added to, the add-and-land opener for one of them,
1162// the space-wide opener, and whether this page is a native embed (where only
1163// the personal open can hand off to the native shell today). All thin
1164// forwards to the host context — the studio never touches shell state itself.
1165export function memberAppGroups() {
1166 const admin = memberAdmin && memberAdmin();
1167 return admin && admin.groups ? admin.groups() : [];
1168}
1169export function openMemberAppInGroup(name, threadId) {
1170 const admin = memberAdmin && memberAdmin();
1171 return admin && admin.openAppInGroup ? admin.openAppInGroup(name, threadId) : false;
1172}
1173export function openMemberAppForSpace(name) {
1174 const admin = memberAdmin && memberAdmin();
1175 return admin && admin.openAppForSpace ? admin.openAppForSpace(name) : false;
1176}
1177export function memberHostEmbedded() {
1178 const admin = memberAdmin && memberAdmin();
1179 return !!(admin && admin.embedded);
1180}
1181
1182// retireMemberApp deletes a member app (author-only, enforced server-side).
1183export async function retireMemberApp(id) {
1184 const admin = memberAdmin && memberAdmin();
1185 if (!admin) return { error: "not ready" };
1186 try {
1187 const r = await fetch(`/api/member-apps/${encodeURIComponent(id)}?token=${encodeURIComponent(admin.token)}`, { method: "DELETE" });
1188 if (!r.ok) return { error: r.status === 403 ? "only the author can remove this app" : `couldn't remove it (${r.status})` };
1189 } catch {
1190 return { error: "couldn't reach the server" };
1191 }
1192 return { ok: true };
1193}
1194