// App Studio — the member-facing manager for member apps (docs/member-apps.md).
//
// Two ways an app reaches this instance: ask a bot to build one, or write the
// code yourself and deploy it here. This surface lists the apps you've made,
// and lets you paste a single-file app module, Check it (evaluated in the
// sandbox to read back its manifest), and Deploy it — the SAME encryption +
// opaque-origin sandbox every member app runs in, so your code never touches
// keys, the page DOM, or the network.
//
// It's a TRUSTED compiled-in app (it holds the token + instance key to
// encrypt/deploy), so the privileged work lives in ../sandbox.js; App Studio
// is just the UI over it. The authoring rules are at GET /api/member-app-guide.
import { defineApp, namespace, appByName } from "../apps.js";
import { ICON_PUZZLE } from "../icons.js";
import { ENABLE_MEMBER_APPS } from "../shellstate.js";
import { appOpenPlan } from "../ui.js";
import {
listDeployedApps,
listAppVersions,
deployMemberApp,
retireMemberApp,
extractManifest,
openMemberApp,
memberAppGroups,
openMemberAppInGroup,
openMemberAppForSpace,
memberHostEmbedded,
manifestIsLegacy,
} from "../sandbox.js";
// One declaration of the app's prefix: N.ev("x") → the "appstudio.x" wire type,
// N.cls("x") → the "appstudio-x" CSS class (reached as ctx.ev / ctx.cls in render).
const N = namespace("appstudio");
// Static in-code SVG (safe for innerHTML — no user content).
const ICON_DOWNLOAD =
'';
const ICON_BOT =
'';
// Download an app's source as a .js file (App Studio runs in the trusted page).
function downloadSource(source, name) {
const safe = (name || "app").replace(/[^a-z0-9._-]/gi, "") || "app";
const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
const a = document.createElement("a");
a.href = url;
a.download = safe + ".js";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const STARTER = `import { defineApp, namespace } from "../apps.js";
const N = namespace("hello"); // N.ev("tap") → "hello.tap", N.cls("btn") → "hello-btn"
export const app = defineApp({
name: "hello", // unique lowercase id — rename for your app
title: "Hello", // shown in the Apps list
glyph: "👋", // one emoji
symbol: "puzzlepiece", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
initialState: () => ({ count: 0 }),
events: {
[N.ev("tap")]: (s) => ({ count: s.count + 1 }),
},
render(state, el, ctx) {
const btn = document.createElement("button");
btn.textContent = "Say hello";
btn.className = ctx.cls("btn");
btn.onclick = () => ctx.emit({ type: ctx.ev("tap") });
el.appendChild(btn);
if (state.count > 0) {
const p = document.createElement("p");
p.textContent = "hello 👋 (×" + state.count + ")";
el.appendChild(p);
}
},
});
`;
// Module-level editor state: the framework clears our pane on each render, so
// keep the draft + edit target here so a re-render (after deploy/remove) is
// seamless. `editing` is the id of the app being updated, or "" for a new one.
let draft = "";
let editing = "";
let editingName = "";
let busy = false;
// Version-history view: the app whose history is open (""), its title, and the
// fetched history (null until loaded).
let historyFor = "";
let historyTitle = "";
let historyData = null;
function el(tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
// ---- the place-aware "Open" (appOpenPlan in ui.js) -------------------------
// Where this app can live. The live registry has the normalized answer
// (registerMemberApp folds the legacy boolean flags into places); fall back
// to the stored manifest, normalized the same way, for an app that didn't
// register this session (e.g. its name collided with another's).
function placesOf(m) {
const reg = appByName(m.name);
if (reg && Array.isArray(reg.places) && reg.places.length) return reg.places;
return m.places || ["you", ...(m.groupCapable ? ["chat"] : []), ...(m.spaceCapable ? ["space"] : [])];
}
// The one open chooser — a transient strip under the tapped row (never a
// floating overlay). Any other Open tap or its Cancel closes it; a re-render
// wipes the pane, so render() just drops the stale handle.
let chooser = null;
function closeChooser() {
if (chooser) {
chooser.remove();
chooser = null;
}
}
function chooserFor(row, label) {
closeChooser();
const box = el("div", "chooser");
box.setAttribute("role", "group");
box.setAttribute("aria-label", label);
row.after(box);
chooser = box;
return box;
}
function chooserCancel(box) {
const c = el("button", "cancel", "Cancel");
c.onclick = closeChooser;
box.appendChild(c);
}
function chooserNote(row, text) {
const box = chooserFor(row, "Open");
box.appendChild(el("span", "chooser-note", text));
chooserCancel(box);
box.querySelector("button").focus();
}
// "Add to which group chat?" — pick a group, install the shared copy there
// (the same groupapps.add every Apps sheet emits), and land in that chat.
function showGroupPicker(m, row) {
const title = m.title || m.name;
const groups = memberAppGroups();
const box = chooserFor(row, `Add ${title} to a group chat`);
if (!groups.length) {
box.appendChild(el("span", "chooser-note", "This app lives in group chats, and you're not in any yet — start one from + New chat, then add the app there."));
chooserCancel(box);
box.querySelector("button").focus();
return;
}
box.appendChild(el("span", "chooser-label", "Add to which group chat?"));
for (const g of groups) {
const b = el("button", null, g.title); // group names are user content — textContent via el()
b.onclick = async () => {
b.disabled = true;
if (await openMemberAppInGroup(m.name, g.id)) {
closeChooser();
return;
}
b.disabled = false;
box.appendChild(el("span", "chooser-note err", "Couldn't add it there — try again."));
};
box.appendChild(b);
}
chooserCancel(box);
box.querySelector("button").focus();
}
// "Open it where?" — for an app that supports group chats AND another place.
// Options name places, in the Apps sheets' vocabulary; the group option leads
// to the picker above.
function showHowChooser(m, row) {
const places = placesOf(m);
const box = chooserFor(row, `Where to open ${m.title || m.name}`);
box.appendChild(el("span", "chooser-label", "Open it where?"));
if (places.includes("you")) {
const you = el("button", null, "Just for you");
you.onclick = () => {
closeChooser();
openMemberApp(m.name);
};
box.appendChild(you);
}
if (places.includes("space")) {
const sp = el("button", null, "For the whole space");
sp.onclick = async () => {
sp.disabled = true;
if (await openMemberAppForSpace(m.name)) {
closeChooser();
return;
}
sp.disabled = false;
box.appendChild(el("span", "chooser-note err", "Couldn't add it for the whole space — that may be admins-only here."));
};
box.appendChild(sp);
}
const grp = el("button", null, "In a group chat…");
grp.onclick = () => showGroupPicker(m, row);
box.appendChild(grp);
chooserCancel(box);
box.querySelector("button").focus();
}
// The Open button: route by the app's places. No group place → open straight
// into where it lives (personal thread, else its space home); group-only →
// ask which group; group + another place → ask where.
async function openApp(m, row) {
closeChooser();
const places = placesOf(m);
const plan = appOpenPlan(places);
// A native embed is one thread's pane: only the personal open can hand off
// to the native shell today, so take it whenever the app supports it
// (asking would offer options this pane can't do), and point group/space
// shapes at the native Apps sheet instead of opening inside this pane.
if (memberHostEmbedded()) {
if (places.includes("you")) return openMemberApp(m.name);
chooserNote(row, places.includes("chat")
? "This app lives in group chats — open one and add it from that chat's Apps menu."
: "This app is for the whole space — add it from a chat's Apps menu.");
return;
}
if (plan.ask === "group") return showGroupPicker(m, row);
if (plan.ask === "how") return showHowChooser(m, row);
if (plan.direct === "space") {
if (!(await openMemberAppForSpace(m.name))) {
chooserNote(row, "Couldn't add it for the whole space — that may be admins-only here.");
}
return;
}
openMemberApp(m.name);
}
export const app = defineApp({
name: "appstudio",
title: "App Studio",
blurb: "Make and manage your own apps",
glyph: "🧩",
logo: ICON_PUZZLE,
places: ["you"],
placeNote: "App Studio manages the apps YOU deploy — the apps it builds are what get shared.",
initialState: () => ({}),
events: {},
snapshot: false, // a live view of the server, not an event-sourced app
styles: `
.studio { padding: 16px; max-width: 720px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.studio h2 { margin: 0; font-size: 18px; }
.studio h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.studio .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
/* The row wraps: glyph + title hold the first line; the action cluster drops
to its own line and wraps among itself when the pane is too narrow to keep
it inline — so nothing overflows off-screen and the title is never crushed
letter-by-letter. On a wide pane the meta flex-grows and pushes the actions
to the right, keeping the familiar single-line look. */
.studio .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.studio .row .g { font-size: 22px; width: 28px; text-align: center; flex: 0 0 auto; align-self: flex-start; }
/* A solid basis keeps the title readable: on a narrow pane the actions wrap
below rather than squeezing meta until "Streaks" stacks into "Strea/ks". */
.studio .row .meta { flex: 1 1 200px; min-width: 0; }
.studio .row .meta .t { font-weight: 600; overflow-wrap: break-word; }
.studio .row .meta .by { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.studio .row .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.studio .bot-badge { display: inline-flex; align-items: center; color: var(--muted); }
.studio .row button { cursor: var(--ui-cursor, pointer); background: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; }
.studio .row button.danger { color: var(--danger); border-color: var(--danger); }
.studio .row button.icon-btn { display: inline-flex; align-items: center; padding: 5px 9px; }
.studio .link-btn { align-self: flex-start; background: none; border: none; color: var(--link); cursor: var(--ui-cursor, pointer); font-size: 13px; padding: 0; }
.studio .empty { color: var(--muted); font-size: 13px; padding: 8px 0; }
/* Shared input surface (background/border/radius/colour) comes from .eleven-input
in style.css; this adds the code-editor specifics + vertical breathing room. */
.studio textarea { width: 100%; box-sizing: border-box; min-height: 220px; margin: 12px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; padding: 10px; resize: vertical; }
.studio .bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.studio .bar button { cursor: var(--ui-cursor, pointer); border-radius: var(--radius); padding: 7px 14px; font-size: 13px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); }
.studio .bar button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.studio .bar button[disabled] { opacity: .5; cursor: default; }
.studio .note { font-size: 13px; line-height: 1.5; margin: 10px 0 0; padding: 8px 10px; border-radius: var(--radius); background: var(--bubble-them); }
/* The member-apps-off callout: the one notice that explains the whole page's
reduced state, so it gets the note surface + full-ink lead, not muted sub. */
.studio .gate-note { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-radius: var(--radius); background: var(--bubble-them); }
.studio .gate-note .gg { font-size: 20px; line-height: 1.3; }
.studio .gate-note .gt { font-weight: 600; font-size: 14px; line-height: 1.4; }
.studio .note.err { color: var(--danger); }
.studio .note.ok { color: var(--accent); }
.studio .spacer { flex: 1 1 auto; }
/* The Open chooser: an inline strip under the tapped row (occupies layout
space, never floats). Wraps like the rows' action cluster on narrow panes. */
.studio .chooser { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 10px 12px; margin: 8px 0; border-radius: var(--radius); background: var(--bubble-them); }
.studio .chooser .chooser-label { font-size: 13px; font-weight: 600; }
.studio .chooser .chooser-note { font-size: 13px; line-height: 1.5; }
.studio .chooser .chooser-note.err { color: var(--danger); flex: 1 1 100%; }
.studio .chooser button { cursor: var(--ui-cursor, pointer); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; }
.studio .chooser button[disabled] { opacity: .5; cursor: default; }
.studio .chooser button.cancel { background: none; border-color: transparent; color: var(--muted); }
`,
render(_state, root, ctx) {
root.textContent = "";
chooser = null; // the wipe above just removed any open chooser's DOM
const wrap = el("div", "studio");
root.appendChild(wrap);
wrap.appendChild(el("h2", null, "App Studio 🧩"));
// The member-apps gate (an admin setting; the server enforces it on every
// deploy). Off hides the whole authoring surface — no editor to type into
// only to be refused — while apps already deployed stay listed and usable.
// The why gets a callout, not the muted intro style: it explains the whole
// page's reduced state, so it has to be the first thing a visitor spots.
if (ENABLE_MEMBER_APPS) {
wrap.appendChild(
el("p", "sub", "Make your own apps: paste a single-file app module below, Check it, and Deploy. It runs sandboxed like any app — it can't reach your messages, keys, or the network. Prefer to just describe one? Ask a bot to build it for you."),
);
} else {
const gate = el("div", "gate-note");
const gg = el("span", "gg", "🔒"); // decorative — the text carries the meaning
gg.setAttribute("aria-hidden", "true");
gate.appendChild(gg);
const txt = el("div");
txt.appendChild(el("div", "gt", "Making your own apps is turned off for this space"));
txt.appendChild(el("p", "sub", "An admin can turn it back on in Admin → Features. Apps already deployed keep working."));
gate.appendChild(txt);
wrap.appendChild(gate);
}
// ---- Version history (replaces the list/editor while open) --------------
if (historyFor) {
const back = el("button", "link-btn", "← Back to apps");
back.onclick = () => { historyFor = ""; historyData = null; rerender(); };
wrap.appendChild(back);
const panel = el("div");
panel.appendChild(el("h3", null, `History — ${historyTitle}`));
panel.appendChild(el("p", "sub", "Every deploy is kept. Restore an earlier version and it deploys as a new one, so nothing is lost."));
wrap.appendChild(panel);
if (!historyData) {
panel.appendChild(el("div", "empty", "Loading…"));
listAppVersions(historyFor).then((res) => { historyData = res; rerender(); });
} else if (historyData.error) {
panel.appendChild(el("div", "note err", historyData.error));
} else if (!historyData.versions.length) {
panel.appendChild(el("div", "empty", "No history yet."));
} else {
for (const v of historyData.versions) {
const row = el("div", "row");
const meta = el("div", "meta");
meta.appendChild(el("div", "t", `Version ${v.version}${v.current ? " · current" : ""}`));
const sub = new Date(v.at * 1000).toLocaleString() + (v.byMe ? " · by you" : "") + (v.based_on ? ` · based on version ${v.based_on}` : "");
meta.appendChild(el("div", "by", sub));
row.appendChild(meta);
const actions = el("div", "actions");
if (v.source) {
const dl = el("button", "icon-btn");
dl.innerHTML = ICON_DOWNLOAD;
dl.title = "Download this version";
dl.onclick = () => downloadSource(v.source, `${historyTitle}-v${v.version}`);
actions.appendChild(dl);
}
if (ENABLE_MEMBER_APPS && !v.current && v.source) {
const revert = el("button", "primary", "Revert to this");
revert.onclick = async () => {
if (!confirm(`Revert to version ${v.version}? It deploys as a new version (marked "based on version ${v.version}") — nothing is lost.`)) return;
revert.disabled = true;
const r = await deployMemberApp(v.source, { id: historyFor, basedOn: v.version });
if (r.error) {
revert.disabled = false;
row.after(el("div", "note err", r.error));
return;
}
// apps_changed reloads to apply; drop back to the list meanwhile.
historyFor = "";
historyData = null;
rerender();
};
actions.appendChild(revert);
}
row.appendChild(actions);
panel.appendChild(row);
}
}
return;
}
// ---- Your apps ----------------------------------------------------------
const listSection = el("div");
listSection.appendChild(el("h3", null, "Apps on this space"));
const listBody = el("div");
listSection.appendChild(listBody);
wrap.appendChild(listSection);
listBody.appendChild(el("div", "empty", "Loading…"));
const refreshList = async () => {
const res = await listDeployedApps();
listBody.textContent = "";
if (res.error) {
listBody.appendChild(el("div", "note err", res.error));
return;
}
if (!res.apps.length) {
listBody.appendChild(
el("div", "empty", ENABLE_MEMBER_APPS ? "No apps yet — deploy one below, or ask a bot to build you one." : "No apps yet."),
);
return;
}
for (const a of res.apps) {
const m = a.manifest || {};
const row = el("div", "row");
row.appendChild(el("span", "g", m.glyph || "▦"));
const meta = el("div", "meta");
meta.appendChild(el("div", "t", m.title || m.name || a.id));
// Ownership and authorship are independent: you can own an app a bot wrote
// for you. Show "you + " (with a bot icon) for those, "by you" for
// hand-written, "by " for someone else's.
const by = el("div", "by");
if (a.mine && a.byBot) {
by.appendChild(document.createTextNode("you + "));
const bot = el("span", "bot-badge");
bot.innerHTML = ICON_BOT;
bot.title = "AI-generated — this bot wrote it, you own it";
by.appendChild(bot);
by.appendChild(document.createTextNode(" " + (m.builtBy || "a bot")));
} else if (a.mine) {
by.appendChild(document.createTextNode("by you"));
} else {
by.appendChild(document.createTextNode("by " + (m.by || "another member")));
}
if (a.mine) {
const extra =
// Version + id let the member cross-check a bot's build report
// ("✨ … version 3 is live … (app id: app-…)") against what's
// actually deployed — the numbers here are the server's, not the
// bot's.
` · version ${a.version || 1}` +
(m.chat ? " · chat view" : "") +
((m.places ? m.places.includes("chat") : m.groupCapable) ? " · group-capable" : "") +
((m.commands || []).length ? " · " + m.commands.map((c) => c.name).join(" ") : "") +
// The legacy-format flag (docs/member-apps.md § Legacy formats):
// the app still loads fine, but its stored manifest predates the
// places vocabulary — the Upgrade button below modernizes it.
(manifestIsLegacy(m) ? " · older format" : "") +
` · ${a.id}`;
by.appendChild(document.createTextNode(extra));
}
meta.appendChild(by);
row.appendChild(meta);
// Buttons live in their own cluster so they wrap as a unit below the
// title on a narrow pane instead of spilling off the right edge.
const actions = el("div", "actions");
if (m.name) {
const open = el("button", null, "Open");
open.onclick = () => openApp(m, row);
actions.appendChild(open);
}
if (a.mine && a.source) {
// A legacy-format app (manifest predates the places vocabulary)
// gets a one-tap upgrade: redeploy the SAME source through the
// current pipeline — the re-minted manifest speaks places, the
// code is untouched, and History keeps the old version. Gated with
// the editor (deploys 403 while member apps are off).
if (ENABLE_MEMBER_APPS && manifestIsLegacy(m)) {
const up = el("button", "primary", "Upgrade");
up.title = "Redeploys the same code with a current-format manifest — nothing about the app changes, and History keeps the old version.";
up.onclick = async () => {
up.disabled = true;
const r = await deployMemberApp(a.source, { id: a.id });
if (r.error) {
up.disabled = false;
row.after(el("div", "note err", r.error));
return;
}
// apps_changed reloads to apply, same as a revert deploy.
up.textContent = "Upgraded ✓";
};
actions.appendChild(up);
}
// Edit code leads to the (gated) editor — with member apps off it
// hides with it; History, Download, and Remove stay open.
if (ENABLE_MEMBER_APPS) {
const edit = el("button", null, "Edit code");
edit.onclick = () => {
draft = a.source;
editing = a.id;
editingName = m.name || "";
rerender();
};
actions.appendChild(edit);
}
const hist = el("button", null, "History");
hist.onclick = () => {
historyFor = a.id;
historyTitle = m.title || m.name || a.id;
historyData = null;
rerender();
};
actions.appendChild(hist);
const dl = el("button", "icon-btn");
dl.innerHTML = ICON_DOWNLOAD;
dl.title = "Download source";
dl.onclick = () => downloadSource(a.source, m.name || a.id);
actions.appendChild(dl);
const del = el("button", "danger", "Remove");
del.onclick = async () => {
if (!confirm(`Remove “${m.title || m.name}”? This deletes the app for everyone.`)) return;
del.disabled = true;
const r = await retireMemberApp(a.id);
if (r.error) {
del.disabled = false;
const e = el("div", "note err", r.error);
row.after(e);
return;
}
refreshList();
};
actions.appendChild(del);
}
row.appendChild(actions);
listBody.appendChild(row);
}
};
// With member apps off there's no authoring surface at all — the header
// note above says why, and the list (Open / History / Download / Remove)
// is the whole page. Deploys would 403 server-side regardless.
if (!ENABLE_MEMBER_APPS) {
refreshList();
return;
}
// ---- Add / edit an app --------------------------------------------------
const editor = el("div");
editor.appendChild(el("h3", null, editing ? `Edit “${editingName}”` : "Write a new app"));
const guide = el("p", "sub");
guide.appendChild(document.createTextNode("One self-contained ES module — see the "));
const link = el("a", null, "authoring guide");
link.href = "/api/member-app-guide";
link.target = "_blank";
link.rel = "noreferrer";
guide.appendChild(link);
guide.appendChild(document.createTextNode("."));
editor.appendChild(guide);
const ta = el("textarea", "eleven-input");
ta.spellcheck = false;
ta.value = draft || (editing ? "" : STARTER);
ta.placeholder = "Paste your app's JavaScript here…";
ta.oninput = () => { draft = ta.value; };
editor.appendChild(ta);
const note = el("div", "note");
note.style.display = "none";
const showNote = (text, kind) => {
note.className = "note" + (kind ? " " + kind : "");
note.textContent = text;
note.style.display = "";
};
const bar = el("div", "bar");
const checkBtn = el("button", null, "Check");
const deployBtn = el("button", "primary", editing ? "Update" : "Deploy");
bar.appendChild(checkBtn);
bar.appendChild(deployBtn);
if (editing) {
const cancel = el("button", null, "New app instead");
cancel.onclick = () => { draft = ""; editing = ""; editingName = ""; rerender(); };
bar.appendChild(cancel);
}
editor.appendChild(bar);
editor.appendChild(note);
wrap.appendChild(editor);
const setBusy = (on) => {
busy = on;
checkBtn.disabled = on;
deployBtn.disabled = on;
};
checkBtn.onclick = async () => {
if (busy) return;
setBusy(true);
showNote("Checking…");
const res = await extractManifest(ta.value);
setBusy(false);
if (res.error) {
showNote(res.error, "err");
return;
}
const m = res.manifest;
const bits = [`${m.glyph || "▦"} ${m.title || m.name} (${m.name})`, `${(m.types || []).length} event type(s)`];
if ((m.commands || []).length) bits.push("commands: " + m.commands.map((c) => c.name).join(" "));
if (m.chat) bits.push("companion chat view");
if (m.places ? m.places.includes("chat") : m.groupCapable) bits.push("can be added to groups");
showNote("Looks good — " + bits.join(" · "), "ok");
};
deployBtn.onclick = async () => {
if (busy) return;
setBusy(true);
showNote(editing ? "Updating…" : "Deploying…");
const res = await deployMemberApp(ta.value, editing ? { id: editing } : {});
if (res.error) {
setBusy(false);
showNote(res.error, "err");
return;
}
if (res.updated) {
showNote("Updated ✓ — reloading to apply the new code…", "ok");
// The apps_changed broadcast reloads to pick up the new code.
return;
}
setBusy(false);
draft = "";
editing = "";
editingName = "";
showNote("Deployed ✓ — it's in your Apps list now.", "ok");
refreshList();
};
// Re-render the whole surface (after edit/deploy/remove state changes).
function rerender() {
app.render(_state, root, ctx);
}
refreshList();
},
});