sealkeep 0.7.2 → 0.8.1

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,36 @@
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.1 — 2026-08-20
7
+
8
+ - **`sealkeep recover --key`** — open an archive with a registered recipient's
9
+ private key, no recovery phrase involved. This is how an organisation reads
10
+ what someone who has left sealed: register the company key once
11
+ (`sealkeep recipients add --public-key …`), and every archive sealed after
12
+ that is wrapped for it. Existing archives are brought in with
13
+ `sealkeep rewrap`. A key that was never a recipient opens nothing.
14
+
15
+ ## 0.8.0 — 2026-08-20
16
+
17
+ Fixes from a review of the screens that had never had one, and a test suite
18
+ that no longer needs a spacious disk.
19
+
20
+ - **The setup wizard could finish "use my own bucket" without a vendor.** An
21
+ unrecognised provider fell back to *local*, so the guard could not fire: a
22
+ credentials form with no fields validated, setup completed, and the vault
23
+ stayed local-only. The one task the wizard exists for, silently not done.
24
+ - Every "24 words" on those screens — including the checkbox you tick to
25
+ finish — is derived from the phrase now. That copy once said seventeen.
26
+ - Doctor's warnings were drawn like passes, four checks rendered as raw slugs,
27
+ a failed queue job used a stylesheet class that did not exist, five notes
28
+ used an undefined token, and the retention view typed over edits in progress
29
+ and could then save the policy you thought you had changed.
30
+ - `archiveFile` accepts a chunk size (and honours `SEALKEEP_CHUNK_BYTES`).
31
+ Proving a multi-chunk archive used to mean writing 24 MB; it now takes 120 KB,
32
+ which is why the suite stopped exhausting disks.
33
+ - New: `npm run browser:audit` and `npm run browser:panel` — real Chrome,
34
+ accessibility and layout at two widths, eight signed-in panel scenarios.
35
+
6
36
  ## 0.7.2 — 2026-08-20
7
37
 
8
38
  - 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() };
package/dist/src/cli.js CHANGED
@@ -207,7 +207,7 @@ function usage() {
207
207
  ["index status", "what is searchable and what is not yet indexed"],
208
208
  ["storage targets", "several storages at once, with limits, pins, and priority"],
209
209
  ["mcp install", "let your agents search this history themselves"],
210
- ["recover <id> <dest>", "restore original bytes; --native puts it back where it came from"],
210
+ ["recover <id> <dest>", "restore original bytes; --native puts it back where it came from, --key opens it as a registered recipient"],
211
211
  ["recover <session-file>", "agent says a session file is missing? name it and it goes back where resume expects it"],
212
212
  ["tui", "the same view in the terminal"]
213
213
  ]),
@@ -1117,9 +1117,21 @@ async function main() {
1117
1117
  if (!json)
1118
1118
  print(` ${dim(`Matched ${newest.source.path} → archive ${newest.id}${native ? " · restoring to its original location" : ""}`)}`);
1119
1119
  }
1120
- const outcome = await restoreArchive(dataDir, id, required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required"), {
1120
+ // A recipient key is the other way in: an archive wrapped for a registered
1121
+ // key opens with that key and no phrase, which is how an organisation reads
1122
+ // what a departed colleague sealed. Accepts the base64 the keygen printed,
1123
+ // or a path to a file holding it.
1124
+ const keyArg = take(args, "--key");
1125
+ const privateKey = keyArg
1126
+ ? (await readFile(keyArg, "utf8").then((text) => text.trim()).catch(() => keyArg.trim()))
1127
+ : undefined;
1128
+ const secret = privateKey
1129
+ ? ""
1130
+ : required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or pass --key with a recipient's private key)");
1131
+ const outcome = await restoreArchive(dataDir, id, secret, {
1121
1132
  destination: native ? undefined : required(destination, "Destination is required unless --native is used"),
1122
- native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse")
1133
+ native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse"),
1134
+ ...(privateKey ? { privateKey } : {})
1123
1135
  });
1124
1136
  if (json) {
1125
1137
  print(JSON.stringify({ id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath }, null, 2));
@@ -31,4 +31,6 @@ export declare function nativeRestoreTarget(record: ArchiveRecord, home?: string
31
31
  * is refused by default; `backup` preserves it beside the restored copy and
32
32
  * `replace` is the only policy that discards it.
33
33
  */
34
- export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions): Promise<RestoreOutcome>;
34
+ export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions & {
35
+ privateKey?: string;
36
+ }): Promise<RestoreOutcome>;
@@ -42,6 +42,10 @@ export function nativeRestoreTarget(record, home = homedir()) {
42
42
  * `replace` is the only policy that discards it.
43
43
  */
44
44
  export async function restoreArchive(dataDir, id, phrase, options = {}) {
45
+ // A recipient key opens an archive it was wrapped for, without the phrase.
46
+ // That is how an organisation reads what someone who has left sealed, and
47
+ // how a project member reads a project they were added to.
48
+ const key = options.privateKey;
45
49
  const record = await findArchive(dataDir, id);
46
50
  const native = options.native === true;
47
51
  if (native && options.destination)
@@ -58,13 +62,16 @@ export async function restoreArchive(dataDir, id, phrase, options = {}) {
58
62
  // decrypts: a wrong phrase must not leave a directory or a partial file
59
63
  // behind. The config decides what "proved" means — unwrapping for a sealed
60
64
  // vault, the identity check for a plain one.
61
- assertPhraseOpens(await readConfig(dataDir), record, phrase);
65
+ // The phrase check proves the PHRASE opens this vault; a key holder has no
66
+ // phrase and the wrap itself is the proof, so the unwrap below is the gate.
67
+ if (!key)
68
+ assertPhraseOpens(await readConfig(dataDir), record, phrase);
62
69
  await mkdir(dirname(output), { recursive: true });
63
70
  const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
64
71
  let backupPath;
65
72
  let bytes;
66
73
  try {
67
- ({ bytes } = await restoreRecordToFile(dataDir, record, phrase, temp));
74
+ ({ bytes } = await restoreRecordToFile(dataDir, record, key ? { privateKey: key } : phrase, temp));
68
75
  if (existing && policy === "backup") {
69
76
  backupPath = `${output}.vaultline-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
70
77
  await rename(output, backupPath);
@@ -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
@@ -160,7 +164,17 @@ export declare function assertPhraseOpens(config: VaultConfig, record: ArchiveRe
160
164
  * rejection can leave bytes behind. `restoreArchive` renames a temporary into
161
165
  * place for exactly this reason.
162
166
  */
163
- export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord, rawPhrase: string, destination: string,
167
+ export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord,
168
+ /**
169
+ * The phrase, or a recipient's private key. A key is how an organisation
170
+ * opens what an employee sealed: the archive was wrapped for that key when
171
+ * it was sealed (or by a later rewrap), and the phrase never has to be
172
+ * shared for it to work. Until this existed, a registered recipient could be
173
+ * wrapped in and still had no way to decrypt anything.
174
+ */
175
+ rawPhrase: string | {
176
+ privateKey: string;
177
+ }, destination: string,
164
178
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
165
179
  options?: {
166
180
  client?: import("./offload.js").FetchClient;
package/dist/src/vault.js CHANGED
@@ -6,7 +6,7 @@ import { createGzip, createGunzip, gunzipSync } from "node:zlib";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { decryptLegacyArchive, equalHex, isLegacyPhraseCheck, phraseCheck, sha256, upgradeLegacyPhraseCheck } from "./crypto.js";
8
8
  import { canonicalPhrase, generateRecoveryPhrase } from "./mnemonic.js";
9
- import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, zeroize } from "../packages/vaultline-crypto/src/index.js";
9
+ import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, x25519PrivateKeyFromRaw, zeroize } from "../packages/vaultline-crypto/src/index.js";
10
10
  import { fail, VaultlineError } from "./errors.js";
11
11
  import { recordAudit } from "./audit.js";
12
12
  import { isV2 } from "./types.js";
@@ -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
  }
@@ -536,16 +556,38 @@ function openFailure(archiveId, error) {
536
556
  * rejection can leave bytes behind. `restoreArchive` renames a temporary into
537
557
  * place for exactly this reason.
538
558
  */
539
- export async function restoreRecordToFile(dataDir, record, rawPhrase, destination,
559
+ export async function restoreRecordToFile(dataDir, record,
560
+ /**
561
+ * The phrase, or a recipient's private key. A key is how an organisation
562
+ * opens what an employee sealed: the archive was wrapped for that key when
563
+ * it was sealed (or by a later rewrap), and the phrase never has to be
564
+ * shared for it to work. Until this existed, a registered recipient could be
565
+ * wrapped in and still had no way to decrypt anything.
566
+ */
567
+ rawPhrase, destination,
540
568
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
541
569
  options = {}) {
542
- const phrase = canonicalPhrase(rawPhrase);
570
+ const withKey = typeof rawPhrase === "object";
571
+ const phrase = withKey ? "" : canonicalPhrase(rawPhrase);
572
+ // The keygen prints the raw 32 bytes as base64; the crypto wants a key object
573
+ // (its string form is PEM). Accept either, so someone can paste what they
574
+ // were given rather than convert it themselves.
575
+ const unlock = withKey
576
+ ? { privateKey: /BEGIN [A-Z ]*PRIVATE KEY/.test(rawPhrase.privateKey)
577
+ ? rawPhrase.privateKey
578
+ : x25519PrivateKeyFromRaw(Buffer.from(rawPhrase.privateKey, "base64")) }
579
+ : { phrase };
543
580
  const config = await readConfig(dataDir);
581
+ if (withKey && (config.storageMode === "plain" || !isV2(record))) {
582
+ fail("invalid_argument", "A recipient key opens sealed v2 archives. This one predates them, or the vault stores archives unencrypted.");
583
+ }
544
584
  // A delta archive's object holds only what an append added; the bytes before
545
585
  // it live in its base chain. Routed through the one chain-aware helper so
546
586
  // every caller of this function — the CLI and dashboard restore included —
547
587
  // gets the whole transcript back, not a tail pretending to be one.
548
588
  if (deltaOf(record)) {
589
+ if (withKey)
590
+ fail("invalid_argument", "This archive is an append onto an earlier one, and the chain is opened with the phrase. Restore it with --recovery-phrase.");
549
591
  const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
550
592
  return { bytes };
551
593
  }
@@ -571,7 +613,7 @@ options = {}) {
571
613
  fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: record.id });
572
614
  let opened;
573
615
  try {
574
- opened = await openArchiveToFile(record.envelope, source.path, destination, { phrase });
616
+ opened = await openArchiveToFile(record.envelope, source.path, destination, unlock);
575
617
  }
576
618
  catch (error) {
577
619
  throw openFailure(record.id, error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
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 {