sealkeep 0.5.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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,677 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { decryptArchive as openEnvelope, encryptArchive as sealEnvelope } from "../packages/vaultline-crypto/src/index.js";
5
+ import { fail } from "./errors.js";
6
+ import { canonicalPhrase } from "./mnemonic.js";
7
+ import { decryptRecord, listArchives, readConfig } from "./vault.js";
8
+ import { openChunkWindow, supportsChunkAccess } from "../packages/vaultline-crypto/src/chunk-access.js";
9
+ import { isV2 } from "./types.js";
10
+ const indexPath = (dataDir) => join(dataDir, "index", "content-index.vlindex");
11
+ const envelopePath = (dataDir) => join(dataDir, "index", "content-index.json");
12
+ const coveragePath = (dataDir) => join(dataDir, "index", "coverage.json");
13
+ const MIN_TOKEN = 3;
14
+ const MAX_TOKENS_PER_ARCHIVE = 4000;
15
+ /**
16
+ * What deserves an index slot. Transcripts are full of hex digests, base64
17
+ * blobs, and minified identifiers; every one of those admitted is a real word
18
+ * crowded out of the cap. Queries still tokenize unfiltered — searching FOR a
19
+ * long id simply misses content and falls to metadata search, which owns ids.
20
+ */
21
+ export function indexableToken(token) {
22
+ if (token.length > 24)
23
+ return false;
24
+ if (token.length >= 12 && /^[0-9a-f]+$/.test(token))
25
+ return false;
26
+ if (token.length >= 16 && /^[A-Za-z0-9+/=_-]+$/.test(token) && !/[aeiou]{1}/i.test(token.slice(0, 8)))
27
+ return false;
28
+ return true;
29
+ }
30
+ /**
31
+ * Round-robin merge of per-chunk token sets: every chunk gets a fair slice of
32
+ * the archive's token budget. First-chunks-win was the live finding — with a
33
+ * 40k budget, chunks 0–7 of a 399-chunk archive consumed everything and a
34
+ * word living in chunk 50 stayed unsearchable.
35
+ */
36
+ export function mergeChunkTokens(byChunk, cap) {
37
+ const merged = new Map();
38
+ const iterators = [...byChunk.entries()]
39
+ .sort(([a], [b]) => a - b)
40
+ .map(([chunkIndex, tokens]) => ({ chunkIndex, iterator: tokens[Symbol.iterator]() }));
41
+ let admitted = 0;
42
+ let progressed = true;
43
+ while (progressed && iterators.length > 0) {
44
+ progressed = false;
45
+ for (const lane of iterators) {
46
+ let step = lane.iterator.next();
47
+ while (!step.done) {
48
+ const token = step.value;
49
+ const existing = merged.get(token);
50
+ if (existing) {
51
+ if (existing.size < 2)
52
+ existing.add(lane.chunkIndex);
53
+ step = lane.iterator.next();
54
+ continue; // an already-admitted token only collects a tag; keep drawing for a NEW one
55
+ }
56
+ if (admitted < cap) {
57
+ merged.set(token, new Set([lane.chunkIndex]));
58
+ admitted += 1;
59
+ progressed = true;
60
+ }
61
+ break;
62
+ }
63
+ }
64
+ if (admitted >= cap)
65
+ break;
66
+ }
67
+ // Budget spent or lanes dry: sweep remaining occurrences so admitted tokens
68
+ // still collect their second tag.
69
+ if (admitted >= cap) {
70
+ for (const lane of iterators) {
71
+ let step = lane.iterator.next();
72
+ while (!step.done) {
73
+ const existing = merged.get(step.value);
74
+ if (existing && existing.size < 2)
75
+ existing.add(lane.chunkIndex);
76
+ step = lane.iterator.next();
77
+ }
78
+ }
79
+ }
80
+ return merged;
81
+ }
82
+ /**
83
+ * The token budget grows with the archive: a 40 KB session and a 1.7 GB one
84
+ * do not deserve the same 4000 slots — the live drill proved the flat cap
85
+ * starves everything past the first chunk of a big archive ("retention"
86
+ * indexed, "chunk" lost). Bounded so the phonebook stays a phonebook.
87
+ */
88
+ export function tokenCapFor(sourceBytes) {
89
+ // 72 tokens/MB, capped at 120k: a 1.7 GB transcript's real (junk-filtered)
90
+ // vocabulary measured ~40–100k distinct words, and a 40k budget cut the
91
+ // tail off — "chunk", 141 honest mentions, lost every per-chunk seat. At
92
+ // ~100 bytes per entry the whale archive costs ~2 MB gzipped in the index;
93
+ // ordinary sessions stay in the low thousands.
94
+ return Math.min(120_000, MAX_TOKENS_PER_ARCHIVE + Math.floor(sourceBytes / (1024 * 1024)) * 72);
95
+ }
96
+ /**
97
+ * Own-property-safe append to the tokens map. A transcript containing the
98
+ * word "constructor" made `tokens[token] ??= []` find — and push into —
99
+ * Object.prototype.constructor. Every read and write of a token entry must
100
+ * go through own-property checks; the token space is attacker-adjacent input.
101
+ */
102
+ function pushTokenTag(tokens, token, tag) {
103
+ if (Object.prototype.hasOwnProperty.call(tokens, token) && Array.isArray(tokens[token]))
104
+ tokens[token].push(tag);
105
+ else
106
+ tokens[token] = [tag];
107
+ }
108
+ /** Own-property-safe read: inherited names ("constructor", "toString") answer as absent. */
109
+ export function tokenEntries(tokens, token) {
110
+ return Object.prototype.hasOwnProperty.call(tokens, token) && Array.isArray(tokens[token]) ? tokens[token] : [];
111
+ }
112
+ /**
113
+ * A query term also matches indexed tokens it prefixes — "chunk" finds
114
+ * "chunks" and "chunkbytes", which is what a person typing it meant. Bounded
115
+ * fan-out; exact entries come first so precise queries stay precise.
116
+ */
117
+ export function tokenEntriesExpanded(tokens, term) {
118
+ const exact = tokenEntries(tokens, term);
119
+ if (term.length < MIN_TOKEN)
120
+ return exact;
121
+ const expanded = [...exact];
122
+ let fanOut = 0;
123
+ for (const key of Object.keys(tokens)) {
124
+ if (key === term || !key.startsWith(term))
125
+ continue;
126
+ expanded.push(...tokenEntries(tokens, key));
127
+ fanOut += 1;
128
+ if (fanOut >= 40)
129
+ break;
130
+ }
131
+ return expanded;
132
+ }
133
+ export function tokenize(text) {
134
+ const out = new Set();
135
+ for (const raw of text.toLowerCase().split(/[^a-z0-9_.@/-]+/)) {
136
+ if (raw.length < MIN_TOKEN)
137
+ continue;
138
+ out.add(raw);
139
+ // Compounds also index by their segments: "chunk-store.ts" answers for
140
+ // "chunk" and "store", and a hyphenated marker longer than the storage
141
+ // cap is still findable through every piece of itself. Queries run the
142
+ // same split, so a compound query becomes an AND of its segments.
143
+ if (/[-._/@]/.test(raw)) {
144
+ for (const segment of raw.split(/[-._/@]+/)) {
145
+ if (segment.length >= MIN_TOKEN)
146
+ out.add(segment);
147
+ }
148
+ }
149
+ }
150
+ return [...out];
151
+ }
152
+ function metadataHaystack(record) {
153
+ return `${record.id} ${record.source.agent} ${record.source.path} ${record.createdAt}`.toLowerCase();
154
+ }
155
+ /** Metadata search needs no secret and never touches an archive's contents. */
156
+ export async function searchMetadata(dataDir, query, options = {}) {
157
+ const needle = query.trim().toLowerCase();
158
+ if (!needle)
159
+ fail("invalid_argument", "A search query is required");
160
+ return (await listArchives(dataDir))
161
+ .filter((record) => (!options.agent || record.source.agent === options.agent) && metadataHaystack(record).includes(needle))
162
+ .map((record) => ({ id: record.id, agent: record.source.agent, path: record.source.path, bytes: record.source.bytes, createdAt: record.createdAt, matched: "metadata" }));
163
+ }
164
+ /**
165
+ * Builds the opt-in content index.
166
+ *
167
+ * This decrypts every archive locally, so it requires the recovery phrase. The
168
+ * resulting index is itself encrypted with the same envelope before it touches the
169
+ * disk, and it is never uploaded: `sealkeep upload` only ever sends archive objects.
170
+ */
171
+ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
172
+ const phrase = canonicalPhrase(rawPhrase);
173
+ await readConfig(dataDir);
174
+ const records = await listArchives(dataDir);
175
+ // Start from what already exists — the remote index when it is newer (a
176
+ // teammate's machine may have indexed archives this one never decrypted),
177
+ // the local one otherwise. Rebuilding from zero used to be the only mode,
178
+ // which on a second machine meant fetching and decrypting the whole
179
+ // history to answer one search.
180
+ const { remoteIndexStoreFor, pullIndexIfNewer, localIndexBuiltAt } = await import("./index-sync.js");
181
+ const store = options.sync === false ? null : await remoteIndexStoreFor(dataDir, phrase);
182
+ let pulled = false;
183
+ if (store) {
184
+ const outcome = await pullIndexIfNewer(dataDir, store, await localIndexBuiltAt(envelopePath(dataDir)), async (envelope, ciphertext) => {
185
+ await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
186
+ await writeFile(indexPath(dataDir), ciphertext, { mode: 0o600 });
187
+ await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
188
+ });
189
+ pulled = outcome.pulled;
190
+ }
191
+ let index;
192
+ try {
193
+ const existing = normalizeContentIndex(await loadContentIndex(dataDir, phrase));
194
+ // Merge-by-union is safe because an archive's tokens are a pure function
195
+ // of its content: whoever indexed it got the same answer.
196
+ index = { version: 2, builtAt: new Date().toISOString(), archives: existing.archives, tokens: existing.tokens };
197
+ }
198
+ catch {
199
+ index = { version: 2, builtAt: new Date().toISOString(), archives: {}, tokens: {} };
200
+ }
201
+ // Drop entries for archives that no longer exist, then index only what is
202
+ // NEW — the difference between "re-decrypt 53 archives" and "decrypt the
203
+ // two sealed since the last build".
204
+ const liveIds = new Set(records.map((record) => record.id));
205
+ for (const id of Object.keys(index.archives)) {
206
+ if (liveIds.has(id))
207
+ continue;
208
+ delete index.archives[id];
209
+ for (const token of Object.keys(index.tokens)) {
210
+ index.tokens[token] = index.tokens[token].filter((entry) => entry !== id && !entry.startsWith(`${id}@`));
211
+ if (index.tokens[token].length === 0)
212
+ delete index.tokens[token];
213
+ }
214
+ }
215
+ const fresh = records.filter((record) => !index.archives[record.id]);
216
+ let indexedNow = 0;
217
+ let skipped = 0;
218
+ for (const record of fresh) {
219
+ // One flaky download must not kill a 54-archive build: each archive gets
220
+ // two tries, then the build moves on and reports what it left for the
221
+ // next run. The archives entry is only written on success — a listed id
222
+ // with no tokens would be "indexed" yet unfindable forever.
223
+ try {
224
+ try {
225
+ await indexOneRecord(dataDir, index, record, phrase);
226
+ }
227
+ catch {
228
+ await indexOneRecord(dataDir, index, record, phrase);
229
+ }
230
+ indexedNow += 1;
231
+ }
232
+ catch {
233
+ skipped += 1;
234
+ }
235
+ }
236
+ const pushed = await persistContentIndex(dataDir, phrase, index, store);
237
+ return { archives: Object.keys(index.archives).length, tokens: Object.keys(index.tokens).length, builtAt: index.builtAt, indexedNow, skipped, pulled, pushed };
238
+ }
239
+ /** Indexes one archive into `index`, chunk-tagged when the format allows, windowed otherwise. Writes the archives entry LAST, so a failure leaves no half-indexed id. */
240
+ async function indexOneRecord(dataDir, index, record, phrase) {
241
+ const cap = tokenCapFor(record.source.bytes);
242
+ if (isV2(record) && supportsChunkAccess(record.envelope)) {
243
+ const { readFile: readBlob } = await import("node:fs/promises");
244
+ const { materialiseArchive } = await import("./offload.js");
245
+ const materialised = await materialiseArchive(dataDir, record);
246
+ try {
247
+ const stored = await readBlob(materialised.path);
248
+ const byChunk = new Map();
249
+ const perChunkCap = Math.max(4000, Math.floor(cap / 4));
250
+ let offset = 0;
251
+ for (const chunk of record.envelope.chunks) {
252
+ const slice = stored.subarray(offset, offset + chunk.storedBytes);
253
+ offset += chunk.storedBytes;
254
+ const window = { firstIndex: chunk.index, lastIndex: chunk.index, storedOffset: 0, storedLength: chunk.storedBytes, rawStartInWindow: 0, rawLengthInWindow: chunk.rawBytes ?? 0 };
255
+ const raw = openChunkWindow(record.envelope, window, slice, { phrase });
256
+ const bucket = new Set();
257
+ for (const token of tokenize(raw.toString("utf8"))) {
258
+ if (bucket.size >= perChunkCap)
259
+ break;
260
+ if (!indexableToken(token))
261
+ continue;
262
+ bucket.add(token);
263
+ }
264
+ byChunk.set(chunk.index, bucket);
265
+ raw.fill(0);
266
+ }
267
+ // Round-robin merge: every chunk gets a fair slice of the budget, so a
268
+ // word living only in chunk 50 is as findable as one from chunk 0.
269
+ for (const [token, chunkSet] of mergeChunkTokens(byChunk, cap)) {
270
+ for (const chunkIndex of [...chunkSet].slice(0, 2))
271
+ pushTokenTag(index.tokens, token, `${record.id}@c${chunkIndex}`);
272
+ }
273
+ }
274
+ finally {
275
+ await materialised.release();
276
+ }
277
+ index.archives[record.id] = [record.source.agent, record.source.path];
278
+ return;
279
+ }
280
+ const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
281
+ // Windowed, never one giant string: a legacy archive can decrypt to more
282
+ // than Node's ~512 MB string ceiling, and the live index build died on
283
+ // exactly that ("Cannot create a string longer than 0x1fffffe8"). Small
284
+ // overlap keeps tokens that straddle a window boundary.
285
+ const seen = new Set();
286
+ const WINDOW = 8 * 1024 * 1024;
287
+ const OVERLAP = 256;
288
+ for (let at = 0; at < plaintext.length && seen.size < cap; at += WINDOW) {
289
+ const slice = plaintext.subarray(Math.max(0, at - OVERLAP), Math.min(plaintext.length, at + WINDOW));
290
+ for (const token of tokenize(slice.toString("utf8"))) {
291
+ if (!indexableToken(token))
292
+ continue;
293
+ seen.add(token);
294
+ if (seen.size >= cap)
295
+ break;
296
+ }
297
+ }
298
+ for (const token of seen)
299
+ pushTokenTag(index.tokens, token, record.id);
300
+ plaintext.fill(0);
301
+ index.archives[record.id] = [record.source.agent, record.source.path];
302
+ }
303
+ /**
304
+ * Seals the index to disk atomically and pushes it to the vault's remote slot
305
+ * when one is reachable. Sealed local, so sealed remote: pushing costs two
306
+ * PUTs of ciphertext and buys every other machine of this vault a search that
307
+ * needs no rebuild. A token index is highly repetitive JSON and compresses hard.
308
+ */
309
+ async function persistContentIndex(dataDir, phrase, index, store) {
310
+ const { envelope, ciphertext } = sealEnvelope(Buffer.from(JSON.stringify(index)), { recipients: [{ kind: "phrase", phrase }], compression: "gzip", adapter: { agent: "vaultline-index", version: "1" } });
311
+ const target = indexPath(dataDir);
312
+ await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
313
+ const temp = `${target}.${randomUUID()}.tmp`;
314
+ await writeFile(temp, ciphertext, { mode: 0o600 });
315
+ await rename(temp, target);
316
+ await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
317
+ // The unsealed coverage sidecar: WHICH archive ids are in the index, and
318
+ // nothing else. Ids are metadata the records directory already shows, so
319
+ // status, doctor, and the panel can answer "what is searchable and what is
320
+ // not" without asking for the phrase. Tokens stay sealed next door.
321
+ await writeFile(coveragePath(dataDir), JSON.stringify({ builtAt: index.builtAt, indexed: Object.keys(index.archives).sort() }, null, 2) + "\n", { mode: 0o600 });
322
+ if (!store)
323
+ return false;
324
+ try {
325
+ await store.push(envelope, ciphertext);
326
+ return true;
327
+ }
328
+ catch {
329
+ return false; /* an offline build is still a build; the next one pushes */
330
+ }
331
+ }
332
+ /**
333
+ * Seal-time indexing: the seal already held every plaintext slice in its
334
+ * hands, so the tokens arrive here for free — no downloading an archive back
335
+ * just to read what was just read. Merges one archive's chunk-tagged tokens
336
+ * into the index (pulling a newer remote first, exactly like a build) and
337
+ * persists. Failure must never fail a seal; callers treat this as advisory.
338
+ */
339
+ export async function indexArchiveTokens(dataDir, rawPhrase, record, tokensByChunk, options = {}) {
340
+ const phrase = canonicalPhrase(rawPhrase);
341
+ const { remoteIndexStoreFor, pullIndexIfNewer, localIndexBuiltAt } = await import("./index-sync.js");
342
+ const store = options.sync === false ? null : await remoteIndexStoreFor(dataDir, phrase);
343
+ if (store) {
344
+ await pullIndexIfNewer(dataDir, store, await localIndexBuiltAt(envelopePath(dataDir)), async (envelope, ciphertext) => {
345
+ await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
346
+ await writeFile(indexPath(dataDir), ciphertext, { mode: 0o600 });
347
+ await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
348
+ }).catch(() => undefined);
349
+ }
350
+ let index;
351
+ try {
352
+ index = normalizeContentIndex(await loadContentIndex(dataDir, phrase));
353
+ }
354
+ catch {
355
+ index = { version: 2, builtAt: new Date().toISOString(), archives: {}, tokens: {} };
356
+ }
357
+ // Replace any previous entries for this archive id, then apply the same
358
+ // caps the build applies: bounded tokens per archive, two tags per token.
359
+ for (const token of Object.keys(index.tokens)) {
360
+ index.tokens[token] = index.tokens[token].filter((entry) => entry !== record.id && !entry.startsWith(`${record.id}@`));
361
+ if (index.tokens[token].length === 0)
362
+ delete index.tokens[token];
363
+ }
364
+ index.archives[record.id] = [record.source.agent, record.source.path];
365
+ const filtered = new Map();
366
+ for (const [chunkIndex, tokens] of tokensByChunk) {
367
+ const keep = new Set();
368
+ for (const token of tokens)
369
+ if (indexableToken(token))
370
+ keep.add(token);
371
+ filtered.set(chunkIndex, keep);
372
+ }
373
+ const merged = mergeChunkTokens(filtered, tokenCapFor(record.source.bytes));
374
+ for (const [token, chunkSet] of merged) {
375
+ for (const chunkIndex of [...chunkSet].slice(0, 2))
376
+ pushTokenTag(index.tokens, token, `${record.id}@c${chunkIndex}`);
377
+ }
378
+ index.builtAt = new Date().toISOString();
379
+ const pushed = await persistContentIndex(dataDir, phrase, index, store);
380
+ return { pushed };
381
+ }
382
+ /**
383
+ * Whatever shape an old index decrypts to, the build merges only what it can
384
+ * use: token entries must be arrays of archive tags, archive entries must
385
+ * exist. The live rebuild found a version-1 index whose token values were not
386
+ * arrays — `.push is not a function`, four archives in. Anything unusable is
387
+ * dropped here and simply re-indexed, which is what "incremental" owes its
388
+ * caller anyway.
389
+ */
390
+ export function normalizeContentIndex(raw) {
391
+ const source = (typeof raw === "object" && raw !== null ? raw : {});
392
+ const archives = {};
393
+ for (const [id, value] of Object.entries(source.archives ?? {})) {
394
+ archives[id] = Array.isArray(value) ? value.map(String) : [];
395
+ }
396
+ const tokens = {};
397
+ for (const [token, value] of Object.entries(source.tokens ?? {})) {
398
+ if (Array.isArray(value))
399
+ tokens[token] = value.filter((entry) => typeof entry === "string");
400
+ else if (typeof value === "string")
401
+ tokens[token] = [value];
402
+ // anything else (counts, objects from the v1 experiment) is dropped
403
+ }
404
+ // An archive no surviving token points at would be listed as indexed yet
405
+ // unfindable forever. Unlisting it here makes the next build re-index it —
406
+ // the one-time cost that turns a relic index into a working one.
407
+ const referenced = new Set();
408
+ for (const entries of Object.values(tokens))
409
+ for (const entry of entries)
410
+ referenced.add(entry.split("@")[0]);
411
+ for (const id of Object.keys(archives))
412
+ if (!referenced.has(id))
413
+ delete archives[id];
414
+ return { version: 2, builtAt: typeof source.builtAt === "string" ? source.builtAt : new Date().toISOString(), archives, tokens };
415
+ }
416
+ export async function loadContentIndex(dataDir, rawPhrase) {
417
+ const phrase = canonicalPhrase(rawPhrase);
418
+ const envelope = await readFile(envelopePath(dataDir), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
419
+ const ciphertext = await readFile(indexPath(dataDir)).catch(() => null);
420
+ if (!envelope || !ciphertext)
421
+ fail("invalid_argument", "No local content index. Build one with: sealkeep index build", { dataDir });
422
+ try {
423
+ return JSON.parse(openEnvelope(envelope, ciphertext, { phrase }).toString("utf8"));
424
+ }
425
+ catch {
426
+ return fail("recovery_phrase_mismatch", "The content index could not be opened with this recovery phrase");
427
+ }
428
+ }
429
+ export async function dropContentIndex(dataDir) {
430
+ await rm(indexPath(dataDir), { force: true });
431
+ await rm(envelopePath(dataDir), { force: true });
432
+ }
433
+ /**
434
+ * Content search over the opt-in index. Every returned snippet is read from the
435
+ * archive at query time, so nothing readable is kept on disk between searches.
436
+ */
437
+ export async function searchContent(dataDir, query, rawPhrase, options = {}) {
438
+ const phrase = canonicalPhrase(rawPhrase);
439
+ const terms = tokenize(query);
440
+ if (terms.length === 0)
441
+ fail("invalid_argument", "A content query needs a term of at least three characters");
442
+ // Terms the index could never hold (an over-long compound, a hex digest)
443
+ // must not be REQUIRED — their segments carry the query. They still count
444
+ // for snippet line matching, where the literal text exists.
445
+ const requiredTerms = terms.filter((term) => indexableToken(term));
446
+ const effectiveTerms = requiredTerms.length > 0 ? requiredTerms : terms;
447
+ // A teammate's machine may have pushed a newer index since this one last
448
+ // built — one tiny meta GET decides, and search answers from the freshest
449
+ // sealed index either side has produced. Tolerated failure: offline search
450
+ // over yesterday's index beats no search.
451
+ try {
452
+ const { remoteIndexStoreFor, pullIndexIfNewer, localIndexBuiltAt } = await import("./index-sync.js");
453
+ const store = await remoteIndexStoreFor(dataDir, phrase);
454
+ if (store) {
455
+ await pullIndexIfNewer(dataDir, store, await localIndexBuiltAt(envelopePath(dataDir)), async (envelope, ciphertext) => {
456
+ await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
457
+ await writeFile(indexPath(dataDir), ciphertext, { mode: 0o600 });
458
+ await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
459
+ });
460
+ }
461
+ }
462
+ catch { /* the local index still answers */ }
463
+ const index = await loadContentIndex(dataDir, phrase);
464
+ const counts = new Map();
465
+ const chunkTags = new Map();
466
+ for (const term of effectiveTerms) {
467
+ // One term may carry several chunk tags for the same archive; a match is
468
+ // still ONE term seen — counting entries instead of archives would let a
469
+ // single-term archive impersonate an every-term one.
470
+ const idsThisTerm = new Set();
471
+ for (const entry of tokenEntriesExpanded(index.tokens, term)) {
472
+ const at = entry.indexOf("@c");
473
+ const id = at === -1 ? entry : entry.slice(0, at);
474
+ idsThisTerm.add(id);
475
+ if (at !== -1) {
476
+ const tags = chunkTags.get(id) ?? new Set();
477
+ tags.add(Number(entry.slice(at + 2)));
478
+ chunkTags.set(id, tags);
479
+ }
480
+ }
481
+ for (const id of idsThisTerm)
482
+ counts.set(id, (counts.get(id) ?? 0) + 1);
483
+ }
484
+ const matches = [...counts.entries()].filter(([, hitCount]) => hitCount === effectiveTerms.length).map(([id]) => id);
485
+ const records = await listArchives(dataDir);
486
+ const hits = [];
487
+ for (const id of matches) {
488
+ // A matched id with no local record used to be dropped silently — which
489
+ // made a second machine of the vault blind to everything it had not
490
+ // archived itself, defeating the shared index entirely. The archive is
491
+ // self-carrying in the cloud (envelope sidecar + chunk objects), so the
492
+ // hit is reconstructed from there: phrase opens the sidecar, the sealed
493
+ // identity restores agent/path, and snippets fetch like any cold archive.
494
+ const record = records.find((item) => item.id === id) ?? await remoteOnlyRecord(dataDir, id, phrase);
495
+ if (!record || (options.agent && record.source.agent !== options.agent))
496
+ continue;
497
+ let snippets = [];
498
+ if (options.snippets !== false) {
499
+ snippets = await snippetsFor(dataDir, record, phrase, terms, chunkTags.get(id));
500
+ }
501
+ hits.push({ id: record.id, agent: record.source.agent, path: record.source.path, bytes: record.source.bytes, createdAt: record.createdAt, matched: "content", snippets });
502
+ }
503
+ return hits;
504
+ }
505
+ /**
506
+ * Reconstructs enough of an archive to search it, from the cloud alone: the
507
+ * managed chunk layout stores `<id>.envelope.vlmeta` beside the chunks, and
508
+ * that sidecar carries the envelope plus the sealed identity. Nothing is
509
+ * persisted — this is a read-time view for machines that never held the
510
+ * record. Managed accounts only; a vault with no cloud sign-in answers null
511
+ * and the hit stays dropped, exactly as before.
512
+ */
513
+ async function remoteOnlyRecord(dataDir, id, phrase) {
514
+ try {
515
+ const { pullCiphertext, unframeObject } = await import("./cloud.js");
516
+ const { openIdentity } = await import("./chunk-store.js");
517
+ const { unwrapArchiveKey } = await import("../packages/vaultline-crypto/src/recipients.js");
518
+ const { ciphertext: sidecar } = await pullCiphertext(dataDir, `${id}.envelope.vlmeta`);
519
+ const { envelope, ciphertext: identityBlob } = unframeObject(sidecar);
520
+ if (!envelope)
521
+ return null;
522
+ const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, { phrase });
523
+ let identity;
524
+ try {
525
+ identity = openIdentity(archiveKey, id, identityBlob);
526
+ }
527
+ finally {
528
+ archiveKey.fill(0);
529
+ }
530
+ const storedBytes = envelope.chunks.reduce((sum, chunk) => sum + chunk.storedBytes, 0);
531
+ return {
532
+ version: 2, id, createdAt: identity.createdAt,
533
+ source: { path: identity.sourcePath, agent: identity.agent, bytes: envelope.manifest.originalBytes ?? 0, sha256: envelope.manifest.originalSha256 ?? envelope.manifest.plaintextSha256 },
534
+ cipher: { algorithm: envelope.suite, ciphertextSha256: "", storedBytes, chunks: envelope.chunks.length },
535
+ envelope,
536
+ objectPath: join(dataDir, "never-materialised", `${id}.vlarchive`),
537
+ remote: {
538
+ provider: "vaultline", bucket: "vaultline-managed", objectKey: id,
539
+ bytes: storedBytes, checksum: "", verifiedAt: identity.createdAt,
540
+ layout: { kind: "chunks", chunkCount: envelope.chunks.length }
541
+ }
542
+ };
543
+ }
544
+ catch {
545
+ return null;
546
+ }
547
+ }
548
+ /**
549
+ * Snippets, fetched as narrowly as the archive allows. A chunk-tagged hit on a
550
+ * chunk-independent archive reads ONLY the tagged chunks (padded one chunk
551
+ * each side, because a matching line can straddle a boundary): from the local
552
+ * blob when present, and through a ranged managed download when the local copy
553
+ * was offloaded — kilobytes over the wire against a cold 1.6 GB object.
554
+ * Anything else falls back to the whole-archive read it always was.
555
+ */
556
+ async function snippetsFor(dataDir, record, phrase, terms, tagged) {
557
+ const lineMatches = (text) => text.split("\n").filter((line) => terms.every((term) => line.toLowerCase().includes(term))).slice(0, 3).map((line) => line.slice(0, 200));
558
+ if (tagged && tagged.size > 0 && isV2(record) && supportsChunkAccess(record.envelope)) {
559
+ try {
560
+ // Up to two SEPARATE small windows, one per tagged chunk, each padded a
561
+ // chunk either side. The old code built ONE window spanning from the
562
+ // lowest tag to the highest — tags at c3 and c350 fetched 349 chunks of
563
+ // a cold archive to show two snippet lines. Bounded now: ≤ 6 chunks,
564
+ // and a multi-term query costs the same as a single-term one.
565
+ const anchors = [...tagged].sort((a, b) => a - b).slice(0, 2);
566
+ const windows = [];
567
+ for (const anchor of anchors) {
568
+ const first = Math.max(0, anchor - 1);
569
+ const last = Math.min(record.envelope.chunks.length - 1, anchor + 1);
570
+ const previous = windows[windows.length - 1];
571
+ if (previous && first <= previous.last + 1)
572
+ previous.last = Math.max(previous.last, last);
573
+ else
574
+ windows.push({ first, last });
575
+ }
576
+ const { stat: statFile, readFile: readBlob } = await import("node:fs/promises");
577
+ const local = await statFile(record.objectPath).then((entry) => entry.isFile()).catch(() => false);
578
+ const found = [];
579
+ for (const { first, last } of windows) {
580
+ const window = {
581
+ firstIndex: first, lastIndex: last,
582
+ storedOffset: record.envelope.chunks.slice(0, first).reduce((sum, chunk) => sum + chunk.storedBytes, 0),
583
+ storedLength: record.envelope.chunks.slice(first, last + 1).reduce((sum, chunk) => sum + chunk.storedBytes, 0),
584
+ rawStartInWindow: 0,
585
+ rawLengthInWindow: record.envelope.chunks.slice(first, last + 1).reduce((sum, chunk) => sum + (chunk.rawBytes ?? 0), 0)
586
+ };
587
+ let slice;
588
+ if (local) {
589
+ const blob = await readBlob(record.objectPath);
590
+ slice = blob.subarray(window.storedOffset, window.storedOffset + window.storedLength);
591
+ }
592
+ else if (record.remote?.provider === "vaultline" && record.remote.layout?.kind === "chunks") {
593
+ // A chunk-folder copy IS the ranged read — and the window's chunks
594
+ // fetch in PARALLEL, each read hash-checked by the account.
595
+ const { managedChunkClient } = await import("./managed-chunks.js");
596
+ const { chunkObjectName } = await import("./chunk-store.js");
597
+ const managed = managedChunkClient(dataDir);
598
+ const parts = await Promise.all(Array.from({ length: last - first + 1 }, (_, offsetIndex) => managed.getObject(`${record.remote.objectKey}/${chunkObjectName(first + offsetIndex)}`)));
599
+ slice = Buffer.concat(parts);
600
+ }
601
+ else if (record.offloaded?.provider === "vaultline" || record.remote?.provider === "vaultline") {
602
+ const { pullCiphertextRange, frameObject } = await import("./cloud.js");
603
+ // A managed object is envelope-framed: the ciphertext starts after a
604
+ // header whose size is knowable from the envelope in hand — frame it
605
+ // over nothing and measure. Offsets in the window address the
606
+ // ciphertext; the wire range must address the object.
607
+ const frameHeaderBytes = frameObject(record.envelope, Buffer.alloc(0)).length;
608
+ slice = await pullCiphertextRange(dataDir, record.id, frameHeaderBytes + window.storedOffset, window.storedLength);
609
+ }
610
+ else {
611
+ throw new Error("no ranged path for this provider yet");
612
+ }
613
+ const raw = openChunkWindow(record.envelope, window, slice, { phrase });
614
+ found.push(...lineMatches(raw.toString("utf8")));
615
+ raw.fill(0);
616
+ if (found.length >= 3)
617
+ break;
618
+ }
619
+ if (found.length > 0)
620
+ return found.slice(0, 3);
621
+ // A boundary case the padding did not cover — fall through to whole.
622
+ }
623
+ catch { /* ranged read unavailable — the whole path below still answers */ }
624
+ }
625
+ const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
626
+ // Same string-ceiling discipline as the index build: scan line by line in
627
+ // windows, carrying the partial last line across the boundary.
628
+ const found = [];
629
+ const WINDOW = 8 * 1024 * 1024;
630
+ let carry = "";
631
+ for (let at = 0; at < plaintext.length && found.length < 3; at += WINDOW) {
632
+ const text = carry + plaintext.subarray(at, Math.min(plaintext.length, at + WINDOW)).toString("utf8");
633
+ const lines = text.split("\n");
634
+ carry = at + WINDOW < plaintext.length ? lines.pop() ?? "" : "";
635
+ for (const line of lines) {
636
+ if (terms.every((term) => line.toLowerCase().includes(term))) {
637
+ found.push(line.slice(0, 200));
638
+ if (found.length >= 3)
639
+ break;
640
+ }
641
+ }
642
+ }
643
+ if (found.length < 3 && carry && terms.every((term) => carry.toLowerCase().includes(term)))
644
+ found.push(carry.slice(0, 200));
645
+ plaintext.fill(0);
646
+ return found;
647
+ }
648
+ export async function search(dataDir, query, options = {}) {
649
+ if ((options.mode ?? "metadata") === "metadata")
650
+ return searchMetadata(dataDir, query, options);
651
+ if (!options.phrase)
652
+ fail("recovery_phrase_missing", "Content search decrypts archives locally and needs the recovery phrase");
653
+ return searchContent(dataDir, query, options.phrase, options);
654
+ }
655
+ /**
656
+ * What the index covers, from the unsealed sidecar — no phrase required.
657
+ * "Missing" means sealed and durable yet not yet searchable, which is exactly
658
+ * the list `sealkeep index build` would work through.
659
+ */
660
+ export async function indexCoverage(dataDir) {
661
+ const records = await listArchives(dataDir);
662
+ const coverage = await readFile(coveragePath(dataDir), "utf8")
663
+ .then((raw) => JSON.parse(raw))
664
+ .catch(() => null);
665
+ const indexedIds = new Set(coverage?.indexed ?? []);
666
+ const missing = records
667
+ .filter((record) => !indexedIds.has(record.id))
668
+ .map((record) => ({ id: record.id, path: record.source.path, agent: record.source.agent }));
669
+ const indexBytes = await import("node:fs/promises").then(({ stat }) => stat(indexPath(dataDir))).then((info) => info.size).catch(() => 0);
670
+ return {
671
+ total: records.length,
672
+ indexed: records.length - missing.length,
673
+ builtAt: coverage?.builtAt ?? null,
674
+ indexBytes,
675
+ missing
676
+ };
677
+ }