eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The Vault mini-app: a secure store for named secrets. Everything in lchat is
2// already end-to-end encrypted, so a vault is almost free — a secret is just a
3// named value folded from a thread's event log, so the server only ever sees
4// ciphertext, never a name or a value.
5//
6// It's a `personal` app with `groupCapable: true`, so it works two ways from one
7// codebase (see apps/README.md "Group-scoped apps"):
8// - PERSONAL: its own full-pane thread in the sidebar, just yours, synced across
9// your devices — exactly like To-do.
10// - GROUP: added to a group chat (⋮ → Add apps), where ONE shared instance runs
11// for everyone. Its events post into the group thread's encrypted log, so every
12// member folds the same vault. Nothing about render() changes — the same
13// surface, ctx bound to whichever thread it's acting in.
14//
15// It is a TAKEOVER app (`groupFeedMessage`, never `chatMessage`): the vault
16// IS the thread's surface — your personal Vault never grows a Chat view,
17// and no secret's NAME ever spills into the conversation, a sidebar preview, or
18// push. 🤖 Two places still surface UNNAMED activity, "you"-aware (same as a
19// membership line — see `format.js`'s `membershipLine`):
20// - The chat list's "last message" preview (`preview` below) — "You added a
21// secret to the Vault" / "Ciara updated a secret" — for the vault's own
22// thread (personal or group), so the row doesn't sit blank.
23// - When the vault is added to a GROUP, an add/remove also posts the same
24// unnamed line into that group's chat feed (`chatMessage`'s `{ group }`
25// form), so members notice without opening it.
26// The name (which — unlike the value — every member can already see inside the
27// vault) never lands in either place. The full audit trail, WITH names, still
28// lives only inside the vault surface.
29//
30// Two ideas beyond a plain list:
31// - Locked by default. A value renders masked (••••••) and only reveals when you
32// click it — so glancing at the screen can't leak a secret to whoever's looking
33// over your shoulder. A revealed value auto-re-locks after a moment.
34// - An audit trail. Every add / change / remove is recorded in an append-only
35// `log` with who did it and when, so you can see how a secret got there.
36//
37// 🤖 THE LOCK LAYER (docs/locked-vaults.md). A vault can be LOCKED: it gains a
38// dedicated ECDH keypair whose public half sits in this state and whose private
39// half exists only inside keyring `wraps`, each openable solely by a
40// biometric-gated key — a passkey's WebAuthn PRF output here, or a Touch
41// ID-guarded keychain key in the eleven CLI. Once locked, values ride and rest
42// ECIES-SEALED to the vault public key (`sealed`, never `value`): anyone in the
43// thread can still ADD a secret without an unlock (encrypting to a public key
44// is free), but READING a value takes a fresh biometric. A signed-in session
45// alone — thread key in hand — sees names and the audit trail, never values.
46// Access follows THREAD MEMBERSHIP, not a request dance: locking HANDS the vault
47// key to each current member (sealed to their roster key — a `member` handoff),
48// so every member is entitled by being in the thread. Each member self-enrols
49// their own passkey (vault.enroll), and their handoff drops. An already-unlocked
50// insider can give a later joiner a fresh handoff (vault.give). There is no
51// "request access" — you never ask for a key to a vault you're already in. All of
52// it is ordinary events through the same reducer — malformed folds to a no-op, so
53// hostile inputs stay safe, which `groupCapable` requires. The crypto lives in
54// vaultcrypto.js; reducers here never touch it (folds stay pure and sync).
55//
56// Files are a deliberate TODO: a value can only be text today because the mini-app
57// ctx has no file upload/download surface yet (see the "apps"-tagged issue #408).
58// `kind` is stored on every item so file items fold cleanly once it lands.
59//
60// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
61// tests while still resolving to /static/apps.js in the browser.
62import { defineApp, addItem, updateItem, removeItem, currentUserId } from "../apps.js";
63import { ICON_LOCK } from "../icons.js";
64import {
65 registerVaultPasskey,
66 assertVaultPasskeyFull,
67 kekFromPRF,
68 generateVaultKeypair,
69 wrapVaultPriv,
70 unwrapVaultPriv,
71 sealValue,
72 unsealValue,
73 sealVaultKeyTo,
74} from "../vaultcrypto.js";
75
76// How long a revealed value stays visible before it re-locks itself — short, so a
77// vault left open on screen doesn't sit unmasked. Clicking it again re-arms it.
78const RELOCK_MS = 30000;
79// How long an UNLOCK (the vault private key, in memory only) survives before the
80// vault forgets it and the next read costs a fresh biometric. Never persisted.
81const UNLOCK_TTL_MS = 5 * 60000;
82// A fixed-width mask that never leaks the value's length (always the same width,
83// whether the secret is 4 chars or 400).
84const MASK = "•".repeat(10);
85
86// parseVaultCommand pulls "<name> = <value>" out of a /vault command's args —
87// split on the FIRST "=" so a value may itself contain "=" (base64, query
88// strings). Returns { name, value } or null when either side is empty. Pure and
89// exported so it's unit-tested without a DOM.
90export function parseVaultCommand(args) {
91 const s = (args || "").trim();
92 const i = s.indexOf("=");
93 if (i < 0) return null;
94 const name = s.slice(0, i).trim();
95 const value = s.slice(i + 1).trim();
96 if (!name || !value) return null;
97 return { name, value };
98}
99
100// findByName does a case-insensitive lookup so "/vault WiFi = …" updates an
101// existing "wifi" secret in place instead of creating a near-duplicate.
102function findByName(items, name) {
103 const n = (name || "").trim().toLowerCase();
104 return (items || []).find((it) => (it.name || "").toLowerCase() === n) || null;
105}
106
107// 🤖 wrapShape vets keyring objects arriving in events before the reducer stores
108// them — every field a later unlock will read must be a non-empty string, and
109// anything extra is dropped (never store a stranger's spare fields). Return the
110// cleaned object, or null. Pure; exported for tests.
111// Wrap kinds (docs/locked-vaults.md):
112// passkey / keychain — a member's DURABLE biometric wrap (WebAuthn PRF KEK /
113// CLI Touch ID keychain KEK). "Requires a passkey to open."
114// member — a HANDOFF: the vault key sealed to a member's roster
115// public key, so that member can SELF-ENROL a passkey without anyone
116// approving. Soft (their session key opens it); dropped the moment they
117// enrol their own biometric wrap. Carries `uid` = whose handoff it is.
118// Every wrap carries `uid` (the member it belongs to) so the reducer can drop a
119// member's handoff when they enrol, and the UI can tell whose key is whose.
120export function wrapShape(w) {
121 if (!w || typeof w.id !== "string" || !w.id) return null;
122 if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "member") return null;
123 if (typeof w.wrapped !== "string" || !w.wrapped) return null;
124 const out = {
125 id: w.id,
126 kind: w.kind,
127 uid: typeof w.uid === "string" ? w.uid : "",
128 wrapped: w.wrapped,
129 label: typeof w.label === "string" ? w.label : "",
130 };
131 if (typeof w.credId === "string" && w.credId) out.credId = w.credId;
132 return out;
133}
134
135// State is { items, log, keyring }.
136// item: { id, name, value?, sealed?, kind, by, byName, at, updatedBy, updatedByName, updatedAt }
137// — exactly one of value (plain vault) / sealed (locked vault: ECIES to the
138// vault public key, b64url ephPub||iv||ct; see vaultcrypto.js).
139// log: { id (audit id), ref (item id), action, name, by, byName, at }
140// — action: add | change | remove | lock | enroll | give | revoke
141// keyring (locked vaults only): { v: 1, pub, wraps: [wrapShape] } — see the
142// lock-layer note at the top of this file and docs/locked-vaults.md.
143//
144// The secret's name rides in the event as `label`, NOT `name` — the emit
145// transport OVERWRITES `event.name` with the sender's screen name (that's how the
146// audit trail learns who acted), so a secret called `name` would be clobbered. So
147// the reducer reads `e.label` for the secret's name and `e.name`/`e.uid`/`e.ts`
148// for the actor + time (all injected by the transport). Every event also carries
149// its own `aid` (audit id) minted fresh at emit, so the append-only log dedupes by
150// aid (addItem) — the item id can't, since a change or remove reuses it. The
151// reducer is the deterministic arbiter: a malformed event folds to a no-op, so
152// anything a member (or bot) can post is a safe input — which is exactly what
153// `groupCapable` requires (strangers' events are now first-class inputs).
154export const app = defineApp({
155 name: "vault",
156 title: "Vault",
157 blurb: "Keep secrets safe", // sidebar subtitle before first use (sync w/ native)
158 glyph: "🔒",
159 symbol: "lock", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
160 logo: ICON_LOCK,
161 // The SPACE place (a space vault as the team credential shelf — one shared
162 // set of secrets for everyone the space already trusts) was a deliberate
163 // choice and the reducer still supports it: every member can read it,
164 // additions are attributed, every member's events are first-class inputs
165 // exactly as groupCapable already required. It's parked while the places
166 // UI is hidden (docs/space-apps.md § The pullback) — restore by adding
167 // "space" back to this line.
168 places: ["you", "chat"],
169 initialState: () => ({ items: [], log: [], keyring: null }),
170 events: {
171 "vault.add": (s, e) => {
172 const label = typeof e.label === "string" ? e.label.trim() : "";
173 // Exactly one of value/sealed (sealed must be non-empty; an empty VALUE is
174 // rejected by the emitters, but an empty string here would still fold —
175 // matching the old validation, which only type-checked).
176 const hasValue = typeof e.value === "string";
177 const hasSealed = typeof e.sealed === "string" && e.sealed !== "";
178 if (!e.id || !e.aid || !label || hasValue === hasSealed) return s;
179 const items = addItem(s.items, {
180 id: e.id,
181 name: label,
182 ...(hasSealed ? { sealed: e.sealed } : { value: e.value }),
183 kind: e.kind === "file" ? "file" : "text",
184 by: e.uid || "",
185 byName: e.name || "",
186 at: e.ts || 0,
187 updatedBy: "",
188 updatedByName: "",
189 updatedAt: 0,
190 });
191 return { ...s, items, log: logEntry(s.log, e, "add", label) };
192 },
193 "vault.change": (s, e) => {
194 const item = (s.items || []).find((it) => it.id === e.id);
195 if (!e.id || !e.aid || !item) return s;
196 const patch = { updatedBy: e.uid || "", updatedByName: e.name || "", updatedAt: e.ts || 0 };
197 if (typeof e.label === "string" && e.label.trim()) patch.name = e.label.trim();
198 // A change may carry a new value (plain vault) or a new sealed blob
199 // (locked) — whichever arrives replaces the other form, so re-sealing a
200 // stale plaintext item is just a change carrying `sealed`.
201 if (typeof e.sealed === "string" && e.sealed !== "") {
202 patch.sealed = e.sealed;
203 patch.value = undefined;
204 } else if (typeof e.value === "string") {
205 patch.value = e.value;
206 patch.sealed = undefined;
207 }
208 return { ...s, items: updateItem(s.items, e.id, patch), log: logEntry(s.log, e, "change", patch.name || item.name) };
209 },
210 "vault.remove": (s, e) => {
211 const item = (s.items || []).find((it) => it.id === e.id);
212 if (!e.id || !e.aid) return s;
213 // Carry the name into the log entry (the item is about to be gone) so the
214 // audit trail still reads "… removed GITHUB_TOKEN".
215 return { ...s, items: removeItem(s.items, e.id), log: logEntry(s.log, e, "remove", (item && item.name) || "") };
216 },
217
218 // ---- 🤖 the lock layer (docs/locked-vaults.md) --------------------------
219
220 // vault.lock turns a shared vault passkey-locked: installs the vault public
221 // key, seals every value to it, adds the LOCKER's own biometric wrap, and —
222 // so nobody has to "request" anything — HANDS the vault key to each other
223 // current member by sealing it to their roster key (`handoffs`, each a
224 // `member` wrap). Every member is entitled by membership; each self-enrols
225 // their own passkey next (vault.enroll), and their handoff drops then.
226 // One-way: a keyring is never replaced.
227 "vault.lock": (s, e) => {
228 if (s.keyring) return s;
229 const wrap = wrapShape(e.wrap);
230 if (!e.aid || typeof e.pub !== "string" || !e.pub || !wrap) return s;
231 const sealed = e.sealed && typeof e.sealed === "object" ? e.sealed : {};
232 const items = (s.items || []).map((it) => {
233 const blob = sealed[it.id];
234 if (typeof blob !== "string" || !blob) return it; // uncovered items keep value; render flags them
235 const { value, ...rest } = it;
236 return { ...rest, sealed: blob };
237 });
238 let wraps = [wrap];
239 for (const h of Array.isArray(e.handoffs) ? e.handoffs : []) {
240 const hw = wrapShape(h);
241 if (hw && hw.kind === "member") wraps = addItem(wraps, hw);
242 }
243 return { items, log: logEntry(s.log, e, "lock", ""), keyring: { v: 1, pub: e.pub, wraps } };
244 },
245
246 // vault.enroll installs a member's DURABLE biometric wrap (passkey/keychain)
247 // and DROPS that member's soft `member` handoff — from here on they need
248 // their passkey. `uid` says whose it is; a member self-enrols with no
249 // approval (they already hold the key by membership).
250 "vault.enroll": (s, e) => {
251 const wrap = wrapShape(e.wrap);
252 if (!s.keyring || !e.aid || !wrap || wrap.kind === "member") return s;
253 const uid = wrap.uid || e.uid || "";
254 const wraps = (s.keyring.wraps || []).filter((w) => !(w.kind === "member" && uid && w.uid === uid));
255 return {
256 ...s,
257 log: logEntry(s.log, e, "enroll", wrap.label),
258 keyring: { ...s.keyring, wraps: addItem(wraps, wrap) },
259 };
260 },
261
262 // vault.give: an already-unlocked member admits someone who wasn't present at
263 // lock time (or lost every device) — seals the vault key to that member's
264 // roster key as a `member` handoff, which they self-enrol from. The ONLY
265 // deliberate hand-off, and it's an insider GIVING access, never an outsider
266 // "requesting" it. `uid` is the recipient.
267 "vault.give": (s, e) => {
268 const wrap = wrapShape(e.wrap);
269 if (!s.keyring || !e.aid || !wrap || wrap.kind !== "member" || !wrap.uid) return s;
270 return {
271 ...s,
272 log: logEntry(s.log, e, "give", wrap.label),
273 keyring: { ...s.keyring, wraps: addItem(s.keyring.wraps, wrap) },
274 };
275 },
276
277 // vault.revoke drops a wrap — housekeeping, NOT crypto revocation (an
278 // ex-holder saw the values; rotating the real secrets is the honest move,
279 // docs/locked-vaults.md "What this does not do"). The LAST wrap can never be
280 // dropped — it would strand the vault keyless.
281 "vault.revoke": (s, e) => {
282 if (!s.keyring || !e.aid || typeof e.wrapId !== "string" || !e.wrapId) return s;
283 const gone = (s.keyring.wraps || []).find((w) => w.id === e.wrapId);
284 if (!gone || s.keyring.wraps.length <= 1) return s;
285 return {
286 ...s,
287 log: logEntry(s.log, e, "revoke", gone.label),
288 keyring: { ...s.keyring, wraps: removeItem(s.keyring.wraps, e.wrapId) },
289 };
290 },
291 },
292 commands: {
293 // /vault <name> = <value> — familiar first: a Slack/Discord-style command,
294 // with the "= value" shape advertised in the "/" typeahead. Because the app is
295 // groupCapable, the framework routes this to the RIGHT instance: your personal
296 // vault from anywhere, or the GROUP's shared vault when typed in a chat that has
297 // Vault added (and "/vault @here …" adds it to a group in one gesture).
298 "/vault": {
299 desc: "Store a secret",
300 args: "<name> = <value>",
301 run: async (args, ctx) => {
302 const parsed = parseVaultCommand(args);
303 if (!parsed) {
304 // Bad/blank input still gets a hint (private, where you typed) — but not
305 // the secret typed back at them.
306 return ctx.reply && ctx.reply({ text: "Usage: /vault <name> = <value> — e.g. /vault WiFi = hunter2", short: "vault usage" });
307 }
308 await addOrUpdate(ctx, parsed.name, parsed.value);
309 ctx.reply && ctx.reply({ text: `Stored “${parsed.name}” in the vault.`, short: `stored “${parsed.name}”`, actions: [{ label: "Open Vault", run: ctx.open }] });
310 },
311 },
312 },
313
314 // 🤖 groupFeedMessage is the FEED-ONLY projection hook: the host calls it
315 // when it folds a GROUP/DM chat's own feed (foldGroupAppEventBubble in
316 // shell.js) and for nothing else — implementing it (and NOT chatMessage)
317 // is exactly how the personal Vault stays a takeover with no Chat view
318 // (appWantsChatView; apps/README.md "Companion projection"). Deliberately
319 // unnamed and deliberately narrow: an ADD or REMOVE is worth a group
320 // noticing ("someone touched the vault"), but the secret's name stays out
321 // of the conversation, its previews, and push — and a value CHANGE (no
322 // new/gone item, same name) stays silent, since there's nothing new for a
323 // passerby to learn from it. Locking is group-visible too: reads now cost
324 // a biometric, worth noticing.
325 groupFeedMessage(e) {
326 if (e.type === "vault.add") return { text: "added a secret to the Vault", action: true };
327 if (e.type === "vault.remove") return { text: "removed a secret from the Vault", action: true };
328 if (e.type === "vault.lock") return { text: "locked the Vault with a passkey", action: true };
329 return null;
330 },
331
332 // 🤖 The sidebar's "last message" line, for both a personal Vault thread and
333 // a group's — same unnamed shape as groupFeedMessage above (never the
334 // secret's name), and on for BOTH places: a preview line doesn't grow a
335 // Chat view, it's just text on a row you already see. "You" for your own
336 // action, same idea as the group action line's actor substitution.
337 preview(e) {
338 const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
339 if (e.type === "vault.add") return `🔒 ${who} added a secret to the Vault`;
340 if (e.type === "vault.remove") return `🔒 ${who} removed a secret from the Vault`;
341 if (e.type === "vault.change") return `🔒 ${who} updated a secret in the Vault`;
342 if (e.type === "vault.lock") return `🔒 ${who} locked the Vault`;
343 return null;
344 },
345
346 // This app's CSS travels with it — the framework injects it once. It reads the
347 // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
348 styles: `
349.vault-wrap { display: flex; flex-direction: column; gap: 12px; max-width: 560px; width: 100%; margin: 0 auto; }
350.vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
351.vault-head-left { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
352.vault-title { font-size: 15px; font-weight: 700; color: var(--ink); }
353.vault-count { font-size: 12px; color: var(--muted); }
354.vault-lockchip {
355 border: 1px solid var(--border); background: var(--panel); color: var(--ink);
356 font: inherit; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px;
357 cursor: var(--ui-cursor, pointer); white-space: nowrap;
358}
359.vault-lockchip:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
360.vault-lockchip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
361.vault-note { font-size: 12px; color: var(--muted); padding: 2px 0; }
362.vault-note:empty { display: none; }
363.vault-add { display: flex; flex-wrap: wrap; gap: 8px; }
364.vault-add input {
365 flex: 1 1 140px; min-width: 0; padding: 9px 11px; border-radius: 10px; font: inherit; font-size: 14px;
366 border: 1px solid var(--border); background: var(--panel); color: var(--ink);
367}
368.vault-add .vault-name { flex: 0 1 150px; }
369.vault-add-btn { flex: 0 0 auto; }
370.vault-empty { font-size: 13px; color: var(--muted); padding: 4px 0; }
371.vault-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
372.vault-item {
373 display: flex; flex-direction: column; gap: 6px; padding: 11px 12px;
374 border: 1px solid var(--border); border-radius: 12px; background: var(--panel);
375 transition: border-color 0.15s;
376}
377.vault-item:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
378.vault-row { display: flex; align-items: center; gap: 10px; }
379.vault-name-label {
380 flex: 0 0 auto; max-width: 40%; font-size: 13px; font-weight: 700; color: var(--ink);
381 overflow-wrap: anywhere; letter-spacing: 0.01em;
382}
383/* The masked value is a full-width button that reads as tappable: click it to
384 reveal. Locked it's a muted monospace mask; revealed it's crisp and selectable. */
385.vault-value {
386 flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 7px;
387 padding: 5px 9px; border: 1px solid transparent; border-radius: 8px;
388 background: var(--bubble-them); color: var(--muted); font: inherit;
389 cursor: var(--ui-cursor, pointer); text-align: left; transition: background 0.12s, border-color 0.12s;
390}
391.vault-value:hover { border-color: var(--border); }
392.vault-value:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
393.vault-lock { flex: 0 0 auto; font-size: 12px; line-height: 1; }
394.vault-value-text {
395 flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
396 font-size: 13px; letter-spacing: 0.18em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
397}
398.vault-value.revealed { background: transparent; color: var(--ink); }
399.vault-value.revealed .vault-value-text {
400 letter-spacing: normal; white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible; user-select: all;
401}
402.vault-unsealed { color: var(--muted); font-style: italic; }
403.vault-actions { flex: 0 0 auto; display: flex; gap: 1px; }
404.vault-btn {
405 border: none; background: transparent; color: var(--link); font: inherit; font-size: 12px; font-weight: 600;
406 cursor: var(--ui-cursor, pointer); padding: 4px 7px; border-radius: 7px; white-space: nowrap;
407}
408.vault-btn:hover { background: var(--bubble-them); }
409.vault-btn:disabled { color: var(--muted); cursor: default; background: transparent; }
410.vault-btn--danger { color: var(--muted); }
411.vault-btn--danger:hover { color: #e5484d; background: color-mix(in srgb, #e5484d 12%, transparent); }
412.vault-meta { font-size: 11px; color: var(--muted); }
413.vault-access { border-top: 1px solid var(--border); padding-top: 8px; }
414.vault-access-list { list-style: none; margin: 6px 0 4px; padding: 0; display: flex; flex-direction: column; gap: 5px; }
415.vault-access-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
416.vault-access-who { color: var(--ink); font-weight: 600; }
417.vault-history { border-top: 1px solid var(--border); padding-top: 8px; }
418.vault-history-toggle, .vault-access-toggle {
419 background: none; border: none; padding: 4px 2px; font: inherit; font-size: 13px; font-weight: 600;
420 color: var(--muted); cursor: var(--ui-cursor, pointer);
421}
422.vault-history-toggle:hover, .vault-access-toggle:hover { color: var(--ink); }
423.vault-log { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
424.vault-log li { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
425.vault-log .vault-log-who { color: var(--ink); font-weight: 600; }
426`,
427
428 // Full-pane render (like To-do): an add form, the secrets, then a collapsible
429 // history. Rebuilt from state on every change (not incremental) — the proven
430 // pattern for a groupCapable app, since a group's Interface pane is one shared
431 // element and other members' edits refold here. Handlers close over `ctx` (fresh
432 // each render, bound to whichever thread — your personal one or the group's), so
433 // an emit always lands in the right log. Reveal state lives only on the row DOM,
434 // so a concurrent edit that rebuilds the list re-locks every value — a feature.
435 render(state, el, ctx) {
436 const items = (state && state.items) || [];
437 const log = (state && state.log) || [];
438 const keyring = (state && state.keyring) || null;
439
440 // A render that observes the vault becoming locked (this device locked it,
441 // or a remote lock just folded) revokes any reveal still open from before —
442 // the belt to doLock's braces, and the whole story for a remote lock.
443 if (keyring && !lockedSeen[ctx.threadId]) relockOpenReveals();
444 lockedSeen[ctx.threadId] = !!keyring;
445
446 const wrap = document.createElement("div");
447 wrap.className = "vault-wrap";
448
449 // One inline status line for the whole surface — the lock/unlock/enrol
450 // ceremonies report here. In-flow (occupies layout space), never a
451 // floating toast; role=status so screen readers announce it.
452 const note = document.createElement("div");
453 note.className = "vault-note";
454 note.setAttribute("role", "status");
455 const say = (msg) => {
456 note.textContent = msg || "";
457 };
458
459 const head = document.createElement("div");
460 head.className = "vault-head";
461 const headLeft = document.createElement("div");
462 headLeft.className = "vault-head-left";
463 const title = document.createElement("span");
464 title.className = "vault-title";
465 title.textContent = "🔒 Vault";
466 const count = document.createElement("span");
467 count.className = "vault-count";
468 count.textContent = items.length ? (items.length === 1 ? "1 secret" : `${items.length.toLocaleString()} secrets`) : "";
469 headLeft.append(title, count);
470 head.append(headLeft, buildLockChip(keyring, ctx, say));
471 wrap.appendChild(head);
472 wrap.appendChild(note);
473
474 // Add form — emits directly, so the secret never rides a chat composer.
475 // On a locked vault the value seals to the vault public key before it
476 // leaves the handler — adding never costs an unlock.
477 const form = document.createElement("form");
478 form.className = "vault-add";
479 // Keep password managers OUT of the add form. A "Name" text field beside a
480 // type=password field reads to 1Password/Chrome/etc. as a username+password
481 // LOGIN, so they offer to "save" the secret you're storing — clutter, and
482 // (Paul's report) their save popup reads like a passkey prompt. These are the
483 // managers' documented opt-outs, plus declaring the whole form non-login so no
484 // credential pair is inferred. A vault secret is content, never a saved login.
485 const noPwManager = (el) => {
486 el.autocomplete = "off";
487 el.setAttribute("data-1p-ignore", ""); // 1Password
488 el.setAttribute("data-lpignore", "true"); // LastPass
489 el.setAttribute("data-bwignore", ""); // Bitwarden
490 el.setAttribute("data-form-type", "other"); // Dashlane + a 1Password hint
491 };
492 form.setAttribute("data-form-type", "other");
493 form.autocomplete = "off";
494 const nameIn = document.createElement("input");
495 nameIn.className = "vault-name";
496 nameIn.placeholder = "Name";
497 nameIn.maxLength = 80;
498 noPwManager(nameIn);
499 const valIn = document.createElement("input");
500 valIn.className = "vault-val";
501 valIn.type = "password"; // masked while typing — this is a secret going in
502 valIn.placeholder = "Secret value";
503 noPwManager(valIn);
504 const add = document.createElement("button");
505 add.type = "submit";
506 add.className = "vault-add-btn";
507 add.textContent = "Add";
508 form.append(nameIn, valIn, add);
509 form.onsubmit = async (ev) => {
510 ev.preventDefault();
511 const name = nameIn.value.trim();
512 const value = valIn.value; // don't trim a value — leading/trailing chars can matter
513 if (!name || !value) return;
514 await addOrUpdate(ctx, name, value);
515 nameIn.value = "";
516 valIn.value = "";
517 nameIn.focus();
518 };
519 wrap.appendChild(form);
520
521 if (!items.length) {
522 const empty = document.createElement("div");
523 empty.className = "vault-empty";
524 empty.textContent = "No secrets yet — add one above.";
525 wrap.appendChild(empty);
526 } else {
527 const list = document.createElement("ul");
528 list.className = "vault-list";
529 for (const item of items) list.appendChild(buildRow(item, ctx, keyring, say));
530 wrap.appendChild(list);
531 }
532
533 if (keyring) {
534 const access = buildAccess(keyring, ctx, say);
535 if (access) wrap.appendChild(access); // null for a personal vault (no sharing surface)
536 }
537 if (log.length) wrap.appendChild(buildHistory(log, ctx));
538
539 el.appendChild(wrap);
540 },
541});
542
543// ---- 🤖 unlock state (memory only, never persisted) ---------------------------
544
545// threadId -> { priv (pkcs8 b64url), timer }. Cleared by the timer, by "lock
546// now", and never written anywhere — a reload always starts locked.
547const unlockedVaults = {};
548
549function cacheUnlock(threadId, priv) {
550 forgetUnlock(threadId);
551 // When the unlock window lapses, the key leaves memory AND any value still
552 // on screen must re-mask — same invariant as an explicit lock.
553 unlockedVaults[threadId] = {
554 priv,
555 timer: setTimeout(() => lockNow(threadId), UNLOCK_TTL_MS),
556 };
557}
558
559// forgetUnlock drops the in-memory key only — the pure reset cacheUnlock reuses
560// before caching a new one (where re-masking reveals would be wrong).
561export function forgetUnlock(threadId) {
562 const u = unlockedVaults[threadId];
563 if (u) {
564 clearTimeout(u.timer);
565 delete unlockedVaults[threadId];
566 }
567}
568
569// lockNow is the user-facing "the vault is locking again" action: forget the
570// key AND revoke every open reveal, so plaintext can never outlive the unlock.
571// Used by the header "lock now" toggle and the auto-expiry timer; the initial
572// lock ceremony (doLock) and a remote lock (render) revoke reveals directly.
573function lockNow(threadId) {
574 forgetUnlock(threadId);
575 relockOpenReveals();
576}
577
578function unlockedPriv(threadId) {
579 return unlockedVaults[threadId] ? unlockedVaults[threadId].priv : null;
580}
581
582// 🤖 A row showing plaintext registers a "slam shut" callback here while it's
583// revealed. Locking a vault — from this device, another device, or another
584// member — must revoke every open reveal IMMEDIATELY and deterministically: a
585// value that was on screen the instant you locked is live secret material, and
586// it can't be left to linger until an incidental re-render or a manual Hide.
587// Module scope so it survives the per-render rebuild.
588const openReveals = new Set();
589
590function relockOpenReveals() {
591 for (const close of [...openReveals]) close(); // each close() removes itself
592 openReveals.clear();
593}
594
595// lockedSeen tracks, per thread, whether the last render saw a keyring — so a
596// render that observes the unlocked→locked transition (a remote lock) can slam
597// open reveals shut, the same as the local doLock path does instantly.
598const lockedSeen = {};
599
600// ensureUnlocked returns the vault private key, running one passkey assertion
601// (one biometric) when it isn't already in memory. Returns null after
602// reporting the reason via say().
603// passkeyError turns a WebAuthn/ceremony failure into honest inline copy — and
604// console.errors anything that isn't the user simply backing out, so a real
605// misconfiguration never reads as "nothing happened". The commonest dev
606// gotcha: WebAuthn refuses an IP-address RP id, so the 127.0.0.1 dev server
607// throws SecurityError before any OS sheet — open it over http://localhost
608// instead. `action` is the verb for the plain-cancel case ("Lock", "Unlock").
609function passkeyError(e, action) {
610 const name = (e && e.name) || "";
611 const msg = (e && e.message) || "";
612 if (msg === "no-prf") return "This passkey manager can't protect a vault (no PRF support) — try the browser's own passkeys.";
613 if (name === "NotAllowedError") return `${action} cancelled.`; // dismissed or timed out
614 console.error("vault: passkey ceremony failed", e);
615 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.`;
616 return `Couldn't reach a passkey here: ${name || msg || "unknown error"} (details in the console).`;
617}
618
619async function ensureUnlocked(ctx, keyring, say) {
620 const cached = unlockedPriv(ctx.threadId);
621 if (cached) return cached;
622 const passkeys = (keyring.wraps || []).filter((w) => w.kind === "passkey" && w.credId);
623 if (!passkeys.length) {
624 say("Only CLI keys are enrolled in this vault — unlock isn't possible in the browser yet. Set up your passkey below.");
625 return null;
626 }
627 let res;
628 try {
629 // All enrolled credIds go in the allowlist — the authenticator answers with
630 // whichever it holds, so "which wrap is mine" never needs guessing.
631 res = await assertVaultPasskeyFull(passkeys.map((w) => w.credId));
632 } catch (e) {
633 say(passkeyError(e, "Unlock"));
634 return null;
635 }
636 const wrap = passkeys.find((w) => w.credId === res.credId);
637 if (!wrap) {
638 say("That passkey isn't enrolled in this vault — set up your passkey below.");
639 return null;
640 }
641 try {
642 const kek = await kekFromPRF(res.prf);
643 const priv = await unwrapVaultPriv(wrap.wrapped, kek);
644 cacheUnlock(ctx.threadId, priv);
645 return priv;
646 } catch {
647 say("Couldn't open the vault with that passkey — its enrolment may be stale. Set up your passkey below.");
648 return null;
649 }
650}
651
652// ---- write paths ----------------------------------------------------------------
653
654// addOrUpdate is the one write path the command and the in-form Add share:
655// overwrite the secret if its name already exists (case-insensitive), else add a
656// new one. Each event carries a fresh audit id (aid) so the log records it. On a
657// locked vault the value seals to the vault public key first — no unlock needed.
658async function addOrUpdate(ctx, name, value) {
659 const state = ctx.getState() || {};
660 const existing = findByName(state.items, name);
661 const body = state.keyring ? { sealed: await sealValue(state.keyring.pub, value) } : { value };
662 // The secret's name travels as `label` (see the reducer note on why not `name`).
663 if (existing) ctx.emit({ type: "vault.change", id: existing.id, aid: ctx.id(), label: name, ...body });
664 else ctx.emit({ type: "vault.add", id: ctx.id(), aid: ctx.id(), label: name, ...body });
665}
666
667// doLock runs the whole lock ceremony: mint a passkey (verifying PRF end-to-end
668// BEFORE anything posts — the no-prf lesson from home), mint the vault keypair,
669// re-seal every existing value, and HAND the vault key to every other current
670// member (sealed to their roster key — a `member` handoff), so each can set up
671// their own passkey without anyone approving. One vault.lock carries it all. The
672// locker ends up unlocked, so their next reveal is free.
673async function doLock(ctx, say) {
674 let pk;
675 try {
676 pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`);
677 } catch (e) {
678 say(passkeyError(e, "Lock"));
679 return;
680 }
681 const kek = await kekFromPRF(pk.prf);
682 const kp = await generateVaultKeypair();
683 const state = ctx.getState() || {};
684 const sealed = {};
685 for (const it of state.items || []) {
686 if (typeof it.value === "string") sealed[it.id] = await sealValue(kp.publicKey, it.value);
687 }
688 // A handoff per other member: the vault key sealed to their roster key. The
689 // host excludes me and bots; a personal vault has no other members → none.
690 const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(ctx.threadId) : [];
691 const handoffs = [];
692 for (const m of members) {
693 handoffs.push({ id: ctx.id(), kind: "member", uid: m.uid, label: m.name || "", wrapped: await sealVaultKeyTo(m.pub, kp.privateKey) });
694 }
695 ctx.emit({
696 type: "vault.lock",
697 aid: ctx.id(),
698 pub: kp.publicKey,
699 wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(kp.privateKey, kek) },
700 handoffs,
701 sealed,
702 });
703 // Slam every on-screen reveal shut the instant we lock — don't wait for the
704 // event to echo back and re-render (a value shown at lock time is live secret
705 // material). cacheUnlock keeps the locker able to re-reveal with no biometric.
706 relockOpenReveals();
707 cacheUnlock(ctx.threadId, kp.privateKey);
708 say(
709 handoffs.length
710 ? `Vault locked — values now need a passkey to read. ${handoffs.length === 1 ? "1 member" : handoffs.length.toLocaleString() + " members"} can set up their own passkey.`
711 : "Vault locked — values now need a passkey to read."
712 );
713}
714
715// doEnrolMe: a member who holds a `member` handoff (the vault key sealed to them
716// at lock, or by a give) turns it into a durable passkey wrap. Open the handoff
717// with my session key (host.openMine), verify it actually opens the vault, mint
718// a passkey, wrap the vault key under it, emit vault.enroll — which DROPS my
719// handoff, so from here on I need my passkey. No approval: I already hold the
720// key by membership.
721async function doEnrolMe(ctx, keyring, say) {
722 const mine = (keyring.wraps || []).find((w) => w.kind === "member" && w.uid === ctx.me);
723 if (!mine) {
724 say("You don't have a key for this vault yet — ask someone already in to give you access.");
725 return;
726 }
727 if (!ctx.host || !ctx.host.vault) {
728 say("Setting up a vault passkey isn't supported here yet.");
729 return;
730 }
731 const opened = await ctx.host.vault.openMine(mine.wrapped);
732 const vaultPriv = opened && typeof opened.k === "string" ? opened.k : null;
733 if (!vaultPriv) {
734 say("Couldn't open your vault key — ask someone in the vault to give you access again.");
735 return;
736 }
737 // Integrity: the handoff key must actually open this vault. If any value is
738 // sealed, it has to decrypt — a mismatched or hostile handoff fails HERE,
739 // before it gets wrapped under a passkey. An empty vault has nothing to check.
740 const sample = ((ctx.getState() || {}).items || []).find((it) => typeof it.sealed === "string" && it.sealed);
741 if (sample) {
742 try {
743 await unsealValue(sample.sealed, vaultPriv);
744 } catch {
745 say("The key you were given doesn't match this vault — ask for access again.");
746 return;
747 }
748 }
749 let pk;
750 try {
751 pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`);
752 } catch (e) {
753 say(passkeyError(e, "Set up"));
754 return;
755 }
756 const kek = await kekFromPRF(pk.prf);
757 ctx.emit({
758 type: "vault.enroll",
759 aid: ctx.id(),
760 wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(vaultPriv, kek) },
761 });
762 cacheUnlock(ctx.threadId, vaultPriv);
763 say("You're set — this vault now opens with your passkey.");
764}
765
766// doGive: an unlocked member admits someone in the thread who has no key yet —
767// seal the vault key to that member's roster key as a `member` handoff, which
768// they self-enrol from. One biometric (the unlock). The deliberate hand-off, and
769// it's an insider GIVING, never an outsider requesting.
770async function doGive(ctx, keyring, member, say) {
771 const priv = await ensureUnlocked(ctx, keyring, say);
772 if (!priv) return;
773 if (!ctx.host || !ctx.host.vault) {
774 say("Giving access isn't supported here yet.");
775 return;
776 }
777 ctx.emit({
778 type: "vault.give",
779 aid: ctx.id(),
780 wrap: { id: ctx.id(), kind: "member", uid: member.uid, label: member.name || "", wrapped: await sealVaultKeyTo(member.pub, priv) },
781 });
782 say(`${member.name || "They"} can now set up their passkey.`);
783}
784
785// doEnrollHere: an already-enrolled member adds another passkey (a new browser
786// or device whose passkeys don't sync) — unlock, mint, wrap, enroll.
787async function doEnrollHere(ctx, keyring, say) {
788 const priv = await ensureUnlocked(ctx, keyring, say);
789 if (!priv) return;
790 let pk;
791 try {
792 pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`);
793 } catch (e) {
794 say(passkeyError(e, "Enrol"));
795 return;
796 }
797 const kek = await kekFromPRF(pk.prf);
798 ctx.emit({
799 type: "vault.enroll",
800 aid: ctx.id(),
801 wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(priv, kek) },
802 });
803 say("Passkey added — this device can now unlock the vault.");
804}
805
806// logEntry appends one audit record, deduped by its audit id (addItem) so a
807// re-fold of the window never double-logs. The actor is the transport-injected
808// `e.uid` / `e.name`; `at` is their send time (e.ts).
809function logEntry(log, e, action, name) {
810 return addItem(log || [], { id: e.aid, ref: e.id || "", action, name, by: e.uid || "", byName: e.name || "", at: e.ts || 0 });
811}
812
813// ---- surface pieces ---------------------------------------------------------------
814
815// 🤖 buildLockChip is the header's one lock affordance: plain vault → offer the
816// lock ceremony (two-step arm — it's a one-way door); locked → Unlock, or
817// "lock now" to drop the in-memory key early.
818function buildLockChip(keyring, ctx, say) {
819 const chip = document.createElement("button");
820 chip.type = "button";
821 chip.className = "vault-lockchip";
822 if (!keyring) {
823 chip.textContent = "Lock vault…";
824 chip.setAttribute("aria-label", "Lock this vault with a passkey");
825 chip.onclick = () => {
826 if (!chip.dataset.armed) {
827 chip.dataset.armed = "1";
828 chip.textContent = "Lock with a passkey?";
829 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.");
830 setTimeout(() => {
831 delete chip.dataset.armed;
832 if (chip.isConnected) {
833 chip.textContent = "Lock vault…";
834 say("");
835 }
836 }, 8000);
837 return;
838 }
839 delete chip.dataset.armed;
840 doLock(ctx, say);
841 };
842 return chip;
843 }
844 const paint = () => {
845 const open = !!unlockedPriv(ctx.threadId);
846 chip.textContent = open ? "🔓 Unlocked · lock now" : "🔐 Unlock";
847 chip.setAttribute("aria-label", open ? "Vault is unlocked on this device — click to lock now" : "Unlock this vault with your passkey");
848 };
849 chip.onclick = async () => {
850 if (unlockedPriv(ctx.threadId)) {
851 lockNow(ctx.threadId); // drop the key AND re-mask anything still revealed
852 say("Locked — the key is out of memory.");
853 } else if (await ensureUnlocked(ctx, keyring, say)) {
854 say("Unlocked on this device for a few minutes.");
855 }
856 paint();
857 };
858 paint();
859 return chip;
860}
861
862// buildRow renders one secret: the name, its masked value, and Reveal / Copy /
863// Remove actions, plus a small "added / updated by" audit line. The value starts
864// LOCKED; clicking it (or Reveal) swaps in the plaintext and arms a re-lock timer.
865// A sealed item resolves its plaintext through the vault key (at most one
866// biometric); a plaintext item in a LOCKED vault is flagged and gets a one-click
867// Seal — sealing needs no unlock, so anyone can do the tidying.
868function buildRow(item, ctx, keyring, say) {
869 const li = document.createElement("li");
870 li.className = "vault-item";
871
872 const row = document.createElement("div");
873 row.className = "vault-row";
874
875 const name = document.createElement("span");
876 name.className = "vault-name-label";
877 name.textContent = item.name; // textContent — never innerHTML for user content
878
879 // The value is a button so it's keyboard-focusable and reads as tappable — the
880 // literal "click to reveal" affordance. A small lock/unlock glyph leads it.
881 const val = document.createElement("button");
882 val.type = "button";
883 val.className = "vault-value";
884 const lock = document.createElement("span");
885 lock.className = "vault-lock";
886 lock.setAttribute("aria-hidden", "true");
887 const valText = document.createElement("span");
888 valText.className = "vault-value-text";
889 val.append(lock, valText);
890
891 let revealed = false;
892 let shown = ""; // the plaintext while revealed — sealed items decrypt on demand
893 let relock = null;
894 const reveal = document.createElement("button"); // declared here so paint() can label it
895 // closeReveal masks this row now; it's what a lock (or the re-lock timer)
896 // calls. Registered in openReveals only while the row is actually showing.
897 const closeReveal = () => {
898 if (revealed) {
899 revealed = false;
900 shown = "";
901 paint();
902 }
903 };
904 const paint = () => {
905 if (relock) {
906 clearTimeout(relock);
907 relock = null;
908 }
909 lock.textContent = revealed ? "🔓" : "🔒";
910 valText.textContent = revealed ? shown : MASK;
911 val.classList.toggle("revealed", revealed);
912 val.setAttribute("aria-label", revealed ? `${item.name}: shown, click to hide` : `${item.name}: hidden, click to reveal`);
913 val.title = revealed ? "Click to hide" : "Click to reveal";
914 reveal.textContent = revealed ? "Hide" : "Reveal";
915 // Track the live reveal so a lock can revoke it, and re-lock after a beat so
916 // a revealed secret doesn't sit on screen indefinitely.
917 if (revealed) {
918 openReveals.add(closeReveal);
919 relock = setTimeout(closeReveal, RELOCK_MS);
920 } else {
921 openReveals.delete(closeReveal);
922 }
923 };
924
925 // plaintextOf resolves this item's value — directly for a plain item, through
926 // the vault key for a sealed one. null = couldn't (reason already said).
927 const plaintextOf = async () => {
928 if (typeof item.value === "string") return item.value;
929 if (!keyring) {
930 say("This item is sealed but the vault has no keyring — it can't be read.");
931 return null;
932 }
933 const priv = await ensureUnlocked(ctx, keyring, say);
934 if (!priv) return null;
935 try {
936 return await unsealValue(item.sealed, priv);
937 } catch {
938 say(`“${item.name}” won't open with this vault's key — it may have been sealed to an older one.`);
939 return null;
940 }
941 };
942
943 const toggleReveal = async () => {
944 if (revealed) {
945 revealed = false;
946 shown = "";
947 paint();
948 return;
949 }
950 const v = await plaintextOf();
951 if (v == null) return;
952 shown = v;
953 revealed = true;
954 paint();
955 };
956 val.onclick = toggleReveal;
957
958 const actions = document.createElement("div");
959 actions.className = "vault-actions";
960 reveal.type = "button";
961 reveal.className = "vault-btn";
962 reveal.onclick = toggleReveal;
963 const copy = document.createElement("button");
964 copy.type = "button";
965 copy.className = "vault-btn";
966 copy.textContent = "Copy";
967 copy.onclick = async () => {
968 const v = await plaintextOf();
969 if (v == null) return;
970 try {
971 await navigator.clipboard.writeText(v);
972 copy.textContent = "Copied";
973 setTimeout(() => (copy.textContent = "Copy"), 1200);
974 } catch {
975 // Clipboard blocked (no permission / insecure context) — reveal instead so
976 // the user can select the value by hand.
977 shown = v;
978 revealed = true;
979 paint();
980 }
981 };
982 const remove = document.createElement("button");
983 remove.type = "button";
984 remove.className = "vault-btn vault-btn--danger";
985 remove.textContent = "Remove";
986 remove.onclick = () => {
987 // Two-step inline confirm (embedded webviews have no confirm() panel): the
988 // first click arms it, a second within a few seconds removes.
989 if (!remove.dataset.armed) {
990 remove.dataset.armed = "1";
991 remove.textContent = "Remove?";
992 setTimeout(() => {
993 delete remove.dataset.armed;
994 if (remove.isConnected) remove.textContent = "Remove";
995 }, 4000);
996 return;
997 }
998 ctx.emit({ type: "vault.remove", id: item.id, aid: ctx.id() });
999 };
1000 actions.append(reveal, copy);
1001 if (keyring && typeof item.value === "string") {
1002 const seal = document.createElement("button");
1003 seal.type = "button";
1004 seal.className = "vault-btn";
1005 seal.textContent = "Seal";
1006 seal.setAttribute("aria-label", `Seal ${item.name} with the vault key`);
1007 seal.onclick = async () => {
1008 ctx.emit({ type: "vault.change", id: item.id, aid: ctx.id(), sealed: await sealValue(keyring.pub, item.value) });
1009 };
1010 actions.append(seal);
1011 }
1012 actions.append(remove);
1013 paint(); // start locked, with the lock glyph + labels set
1014
1015 row.append(name, val, actions);
1016 li.appendChild(row);
1017
1018 const meta = document.createElement("div");
1019 meta.className = "vault-meta";
1020 meta.textContent = auditLine(item);
1021 if (keyring && typeof item.value === "string") {
1022 const flag = document.createElement("span");
1023 flag.className = "vault-unsealed";
1024 flag.textContent = " · not sealed yet";
1025 meta.appendChild(flag);
1026 }
1027 li.appendChild(meta);
1028
1029 // Clear the re-lock timer and drop this row's reveal callback when the surface
1030 // is torn down while a value is showing (a redraw or thread switch) so neither
1031 // fires against a detached node.
1032 ctx.onCleanup(() => {
1033 if (relock) clearTimeout(relock);
1034 openReveals.delete(closeReveal);
1035 });
1036
1037 return li;
1038}
1039
1040// auditLine is the per-item "who put this here" summary: who added it, and — if
1041// it's since been edited — who last changed it.
1042function auditLine(item) {
1043 const who = item.byName || "someone";
1044 let line = `Added by ${who}${item.at ? " · " + shortWhen(item.at) : ""}`;
1045 if (item.updatedAt) line += ` · updated by ${item.updatedByName || "someone"} ${shortWhen(item.updatedAt)}`;
1046 return line;
1047}
1048
1049// The access/history sections' expand state, per thread (module scope so it
1050// survives the rebuild-on-every-change render — transient UI, not fold state).
1051const historyOpen = {};
1052const accessOpen = {};
1053
1054// 🤖 buildAccess renders a SHARED locked vault's access: who can open it (the
1055// durable wraps + any pending handoffs), the one action that's mine ("Set up
1056// your passkey" if I hold a handoff, else "Add a passkey on this device"), and
1057// "Give <name> access" for each thread member who has no key yet. There is NO
1058// "request access" — membership is the entitlement. A PERSONAL vault (no other
1059// members) has no sharing surface at all, so this returns null there and the
1060// header lock chip is the whole story. Collapsed by default; auto-opens while I
1061// have a handoff to set up, or someone's waiting to be given access.
1062function buildAccess(keyring, ctx, say) {
1063 const tid = ctx.threadId;
1064 const wraps = keyring.wraps || [];
1065 const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(tid) : [];
1066
1067 // Who I am to this vault: a durable holder, a pending handoff, or neither.
1068 const iAmEnrolled = wraps.some((w) => (w.kind === "passkey" || w.kind === "keychain") && w.uid === ctx.me);
1069 const myHandoff = wraps.find((w) => w.kind === "member" && w.uid === ctx.me);
1070 // Thread members with no key of any kind — an insider can give them one.
1071 const held = new Set(wraps.map((w) => w.uid).filter(Boolean));
1072 const missing = members.filter((m) => !held.has(m.uid));
1073
1074 // Personal vault (no other members) with nothing of mine to set up → no
1075 // sharing surface. The lock chip already handles lock/unlock; a lone vault
1076 // never shows "access controls".
1077 if (!members.length && !myHandoff && iAmEnrolled) return null;
1078
1079 const wrapEl = document.createElement("div");
1080 wrapEl.className = "vault-access";
1081 const open = accessOpen[tid] !== undefined ? !!accessOpen[tid] : (!!myHandoff || missing.length > 0);
1082
1083 const toggle = document.createElement("button");
1084 toggle.type = "button";
1085 toggle.className = "vault-access-toggle";
1086 const keyCount = wraps.length === 1 ? "1 key" : `${wraps.length.toLocaleString()} keys`;
1087 toggle.textContent = `${open ? "▾" : "▸"} Access (${keyCount})`;
1088 toggle.setAttribute("aria-expanded", open ? "true" : "false");
1089 toggle.onclick = () => {
1090 accessOpen[tid] = !open;
1091 const state = ctx.getState() || {};
1092 const next = buildAccess(state.keyring || keyring, ctx, say);
1093 if (next) wrapEl.replaceWith(next);
1094 };
1095 wrapEl.appendChild(toggle);
1096
1097 if (!open) return wrapEl;
1098
1099 const list = document.createElement("ul");
1100 list.className = "vault-access-list";
1101 for (const w of wraps) {
1102 const li = document.createElement("li");
1103 const who = document.createElement("span");
1104 who.className = "vault-access-who";
1105 who.textContent = w.label || "someone";
1106 // A `member` wrap is a handoff not yet turned into a passkey — an invite in
1107 // flight, not a key that can open the vault.
1108 const kindLabel =
1109 w.kind === "keychain"
1110 ? " · CLI key"
1111 : w.kind === "member"
1112 ? " · invited — waiting to set up a passkey"
1113 : " · passkey";
1114 li.append(who, document.createTextNode(kindLabel));
1115 // Revoke drops a wrap (housekeeping, not crypto revocation). Never the last
1116 // — so we never offer a Revoke that would silently no-op.
1117 if (wraps.length > 1) {
1118 const revoke = document.createElement("button");
1119 revoke.type = "button";
1120 revoke.className = "vault-btn vault-btn--danger";
1121 revoke.textContent = "Revoke";
1122 revoke.setAttribute("aria-label", `Revoke the vault key for ${w.label || "this member"}`);
1123 revoke.onclick = () => {
1124 if (!revoke.dataset.armed) {
1125 revoke.dataset.armed = "1";
1126 revoke.textContent = "Revoke?";
1127 setTimeout(() => {
1128 delete revoke.dataset.armed;
1129 if (revoke.isConnected) revoke.textContent = "Revoke";
1130 }, 4000);
1131 return;
1132 }
1133 ctx.emit({ type: "vault.revoke", aid: ctx.id(), wrapId: w.id });
1134 };
1135 li.appendChild(revoke);
1136 }
1137 list.appendChild(li);
1138 }
1139 wrapEl.appendChild(list);
1140
1141 // My action: set up my handoff into a passkey, or add another device's
1142 // passkey once I'm already in. (Only one applies at a time.)
1143 if (myHandoff && !iAmEnrolled) {
1144 const setup = document.createElement("button");
1145 setup.type = "button";
1146 setup.className = "vault-btn";
1147 setup.textContent = "Set up your passkey";
1148 setup.setAttribute("aria-label", "Set up your passkey to open this vault");
1149 setup.onclick = () => doEnrolMe(ctx, keyring, say);
1150 wrapEl.appendChild(setup);
1151 } else if (iAmEnrolled) {
1152 const enroll = document.createElement("button");
1153 enroll.type = "button";
1154 enroll.className = "vault-btn";
1155 enroll.textContent = "Add a passkey on this device";
1156 enroll.onclick = () => doEnrollHere(ctx, keyring, say);
1157 wrapEl.appendChild(enroll);
1158 } else {
1159 // A member with no key and no handoff yet — the honest state is "wait to be
1160 // given access", never a request button.
1161 const note = document.createElement("div");
1162 note.className = "vault-note";
1163 note.textContent = "You don't have a key yet — ask someone already in the vault to give you access.";
1164 wrapEl.appendChild(note);
1165 }
1166
1167 // Give access to each thread member who has no key — only offered to someone
1168 // who can actually unlock (doGive needs the vault key). CLI-only holders can
1169 // still give from their own end.
1170 if (iAmEnrolled) {
1171 for (const m of missing) {
1172 const give = document.createElement("button");
1173 give.type = "button";
1174 give.className = "vault-btn";
1175 give.textContent = `Give ${m.name || "them"} access`;
1176 give.setAttribute("aria-label", `Give ${m.name || "this member"} a key to this vault`);
1177 give.onclick = () => doGive(ctx, keyring, m, say);
1178 wrapEl.appendChild(give);
1179 }
1180 }
1181
1182 return wrapEl;
1183}
1184
1185// buildHistory renders the collapsible audit trail — every add / change / remove,
1186// newest first — for the app's current thread. Collapsed by default.
1187function buildHistory(log, ctx) {
1188 const tid = ctx.threadId;
1189 const wrap = document.createElement("div");
1190 wrap.className = "vault-history";
1191 const open = !!historyOpen[tid];
1192
1193 const toggle = document.createElement("button");
1194 toggle.type = "button";
1195 toggle.className = "vault-history-toggle";
1196 toggle.textContent = `${open ? "▾" : "▸"} History (${log.length.toLocaleString()})`;
1197 toggle.onclick = () => {
1198 historyOpen[tid] = !open;
1199 // Re-render just this section in place (the rest of the surface is unaffected).
1200 const next = buildHistory(((ctx.getState() || {}).log) || log, ctx);
1201 wrap.replaceWith(next);
1202 };
1203 wrap.appendChild(toggle);
1204
1205 if (open) {
1206 const ul = document.createElement("ul");
1207 ul.className = "vault-log";
1208 for (const entry of [...log].reverse()) ul.appendChild(logRow(entry));
1209 wrap.appendChild(ul);
1210 }
1211 return wrap;
1212}
1213
1214// logRow renders one audit-trail line: "<who> <verb> <name> · <when>". The name
1215// and actor are user content, so they go in via textContent nodes, never markup.
1216// Item actions name the secret; keyring actions carry a member/device label.
1217const LOG_VERBS = {
1218 add: " added ",
1219 remove: " removed ",
1220 change: " changed ",
1221 lock: " locked the vault",
1222 enroll: " enrolled a key",
1223 give: " gave access",
1224 revoke: " revoked a key",
1225};
1226
1227function logRow(entry) {
1228 const li = document.createElement("li");
1229 const who = document.createElement("span");
1230 who.className = "vault-log-who";
1231 who.textContent = entry.byName || entry.by || "someone";
1232 const isItemAction = entry.action === "add" || entry.action === "remove" || entry.action === "change" || !LOG_VERBS[entry.action];
1233 li.append(who, document.createTextNode(LOG_VERBS[entry.action] || " changed "));
1234 if (isItemAction) {
1235 const nm = document.createElement("span");
1236 nm.textContent = entry.name || "a secret";
1237 li.append(nm);
1238 } else if (entry.name) {
1239 const nm = document.createElement("span");
1240 nm.textContent = ` (${entry.name})`;
1241 li.append(nm);
1242 }
1243 if (entry.at) li.append(document.createTextNode(" · " + shortWhen(entry.at)));
1244 return li;
1245}
1246
1247// shortWhen is a compact relative-ish timestamp for the audit lines — "just now",
1248// "5m ago", "3h ago", or a date for anything older. Pure enough to keep inline.
1249function shortWhen(ts) {
1250 const now = Date.now();
1251 const diff = now - ts;
1252 if (diff < 60000) return "just now";
1253 if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
1254 if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
1255 try {
1256 return new Date(ts).toLocaleDateString();
1257 } catch {
1258 return "";
1259 }
1260}
1261