sealkeep 0.7.2 → 0.8.0

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
3
3
  Notable changes, by published version. Sealkeep is pre-1.0: minor versions
4
4
  may change behavior, and say so here when they do.
5
5
 
6
+ ## 0.8.0 — 2026-08-20
7
+
8
+ Fixes from a review of the screens that had never had one, and a test suite
9
+ that no longer needs a spacious disk.
10
+
11
+ - **The setup wizard could finish "use my own bucket" without a vendor.** An
12
+ unrecognised provider fell back to *local*, so the guard could not fire: a
13
+ credentials form with no fields validated, setup completed, and the vault
14
+ stayed local-only. The one task the wizard exists for, silently not done.
15
+ - Every "24 words" on those screens — including the checkbox you tick to
16
+ finish — is derived from the phrase now. That copy once said seventeen.
17
+ - Doctor's warnings were drawn like passes, four checks rendered as raw slugs,
18
+ a failed queue job used a stylesheet class that did not exist, five notes
19
+ used an undefined token, and the retention view typed over edits in progress
20
+ and could then save the policy you thought you had changed.
21
+ - `archiveFile` accepts a chunk size (and honours `SEALKEEP_CHUNK_BYTES`).
22
+ Proving a multi-chunk archive used to mean writing 24 MB; it now takes 120 KB,
23
+ which is why the suite stopped exhausting disks.
24
+ - New: `npm run browser:audit` and `npm run browser:panel` — real Chrome,
25
+ accessibility and layout at two widths, eight signed-in panel scenarios.
26
+
6
27
  ## 0.7.2 — 2026-08-20
7
28
 
8
29
  - Sharing is findable: the vault view offers **Share…** beside Restore. The
@@ -105,7 +105,9 @@
105
105
  .primary:disabled{opacity:.45;cursor:default}
106
106
  .ghost{background:none;border:1px solid var(--rule);color:var(--ink);padding:.62rem 1.1rem;font-size:.88rem}
107
107
  .ghost:hover{border-color:var(--ink)}
108
- .fine{font-family:var(--mono);font-size:.76rem;color:var(--soft);margin-top:.9rem}
108
+ /* A caption is prose even when it names versions and carries a command; the
109
+ command inside it is already a <code> and keeps the machine voice. */
110
+ .fine{font-family:var(--sans);font-size:.78rem;color:var(--soft);margin-top:.9rem}
109
111
 
110
112
  /* The machine side of the hero: one session, read out and then sealed. */
111
113
  .side{font-family:var(--mono);font-size:.7rem;color:var(--soft);margin:0 0 .7rem;letter-spacing:.02em}
@@ -10,6 +10,7 @@ import { acquireSpoolLock, createSpool, findSpoolForSource, shredSpool, unwrapSp
10
10
  import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrapAll, zeroize } from "../packages/vaultline-crypto/src/index.js";
11
11
  import { frameObject } from "./cloud.js";
12
12
  import { aeadCipher, aeadDecipher } from "../packages/vaultline-crypto/src/aead.js";
13
+ import { envVar } from "./env.js";
13
14
  const SEAL_SUITE = "chacha20-poly1305";
14
15
  const sha256hex = (input) => createHash("sha256").update(input).digest("hex");
15
16
  export const chunkObjectName = (index) => `chunk-${String(index).padStart(6, "0")}`;
@@ -132,7 +133,10 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
132
133
  fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
133
134
  const phrase = canonicalPhrase(rawPhrase);
134
135
  const totalBytes = source.size;
135
- const chunkBytes = DEFAULT_CHUNK_BYTES;
136
+ // Same override the local seal path honours, for the same reason: proving the
137
+ // streamed layout works should not cost tens of megabytes of disk per run.
138
+ const chunkOverride = Number(envVar("CHUNK_BYTES"));
139
+ const chunkBytes = Number.isInteger(chunkOverride) && chunkOverride > 0 ? chunkOverride : DEFAULT_CHUNK_BYTES;
136
140
  const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
137
141
  // Journal triage, chunk-layout edition. Same drain-the-stack shape as the
138
142
  // streaming path: resume the newest matching journal or abandon it with the
@@ -159,7 +163,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
159
163
  }
160
164
  const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
161
165
  try {
162
- return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, now: options.now }, journal);
166
+ return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, now: options.now }, journal);
163
167
  }
164
168
  catch (error) {
165
169
  if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
@@ -190,7 +194,8 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
190
194
  configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
191
195
  naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
192
196
  });
193
- const recipients = configuredRecipients(config, phrase);
197
+ // Members of this project are wrapped in here, and nowhere else.
198
+ const recipients = configuredRecipients(config, phrase, { project: options.project ?? undefined });
194
199
  const archiveKey = randomBytes(KEY_BYTES);
195
200
  const noncePrefix = randomBytes(4);
196
201
  const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
@@ -206,7 +211,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
206
211
  hashState: { plaintext: "", stored: "" },
207
212
  providerState: { kind: "chunk-folder", folder, uploaded: 0 }
208
213
  }, { now: options.now });
209
- return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, now: options.now }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
214
+ return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, now: options.now }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
210
215
  }
211
216
  finally {
212
217
  zeroize(archiveKey);
@@ -234,7 +239,7 @@ async function abandonFolderJournal(dataDir, client, journal, reason) {
234
239
  */
235
240
  async function runChunkSeal(ctx, args) {
236
241
  const config = await readConfig(ctx.dataDir);
237
- const recipients = configuredRecipients(config, ctx.phrase);
242
+ const recipients = configuredRecipients(config, ctx.phrase, { project: ctx.project ?? undefined });
238
243
  const { tokenize, indexableToken, tokenCapFor } = await import("./search.js");
239
244
  const perChunkTokenCap = Math.max(4000, Math.floor(tokenCapFor(ctx.totalBytes) / 4));
240
245
  const hashers = { plaintext: new StreamingSha256(), stored: new StreamingSha256() };
@@ -171,7 +171,10 @@ export type VaultConfig = {
171
171
  label: string;
172
172
  publicKey: string;
173
173
  addedAt: string;
174
+ /** A rewrap filter — `sealkeep rewrap --group` applies one group's set to existing archives. */
174
175
  group?: string;
176
+ /** Sharing scope: set, this key is a member of one project and is wrapped into that project's archives only. */
177
+ project?: string;
175
178
  }[];
176
179
  /** `deleteAfterDays` is optional so vaults written before archive lifecycle existed still load; absent means never. */
177
180
  retention?: {
@@ -103,12 +103,16 @@ export type ArchiveResult = ArchiveRecord & {
103
103
  export declare function archiveFile(dataDir: string, sourcePath: string, rawPhrase: string, agent?: string, hooks?: {
104
104
  onProgress?: (bytesRead: number) => void;
105
105
  delta?: boolean;
106
+ chunkBytes?: number;
106
107
  }): Promise<ArchiveResult>;
107
108
  /**
108
109
  * The recovery phrase always gets a recipient, so a recovery kit alone can restore.
109
110
  * Registered device and backup keys are added alongside it.
110
111
  */
111
- export declare function configuredRecipients(config: VaultConfig, rawPhrase: string, group?: string): Recipient[];
112
+ export declare function configuredRecipients(config: VaultConfig, rawPhrase: string, scope?: string | {
113
+ group?: string;
114
+ project?: string;
115
+ }): Recipient[];
112
116
  export declare function listArchives(dataDir: string): Promise<ArchiveRecord[]>;
113
117
  /**
114
118
  * Registers an X25519 public key that may open future archives. Only the public
package/dist/src/vault.js CHANGED
@@ -240,6 +240,13 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
240
240
  const config = await readConfig(dataDir);
241
241
  if (!equalHex(config.recovery.phraseCheck, phraseCheck(phrase)))
242
242
  fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
243
+ // The crypto has always taken a chunk size and nothing above it could set
244
+ // one, so exercising a multi-chunk archive meant writing tens of megabytes.
245
+ // A caller — or SEALKEEP_CHUNK_BYTES — can span chunks with kilobytes now.
246
+ const chunkBytes = hooks.chunkBytes ?? (() => {
247
+ const override = Number(envVar("CHUNK_BYTES"));
248
+ return Number.isInteger(override) && override > 0 ? override : undefined;
249
+ })();
243
250
  const absolute = resolve(sourcePath);
244
251
  const source = await stat(absolute).catch(() => null);
245
252
  if (!source?.isFile())
@@ -316,6 +323,7 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
316
323
  // whole-body layout stay readable forever; this changes what is WRITTEN.
317
324
  recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
318
325
  adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
326
+ ...(chunkBytes ? { chunkBytes } : {}),
319
327
  ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
320
328
  }).catch((error) => {
321
329
  const code = error.code;
@@ -407,10 +415,22 @@ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size,
407
415
  * The recovery phrase always gets a recipient, so a recovery kit alone can restore.
408
416
  * Registered device and backup keys are added alongside it.
409
417
  */
410
- export function configuredRecipients(config, rawPhrase, group) {
418
+ export function configuredRecipients(config, rawPhrase, scope) {
411
419
  const phrase = canonicalPhrase(rawPhrase);
420
+ // Two different questions, and conflating them broke one of them.
421
+ //
422
+ // `group` is a REWRAP FILTER: "apply the current recipient set for the
423
+ // engineering group to existing archives", which is how a contractor's
424
+ // access ends. Everyone gets new archives regardless of group — that is the
425
+ // shipped, tested behaviour and it stays.
426
+ //
427
+ // `project` is SHARING SCOPE: a member added to one project must be wrapped
428
+ // into that project's archives and no others, or sharing a project would
429
+ // hand over the whole vault.
430
+ const asked = typeof scope === "string" ? { group: scope } : (scope ?? {});
412
431
  const keys = (config.recipients ?? [])
413
- .filter((recipient) => !group || recipient.group === group)
432
+ .filter((recipient) => (asked.group ? recipient.group === asked.group : true))
433
+ .filter((recipient) => !recipient.project || recipient.project === asked.project)
414
434
  .map((recipient) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(recipient.publicKey, "base64")) }));
415
435
  return [{ kind: "phrase", phrase }, ...keys];
416
436
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -32,7 +32,7 @@
32
32
  "typecheck": "tsc -p tsconfig.check.json",
33
33
  "prepack": "npm run build",
34
34
  "release:manifest": "node scripts/release.mjs",
35
- "test": "node --import tsx --test \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
35
+ "test": "node --import tsx --test --test-concurrency=4 \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
36
36
  "vaultline": "tsx src/cli.ts",
37
37
  "mcp": "tsx src/mcp.ts",
38
38
  "api": "tsx src/cli.ts api",
@@ -42,7 +42,9 @@
42
42
  "bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
43
43
  "acceptance": "node scripts/acceptance.mjs",
44
44
  "acceptance:managed": "node scripts/acceptance.mjs --managed",
45
- "tokens": "node scripts/sync-tokens.mjs"
45
+ "tokens": "node scripts/sync-tokens.mjs",
46
+ "browser:audit": "node scripts/browser/audit.mjs",
47
+ "browser:panel": "node scripts/browser/panel.mjs"
46
48
  },
47
49
  "devDependencies": {
48
50
  "@types/node": "^22.10.2",
package/web/app.js CHANGED
@@ -140,7 +140,15 @@ const CHECK_LABELS = {
140
140
  "remote-storage": "Storage",
141
141
  "sealed-key-copy": "Sealed key backup",
142
142
  signer: "Uploads",
143
- agents: "Agents"
143
+ agents: "Agents",
144
+ // Doctor emits these four too, and without a label each rendered as its raw
145
+ // slug — the exact "debug output in front of a customer" this map exists to
146
+ // prevent: one names the standing warning that archives are stored
147
+ // unencrypted, another says a routing rule matches nothing.
148
+ "storage-mode": "How archives are stored",
149
+ "resumable-history": "Resume protection",
150
+ "search-index": "Search index",
151
+ "routing-pins": "Routing rules"
144
152
  };
145
153
 
146
154
  /** Same names the retention page uses, so the two never disagree. */
@@ -368,7 +376,7 @@ function renderQueue(queue, retention) {
368
376
  state.innerHTML = `<span class="queued">sealing…${pct !== null ? ` ${pct}%` : ""}</span>`;
369
377
  if (pct !== null) state.title = `${bytes(job.progress.bytes)} of ${bytes(job.progress.of)} read`;
370
378
  } else if (job.status === "failed") {
371
- state.innerHTML = '<span class="bad">failed</span>';
379
+ state.replaceChildren(Object.assign(document.createElement("span"), { className: "bad", textContent: "failed" }));
372
380
  if (job.lastError) state.title = job.lastError.message ?? String(job.lastError);
373
381
  } else if (job.lastError) {
374
382
  const span = document.createElement("span");
package/web/index.html CHANGED
@@ -378,13 +378,13 @@
378
378
  </div>
379
379
  <div class="rowgrid" style="margin-top:.75rem">
380
380
  <div><label class="f" for="deleteAfter">Delete the archive from storage after (days)</label><input type="number" id="deleteAfter" min="0" placeholder="never"></div>
381
- <div><label class="f">&nbsp;</label><span style="font-size:.85rem;color:var(--dim)">Leave empty for never.</span></div>
381
+ <div><label class="f">&nbsp;</label><span style="font-size:.85rem;color:var(--soft)">Leave empty for never.</span></div>
382
382
  </div>
383
383
  </fieldset>
384
384
 
385
385
  <fieldset>
386
386
  <legend>Which sessions</legend>
387
- <p style="margin:0 0 .9rem;font-size:.9rem;color:var(--dim)">
387
+ <p style="margin:0 0 .9rem;font-size:.9rem;color:var(--soft)">
388
388
  The timings above measure the <em>archive</em> — how long Sealkeep has held a copy. These two measure the
389
389
  <em>session</em>: when you last touched it, and whether it is big enough to be worth the trouble.
390
390
  </p>
@@ -392,12 +392,12 @@
392
392
  <div>
393
393
  <label class="f" for="idle">Only if untouched for at least (days)</label>
394
394
  <input type="number" id="idle" min="0" placeholder="any">
395
- <span style="font-size:.85rem;color:var(--dim)">Empty means a session's own age is not considered.</span>
395
+ <span style="font-size:.85rem;color:var(--soft)">Empty means a session's own age is not considered.</span>
396
396
  </div>
397
397
  <div>
398
398
  <label class="f" for="minSize">Only if larger than (MB)</label>
399
399
  <input type="number" id="minSize" min="0" placeholder="any">
400
- <span style="font-size:.85rem;color:var(--dim)">Most sessions are tiny; the space is in a few huge ones.</span>
400
+ <span style="font-size:.85rem;color:var(--soft)">Most sessions are tiny; the space is in a few huge ones.</span>
401
401
  </div>
402
402
  </div>
403
403
  </fieldset>
@@ -407,7 +407,7 @@
407
407
  <p id="effect" style="margin:0 0 .9rem">Reading the current rules…</p>
408
408
  <div style="display:flex;gap:.6rem;align-items:center;flex-wrap:wrap">
409
409
  <button type="button" class="action primary" id="save">Save these rules</button>
410
- <span id="saved" style="font-size:.85rem;color:var(--dim)"></span>
410
+ <span id="saved" style="font-size:.85rem;color:var(--soft)"></span>
411
411
  </div>
412
412
  <p style="margin:1.1rem 0 .35rem"><span class="f">or, if you prefer a terminal</span></p>
413
413
  <pre id="out">…</pre>
package/web/rules-view.js CHANGED
@@ -54,13 +54,24 @@
54
54
  }
55
55
 
56
56
  function fill(evaluation) {
57
+ // Never type over someone's hands. A refresh landing while a retention
58
+ // number is being edited used to revert it silently, and the next click on
59
+ // "Save these rules" then saved the old policy — the one that decides when
60
+ // original session files may be moved to the trash. loadCopies already
61
+ // guards its one field this way; every field here needs the same.
62
+ const set = (id, value) => {
63
+ const node = $(id);
64
+ if (!node || node === document.activeElement) return;
65
+ node.value = value;
66
+ };
57
67
  const chosen = document.querySelector(`input[name=policy][value="${evaluation.policy}"]`);
58
- if (chosen) chosen.checked = true;
59
- $("older").value = evaluation.olderThanDays;
60
- $("grace").value = evaluation.graceDays;
61
- $("idle").value = evaluation.sourceIdleDays === null ? "" : evaluation.sourceIdleDays;
62
- $("minSize").value = evaluation.minSourceBytes === null ? "" : Math.round(evaluation.minSourceBytes / MB);
63
- $("deleteAfter").value = evaluation.deleteAfterDays == null ? "" : evaluation.deleteAfterDays;
68
+ const editingPolicy = document.activeElement?.name === "policy";
69
+ if (chosen && !editingPolicy) chosen.checked = true;
70
+ set("older", evaluation.olderThanDays);
71
+ set("grace", evaluation.graceDays);
72
+ set("idle", evaluation.sourceIdleDays === null ? "" : evaluation.sourceIdleDays);
73
+ set("minSize", evaluation.minSourceBytes === null ? "" : Math.round(evaluation.minSourceBytes / MB));
74
+ set("deleteAfter", evaluation.deleteAfterDays == null ? "" : evaluation.deleteAfterDays);
64
75
  render();
65
76
  showEffect(evaluation);
66
77
  }
@@ -330,7 +330,10 @@
330
330
  const sealing = queued.length;
331
331
  if (sealing > 0) {
332
332
  $("result").textContent = waiting === sealing
333
- ? `${waiting} waiting for disk room — ${queued.find((row) => row.held).held} Free some space and they seal on their own.`
333
+ // The guard's own sentence ends without punctuation, so this used to run
334
+ // straight into the instruction: "...rather than filling the disk Free
335
+ // some space and they seal on their own."
336
+ ? `${waiting} waiting for disk room — ${String(queued.find((row) => row.held).held).replace(/[.\s]+$/, "")}. Free some space and they seal on their own.`
334
337
  : `Sealing in the background — ${sealing - waiting} working${waiting ? `, ${waiting} waiting for room` : ""}. You can close or refresh this page; it carries on without it.`;
335
338
  } else {
336
339
  stopPolling();
@@ -93,7 +93,18 @@ export const PROVIDERS = [
93
93
  }
94
94
  ];
95
95
 
96
- export const provider = (id) => PROVIDERS.find((entry) => entry.id === id) ?? PROVIDERS[0];
96
+ /**
97
+ * The provider with this id, or undefined when nothing matches.
98
+ *
99
+ * This used to fall back to PROVIDERS[0] — the LOCAL entry — for any unknown
100
+ * id, including the empty string someone has before they pick a vendor. That
101
+ * made the wizard's own guard (`intent === "own" && !provider(id)`) unable to
102
+ * fire: choosing "use my own bucket" and picking nothing showed a credentials
103
+ * form with no credential fields, validated, and finished. The person believed
104
+ * their bucket was configured while the vault stayed local-only — the exact
105
+ * task they opened the wizard to do, silently not done.
106
+ */
107
+ export const provider = (id) => PROVIDERS.find((entry) => entry.id === id);
97
108
  /**
98
109
  * The three answers to "where do archives go", before any provider is named.
99
110
  *
@@ -180,9 +191,9 @@ export function validateStorage(providerId, values) {
180
191
 
181
192
  if (!normalisePrefix(values.prefix)) errors.prefix = "Give the archives a folder inside the bucket. Sealkeep is a fine answer.";
182
193
 
183
- for (const field of provider(providerId).fields) {
194
+ for (const field of (provider(providerId)?.fields ?? [])) {
184
195
  const value = String(values[field.name] ?? "").trim();
185
- if (!value) { errors[field.name] = `${field.label} is required for ${provider(providerId).label}.`; continue; }
196
+ if (!value) { errors[field.name] = `${field.label} is required for ${provider(providerId)?.label ?? "this provider"}.`; continue; }
186
197
  if (field.name === "serviceAccountJson" && !parseServiceAccount(value)) {
187
198
  errors[field.name] = "That is not a service account key. Paste the whole JSON file, including the braces and the private_key line.";
188
199
  }
package/web/setup.html CHANGED
@@ -267,12 +267,12 @@
267
267
  <p>It unlocks with your password on this machine. If you ever lose the password, the phrase below is the only other way in — which is a good reason to spend a minute on it now.</p>
268
268
  </div>
269
269
  <details class="panel pad explain" id="phrase-fold">
270
- <summary>Show the 24 words that survive this machine</summary>
270
+ <summary id="fold-summary">Show the words that survive this machine</summary>
271
271
  <p id="fold-note"></p>
272
272
  <ol class="phrase" id="fold-phrase-words"></ol>
273
273
  </details>
274
274
  <div class="wizfoot">
275
- <p class="note warn"><b>Your password opens this vault on this machine only.</b> It is kept in this machine's keystore — it does not travel. If this machine is lost or wiped, the 24 words above are the only thing that opens the archives, and nobody, including us, can recreate them. Take them now, or accept that this vault dies with this machine.</p>
275
+ <p class="note warn"><b>Your password opens this vault on this machine only.</b> It is kept in this machine's keystore — it does not travel. If this machine is lost or wiped, the words above are the only thing that opens the archives, and nobody, including us, can recreate them. Take them now, or accept that this vault dies with this machine.</p>
276
276
  <div class="actions"><button type="button" class="action primary" id="finish-password">Finish setup</button></div>
277
277
  </div>
278
278
  </div>
@@ -281,7 +281,7 @@
281
281
  arrive before the secret does. -->
282
282
  <div id="phrase-before" hidden>
283
283
  <div class="panel pad callout warn">
284
- <p>Sealkeep is about to generate <b>24 words</b>. Those words are the key to every archive it will ever make here.</p>
284
+ <p>Sealkeep is about to generate <b id="ceremony-count">its recovery phrase</b>. Those words are the key to every archive it will ever make here.</p>
285
285
  <ul class="plain">
286
286
  <li><b>They are shown once, on this page, and never again.</b> No command, no endpoint and no request to us can produce them a second time.</li>
287
287
  <li><b>Close this page before writing them down and they are gone.</b> The vault would have to be created again from nothing.</li>
@@ -321,7 +321,7 @@
321
321
  <p class="note error" id="verify-error" hidden></p>
322
322
  <label class="opt" for="f-understood">
323
323
  <input type="checkbox" id="f-understood">
324
- <span>I have written down all 24 words, in order. I understand this page is the only place they will ever appear.</span>
324
+ <span id="wrote-them-down">I have written them all down, in order. I understand this page is the only place they will ever appear.</span>
325
325
  </label>
326
326
  <div class="wizfoot">
327
327
  <p class="note" id="confirm-note">Continue unlocks once both of those are true.</p>
package/web/setup.js CHANGED
@@ -210,6 +210,7 @@ function renderProviderFields() {
210
210
  if (form.hidden) return;
211
211
 
212
212
  const entry = provider(draft.providerId);
213
+ if (!entry) return;
213
214
  $("storage-form-title").textContent = `${entry.label} — bucket and credentials`;
214
215
 
215
216
  const holder = $("provider-fields");
@@ -243,7 +244,7 @@ function collectStorage() {
243
244
  draft.storage.prefix = $("f-prefix").value;
244
245
  const { ok, errors } = validateStorage(draft.providerId, draft.storage);
245
246
 
246
- for (const field of ["bucket", "prefix", ...provider(draft.providerId).fields.map((entry) => entry.name)]) {
247
+ for (const field of ["bucket", "prefix", ...(provider(draft.providerId)?.fields ?? []).map((entry) => entry.name)]) {
247
248
  const node = $(`e-${field}`);
248
249
  if (!node) continue;
249
250
  node.textContent = errors[field] ?? "";
@@ -376,6 +377,13 @@ function renderPhrase(words) {
376
377
  // moment they had been told there is no reset and no support ticket. A number
377
378
  // that can disagree with the thing it describes should not be typed twice.
378
379
  $("phrase-lede").textContent = `These ${words.length} words are the only key to everything Sealkeep will seal here. Write them down now — this page will not show them twice.`;
380
+ // Every count on these screens is derived, never typed. The wizard once said
381
+ // "17 words" for a day after the phrase became 24, telling people to write
382
+ // down the wrong number for an artefact with no reset and no support ticket.
383
+ const say = (id, text) => { const node = $(id); if (node) node.textContent = text; };
384
+ say("ceremony-count", `${words.length} words`);
385
+ say("wrote-them-down", `I have written down all ${words.length} words, in order. I understand this page is the only place they will ever appear.`);
386
+ say("fold-summary", `Show the ${words.length} words that survive this machine`);
379
387
  armUnloadGuard();
380
388
  paintRail();
381
389
  }
package/web/style.css CHANGED
@@ -339,9 +339,19 @@ td.muted { color: var(--text-3); }
339
339
  .check strong { font: 600 12.5px var(--mono); }
340
340
  .check span { color: var(--text-2); }
341
341
  .check-pass { border-left-color: var(--verified); }
342
- .check-warn { border-left-color: var(--held); }
342
+ /* A warn is where inaction loses data — "if this machine is lost, the archives
343
+ on it cannot be opened by anyone, including us". It was drawn in --held,
344
+ which aliases to the same neutral grey a passing check gets, so the row most
345
+ needing attention looked like nothing was wrong. --warn is exactly what the
346
+ design system reserves for this. */
347
+ .check-warn { border-left-color: var(--warn); background: var(--warn-bg); }
343
348
  .check-fail { border-left-color: var(--alert); background: #fdf4f2; }
344
349
 
350
+ /* A failed job is the row in the queue that needs a person. It was rendered
351
+ with a class no rule matched, so it read as ordinary text beside states that
352
+ were coloured. */
353
+ .bad { color: var(--warn); font-weight: 600; }
354
+
345
355
  /* ── Agents ───────────────────────────────────────────── */
346
356
 
347
357
  .agent {