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 +21 -0
- package/dist/site/index.html +3 -1
- package/dist/src/chunk-store.js +10 -5
- package/dist/src/types.d.ts +3 -0
- package/dist/src/vault.d.ts +5 -1
- package/dist/src/vault.js +22 -2
- package/package.json +5 -3
- package/web/app.js +10 -2
- package/web/index.html +5 -5
- package/web/rules-view.js +17 -6
- package/web/sessions-view.js +4 -1
- package/web/setup-logic.js +14 -3
- package/web/setup.html +4 -4
- package/web/setup.js +9 -1
- package/web/style.css +11 -1
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
|
package/dist/site/index.html
CHANGED
|
@@ -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
|
-
|
|
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}
|
package/dist/src/chunk-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/types.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/src/vault.d.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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) =>
|
|
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.
|
|
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.
|
|
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"> </label><span style="font-size:.85rem;color:var(--
|
|
381
|
+
<div><label class="f"> </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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
}
|
package/web/sessions-view.js
CHANGED
|
@@ -330,7 +330,10 @@
|
|
|
330
330
|
const sealing = queued.length;
|
|
331
331
|
if (sealing > 0) {
|
|
332
332
|
$("result").textContent = waiting === sealing
|
|
333
|
-
|
|
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();
|
package/web/setup-logic.js
CHANGED
|
@@ -93,7 +93,18 @@ export const PROVIDERS = [
|
|
|
93
93
|
}
|
|
94
94
|
];
|
|
95
95
|
|
|
96
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 {
|