eleven / messenger Public

Add vault mini-app (#410)

* web: add Vault mini-app — a group's shared secret store

A shared mini-app that lets a group store named secrets, each folded from the
thread's encrypted event log (the server only ever sees ciphertext). Values are
locked by default and reveal on click (with auto re-lock) to stop
over-the-shoulder spying, and every add/change/remove is recorded in an
append-only audit log with who acted and when.

Text values only for now — the mini-app ctx has no file upload/download surface
yet; a follow-up issue tracks adding it (secrets already carry a `kind` so file
items fold cleanly once it lands).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* web: Vault is a personal + groupCapable full-pane app

Rebuilt on top of the group-scoped trusted apps model (#400): Vault is now a
personal app (its own full-pane thread, like To-do) with groupCapable: true, so
it can also be added to a group chat (⋮ → Add apps) where one shared instance
runs for everyone — its events post into the group thread's encrypted log and
every member folds the same vault. One codebase, both scopes.

It's a takeover app (no chatMessage) on purpose: the vault IS the thread's
surface, and its events deliberately don't project into the chat feed, previews,
or push notifications — a secrets tool must not spill secret names into the
conversation. The audit trail lives inside the vault surface instead.

Converted the render from a shared panel to a full-pane surface (add form, the
secrets, collapsible history), non-incremental like the other groupCapable apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 5e4cf716271638bc2f1a38797393e2195457d12a parent 96f7fa2
2 files changed, +478 −0
  • web/static/apps/vault.js +477 −0
  • web/static/shell.js +1 −0
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
new file mode 100644
index 0000000..d7f9cc9
--- /dev/null
+++ b/web/static/apps/vault.js
@@ -0,0 +1,477 @@
+// The Vault mini-app: a secure store for named secrets. Everything in lchat is
+// already end-to-end encrypted, so a vault is almost free — a secret is just a
+// named value folded from a thread's event log, so the server only ever sees
+// ciphertext, never a name or a value.
+//
+// It's a `personal` app with `groupCapable: true`, so it works two ways from one
+// codebase (see apps/README.md "Group-scoped apps"):
+// - PERSONAL: its own full-pane thread in the sidebar, just yours, synced across
+// your devices — exactly like To-do.
+// - GROUP: added to a group chat (⋮ → Add apps), where ONE shared instance runs
+// for everyone. Its events post into the group thread's encrypted log, so every
+// member folds the same vault. Nothing about render() changes — the same
+// surface, ctx bound to whichever thread it's acting in.
+//
+// It is a TAKEOVER app (no `chatMessage`): the vault IS the thread's surface, and
+// its events deliberately DON'T project into the chat feed — a secrets tool must
+// not spill "Ana added GITHUB_TOKEN" into the conversation, its previews, or push
+// notifications. The audit trail lives inside the vault surface instead.
+//
+// Two ideas beyond a plain list:
+// - Locked by default. A value renders masked (••••••) and only reveals when you
+// click it — so glancing at the screen can't leak a secret to whoever's looking
+// over your shoulder. A revealed value auto-re-locks after a moment.
+// - An audit trail. Every add / change / remove is recorded in an append-only
+// `log` with who did it and when, so you can see how a secret got there.
+//
+// Files are a deliberate TODO: a value can only be text today because the mini-app
+// ctx has no file upload/download surface yet (see the "apps"-tagged issue #408).
+// `kind` is stored on every item so file items fold cleanly once it lands.
+//
+// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
+// tests while still resolving to /static/apps.js in the browser.
+import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
+
+// How long a revealed value stays visible before it re-locks itself — short, so a
+// vault left open on screen doesn't sit unmasked. Clicking it again re-arms it.
+const RELOCK_MS = 30000;
+// A fixed-width mask that never leaks the value's length (always the same width,
+// whether the secret is 4 chars or 400).
+const MASK = "•".repeat(10);
+
+// parseVaultCommand pulls "<name> = <value>" out of a /vault command's args —
+// split on the FIRST "=" so a value may itself contain "=" (base64, query
+// strings). Returns { name, value } or null when either side is empty. Pure and
+// exported so it's unit-tested without a DOM.
+export function parseVaultCommand(args) {
+ const s = (args || "").trim();
+ const i = s.indexOf("=");
+ if (i < 0) return null;
+ const name = s.slice(0, i).trim();
+ const value = s.slice(i + 1).trim();
+ if (!name || !value) return null;
+ return { name, value };
+}
+
+// findByName does a case-insensitive lookup so "/vault WiFi = …" updates an
+// existing "wifi" secret in place instead of creating a near-duplicate.
+function findByName(items, name) {
+ const n = (name || "").trim().toLowerCase();
+ return (items || []).find((it) => (it.name || "").toLowerCase() === n) || null;
+}
+
+// State is { items, log }.
+// item: { id, name, value, kind, by, byName, at, updatedBy, updatedByName, updatedAt }
+// log: { id (audit id), ref (item id), action: "add"|"change"|"remove", name, by, byName, at }
+//
+// The secret's name rides in the event as `label`, NOT `name` — the emit
+// transport OVERWRITES `event.name` with the sender's screen name (that's how the
+// audit trail learns who acted), so a secret called `name` would be clobbered. So
+// the reducer reads `e.label` for the secret's name and `e.name`/`e.uid`/`e.ts`
+// for the actor + time (all injected by the transport). Every event also carries
+// its own `aid` (audit id) minted fresh at emit, so the append-only log dedupes by
+// aid (addItem) — the item id can't, since a change or remove reuses it. The
+// reducer is the deterministic arbiter: a malformed event folds to a no-op, so
+// anything a member (or bot) can post is a safe input — which is exactly what
+// `groupCapable` requires (strangers' events are now first-class inputs).
+export const app = defineApp({
+ name: "vault",
+ title: "Vault",
+ blurb: "Keep secrets safe", // sidebar subtitle before first use (sync w/ native)
+ glyph: "🔒",
+ scope: "personal",
+ groupCapable: true, // can also be added to a group chat, one shared instance for all
+ initialState: () => ({ items: [], log: [] }),
+ events: {
+ "vault.add": (s, e) => {
+ const label = typeof e.label === "string" ? e.label.trim() : "";
+ if (!e.id || !e.aid || !label || typeof e.value !== "string") return s;
+ const items = addItem(s.items, {
+ id: e.id,
+ name: label,
+ value: e.value,
+ kind: e.kind === "file" ? "file" : "text",
+ by: e.uid || "",
+ byName: e.name || "",
+ at: e.ts || 0,
+ updatedBy: "",
+ updatedByName: "",
+ updatedAt: 0,
+ });
+ return { items, log: logEntry(s.log, e, "add", label) };
+ },
+ "vault.change": (s, e) => {
+ const item = (s.items || []).find((it) => it.id === e.id);
+ if (!e.id || !e.aid || !item) return s;
+ const patch = { updatedBy: e.uid || "", updatedByName: e.name || "", updatedAt: e.ts || 0 };
+ if (typeof e.label === "string" && e.label.trim()) patch.name = e.label.trim();
+ if (typeof e.value === "string") patch.value = e.value;
+ return { items: updateItem(s.items, e.id, patch), log: logEntry(s.log, e, "change", patch.name || item.name) };
+ },
+ "vault.remove": (s, e) => {
+ const item = (s.items || []).find((it) => it.id === e.id);
+ if (!e.id || !e.aid) return s;
+ // Carry the name into the log entry (the item is about to be gone) so the
+ // audit trail still reads "… removed GITHUB_TOKEN".
+ return { items: removeItem(s.items, e.id), log: logEntry(s.log, e, "remove", (item && item.name) || "") };
+ },
+ },
+ commands: {
+ // /vault <name> = <value> — familiar first: a Slack/Discord-style command,
+ // with the "= value" shape advertised in the "/" typeahead. Because the app is
+ // groupCapable, the framework routes this to the RIGHT instance: your personal
+ // vault from anywhere, or the GROUP's shared vault when typed in a chat that has
+ // Vault added (and "/vault @here …" adds it to a group in one gesture).
+ "/vault": {
+ desc: "Store a secret",
+ args: "<name> = <value>",
+ run: (args, ctx) => {
+ const parsed = parseVaultCommand(args);
+ if (!parsed) {
+ // Bad/blank input still gets a hint (private, where you typed) — but not
+ // the secret typed back at them.
+ return ctx.reply && ctx.reply({ text: "Usage: /vault <name> = <value> — e.g. /vault WiFi = hunter2", short: "vault usage" });
+ }
+ addOrUpdate(ctx, parsed.name, parsed.value);
+ ctx.reply && ctx.reply({ text: `Stored “${parsed.name}” in the vault.`, short: `stored “${parsed.name}”`, actions: [{ label: "Open Vault", run: ctx.open }] });
+ },
+ },
+ },
+
+ // This app's CSS travels with it — the framework injects it once. It reads the
+ // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
+ styles: `
+.vault-wrap { display: flex; flex-direction: column; gap: 12px; max-width: 560px; width: 100%; margin: 0 auto; }
+.vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
+.vault-title { font-size: 15px; font-weight: 700; color: var(--ink); }
+.vault-count { font-size: 12px; color: var(--muted); }
+.vault-add { display: flex; flex-wrap: wrap; gap: 8px; }
+.vault-add input {
+ flex: 1 1 140px; min-width: 0; padding: 9px 11px; border-radius: 10px; font: inherit; font-size: 14px;
+ border: 1px solid var(--border); background: var(--panel); color: var(--ink);
+}
+.vault-add .vault-name { flex: 0 1 150px; }
+.vault-add-btn { flex: 0 0 auto; }
+.vault-empty { font-size: 13px; color: var(--muted); padding: 4px 0; }
+.vault-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
+.vault-item {
+ display: flex; flex-direction: column; gap: 6px; padding: 11px 12px;
+ border: 1px solid var(--border); border-radius: 12px; background: var(--panel);
+ transition: border-color 0.15s;
+}
+.vault-item:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
+.vault-row { display: flex; align-items: center; gap: 10px; }
+.vault-name-label {
+ flex: 0 0 auto; max-width: 40%; font-size: 13px; font-weight: 700; color: var(--ink);
+ overflow-wrap: anywhere; letter-spacing: 0.01em;
+}
+/* The masked value is a full-width button that reads as tappable: click it to
+ reveal. Locked it's a muted monospace mask; revealed it's crisp and selectable. */
+.vault-value {
+ flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 7px;
+ padding: 5px 9px; border: 1px solid transparent; border-radius: 8px;
+ background: var(--bubble-them); color: var(--muted); font: inherit;
+ cursor: var(--ui-cursor, pointer); text-align: left; transition: background 0.12s, border-color 0.12s;
+}
+.vault-value:hover { border-color: var(--border); }
+.vault-value:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.vault-lock { flex: 0 0 auto; font-size: 12px; line-height: 1; }
+.vault-value-text {
+ flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px; letter-spacing: 0.18em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.vault-value.revealed { background: transparent; color: var(--ink); }
+.vault-value.revealed .vault-value-text {
+ letter-spacing: normal; white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible; user-select: all;
+}
+.vault-actions { flex: 0 0 auto; display: flex; gap: 1px; }
+.vault-btn {
+ border: none; background: transparent; color: var(--link); font: inherit; font-size: 12px; font-weight: 600;
+ cursor: var(--ui-cursor, pointer); padding: 4px 7px; border-radius: 7px; white-space: nowrap;
+}
+.vault-btn:hover { background: var(--bubble-them); }
+.vault-btn--danger { color: var(--muted); }
+.vault-btn--danger:hover { color: #e5484d; background: color-mix(in srgb, #e5484d 12%, transparent); }
+.vault-meta { font-size: 11px; color: var(--muted); }
+.vault-history { border-top: 1px solid var(--border); padding-top: 8px; }
+.vault-history-toggle {
+ background: none; border: none; padding: 4px 2px; font: inherit; font-size: 13px; font-weight: 600;
+ color: var(--muted); cursor: var(--ui-cursor, pointer);
+}
+.vault-history-toggle:hover { color: var(--ink); }
+.vault-log { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
+.vault-log li { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
+.vault-log .vault-log-who { color: var(--ink); font-weight: 600; }
+`,
+
+ // Full-pane render (like To-do): an add form, the secrets, then a collapsible
+ // history. Rebuilt from state on every change (not incremental) — the proven
+ // pattern for a groupCapable app, since a group's Interface pane is one shared
+ // element and other members' edits refold here. Handlers close over `ctx` (fresh
+ // each render, bound to whichever thread — your personal one or the group's), so
+ // an emit always lands in the right log. Reveal state lives only on the row DOM,
+ // so a concurrent edit that rebuilds the list re-locks every value — a feature.
+ render(state, el, ctx) {
+ const items = (state && state.items) || [];
+ const log = (state && state.log) || [];
+
+ const wrap = document.createElement("div");
+ wrap.className = "vault-wrap";
+
+ const head = document.createElement("div");
+ head.className = "vault-head";
+ const title = document.createElement("span");
+ title.className = "vault-title";
+ title.textContent = "🔒 Vault";
+ const count = document.createElement("span");
+ count.className = "vault-count";
+ count.textContent = items.length ? (items.length === 1 ? "1 secret" : `${items.length} secrets`) : "";
+ head.append(title, count);
+ wrap.appendChild(head);
+
+ // Add form — emits directly, so the secret never rides a chat composer.
+ const form = document.createElement("form");
+ form.className = "vault-add";
+ const nameIn = document.createElement("input");
+ nameIn.className = "vault-name";
+ nameIn.placeholder = "Name";
+ nameIn.maxLength = 80;
+ const valIn = document.createElement("input");
+ valIn.className = "vault-val";
+ valIn.type = "password"; // masked while typing — this is a secret going in
+ valIn.placeholder = "Secret value";
+ valIn.autocomplete = "off";
+ const add = document.createElement("button");
+ add.type = "submit";
+ add.className = "vault-add-btn";
+ add.textContent = "Add";
+ form.append(nameIn, valIn, add);
+ form.onsubmit = (ev) => {
+ ev.preventDefault();
+ const name = nameIn.value.trim();
+ const value = valIn.value; // don't trim a value — leading/trailing chars can matter
+ if (!name || !value) return;
+ addOrUpdate(ctx, name, value);
+ nameIn.value = "";
+ valIn.value = "";
+ nameIn.focus();
+ };
+ wrap.appendChild(form);
+
+ if (!items.length) {
+ const empty = document.createElement("div");
+ empty.className = "vault-empty";
+ empty.textContent = "No secrets yet — add one above.";
+ wrap.appendChild(empty);
+ } else {
+ const list = document.createElement("ul");
+ list.className = "vault-list";
+ for (const item of items) list.appendChild(buildRow(item, ctx));
+ wrap.appendChild(list);
+ }
+
+ if (log.length) wrap.appendChild(buildHistory(log, ctx));
+
+ el.appendChild(wrap);
+ },
+});
+
+// addOrUpdate is the one write path the command and the in-form Add share:
+// overwrite the secret if its name already exists (case-insensitive), else add a
+// new one. Each event carries a fresh audit id (aid) so the log records it.
+function addOrUpdate(ctx, name, value) {
+ const existing = findByName((ctx.getState() || {}).items, name);
+ // The secret's name travels as `label` (see the reducer note on why not `name`).
+ if (existing) ctx.emit({ type: "vault.change", id: existing.id, aid: ctx.id(), label: name, value });
+ else ctx.emit({ type: "vault.add", id: ctx.id(), aid: ctx.id(), label: name, value });
+}
+
+// logEntry appends one audit record, deduped by its audit id (addItem) so a
+// re-fold of the window never double-logs. The actor is the transport-injected
+// `e.uid` / `e.name`; `at` is their send time (e.ts).
+function logEntry(log, e, action, name) {
+ return addItem(log || [], { id: e.aid, ref: e.id, action, name, by: e.uid || "", byName: e.name || "", at: e.ts || 0 });
+}
+
+// buildRow renders one secret: the name, its masked value, and Reveal / Copy /
+// Remove actions, plus a small "added / updated by" audit line. The value starts
+// LOCKED; clicking it (or Reveal) swaps in the plaintext and arms a re-lock timer.
+function buildRow(item, ctx) {
+ const li = document.createElement("li");
+ li.className = "vault-item";
+
+ const row = document.createElement("div");
+ row.className = "vault-row";
+
+ const name = document.createElement("span");
+ name.className = "vault-name-label";
+ name.textContent = item.name; // textContent — never innerHTML for user content
+
+ // The value is a button so it's keyboard-focusable and reads as tappable — the
+ // literal "click to reveal" affordance. A small lock/unlock glyph leads it.
+ const val = document.createElement("button");
+ val.type = "button";
+ val.className = "vault-value";
+ const lock = document.createElement("span");
+ lock.className = "vault-lock";
+ lock.setAttribute("aria-hidden", "true");
+ const valText = document.createElement("span");
+ valText.className = "vault-value-text";
+ val.append(lock, valText);
+
+ let revealed = false;
+ let relock = null;
+ const reveal = document.createElement("button"); // declared here so paint() can label it
+ const paint = () => {
+ if (relock) {
+ clearTimeout(relock);
+ relock = null;
+ }
+ lock.textContent = revealed ? "🔓" : "🔒";
+ valText.textContent = revealed ? item.value : MASK;
+ val.classList.toggle("revealed", revealed);
+ val.setAttribute("aria-label", revealed ? `${item.name}: shown, click to hide` : `${item.name}: hidden, click to reveal`);
+ val.title = revealed ? "Click to hide" : "Click to reveal";
+ reveal.textContent = revealed ? "Hide" : "Reveal";
+ // Re-lock after a beat so a revealed secret doesn't sit on screen indefinitely.
+ if (revealed) relock = setTimeout(() => { revealed = false; paint(); }, RELOCK_MS);
+ };
+ val.onclick = () => {
+ revealed = !revealed;
+ paint();
+ };
+
+ const actions = document.createElement("div");
+ actions.className = "vault-actions";
+ reveal.type = "button";
+ reveal.className = "vault-btn";
+ reveal.onclick = () => {
+ revealed = !revealed;
+ paint();
+ };
+ const copy = document.createElement("button");
+ copy.type = "button";
+ copy.className = "vault-btn";
+ copy.textContent = "Copy";
+ copy.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(item.value);
+ copy.textContent = "Copied";
+ setTimeout(() => (copy.textContent = "Copy"), 1200);
+ } catch {
+ // Clipboard blocked (no permission / insecure context) — reveal instead so
+ // the user can select the value by hand.
+ revealed = true;
+ paint();
+ }
+ };
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.className = "vault-btn vault-btn--danger";
+ remove.textContent = "Remove";
+ remove.onclick = () => {
+ // Two-step inline confirm (embedded webviews have no confirm() panel): the
+ // first click arms it, a second within a few seconds removes.
+ if (!remove.dataset.armed) {
+ remove.dataset.armed = "1";
+ remove.textContent = "Remove?";
+ setTimeout(() => {
+ delete remove.dataset.armed;
+ if (remove.isConnected) remove.textContent = "Remove";
+ }, 4000);
+ return;
+ }
+ ctx.emit({ type: "vault.remove", id: item.id, aid: ctx.id() });
+ };
+ actions.append(reveal, copy, remove);
+ paint(); // start locked, with the lock glyph + labels set
+
+ row.append(name, val, actions);
+ li.appendChild(row);
+
+ const meta = document.createElement("div");
+ meta.className = "vault-meta";
+ meta.textContent = auditLine(item);
+ li.appendChild(meta);
+
+ // Clear the re-lock timer when the surface is torn down while a value is showing
+ // (a redraw or thread switch) so it can't fire against a detached node.
+ ctx.onCleanup(() => {
+ if (relock) clearTimeout(relock);
+ });
+
+ return li;
+}
+
+// auditLine is the per-item "who put this here" summary: who added it, and — if
+// it's since been edited — who last changed it.
+function auditLine(item) {
+ const who = item.byName || "someone";
+ let line = `Added by ${who}${item.at ? " · " + shortWhen(item.at) : ""}`;
+ if (item.updatedAt) line += ` · updated by ${item.updatedByName || "someone"} ${shortWhen(item.updatedAt)}`;
+ return line;
+}
+
+// The history section's expand state, per thread (module scope so it survives the
+// rebuild-on-every-change render — it's transient UI, not fold state).
+const historyOpen = {};
+
+// buildHistory renders the collapsible audit trail — every add / change / remove,
+// newest first — for the app's current thread. Collapsed by default.
+function buildHistory(log, ctx) {
+ const tid = ctx.threadId;
+ const wrap = document.createElement("div");
+ wrap.className = "vault-history";
+ const open = !!historyOpen[tid];
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "vault-history-toggle";
+ toggle.textContent = `${open ? "▾" : "▸"} History (${log.length})`;
+ toggle.onclick = () => {
+ historyOpen[tid] = !open;
+ // Re-render just this section in place (the rest of the surface is unaffected).
+ const next = buildHistory(((ctx.getState() || {}).log) || log, ctx);
+ wrap.replaceWith(next);
+ };
+ wrap.appendChild(toggle);
+
+ if (open) {
+ const ul = document.createElement("ul");
+ ul.className = "vault-log";
+ for (const entry of [...log].reverse()) ul.appendChild(logRow(entry));
+ wrap.appendChild(ul);
+ }
+ return wrap;
+}
+
+// logRow renders one audit-trail line: "<who> <verb> <name> · <when>". The name
+// and actor are user content, so they go in via textContent nodes, never markup.
+function logRow(entry) {
+ const li = document.createElement("li");
+ const who = document.createElement("span");
+ who.className = "vault-log-who";
+ who.textContent = entry.byName || entry.by || "someone";
+ const verb = entry.action === "add" ? " added " : entry.action === "remove" ? " removed " : " changed ";
+ li.append(who, document.createTextNode(verb));
+ const nm = document.createElement("span");
+ nm.textContent = entry.name || "a secret";
+ li.append(nm);
+ if (entry.at) li.append(document.createTextNode(" · " + shortWhen(entry.at)));
+ return li;
+}
+
+// shortWhen is a compact relative-ish timestamp for the audit lines — "just now",
+// "5m ago", "3h ago", or a date for anything older. Pure enough to keep inline.
+function shortWhen(ts) {
+ const now = Date.now();
+ const diff = now - ts;
+ if (diff < 60000) return "just now";
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
+ try {
+ return new Date(ts).toLocaleDateString();
+ } catch {
+ return "";
+ }
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 422b928..f51d72f 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -90,6 +90,7 @@ import "/static/apps/todo.js";
import "/static/apps/remind.js";
import "/static/apps/bookmarks.js";
import "/static/apps/poll.js";
+import "/static/apps/vault.js";
import "/static/apps/board.js";
import "/static/apps/connect.js";
import "/static/apps/dnsimple.js";