aegis-desktop 0.8.12 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * fingerprint.js — the φ(α) panel: authorship key, sealed records, the vault.
5
+ *
6
+ * The IPC surface has existed since 5072779 (`fingerprintStatus` …
7
+ * `fingerprintVaultAudit`, desktop/preload.js:171-183, main.js:750-765) and
8
+ * until now nothing in desktop/renderer/ called any of it —
9
+ * `grep -rn fingerprint desktop/renderer/` returned nothing, so a GUI user had
10
+ * no button for a feature the terminal has had since `aegiscode fingerprint`.
11
+ * This file is that button.
12
+ *
13
+ * It obeys the same two rules the backend half already keeps:
14
+ *
15
+ * 1. **The key never reaches the DOM.** No payload from main carries
16
+ * `secret`, and nothing here invents a field that might. What gets painted
17
+ * is `status.keyFingerprint` — the derived 12-hex tag — which is public by
18
+ * construction (client/authorship-key.js prints exactly this).
19
+ * 2. **A refusal is shown, not swallowed.** Every handler resolves
20
+ * `{ ok: false, error }` instead of throwing, so the panel prints the
21
+ * reason ("this key file is readable by other users", "rclone is not
22
+ * installed") rather than an empty state that reads like success.
23
+ *
24
+ * Pure formatting (`keyLine`, `anchorVerdict`, `auditVerdict`, …) is kept
25
+ * separate from painting so test/renderer-fingerprint.test.mjs can assert the
26
+ * strings that carry the three-claim anchor verdict without standing up a DOM —
27
+ * and the functions it asserts are the exact ones that paint.
28
+ *
29
+ * Classic script, no imports: loaded before app.js by index.html and called
30
+ * from init(). It does not auto-mount, so requiring it under test has no side
31
+ * effects.
32
+ */
33
+
34
+ (function (root, factory) {
35
+ const api = factory();
36
+ // `module` first: under Node the test requires this file directly, and the
37
+ // renderer's `window` is not there.
38
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
39
+ if (root) root.aegisFingerprint = api;
40
+ })(typeof window !== 'undefined' ? window : null, function () {
41
+ /** Every id this file reads. The test asserts each one exists in index.html —
42
+ * a typo here is otherwise a silent no-op, which is how a panel ships with
43
+ * one dead button and no error. */
44
+ const IDS = {
45
+ cardStatus: 'fp-card-status',
46
+ cardOpen: 'fp-open',
47
+ overlay: 'fp-overlay',
48
+ backdrop: 'fp-backdrop',
49
+ close: 'fp-close',
50
+ keySource: 'fp-key-source',
51
+ keyPath: 'fp-key-path',
52
+ keyTag: 'fp-key-tag',
53
+ keyWarning: 'fp-key-warning',
54
+ vaultTarget: 'fp-vault-target',
55
+ label: 'fp-label',
56
+ publishOnMint: 'fp-publish-on-mint',
57
+ autoSeal: 'fp-auto-seal',
58
+ autoSealLabel: 'fp-auto-seal-label',
59
+ autoHint: 'fp-auto-hint',
60
+ sealNow: 'fp-seal-now',
61
+ keygen: 'fp-keygen',
62
+ seal: 'fp-seal',
63
+ sealCard: 'fp-seal-card',
64
+ verify: 'fp-verify',
65
+ publish: 'fp-publish',
66
+ anchor: 'fp-anchor',
67
+ backupTo: 'fp-backup-to',
68
+ backup: 'fp-backup',
69
+ vault: 'fp-vault',
70
+ audit: 'fp-audit',
71
+ vaultList: 'fp-vault-list',
72
+ out: 'fp-out',
73
+ };
74
+
75
+ // ------------------------------------------------------------- formatting
76
+
77
+ const dash = (v) => (v === null || v === undefined || v === '' ? '–' : String(v));
78
+
79
+ /** The one-line state that goes in the sidebar card, where there is room for
80
+ * a sentence and not a table. */
81
+ function keyLine(status) {
82
+ if (!status) return 'checking…';
83
+ if (status.ok === false) return status.error || 'unavailable';
84
+ if (!status.configured) return 'no key — mint one to seal records';
85
+ return `key ${dash(status.keyFingerprint)} · ${dash(status.keySource)}`;
86
+ }
87
+
88
+ /** Everything the panel can say about the key without saying the key. */
89
+ function statusRows(status) {
90
+ if (!status || status.ok === false) {
91
+ return [{ k: 'state', v: (status && status.error) || 'unavailable' }];
92
+ }
93
+ const rows = [
94
+ { k: 'key', v: status.configured ? status.keyFingerprint : 'none' },
95
+ { k: 'source', v: dash(status.keySource) },
96
+ { k: 'expected at', v: dash(status.defaultKeyPath) },
97
+ { k: 'history', v: status.historyPresent ? status.historyPath : 'none yet' },
98
+ ];
99
+ // Only when it resolves somewhere else than expected is it worth a row: a
100
+ // key coming from AEGIS_AUTHORSHIP_SECRET or --secret is exactly the
101
+ // situation where "which key am I actually signing with" needs an answer.
102
+ if (status.configured && status.keyPath && status.keyPath !== status.defaultKeyPath) {
103
+ rows.splice(2, 0, { k: 'in use', v: status.keyPath });
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ /** The 0644 refusal is the one failure where the fix is a command the user
109
+ * can run, so it is surfaced as a warning line rather than buried in the
110
+ * result pane. */
111
+ function keyWarning(status) {
112
+ const err = status && status.error ? String(status.error) : '';
113
+ return /chmod/i.test(err) ? err : '';
114
+ }
115
+
116
+ /** One line per vault entry. Tolerant of the key the shared module chooses,
117
+ * because the panel's job here is to show what came back, not to insist. */
118
+ function vaultRows(list) {
119
+ if (!list || list.ok === false) {
120
+ return [{ name: 'vault unavailable', detail: (list && list.error) || 'unknown error' }];
121
+ }
122
+ const items = Array.isArray(list.records) ? list.records
123
+ : Array.isArray(list.items) ? list.items
124
+ : Array.isArray(list.entries) ? list.entries
125
+ : [];
126
+ if (!items.length) return [{ name: 'nothing published yet', detail: '' }];
127
+ return items.map((it) => {
128
+ const name = it.name || it.file || it.path || it.id || 'record';
129
+ const parts = [it.id, it.publishedAt || it.createdAt, it.url].filter(Boolean);
130
+ return { name: String(name), detail: parts.join(' · ') };
131
+ });
132
+ }
133
+
134
+ /** The anchor answer, kept as three separate claims on purpose: "the file is
135
+ * gone", "the file was replaced" and "the key that sealed it has moved on"
136
+ * are three different situations, and one boolean hides the interesting one
137
+ * (client/record-vault.js checkAnchor). `present` is read as well as
138
+ * `presence` because that spelling was a real bug once. */
139
+ function anchorVerdict(a) {
140
+ if (!a || a.ok === false) {
141
+ return { ok: false, label: (a && a.error) || 'anchor check failed', claims: [] };
142
+ }
143
+ const present = a.present !== undefined ? a.present : a.presence;
144
+ const claims = [
145
+ { k: 'present', v: present },
146
+ { k: 'unchanged', v: a.unchanged },
147
+ { k: 'attested', v: a.attested },
148
+ ];
149
+ const ok = present === true && a.unchanged === true && a.attested === true;
150
+ return {
151
+ ok,
152
+ // Ambiguity is not success: an undefined claim is reported as unknown,
153
+ // never folded into a yes.
154
+ label: ok ? 'the published copy is this record' : 'the published copy is not verifiably this record',
155
+ claims,
156
+ };
157
+ }
158
+
159
+ /** What the audit found. A finding is a fact about the vault, not an error
160
+ * from it, so a clean run says so explicitly rather than showing nothing. */
161
+ function auditVerdict(a) {
162
+ if (!a || a.ok === false) return { ok: false, label: (a && a.error) || 'audit failed', findings: [] };
163
+ const findings = Array.isArray(a.findings) ? a.findings
164
+ : Array.isArray(a.leaks) ? a.leaks
165
+ : Array.isArray(a.hits) ? a.hits
166
+ : [];
167
+ if (findings.length) {
168
+ return { ok: false, label: `${findings.length} item(s) in the vault look like key material`, findings };
169
+ }
170
+ return { ok: true, label: 'no key material in the vault', findings: [] };
171
+ }
172
+
173
+ /** A minted record as the panel reports it. The local file is the evidence —
174
+ * a failed publish does not unseal it (desktop/lib/fingerprint.js mint) —
175
+ * so an upload failure is said beside the seal, not instead of it. */
176
+ function sealLine(r) {
177
+ if (!r || r.ok === false) return { ok: false, text: (r && r.error) || 'seal failed' };
178
+ const rec = r.record || {};
179
+ const bits = [`sealed ${dash(rec.id)}`, `key ${dash(rec.keyFingerprint)}`, r.out];
180
+ // Both halves of the session, in the record and in the line: the key that
181
+ // sealed it, and the mind that answered. An un-nominated AI credential reads
182
+ // as "ai unobserved", never as a borrowed key.
183
+ bits.push(
184
+ rec.aiKey
185
+ ? `ai ${dash(rec.ai)} on ${dash(rec.aiKey)}`
186
+ : `ai unobserved${rec.ai ? ` (${dash(rec.ai)})` : ''}`,
187
+ );
188
+ if (r.vault && r.vault.url) bits.push(`published ${r.vault.url}`);
189
+ else if (r.vault) bits.push(`published ${dash(r.vault.remote || r.vault.path)}`);
190
+ if (r.vaultError) bits.push(`publish failed: ${r.vaultError}`);
191
+ return { ok: true, text: bits.filter(Boolean).join(' · '), file: r.out || null, record: r.record || null };
192
+ }
193
+
194
+ // ---------------------------------------------------------------- painting
195
+
196
+ /**
197
+ * One line describing the auto-seal state, in the same voice as the rest of
198
+ * the panel: what is armed, what it has filed, and the last thing that went
199
+ * wrong. Pure, so the test asserts the string the panel paints.
200
+ */
201
+ function autoMintLine(s) {
202
+ if (!s || typeof s !== 'object') return '–';
203
+ if (s.enabled !== true) return 'off — sealing stays a button';
204
+ const bits = [];
205
+ const secs = Number(s.quietMs);
206
+ bits.push(Number.isFinite(secs) ? `seals ${Math.round(secs / 1000)}s after the last turn` : 'seals when the session goes quiet');
207
+ if (s.publish) bits.push('publishes to the vault');
208
+ if (s.pendingTurns) bits.push(`${s.pendingTurns} turn${s.pendingTurns === 1 ? '' : 's'} pending`);
209
+ if (s.seals) bits.push(`${s.seals} sealed`);
210
+ if (s.failures) bits.push(`${s.failures} failed`);
211
+ if (s.lastError) bits.push(`last error: ${s.lastError}`);
212
+ return bits.join(' · ');
213
+ }
214
+
215
+ function mount({ api, doc, auto } = {}) {
216
+ const d = doc || (typeof document !== 'undefined' ? document : null);
217
+ if (!d) throw new Error('fingerprint: mount needs a document');
218
+ const bridge = api || (typeof window !== 'undefined' ? window.aegis : null);
219
+ // The automation bridge (main.js registerAutoIpc). Injectable for the same
220
+ // reason `api` is, and optional: a preload predating that surface leaves the
221
+ // auto-seal control inert rather than throwing at mount.
222
+ const autoBridge = auto || (typeof window !== 'undefined' ? window.auto : null);
223
+
224
+ const el = (id) => d.getElementById(id);
225
+ const state = { status: null, last: null, busy: false, mounted: true };
226
+
227
+ function say(text, bad) {
228
+ const out = el(IDS.out);
229
+ if (!out) return;
230
+ out.textContent = text;
231
+ out.className = bad ? 'fp-out fp-bad' : 'fp-out';
232
+ }
233
+
234
+ /** Every bridge call goes through here. A handler that rejects (Electron
235
+ * turns a thrown main-process error into a rejected invoke) must not leave
236
+ * the panel mid-action, so the catch produces the same `{ ok, error }`
237
+ * shape the handlers themselves do. */
238
+ async function call(name, payload) {
239
+ if (!bridge || typeof bridge[name] !== 'function') {
240
+ return { ok: false, error: `this build has no ${name} bridge` };
241
+ }
242
+ try {
243
+ const r = await bridge[name](payload);
244
+ if (!r || typeof r !== 'object') return { ok: false, error: `${name} returned nothing` };
245
+ return r;
246
+ } catch (e) {
247
+ return { ok: false, error: (e && e.message) || String(e) };
248
+ }
249
+ }
250
+
251
+ function paintStatus() {
252
+ const s = state.status;
253
+ const card = el(IDS.cardStatus);
254
+ if (card) card.textContent = keyLine(s);
255
+ const src = el(IDS.keySource);
256
+ const pth = el(IDS.keyPath);
257
+ const tag = el(IDS.keyTag);
258
+ const warn = el(IDS.keyWarning);
259
+ const vt = el(IDS.vaultTarget);
260
+ const rows = statusRows(s);
261
+ const pick = (k) => {
262
+ const row = rows.find((r) => r.k === k);
263
+ return row ? row.v : '–';
264
+ };
265
+ if (src) src.textContent = pick('source');
266
+ if (pth) pth.textContent = s && s.keyPath ? s.keyPath : pick('expected at');
267
+ if (tag) tag.textContent = s && s.configured ? dash(s.keyFingerprint) : 'none';
268
+ if (warn) {
269
+ const w = keyWarning(s);
270
+ warn.textContent = w;
271
+ warn.hidden = !w;
272
+ }
273
+ if (vt && s && s.vault) {
274
+ vt.textContent = `${dash(s.vault.remote)}:${dash(s.vault.recordsDir || s.vault.root)}`;
275
+ } else if (vt) {
276
+ vt.textContent = '–';
277
+ }
278
+ // A key that is present makes keygen a REPLACE, which orphans every
279
+ // record sealed with the old one. Saying that on the button is the whole
280
+ // warning; the confirm below is the second line of defence.
281
+ const kg = el(IDS.keygen);
282
+ if (kg) kg.textContent = s && s.configured ? 'Replace key…' : 'Mint key';
283
+ }
284
+
285
+ function paintVault(list) {
286
+ const ul = el(IDS.vaultList);
287
+ if (!ul) return;
288
+ ul.textContent = '';
289
+ for (const row of vaultRows(list)) {
290
+ const li = d.createElement('li');
291
+ li.className = 'fp-vault-row';
292
+ const name = d.createElement('span');
293
+ name.className = 'fp-vault-name';
294
+ name.textContent = row.name;
295
+ li.appendChild(name);
296
+ if (row.detail) {
297
+ const det = d.createElement('span');
298
+ det.className = 'fp-vault-detail';
299
+ det.textContent = row.detail;
300
+ li.appendChild(det);
301
+ }
302
+ ul.appendChild(li);
303
+ }
304
+ }
305
+
306
+ async function refresh() {
307
+ state.status = await call('fingerprintStatus');
308
+ paintStatus();
309
+ await loadAutoMint();
310
+ return state.status;
311
+ }
312
+
313
+ /**
314
+ * The auto-seal control (settings.js `__autoMint`, through lib/auto.js).
315
+ *
316
+ * Read-only paint from MAIN, for the reason the rest of this panel is: the
317
+ * renderer must never be the place that decides a record gets filed, and a
318
+ * checkbox state this file invented would disagree with the timer the moment
319
+ * anything else (the queue card's window, a restart) changed the setting.
320
+ */
321
+ function renderAutoMint(s) {
322
+ if (!s || typeof s !== 'object') return;
323
+ const sw = el(IDS.autoSeal);
324
+ if (sw) sw.checked = s.enabled === true;
325
+ const label = el(IDS.autoSealLabel);
326
+ if (label) label.textContent = s.enabled ? 'sealing automatically when quiet' : 'seal automatically when quiet';
327
+ const hint = el(IDS.autoHint);
328
+ if (!hint) return;
329
+ hint.textContent = autoMintLine(s);
330
+ }
331
+
332
+ async function loadAutoMint() {
333
+ if (!autoBridge || typeof autoBridge.status !== 'function') {
334
+ const hint = el(IDS.autoHint);
335
+ // Said plainly rather than left blank: an inert toggle with no
336
+ // explanation is the failure mode this whole panel exists to avoid.
337
+ if (hint) hint.textContent = 'this build has no automation bridge — sealing stays a button';
338
+ return null;
339
+ }
340
+ try {
341
+ const r = await autoBridge.status();
342
+ if (r && r.mint) renderAutoMint(r.mint);
343
+ return r;
344
+ } catch (e) {
345
+ const hint = el(IDS.autoHint);
346
+ if (hint) hint.textContent = `automation status unavailable: ${(e && e.message) || String(e)}`;
347
+ return null;
348
+ }
349
+ }
350
+
351
+ /** Persist the switch through main and paint main's answer. */
352
+ async function setAutoMint(enabled) {
353
+ if (!autoBridge || typeof autoBridge.setMint !== 'function') return null;
354
+ const publish = Boolean(el(IDS.publishOnMint) && el(IDS.publishOnMint).checked);
355
+ try {
356
+ const r = await autoBridge.setMint({ enabled: Boolean(enabled), publish });
357
+ if (r && r.mint) renderAutoMint(r.mint);
358
+ else if (r && r.ok === false) say(r.error || 'could not save that', true);
359
+ return r;
360
+ } catch (e) {
361
+ say(`could not save that: ${(e && e.message) || String(e)}`, true);
362
+ return null;
363
+ }
364
+ }
365
+
366
+ /** Seal right now, through the automation rather than the raw mint: the
367
+ * same call resets the pending-turn counter, so the quiet timer will not
368
+ * file a second identical record for this session. */
369
+ async function doSealNow() {
370
+ const publish = Boolean(el(IDS.publishOnMint) && el(IDS.publishOnMint).checked);
371
+ return action('seal now', async () => {
372
+ if (!autoBridge || typeof autoBridge.sealNow !== 'function') {
373
+ return { ok: false, error: 'this build has no automation bridge' };
374
+ }
375
+ return autoBridge.sealNow(publish);
376
+ }, {
377
+ paint: (r) => { if (r && r.ok && r.out) state.last = { file: r.out, record: r.record || null }; },
378
+ say: (r) => {
379
+ if (!r) return 'seal now: nothing happened';
380
+ if (r.ok === false) return `seal failed: ${r.error || 'unknown'}`;
381
+ if (r.type === 'skip') return `nothing to seal (${r.reason || 'no new turns'})`;
382
+ const line = sealLine(r);
383
+ return line.ok ? `sealed ${dash((r.record || {}).id)} → ${dash(r.out)}` : `seal failed: ${line.text}`;
384
+ },
385
+ });
386
+ }
387
+
388
+ /** Run one action, repaint what it changed, and say what it did. `confirm`
389
+ * is injectable so the harness can answer it deterministically. */
390
+ async function action(label, fn, opts) {
391
+ const o = opts || {};
392
+ if (state.busy) return null;
393
+ state.busy = true;
394
+ say(`${label}…`, false);
395
+ try {
396
+ const r = await fn();
397
+ if (o.paint) o.paint(r);
398
+ if (o.after) await o.after(r);
399
+ say(o.say ? o.say(r) : `${label}: ${r && r.ok === false ? r.error : 'done'}`, Boolean(r && r.ok === false));
400
+ return r;
401
+ } finally {
402
+ state.busy = false;
403
+ }
404
+ }
405
+
406
+ const doKeygen = () =>
407
+ action('mint key', () => call('fingerprintKeygen', {
408
+ force: Boolean(state.status && state.status.configured),
409
+ }), {
410
+ // Only after a successful mint: status moved from "no key" to a key,
411
+ // and repainting is what turns "Mint key" into "Replace key…".
412
+ after: refresh,
413
+ say: (r) => (r.ok
414
+ ? `minted ${dash(r.keyFingerprint)} at ${dash(r.path)}`
415
+ : `mint failed: ${r.error}`),
416
+ });
417
+
418
+ const doSeal = () =>
419
+ action('seal session', () => call('fingerprintMint', {
420
+ label: (el(IDS.label) && el(IDS.label).value.trim()) || null,
421
+ publish: Boolean(el(IDS.publishOnMint) && el(IDS.publishOnMint).checked),
422
+ }), {
423
+ paint: (r) => { if (r.ok) state.last = { file: r.out, record: r.record }; },
424
+ say: (r) => {
425
+ const line = sealLine(r);
426
+ return line.ok ? `sealed ${dash((r.record || {}).id)} → ${dash(r.out)}` : `seal failed: ${line.text}`;
427
+ },
428
+ });
429
+
430
+ /** Verify/publish/anchor act on the record this panel sealed. A path is
431
+ * preferred over the in-memory record because the sha256 in the anchor is
432
+ * then about the bytes on disk rather than a re-serialisation of them
433
+ * (desktop/lib/fingerprint.js publish makes the same argument) — so `file`
434
+ * wins when there is one, and the record is the fallback for a host where
435
+ * the write failed but the seal did not. */
436
+ const doVerify = () =>
437
+ action('verify', async () => {
438
+ if (state.last && state.last.file) return call('fingerprintVerify', { file: state.last.file });
439
+ if (state.last && state.last.record) return call('fingerprintVerify', { record: state.last.record });
440
+ return { ok: false, error: 'nothing sealed in this window yet — seal a session first' };
441
+ }, {
442
+ say: (r) => (r.ok ? `verified ${dash(r.id)} · key ${dash(r.keySource)}` : `not verified: ${r.error || r.reason}`),
443
+ });
444
+
445
+ const doPublish = () =>
446
+ action('publish', () => {
447
+ if (state.last && state.last.file) return call('fingerprintPublish', { file: state.last.file });
448
+ if (state.last && state.last.record) return call('fingerprintPublish', { record: state.last.record });
449
+ return { ok: false, error: 'nothing sealed in this window yet — seal a session first' };
450
+ }, {
451
+ say: (r) => (r.ok
452
+ ? `published ${dash(r.url || r.path)}${r.sha256 ? ` · sha256 ${String(r.sha256).slice(0, 12)}` : ''}`
453
+ : `publish failed: ${r.error}`),
454
+ });
455
+
456
+ const doAnchor = () =>
457
+ action('check anchor', () => {
458
+ if (state.last && state.last.file) return call('fingerprintAnchor', { file: state.last.file });
459
+ return call('fingerprintAnchor', {});
460
+ }, {
461
+ say: (r) => {
462
+ const v = anchorVerdict(r);
463
+ const claims = v.claims.map((c) => `${c.k} ${c.v === true ? 'yes' : c.v === false ? 'no' : 'unknown'}`).join(' · ');
464
+ return `${v.label}${claims ? ` — ${claims}` : ''}`;
465
+ },
466
+ });
467
+
468
+ const doBackup = () =>
469
+ action('back up key', () => {
470
+ const to = el(IDS.backupTo) && el(IDS.backupTo).value.trim();
471
+ if (!to) return { ok: false, error: 'backup needs a destination directory' };
472
+ return call('fingerprintBackup', { to });
473
+ }, {
474
+ say: (r) => (r.ok ? `key copied to ${dash(r.to || r.path)}` : `backup refused: ${r.error}`),
475
+ });
476
+
477
+ const doVault = () =>
478
+ action('list vault', () => call('fingerprintVault'), {
479
+ paint: paintVault,
480
+ say: (r) => (r.ok ? `vault listed — ${vaultRows(r).length} entry(ies)` : `vault unavailable: ${r.error}`),
481
+ });
482
+
483
+ const doAudit = () =>
484
+ action('audit vault', () => call('fingerprintVaultAudit'), {
485
+ say: (r) => {
486
+ const v = auditVerdict(r);
487
+ const names = v.findings.map((f) => (f && (f.path || f.name)) || String(f)).slice(0, 3);
488
+ return `${v.label}${names.length ? ` — ${names.join(', ')}` : ''}`;
489
+ },
490
+ });
491
+
492
+ const isOpen = () => {
493
+ const o = el(IDS.overlay);
494
+ return Boolean(o) && !o.hidden;
495
+ };
496
+
497
+ async function open() {
498
+ const o = el(IDS.overlay);
499
+ if (!o) return;
500
+ o.hidden = false;
501
+ // Body class so the page behind cannot scroll under the sheet, matching
502
+ // the memory inspector's `.memory-open` rule.
503
+ if (d.body && d.body.classList) d.body.classList.add('fp-open');
504
+ await refresh();
505
+ await doVault();
506
+ const f = el(IDS.close);
507
+ if (f) f.focus();
508
+ }
509
+
510
+ function close() {
511
+ const o = el(IDS.overlay);
512
+ if (!o) return;
513
+ o.hidden = true;
514
+ if (d.body && d.body.classList) d.body.classList.remove('fp-open');
515
+ const btn = el(IDS.cardOpen);
516
+ if (btn) btn.focus();
517
+ }
518
+
519
+ // ------------------------------------------------------------- listeners
520
+ // Bound here rather than in app.js so the panel owns its own markup: every
521
+ // id in IDS is read by this file only, and the test asserts they all exist.
522
+ const bind = (id, type, fn) => {
523
+ const node = el(id);
524
+ if (node) node.addEventListener(type, fn);
525
+ };
526
+
527
+ bind(IDS.cardOpen, 'click', open);
528
+ bind(IDS.close, 'click', close);
529
+ bind(IDS.backdrop, 'click', close);
530
+ bind(IDS.keygen, 'click', doKeygen);
531
+ bind(IDS.seal, 'click', doSeal);
532
+ // The sidebar's one-click seal runs the same action; on success it also
533
+ // opens the panel, because a record filed silently behind a closed sheet is
534
+ // indistinguishable from a button that did nothing.
535
+ bind(IDS.sealCard, 'click', async () => {
536
+ const r = await doSeal();
537
+ if (r && r.ok) await open();
538
+ });
539
+ bind(IDS.verify, 'click', doVerify);
540
+ bind(IDS.publish, 'click', doPublish);
541
+ bind(IDS.anchor, 'click', doAnchor);
542
+ bind(IDS.backup, 'click', doBackup);
543
+ bind(IDS.vault, 'click', doVault);
544
+ bind(IDS.audit, 'click', doAudit);
545
+ // The auto-seal switch is the ONE control on this panel that can file a
546
+ // record with nobody watching, so it is the one that must repaint from
547
+ // main's answer rather than from the checkbox the user just clicked.
548
+ if (el(IDS.autoSeal)) {
549
+ bind(IDS.autoSeal, 'change', () => {
550
+ const box = el(IDS.autoSeal);
551
+ return setAutoMint(Boolean(box && box.checked));
552
+ });
553
+ }
554
+ if (el(IDS.sealNow)) bind(IDS.sealNow, 'click', doSealNow);
555
+
556
+ return {
557
+ open,
558
+ close,
559
+ isOpen,
560
+ refresh,
561
+ loadAutoMint,
562
+ state,
563
+ // Exposed for the test and for app.js's on-demand repaint. Every one is
564
+ // idempotent, so calling them from a console is safe.
565
+ actions: { doKeygen, doSeal, doSealNow, doVerify, doPublish, doAnchor, doBackup, doVault, doAudit },
566
+ };
567
+ }
568
+
569
+ return {
570
+ IDS,
571
+ mount,
572
+ // Pure, and asserted as such by the test.
573
+ keyLine,
574
+ statusRows,
575
+ keyWarning,
576
+ vaultRows,
577
+ anchorVerdict,
578
+ auditVerdict,
579
+ sealLine,
580
+ autoMintLine,
581
+ dash,
582
+ };
583
+ });