| 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 ""; |
| + } |
| +} |