// 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 (`groupFeedMessage`, never `chatMessage`): the vault // IS the thread's surface — your personal Vault never grows a Chat view, // and no secret's NAME ever spills into the conversation, a sidebar preview, or // push. 🤖 Two places still surface UNNAMED activity, "you"-aware (same as a // membership line — see `format.js`'s `membershipLine`): // - The chat list's "last message" preview (`preview` below) — "You added a // secret to the Vault" / "Ciara updated a secret" — for the vault's own // thread (personal or group), so the row doesn't sit blank. // - When the vault is added to a GROUP, an add/remove also posts the same // unnamed line into that group's chat feed (`chatMessage`'s `{ group }` // form), so members notice without opening it. // The name (which — unlike the value — every member can already see inside the // vault) never lands in either place. The full audit trail, WITH names, still // lives only inside the vault surface. // // 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. // // 🤖 THE LOCK LAYER (docs/locked-vaults.md). A vault can be LOCKED: it gains a // dedicated ECDH keypair whose public half sits in this state and whose private // half exists only inside keyring `wraps`, each openable solely by a // biometric-gated key — a passkey's WebAuthn PRF output here, or a Touch // ID-guarded keychain key in the eleven CLI. Once locked, values ride and rest // ECIES-SEALED to the vault public key (`sealed`, never `value`): anyone in the // thread can still ADD a secret without an unlock (encrypting to a public key // is free), but READING a value takes a fresh biometric. A signed-in session // alone — thread key in hand — sees names and the audit trail, never values. // Access follows THREAD MEMBERSHIP, not a request dance: locking HANDS the vault // key to each current member (sealed to their roster key — a `member` handoff), // so every member is entitled by being in the thread. Each member self-enrols // their own passkey (vault.enroll), and their handoff drops. An already-unlocked // insider can give a later joiner a fresh handoff (vault.give). There is no // "request access" — you never ask for a key to a vault you're already in. All of // it is ordinary events through the same reducer — malformed folds to a no-op, so // hostile inputs stay safe, which `groupCapable` requires. The crypto lives in // vaultcrypto.js; reducers here never touch it (folds stay pure and sync). // // 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, currentUserId } from "../apps.js"; import { ICON_LOCK } from "../icons.js"; import { registerVaultPasskey, assertVaultPasskeyFull, kekFromPRF, generateVaultKeypair, wrapVaultPriv, unwrapVaultPriv, sealValue, unsealValue, sealVaultKeyTo, } from "../vaultcrypto.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; // How long an UNLOCK (the vault private key, in memory only) survives before the // vault forgets it and the next read costs a fresh biometric. Never persisted. const UNLOCK_TTL_MS = 5 * 60000; // 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 " = " 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; } // 🤖 wrapShape vets keyring objects arriving in events before the reducer stores // them — every field a later unlock will read must be a non-empty string, and // anything extra is dropped (never store a stranger's spare fields). Return the // cleaned object, or null. Pure; exported for tests. // Wrap kinds (docs/locked-vaults.md): // passkey / keychain — a member's DURABLE biometric wrap (WebAuthn PRF KEK / // CLI Touch ID keychain KEK). "Requires a passkey to open." // member — a HANDOFF: the vault key sealed to a member's roster // public key, so that member can SELF-ENROL a passkey without anyone // approving. Soft (their session key opens it); dropped the moment they // enrol their own biometric wrap. Carries `uid` = whose handoff it is. // Every wrap carries `uid` (the member it belongs to) so the reducer can drop a // member's handoff when they enrol, and the UI can tell whose key is whose. export function wrapShape(w) { if (!w || typeof w.id !== "string" || !w.id) return null; if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "member") return null; if (typeof w.wrapped !== "string" || !w.wrapped) return null; const out = { id: w.id, kind: w.kind, uid: typeof w.uid === "string" ? w.uid : "", wrapped: w.wrapped, label: typeof w.label === "string" ? w.label : "", }; if (typeof w.credId === "string" && w.credId) out.credId = w.credId; return out; } // State is { items, log, keyring }. // item: { id, name, value?, sealed?, kind, by, byName, at, updatedBy, updatedByName, updatedAt } // — exactly one of value (plain vault) / sealed (locked vault: ECIES to the // vault public key, b64url ephPub||iv||ct; see vaultcrypto.js). // log: { id (audit id), ref (item id), action, name, by, byName, at } // — action: add | change | remove | lock | enroll | give | revoke // keyring (locked vaults only): { v: 1, pub, wraps: [wrapShape] } — see the // lock-layer note at the top of this file and docs/locked-vaults.md. // // 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: "🔒", symbol: "lock", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_LOCK, // The SPACE place (a space vault as the team credential shelf — one shared // set of secrets for everyone the space already trusts) was a deliberate // choice and the reducer still supports it: every member can read it, // additions are attributed, every member's events are first-class inputs // exactly as groupCapable already required. It's parked while the places // UI is hidden (docs/space-apps.md § The pullback) — restore by adding // "space" back to this line. places: ["you", "chat"], initialState: () => ({ items: [], log: [], keyring: null }), events: { "vault.add": (s, e) => { const label = typeof e.label === "string" ? e.label.trim() : ""; // Exactly one of value/sealed (sealed must be non-empty; an empty VALUE is // rejected by the emitters, but an empty string here would still fold — // matching the old validation, which only type-checked). const hasValue = typeof e.value === "string"; const hasSealed = typeof e.sealed === "string" && e.sealed !== ""; if (!e.id || !e.aid || !label || hasValue === hasSealed) return s; const items = addItem(s.items, { id: e.id, name: label, ...(hasSealed ? { sealed: e.sealed } : { value: e.value }), kind: e.kind === "file" ? "file" : "text", by: e.uid || "", byName: e.name || "", at: e.ts || 0, updatedBy: "", updatedByName: "", updatedAt: 0, }); return { ...s, 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(); // A change may carry a new value (plain vault) or a new sealed blob // (locked) — whichever arrives replaces the other form, so re-sealing a // stale plaintext item is just a change carrying `sealed`. if (typeof e.sealed === "string" && e.sealed !== "") { patch.sealed = e.sealed; patch.value = undefined; } else if (typeof e.value === "string") { patch.value = e.value; patch.sealed = undefined; } return { ...s, 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 { ...s, items: removeItem(s.items, e.id), log: logEntry(s.log, e, "remove", (item && item.name) || "") }; }, // ---- 🤖 the lock layer (docs/locked-vaults.md) -------------------------- // vault.lock turns a shared vault passkey-locked: installs the vault public // key, seals every value to it, adds the LOCKER's own biometric wrap, and — // so nobody has to "request" anything — HANDS the vault key to each other // current member by sealing it to their roster key (`handoffs`, each a // `member` wrap). Every member is entitled by membership; each self-enrols // their own passkey next (vault.enroll), and their handoff drops then. // One-way: a keyring is never replaced. "vault.lock": (s, e) => { if (s.keyring) return s; const wrap = wrapShape(e.wrap); if (!e.aid || typeof e.pub !== "string" || !e.pub || !wrap) return s; const sealed = e.sealed && typeof e.sealed === "object" ? e.sealed : {}; const items = (s.items || []).map((it) => { const blob = sealed[it.id]; if (typeof blob !== "string" || !blob) return it; // uncovered items keep value; render flags them const { value, ...rest } = it; return { ...rest, sealed: blob }; }); let wraps = [wrap]; for (const h of Array.isArray(e.handoffs) ? e.handoffs : []) { const hw = wrapShape(h); if (hw && hw.kind === "member") wraps = addItem(wraps, hw); } return { items, log: logEntry(s.log, e, "lock", ""), keyring: { v: 1, pub: e.pub, wraps } }; }, // vault.enroll installs a member's DURABLE biometric wrap (passkey/keychain) // and DROPS that member's soft `member` handoff — from here on they need // their passkey. `uid` says whose it is; a member self-enrols with no // approval (they already hold the key by membership). "vault.enroll": (s, e) => { const wrap = wrapShape(e.wrap); if (!s.keyring || !e.aid || !wrap || wrap.kind === "member") return s; const uid = wrap.uid || e.uid || ""; const wraps = (s.keyring.wraps || []).filter((w) => !(w.kind === "member" && uid && w.uid === uid)); return { ...s, log: logEntry(s.log, e, "enroll", wrap.label), keyring: { ...s.keyring, wraps: addItem(wraps, wrap) }, }; }, // vault.give: an already-unlocked member admits someone who wasn't present at // lock time (or lost every device) — seals the vault key to that member's // roster key as a `member` handoff, which they self-enrol from. The ONLY // deliberate hand-off, and it's an insider GIVING access, never an outsider // "requesting" it. `uid` is the recipient. "vault.give": (s, e) => { const wrap = wrapShape(e.wrap); if (!s.keyring || !e.aid || !wrap || wrap.kind !== "member" || !wrap.uid) return s; return { ...s, log: logEntry(s.log, e, "give", wrap.label), keyring: { ...s.keyring, wraps: addItem(s.keyring.wraps, wrap) }, }; }, // vault.revoke drops a wrap — housekeeping, NOT crypto revocation (an // ex-holder saw the values; rotating the real secrets is the honest move, // docs/locked-vaults.md "What this does not do"). The LAST wrap can never be // dropped — it would strand the vault keyless. "vault.revoke": (s, e) => { if (!s.keyring || !e.aid || typeof e.wrapId !== "string" || !e.wrapId) return s; const gone = (s.keyring.wraps || []).find((w) => w.id === e.wrapId); if (!gone || s.keyring.wraps.length <= 1) return s; return { ...s, log: logEntry(s.log, e, "revoke", gone.label), keyring: { ...s.keyring, wraps: removeItem(s.keyring.wraps, e.wrapId) }, }; }, }, commands: { // /vault = — 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: " = ", run: async (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 = — e.g. /vault WiFi = hunter2", short: "vault usage" }); } await 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 }] }); }, }, }, // 🤖 groupFeedMessage is the FEED-ONLY projection hook: the host calls it // when it folds a GROUP/DM chat's own feed (foldGroupAppEventBubble in // shell.js) and for nothing else — implementing it (and NOT chatMessage) // is exactly how the personal Vault stays a takeover with no Chat view // (appWantsChatView; apps/README.md "Companion projection"). Deliberately // unnamed and deliberately narrow: an ADD or REMOVE is worth a group // noticing ("someone touched the vault"), but the secret's name stays out // of the conversation, its previews, and push — and a value CHANGE (no // new/gone item, same name) stays silent, since there's nothing new for a // passerby to learn from it. Locking is group-visible too: reads now cost // a biometric, worth noticing. groupFeedMessage(e) { if (e.type === "vault.add") return { text: "added a secret to the Vault", action: true }; if (e.type === "vault.remove") return { text: "removed a secret from the Vault", action: true }; if (e.type === "vault.lock") return { text: "locked the Vault with a passkey", action: true }; return null; }, // 🤖 The sidebar's "last message" line, for both a personal Vault thread and // a group's — same unnamed shape as groupFeedMessage above (never the // secret's name), and on for BOTH places: a preview line doesn't grow a // Chat view, it's just text on a row you already see. "You" for your own // action, same idea as the group action line's actor substitution. preview(e) { const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone"; if (e.type === "vault.add") return `🔒 ${who} added a secret to the Vault`; if (e.type === "vault.remove") return `🔒 ${who} removed a secret from the Vault`; if (e.type === "vault.change") return `🔒 ${who} updated a secret in the Vault`; if (e.type === "vault.lock") return `🔒 ${who} locked the Vault`; return null; }, // 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-head-left { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .vault-title { font-size: 15px; font-weight: 700; color: var(--ink); } .vault-count { font-size: 12px; color: var(--muted); } .vault-lockchip { border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px; cursor: var(--ui-cursor, pointer); white-space: nowrap; } .vault-lockchip:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } .vault-lockchip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .vault-note { font-size: 12px; color: var(--muted); padding: 2px 0; } .vault-note:empty { display: none; } .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-unsealed { color: var(--muted); font-style: italic; } .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:disabled { color: var(--muted); cursor: default; background: transparent; } .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-access { border-top: 1px solid var(--border); padding-top: 8px; } .vault-access-list { list-style: none; margin: 6px 0 4px; padding: 0; display: flex; flex-direction: column; gap: 5px; } .vault-access-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } .vault-access-who { color: var(--ink); font-weight: 600; } .vault-history { border-top: 1px solid var(--border); padding-top: 8px; } .vault-history-toggle, .vault-access-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, .vault-access-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 keyring = (state && state.keyring) || null; // A render that observes the vault becoming locked (this device locked it, // or a remote lock just folded) revokes any reveal still open from before — // the belt to doLock's braces, and the whole story for a remote lock. if (keyring && !lockedSeen[ctx.threadId]) relockOpenReveals(); lockedSeen[ctx.threadId] = !!keyring; const wrap = document.createElement("div"); wrap.className = "vault-wrap"; // One inline status line for the whole surface — the lock/unlock/enrol // ceremonies report here. In-flow (occupies layout space), never a // floating toast; role=status so screen readers announce it. const note = document.createElement("div"); note.className = "vault-note"; note.setAttribute("role", "status"); const say = (msg) => { note.textContent = msg || ""; }; const head = document.createElement("div"); head.className = "vault-head"; const headLeft = document.createElement("div"); headLeft.className = "vault-head-left"; 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.toLocaleString()} secrets`) : ""; headLeft.append(title, count); head.append(headLeft, buildLockChip(keyring, ctx, say)); wrap.appendChild(head); wrap.appendChild(note); // Add form — emits directly, so the secret never rides a chat composer. // On a locked vault the value seals to the vault public key before it // leaves the handler — adding never costs an unlock. const form = document.createElement("form"); form.className = "vault-add"; // Keep password managers OUT of the add form. A "Name" text field beside a // type=password field reads to 1Password/Chrome/etc. as a username+password // LOGIN, so they offer to "save" the secret you're storing — clutter, and // (Paul's report) their save popup reads like a passkey prompt. These are the // managers' documented opt-outs, plus declaring the whole form non-login so no // credential pair is inferred. A vault secret is content, never a saved login. const noPwManager = (el) => { el.autocomplete = "off"; el.setAttribute("data-1p-ignore", ""); // 1Password el.setAttribute("data-lpignore", "true"); // LastPass el.setAttribute("data-bwignore", ""); // Bitwarden el.setAttribute("data-form-type", "other"); // Dashlane + a 1Password hint }; form.setAttribute("data-form-type", "other"); form.autocomplete = "off"; const nameIn = document.createElement("input"); nameIn.className = "vault-name"; nameIn.placeholder = "Name"; nameIn.maxLength = 80; noPwManager(nameIn); 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"; noPwManager(valIn); const add = document.createElement("button"); add.type = "submit"; add.className = "vault-add-btn"; add.textContent = "Add"; form.append(nameIn, valIn, add); form.onsubmit = async (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; await 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, keyring, say)); wrap.appendChild(list); } if (keyring) { const access = buildAccess(keyring, ctx, say); if (access) wrap.appendChild(access); // null for a personal vault (no sharing surface) } if (log.length) wrap.appendChild(buildHistory(log, ctx)); el.appendChild(wrap); }, }); // ---- 🤖 unlock state (memory only, never persisted) --------------------------- // threadId -> { priv (pkcs8 b64url), timer }. Cleared by the timer, by "lock // now", and never written anywhere — a reload always starts locked. const unlockedVaults = {}; function cacheUnlock(threadId, priv) { forgetUnlock(threadId); // When the unlock window lapses, the key leaves memory AND any value still // on screen must re-mask — same invariant as an explicit lock. unlockedVaults[threadId] = { priv, timer: setTimeout(() => lockNow(threadId), UNLOCK_TTL_MS), }; } // forgetUnlock drops the in-memory key only — the pure reset cacheUnlock reuses // before caching a new one (where re-masking reveals would be wrong). export function forgetUnlock(threadId) { const u = unlockedVaults[threadId]; if (u) { clearTimeout(u.timer); delete unlockedVaults[threadId]; } } // lockNow is the user-facing "the vault is locking again" action: forget the // key AND revoke every open reveal, so plaintext can never outlive the unlock. // Used by the header "lock now" toggle and the auto-expiry timer; the initial // lock ceremony (doLock) and a remote lock (render) revoke reveals directly. function lockNow(threadId) { forgetUnlock(threadId); relockOpenReveals(); } function unlockedPriv(threadId) { return unlockedVaults[threadId] ? unlockedVaults[threadId].priv : null; } // 🤖 A row showing plaintext registers a "slam shut" callback here while it's // revealed. Locking a vault — from this device, another device, or another // member — must revoke every open reveal IMMEDIATELY and deterministically: a // value that was on screen the instant you locked is live secret material, and // it can't be left to linger until an incidental re-render or a manual Hide. // Module scope so it survives the per-render rebuild. const openReveals = new Set(); function relockOpenReveals() { for (const close of [...openReveals]) close(); // each close() removes itself openReveals.clear(); } // lockedSeen tracks, per thread, whether the last render saw a keyring — so a // render that observes the unlocked→locked transition (a remote lock) can slam // open reveals shut, the same as the local doLock path does instantly. const lockedSeen = {}; // ensureUnlocked returns the vault private key, running one passkey assertion // (one biometric) when it isn't already in memory. Returns null after // reporting the reason via say(). // passkeyError turns a WebAuthn/ceremony failure into honest inline copy — and // console.errors anything that isn't the user simply backing out, so a real // misconfiguration never reads as "nothing happened". The commonest dev // gotcha: WebAuthn refuses an IP-address RP id, so the 127.0.0.1 dev server // throws SecurityError before any OS sheet — open it over http://localhost // instead. `action` is the verb for the plain-cancel case ("Lock", "Unlock"). function passkeyError(e, action) { const name = (e && e.name) || ""; const msg = (e && e.message) || ""; if (msg === "no-prf") return "This passkey manager can't protect a vault (no PRF support) — try the browser's own passkeys."; if (name === "NotAllowedError") return `${action} cancelled.`; // dismissed or timed out console.error("vault: passkey ceremony failed", e); if (name === "SecurityError") return `Passkeys can't run on this address (${location.origin}). Open the app over https or http://localhost — not an IP like 127.0.0.1.`; return `Couldn't reach a passkey here: ${name || msg || "unknown error"} (details in the console).`; } async function ensureUnlocked(ctx, keyring, say) { const cached = unlockedPriv(ctx.threadId); if (cached) return cached; const passkeys = (keyring.wraps || []).filter((w) => w.kind === "passkey" && w.credId); if (!passkeys.length) { say("Only CLI keys are enrolled in this vault — unlock isn't possible in the browser yet. Set up your passkey below."); return null; } let res; try { // All enrolled credIds go in the allowlist — the authenticator answers with // whichever it holds, so "which wrap is mine" never needs guessing. res = await assertVaultPasskeyFull(passkeys.map((w) => w.credId)); } catch (e) { say(passkeyError(e, "Unlock")); return null; } const wrap = passkeys.find((w) => w.credId === res.credId); if (!wrap) { say("That passkey isn't enrolled in this vault — set up your passkey below."); return null; } try { const kek = await kekFromPRF(res.prf); const priv = await unwrapVaultPriv(wrap.wrapped, kek); cacheUnlock(ctx.threadId, priv); return priv; } catch { say("Couldn't open the vault with that passkey — its enrolment may be stale. Set up your passkey below."); return null; } } // ---- write paths ---------------------------------------------------------------- // 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. On a // locked vault the value seals to the vault public key first — no unlock needed. async function addOrUpdate(ctx, name, value) { const state = ctx.getState() || {}; const existing = findByName(state.items, name); const body = state.keyring ? { sealed: await sealValue(state.keyring.pub, value) } : { value }; // 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, ...body }); else ctx.emit({ type: "vault.add", id: ctx.id(), aid: ctx.id(), label: name, ...body }); } // doLock runs the whole lock ceremony: mint a passkey (verifying PRF end-to-end // BEFORE anything posts — the no-prf lesson from home), mint the vault keypair, // re-seal every existing value, and HAND the vault key to every other current // member (sealed to their roster key — a `member` handoff), so each can set up // their own passkey without anyone approving. One vault.lock carries it all. The // locker ends up unlocked, so their next reveal is free. async function doLock(ctx, say) { let pk; try { pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`); } catch (e) { say(passkeyError(e, "Lock")); return; } const kek = await kekFromPRF(pk.prf); const kp = await generateVaultKeypair(); const state = ctx.getState() || {}; const sealed = {}; for (const it of state.items || []) { if (typeof it.value === "string") sealed[it.id] = await sealValue(kp.publicKey, it.value); } // A handoff per other member: the vault key sealed to their roster key. The // host excludes me and bots; a personal vault has no other members → none. const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(ctx.threadId) : []; const handoffs = []; for (const m of members) { handoffs.push({ id: ctx.id(), kind: "member", uid: m.uid, label: m.name || "", wrapped: await sealVaultKeyTo(m.pub, kp.privateKey) }); } ctx.emit({ type: "vault.lock", aid: ctx.id(), pub: kp.publicKey, wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(kp.privateKey, kek) }, handoffs, sealed, }); // Slam every on-screen reveal shut the instant we lock — don't wait for the // event to echo back and re-render (a value shown at lock time is live secret // material). cacheUnlock keeps the locker able to re-reveal with no biometric. relockOpenReveals(); cacheUnlock(ctx.threadId, kp.privateKey); say( handoffs.length ? `Vault locked — values now need a passkey to read. ${handoffs.length === 1 ? "1 member" : handoffs.length.toLocaleString() + " members"} can set up their own passkey.` : "Vault locked — values now need a passkey to read." ); } // doEnrolMe: a member who holds a `member` handoff (the vault key sealed to them // at lock, or by a give) turns it into a durable passkey wrap. Open the handoff // with my session key (host.openMine), verify it actually opens the vault, mint // a passkey, wrap the vault key under it, emit vault.enroll — which DROPS my // handoff, so from here on I need my passkey. No approval: I already hold the // key by membership. async function doEnrolMe(ctx, keyring, say) { const mine = (keyring.wraps || []).find((w) => w.kind === "member" && w.uid === ctx.me); if (!mine) { say("You don't have a key for this vault yet — ask someone already in to give you access."); return; } if (!ctx.host || !ctx.host.vault) { say("Setting up a vault passkey isn't supported here yet."); return; } const opened = await ctx.host.vault.openMine(mine.wrapped); const vaultPriv = opened && typeof opened.k === "string" ? opened.k : null; if (!vaultPriv) { say("Couldn't open your vault key — ask someone in the vault to give you access again."); return; } // Integrity: the handoff key must actually open this vault. If any value is // sealed, it has to decrypt — a mismatched or hostile handoff fails HERE, // before it gets wrapped under a passkey. An empty vault has nothing to check. const sample = ((ctx.getState() || {}).items || []).find((it) => typeof it.sealed === "string" && it.sealed); if (sample) { try { await unsealValue(sample.sealed, vaultPriv); } catch { say("The key you were given doesn't match this vault — ask for access again."); return; } } let pk; try { pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`); } catch (e) { say(passkeyError(e, "Set up")); return; } const kek = await kekFromPRF(pk.prf); ctx.emit({ type: "vault.enroll", aid: ctx.id(), wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(vaultPriv, kek) }, }); cacheUnlock(ctx.threadId, vaultPriv); say("You're set — this vault now opens with your passkey."); } // doGive: an unlocked member admits someone in the thread who has no key yet — // seal the vault key to that member's roster key as a `member` handoff, which // they self-enrol from. One biometric (the unlock). The deliberate hand-off, and // it's an insider GIVING, never an outsider requesting. async function doGive(ctx, keyring, member, say) { const priv = await ensureUnlocked(ctx, keyring, say); if (!priv) return; if (!ctx.host || !ctx.host.vault) { say("Giving access isn't supported here yet."); return; } ctx.emit({ type: "vault.give", aid: ctx.id(), wrap: { id: ctx.id(), kind: "member", uid: member.uid, label: member.name || "", wrapped: await sealVaultKeyTo(member.pub, priv) }, }); say(`${member.name || "They"} can now set up their passkey.`); } // doEnrollHere: an already-enrolled member adds another passkey (a new browser // or device whose passkeys don't sync) — unlock, mint, wrap, enroll. async function doEnrollHere(ctx, keyring, say) { const priv = await ensureUnlocked(ctx, keyring, say); if (!priv) return; let pk; try { pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`); } catch (e) { say(passkeyError(e, "Enrol")); return; } const kek = await kekFromPRF(pk.prf); ctx.emit({ type: "vault.enroll", aid: ctx.id(), wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(priv, kek) }, }); say("Passkey added — this device can now unlock the vault."); } // 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 }); } // ---- surface pieces --------------------------------------------------------------- // 🤖 buildLockChip is the header's one lock affordance: plain vault → offer the // lock ceremony (two-step arm — it's a one-way door); locked → Unlock, or // "lock now" to drop the in-memory key early. function buildLockChip(keyring, ctx, say) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "vault-lockchip"; if (!keyring) { chip.textContent = "Lock vault…"; chip.setAttribute("aria-label", "Lock this vault with a passkey"); chip.onclick = () => { if (!chip.dataset.armed) { chip.dataset.armed = "1"; chip.textContent = "Lock with a passkey?"; say("Locking is one-way: values will only open with a passkey (Touch ID / Face ID) from then on — here, and via Touch ID in the eleven CLI."); setTimeout(() => { delete chip.dataset.armed; if (chip.isConnected) { chip.textContent = "Lock vault…"; say(""); } }, 8000); return; } delete chip.dataset.armed; doLock(ctx, say); }; return chip; } const paint = () => { const open = !!unlockedPriv(ctx.threadId); chip.textContent = open ? "🔓 Unlocked · lock now" : "🔐 Unlock"; chip.setAttribute("aria-label", open ? "Vault is unlocked on this device — click to lock now" : "Unlock this vault with your passkey"); }; chip.onclick = async () => { if (unlockedPriv(ctx.threadId)) { lockNow(ctx.threadId); // drop the key AND re-mask anything still revealed say("Locked — the key is out of memory."); } else if (await ensureUnlocked(ctx, keyring, say)) { say("Unlocked on this device for a few minutes."); } paint(); }; paint(); return chip; } // 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. // A sealed item resolves its plaintext through the vault key (at most one // biometric); a plaintext item in a LOCKED vault is flagged and gets a one-click // Seal — sealing needs no unlock, so anyone can do the tidying. function buildRow(item, ctx, keyring, say) { 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 shown = ""; // the plaintext while revealed — sealed items decrypt on demand let relock = null; const reveal = document.createElement("button"); // declared here so paint() can label it // closeReveal masks this row now; it's what a lock (or the re-lock timer) // calls. Registered in openReveals only while the row is actually showing. const closeReveal = () => { if (revealed) { revealed = false; shown = ""; paint(); } }; const paint = () => { if (relock) { clearTimeout(relock); relock = null; } lock.textContent = revealed ? "🔓" : "🔒"; valText.textContent = revealed ? shown : 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"; // Track the live reveal so a lock can revoke it, and re-lock after a beat so // a revealed secret doesn't sit on screen indefinitely. if (revealed) { openReveals.add(closeReveal); relock = setTimeout(closeReveal, RELOCK_MS); } else { openReveals.delete(closeReveal); } }; // plaintextOf resolves this item's value — directly for a plain item, through // the vault key for a sealed one. null = couldn't (reason already said). const plaintextOf = async () => { if (typeof item.value === "string") return item.value; if (!keyring) { say("This item is sealed but the vault has no keyring — it can't be read."); return null; } const priv = await ensureUnlocked(ctx, keyring, say); if (!priv) return null; try { return await unsealValue(item.sealed, priv); } catch { say(`“${item.name}” won't open with this vault's key — it may have been sealed to an older one.`); return null; } }; const toggleReveal = async () => { if (revealed) { revealed = false; shown = ""; paint(); return; } const v = await plaintextOf(); if (v == null) return; shown = v; revealed = true; paint(); }; val.onclick = toggleReveal; const actions = document.createElement("div"); actions.className = "vault-actions"; reveal.type = "button"; reveal.className = "vault-btn"; reveal.onclick = toggleReveal; const copy = document.createElement("button"); copy.type = "button"; copy.className = "vault-btn"; copy.textContent = "Copy"; copy.onclick = async () => { const v = await plaintextOf(); if (v == null) return; try { await navigator.clipboard.writeText(v); 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. shown = v; 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); if (keyring && typeof item.value === "string") { const seal = document.createElement("button"); seal.type = "button"; seal.className = "vault-btn"; seal.textContent = "Seal"; seal.setAttribute("aria-label", `Seal ${item.name} with the vault key`); seal.onclick = async () => { ctx.emit({ type: "vault.change", id: item.id, aid: ctx.id(), sealed: await sealValue(keyring.pub, item.value) }); }; actions.append(seal); } actions.append(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); if (keyring && typeof item.value === "string") { const flag = document.createElement("span"); flag.className = "vault-unsealed"; flag.textContent = " · not sealed yet"; meta.appendChild(flag); } li.appendChild(meta); // Clear the re-lock timer and drop this row's reveal callback when the surface // is torn down while a value is showing (a redraw or thread switch) so neither // fires against a detached node. ctx.onCleanup(() => { if (relock) clearTimeout(relock); openReveals.delete(closeReveal); }); 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 access/history sections' expand state, per thread (module scope so it // survives the rebuild-on-every-change render — transient UI, not fold state). const historyOpen = {}; const accessOpen = {}; // 🤖 buildAccess renders a SHARED locked vault's access: who can open it (the // durable wraps + any pending handoffs), the one action that's mine ("Set up // your passkey" if I hold a handoff, else "Add a passkey on this device"), and // "Give access" for each thread member who has no key yet. There is NO // "request access" — membership is the entitlement. A PERSONAL vault (no other // members) has no sharing surface at all, so this returns null there and the // header lock chip is the whole story. Collapsed by default; auto-opens while I // have a handoff to set up, or someone's waiting to be given access. function buildAccess(keyring, ctx, say) { const tid = ctx.threadId; const wraps = keyring.wraps || []; const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(tid) : []; // Who I am to this vault: a durable holder, a pending handoff, or neither. const iAmEnrolled = wraps.some((w) => (w.kind === "passkey" || w.kind === "keychain") && w.uid === ctx.me); const myHandoff = wraps.find((w) => w.kind === "member" && w.uid === ctx.me); // Thread members with no key of any kind — an insider can give them one. const held = new Set(wraps.map((w) => w.uid).filter(Boolean)); const missing = members.filter((m) => !held.has(m.uid)); // Personal vault (no other members) with nothing of mine to set up → no // sharing surface. The lock chip already handles lock/unlock; a lone vault // never shows "access controls". if (!members.length && !myHandoff && iAmEnrolled) return null; const wrapEl = document.createElement("div"); wrapEl.className = "vault-access"; const open = accessOpen[tid] !== undefined ? !!accessOpen[tid] : (!!myHandoff || missing.length > 0); const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "vault-access-toggle"; const keyCount = wraps.length === 1 ? "1 key" : `${wraps.length.toLocaleString()} keys`; toggle.textContent = `${open ? "▾" : "▸"} Access (${keyCount})`; toggle.setAttribute("aria-expanded", open ? "true" : "false"); toggle.onclick = () => { accessOpen[tid] = !open; const state = ctx.getState() || {}; const next = buildAccess(state.keyring || keyring, ctx, say); if (next) wrapEl.replaceWith(next); }; wrapEl.appendChild(toggle); if (!open) return wrapEl; const list = document.createElement("ul"); list.className = "vault-access-list"; for (const w of wraps) { const li = document.createElement("li"); const who = document.createElement("span"); who.className = "vault-access-who"; who.textContent = w.label || "someone"; // A `member` wrap is a handoff not yet turned into a passkey — an invite in // flight, not a key that can open the vault. const kindLabel = w.kind === "keychain" ? " · CLI key" : w.kind === "member" ? " · invited — waiting to set up a passkey" : " · passkey"; li.append(who, document.createTextNode(kindLabel)); // Revoke drops a wrap (housekeeping, not crypto revocation). Never the last // — so we never offer a Revoke that would silently no-op. if (wraps.length > 1) { const revoke = document.createElement("button"); revoke.type = "button"; revoke.className = "vault-btn vault-btn--danger"; revoke.textContent = "Revoke"; revoke.setAttribute("aria-label", `Revoke the vault key for ${w.label || "this member"}`); revoke.onclick = () => { if (!revoke.dataset.armed) { revoke.dataset.armed = "1"; revoke.textContent = "Revoke?"; setTimeout(() => { delete revoke.dataset.armed; if (revoke.isConnected) revoke.textContent = "Revoke"; }, 4000); return; } ctx.emit({ type: "vault.revoke", aid: ctx.id(), wrapId: w.id }); }; li.appendChild(revoke); } list.appendChild(li); } wrapEl.appendChild(list); // My action: set up my handoff into a passkey, or add another device's // passkey once I'm already in. (Only one applies at a time.) if (myHandoff && !iAmEnrolled) { const setup = document.createElement("button"); setup.type = "button"; setup.className = "vault-btn"; setup.textContent = "Set up your passkey"; setup.setAttribute("aria-label", "Set up your passkey to open this vault"); setup.onclick = () => doEnrolMe(ctx, keyring, say); wrapEl.appendChild(setup); } else if (iAmEnrolled) { const enroll = document.createElement("button"); enroll.type = "button"; enroll.className = "vault-btn"; enroll.textContent = "Add a passkey on this device"; enroll.onclick = () => doEnrollHere(ctx, keyring, say); wrapEl.appendChild(enroll); } else { // A member with no key and no handoff yet — the honest state is "wait to be // given access", never a request button. const note = document.createElement("div"); note.className = "vault-note"; note.textContent = "You don't have a key yet — ask someone already in the vault to give you access."; wrapEl.appendChild(note); } // Give access to each thread member who has no key — only offered to someone // who can actually unlock (doGive needs the vault key). CLI-only holders can // still give from their own end. if (iAmEnrolled) { for (const m of missing) { const give = document.createElement("button"); give.type = "button"; give.className = "vault-btn"; give.textContent = `Give ${m.name || "them"} access`; give.setAttribute("aria-label", `Give ${m.name || "this member"} a key to this vault`); give.onclick = () => doGive(ctx, keyring, m, say); wrapEl.appendChild(give); } } return wrapEl; } // 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.toLocaleString()})`; 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: " · ". The name // and actor are user content, so they go in via textContent nodes, never markup. // Item actions name the secret; keyring actions carry a member/device label. const LOG_VERBS = { add: " added ", remove: " removed ", change: " changed ", lock: " locked the vault", enroll: " enrolled a key", give: " gave access", revoke: " revoked a key", }; 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 isItemAction = entry.action === "add" || entry.action === "remove" || entry.action === "change" || !LOG_VERBS[entry.action]; li.append(who, document.createTextNode(LOG_VERBS[entry.action] || " changed ")); if (isItemAction) { const nm = document.createElement("span"); nm.textContent = entry.name || "a secret"; li.append(nm); } else if (entry.name) { const nm = document.createElement("span"); nm.textContent = ` (${entry.name})`; 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 ""; } }