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,331 @@
1
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fail } from "./errors.js";
5
+ import { recordAudit } from "./audit.js";
6
+ import { isV2 } from "./types.js";
7
+ import { listArchives, readConfig, writeRecord } from "./vault.js";
8
+ import { createActiveLease, uploadClientFromStore } from "./providers/index.js";
9
+ import { sha256 } from "./crypto.js";
10
+ /** Providers this build can fetch back from. GCS uploads but has no download client. */
11
+ // Every provider with a proven read-back path. "vaultline" reads back through
12
+ // the account (pullCiphertext, hash-checked); "gcs" through a signed GET on
13
+ // the provider client. Both were live-proven before earning a place here —
14
+ // this set is the safety gate that keeps "offload" from meaning "lose".
15
+ // gdrive joined when its client grew download() — the live Drive drill hit
16
+ // this stale gate first: uploads landed, the local copy could never leave.
17
+ const FETCHABLE = new Set(["s3", "r2", "b2", "gcs", "gdrive", "vaultline"]);
18
+ export async function offloadable(dataDir) {
19
+ const archives = await listArchives(dataDir);
20
+ const candidates = [];
21
+ const blocked = [];
22
+ for (const record of archives) {
23
+ if (!isV2(record)) {
24
+ blocked.push({ id: record.id, reason: "archive uses format v1; run `sealkeep migrate` first" });
25
+ continue;
26
+ }
27
+ if (record.offloaded)
28
+ continue;
29
+ const remote = record.remote;
30
+ if (!remote?.verifiedAt) {
31
+ blocked.push({ id: record.id, reason: "no verified remote copy, so the local one is the only copy" });
32
+ continue;
33
+ }
34
+ if (!FETCHABLE.has(remote.provider)) {
35
+ blocked.push({ id: record.id, reason: `${remote.provider} can be uploaded to but not read back by this build, so the local copy has to stay` });
36
+ continue;
37
+ }
38
+ const local = await stat(record.objectPath).catch(() => null);
39
+ if (!local) {
40
+ blocked.push({ id: record.id, reason: "no local archive file to remove" });
41
+ continue;
42
+ }
43
+ // The remote is only a copy if the sizes agree. For a provider that stores
44
+ // the raw ciphertext that means equality; a managed object carries the
45
+ // self-opening envelope frame around the same ciphertext, so it is larger
46
+ // by exactly that frame — equality there is structurally impossible and
47
+ // demanding it silently blocked every managed offload. The frame only
48
+ // adds, so smaller-than-local is still the same corruption signal, and
49
+ // the true proof either way is the fetch-and-hash below.
50
+ const sameSize = remote.provider === "vaultline" ? remote.bytes >= local.size : remote.bytes === local.size;
51
+ if (!sameSize) {
52
+ blocked.push({ id: record.id, reason: `the remote object is ${remote.bytes} bytes and the local one is ${local.size}; they are not the same archive` });
53
+ continue;
54
+ }
55
+ candidates.push({
56
+ id: record.id, agent: record.source.agent, path: record.source.path,
57
+ localBytes: local.size, provider: remote.provider, bucket: remote.bucket, objectKey: remote.objectKey
58
+ });
59
+ }
60
+ return { candidates, blocked, freeableBytes: candidates.reduce((total, item) => total + item.localBytes, 0) };
61
+ }
62
+ /**
63
+ * Removes local archives that a bucket has a verified copy of.
64
+ *
65
+ * A dry run unless confirmed. The candidate list is recomputed here rather than
66
+ * accepted from a caller, so a page held open since yesterday cannot ask for
67
+ * the deletion of something that has since become the only copy.
68
+ */
69
+ export async function offloadArchives(dataDir, options = {}) {
70
+ const all = await offloadable(dataDir);
71
+ // `only` narrows the sweep — the upload run offloads each archive as its
72
+ // verify lands rather than re-walking the whole vault per item.
73
+ const candidates = options.only ? all.candidates.filter((candidate) => options.only.has(candidate.id)) : all.candidates;
74
+ const blocked = all.blocked;
75
+ const freeableBytes = candidates.reduce((total, item) => total + item.localBytes, 0);
76
+ if (!options.confirm)
77
+ return { mode: "preview", wouldOffload: candidates, blocked, freedBytes: freeableBytes };
78
+ const archives = new Map((await listArchives(dataDir)).map((record) => [record.id, record]));
79
+ const offloaded = [];
80
+ const failed = [];
81
+ const config = await readConfig(dataDir);
82
+ for (const candidate of candidates) {
83
+ const record = archives.get(candidate.id);
84
+ if (!record || !isV2(record))
85
+ continue;
86
+ try {
87
+ // Prove the bytes are really there, now, before removing the local ones.
88
+ // The verifiedAt stamp says they were there once; this asks again, because
89
+ // between then and now is exactly where a bucket lifecycle rule lives.
90
+ // The one honest exception: an archive whose verify landed seconds ago in
91
+ // the same run — between then and now is milliseconds, and re-downloading
92
+ // a gigabyte to re-prove it would double the egress of every push.
93
+ if (!options.freshlyVerified?.has(candidate.id)) {
94
+ const fetched = await fetchCiphertext(dataDir, record, options.client);
95
+ if (sha256(fetched) !== record.cipher.ciphertextSha256) {
96
+ failed.push({ id: candidate.id, error: "what the bucket returned is not this archive; local copy kept" });
97
+ continue;
98
+ }
99
+ }
100
+ await rm(record.objectPath, { force: true });
101
+ const next = {
102
+ ...record,
103
+ offloaded: { at: new Date().toISOString(), provider: candidate.provider, bucket: candidate.bucket, objectKey: candidate.objectKey }
104
+ };
105
+ await writeRecord(config.storage.root, next);
106
+ await recordAudit(dataDir, "archive.offload", "allowed", { archiveId: candidate.id, bytes: candidate.localBytes, bucket: candidate.bucket });
107
+ offloaded.push(candidate);
108
+ }
109
+ catch (error) {
110
+ await recordAudit(dataDir, "archive.offload", "denied", { archiveId: candidate.id });
111
+ failed.push({ id: candidate.id, error: error instanceof Error ? error.message : "could not offload" });
112
+ }
113
+ }
114
+ return { mode: "apply", offloaded, failed, blocked, freedBytes: offloaded.reduce((total, item) => total + item.localBytes, 0) };
115
+ }
116
+ /**
117
+ * Downloads an archive's ciphertext from the bucket it was offloaded to.
118
+ *
119
+ * VAULTLINE_ENABLE_SIGNER gates *uploading*, and it is off unless the installed
120
+ * service turns it on. Reading back an archive in order to restore it is not
121
+ * uploading, and refusing it on that flag would mean an offloaded archive could
122
+ * not be recovered by the person who owns it — which would turn a space-saving
123
+ * feature into data loss. The flag is set for this call only.
124
+ */
125
+ async function fetchCiphertext(dataDir, record, client) {
126
+ const config = await readConfig(dataDir);
127
+ const remote = record.remote ?? record.offloaded;
128
+ if (!remote)
129
+ fail("archive_not_found", `Archive ${record.id} has no remote copy to fetch`, { archiveId: record.id });
130
+ // A managed copy reads back through the account, not a provider credential:
131
+ // pullCiphertext hash-checks what it downloads before returning it. Branch
132
+ // on the record's own provider — a vault can hold managed history alongside
133
+ // a later own-bucket configuration, and each copy fetches its own way.
134
+ if (remote.provider === "vaultline" && !client && record.remote?.layout?.kind !== "chunks") {
135
+ const { pullCiphertext, unframeObject } = await import("./cloud.js");
136
+ const { ciphertext: blob } = await pullCiphertext(dataDir, record.id);
137
+ // A managed object is envelope-framed — the self-carrying wrapper push
138
+ // added so a bare object can be opened anywhere. What the local record
139
+ // hashes is the ciphertext INSIDE the frame, so that is what this must
140
+ // return; handing back the framed bytes made every integrity check
141
+ // compare an apple to the crate it shipped in. A plain-mode object has
142
+ // no frame and IS the stored bytes.
143
+ const { envelope, ciphertext } = unframeObject(blob);
144
+ return envelope ? ciphertext : blob;
145
+ }
146
+ // Chunk-folder remotes fetch object by object, each hash-checked against
147
+ // the envelope's own chunk headers — the layout was made for exactly this.
148
+ // A managed folder reads back through the account's download leases; an own
149
+ // bucket reads through the provider's object verbs.
150
+ if (record.remote?.layout?.kind === "chunks") {
151
+ const { fetchChunkFolder } = await import("./chunk-store.js");
152
+ let folderClient;
153
+ if (client)
154
+ folderClient = client;
155
+ else if (record.remote.provider === "vaultline") {
156
+ folderClient = (await import("./managed-chunks.js")).managedChunkClient(dataDir);
157
+ }
158
+ else {
159
+ const target = config.remoteStorage ?? fail("storage_not_configured", "This vault has no storage configured, so an offloaded archive cannot be fetched back");
160
+ folderClient = await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
161
+ }
162
+ if (typeof folderClient.getObject !== "function")
163
+ fail("provider_unsupported", `${record.remote.provider} cannot fetch chunk folders with this build`);
164
+ return fetchChunkFolder(folderClient, record.remote, record.envelope);
165
+ }
166
+ // The record routes its own read: provider and bucket come from the copy
167
+ // itself, the prefix from its object key, and a configured target (matched
168
+ // by targetId, then by provider+bucket) fills in the rest. config.remoteStorage
169
+ // is only the last resort — a multi-target vault has none.
170
+ const target = await (async () => {
171
+ if (config.remoteStorage && config.remoteStorage.provider === remote.provider && config.remoteStorage.bucket === remote.bucket)
172
+ return config.remoteStorage;
173
+ try {
174
+ const { resolveTargets } = await import("./storage-targets.js");
175
+ const targets = await resolveTargets(dataDir);
176
+ const stampedId = remote.targetId;
177
+ const match = (stampedId ? targets.find((candidate) => candidate.id === stampedId) : undefined)
178
+ ?? targets.find((candidate) => candidate.provider === remote.provider && candidate.bucket === remote.bucket);
179
+ if (match) {
180
+ const derivedPrefix = remote.objectKey.split("/").slice(0, -1).join("/");
181
+ return { provider: match.provider, bucket: match.bucket ?? remote.bucket, prefix: match.prefix ?? (derivedPrefix || "vaultline"), region: match.region };
182
+ }
183
+ }
184
+ catch { /* fall through */ }
185
+ if (config.remoteStorage)
186
+ return config.remoteStorage;
187
+ return fail("storage_not_configured", "This vault has no storage configured, so an offloaded archive cannot be fetched back");
188
+ })();
189
+ if (target.provider === "vaultline")
190
+ fail("storage_not_configured", "This managed copy should have taken the managed read path");
191
+ const resolved = client ?? await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
192
+ const lease = createActiveLease(target, {
193
+ archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.cipher.storedBytes
194
+ }, { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" });
195
+ const readable = resolved;
196
+ if (typeof readable.download !== "function") {
197
+ fail("provider_unsupported", `${target.provider} cannot be read back by this build, so this archive cannot be fetched`);
198
+ }
199
+ return readable.download(lease);
200
+ }
201
+ /**
202
+ * One managed record checked against the account's rows. A single-object copy
203
+ * is one row at the recorded size; a chunk-folder copy is its whole family —
204
+ * every chunk row present, the envelope sidecar present, and the chunk bytes
205
+ * summing to the verified total. The proof standard does not soften because
206
+ * the object count went up: any missing member is "do not trash the original".
207
+ */
208
+ async function managedCopyCheck(dataDir, record, rows, checkedAt) {
209
+ const remote = record.remote;
210
+ if (remote.layout?.kind === "chunks") {
211
+ const { managedChunkRefs } = await import("./managed-chunks.js");
212
+ const refs = managedChunkRefs(record.id, remote.layout.chunkCount);
213
+ const missing = refs.chunks.filter((ref) => !rows.has(ref)).length + (rows.has(refs.envelope) ? 0 : 1);
214
+ if (missing > 0) {
215
+ await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "absent", missing });
216
+ return { ok: false, provider: remote.provider, bytes: 0, checkedAt, detail: `Your managed storage is missing ${missing} of this archive's ${refs.chunks.length + 1} objects. Do not trash the original — this disk may hold the only complete copy.` };
217
+ }
218
+ const bytes = refs.chunks.reduce((sum, ref) => sum + (rows.get(ref) ?? 0), 0);
219
+ if (bytes !== remote.bytes) {
220
+ await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "size", stored: bytes });
221
+ return { ok: false, provider: remote.provider, bytes, checkedAt, detail: `Your storage reports ${bytes} chunk bytes; ${remote.bytes} were verified. Do not trash the original.` };
222
+ }
223
+ await recordAudit(dataDir, "remote.check", "allowed", { archiveId: record.id, bytes, chunks: refs.chunks.length });
224
+ return { ok: true, provider: remote.provider, bytes, checkedAt, detail: `Your managed storage confirmed all ${refs.chunks.length} chunks and the sidecar at the recorded size, just now.` };
225
+ }
226
+ const stored = rows.get(record.id);
227
+ if (stored === undefined) {
228
+ await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "absent" });
229
+ return { ok: false, provider: remote.provider, bytes: 0, checkedAt, detail: "Your managed storage no longer lists this archive. Do not trash the original — this disk may hold the only copy." };
230
+ }
231
+ if (stored !== remote.bytes) {
232
+ await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "size", stored });
233
+ return { ok: false, provider: remote.provider, bytes: stored, checkedAt, detail: `Your storage reports ${stored} bytes; ${remote.bytes} were verified. Do not trash the original.` };
234
+ }
235
+ await recordAudit(dataDir, "remote.check", "allowed", { archiveId: record.id, bytes: stored });
236
+ return { ok: true, provider: remote.provider, bytes: stored, checkedAt, detail: "Your managed storage confirmed it holds this archive at the recorded size, just now." };
237
+ }
238
+ export async function checkRemoteCopy(dataDir, archiveId, client) {
239
+ const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
240
+ if (!record)
241
+ fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
242
+ if (!isV2(record))
243
+ fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
244
+ const remote = record.remote;
245
+ if (!remote?.verifiedAt)
246
+ fail("invalid_argument", "This archive has no verified cloud copy to check — upload it first.", { archiveId });
247
+ const checkedAt = new Date().toISOString();
248
+ if (remote.provider === "vaultline" && !client) {
249
+ const { listCloudArchives } = await import("./cloud.js");
250
+ const rows = new Map((await listCloudArchives(dataDir)).map((row) => [row.vault_ref, Number(row.bytes) || 0]));
251
+ return managedCopyCheck(dataDir, record, rows, checkedAt);
252
+ }
253
+ const config = await readConfig(dataDir);
254
+ const target = config.remoteStorage;
255
+ if (!target)
256
+ fail("storage_not_configured", "This vault has no storage configured, so the copy cannot be checked");
257
+ const resolved = client ?? await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
258
+ const lease = createActiveLease(target, {
259
+ archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.cipher.storedBytes
260
+ }, { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" });
261
+ const probing = resolved;
262
+ if (typeof probing.head !== "function")
263
+ fail("provider_unsupported", `${target.provider} cannot be probed by this build`);
264
+ const head = await probing.head(lease);
265
+ if (!head.exists) {
266
+ await recordAudit(dataDir, "remote.check", "denied", { archiveId, reason: "absent" });
267
+ return { ok: false, provider: remote.provider, bytes: 0, checkedAt, detail: "The bucket no longer holds this object. Do not trash the original — this disk may hold the only copy." };
268
+ }
269
+ if (head.bytes !== remote.bytes) {
270
+ await recordAudit(dataDir, "remote.check", "denied", { archiveId, reason: "size", stored: head.bytes });
271
+ return { ok: false, provider: remote.provider, bytes: head.bytes, checkedAt, detail: `The bucket holds ${head.bytes} bytes; ${remote.bytes} were verified. Do not trash the original.` };
272
+ }
273
+ await recordAudit(dataDir, "remote.check", "allowed", { archiveId, bytes: head.bytes });
274
+ return { ok: true, provider: remote.provider, bytes: head.bytes, checkedAt, detail: "The bucket answered just now and holds this archive at the recorded size." };
275
+ }
276
+ /**
277
+ * The bulk form of `checkRemoteCopy`. Managed rows share ONE control-plane
278
+ * listing instead of asking per archive — checking a 53-row vault is one call
279
+ * plus a HEAD per own-bucket object, not 53 round trips.
280
+ */
281
+ export async function checkRemoteCopyMany(dataDir, ids, client) {
282
+ const archives = await listArchives(dataDir);
283
+ const wanted = ids.map((id) => archives.find((item) => item.id === id));
284
+ const checkedAt = new Date().toISOString();
285
+ const results = [];
286
+ const managedIds = wanted.filter((record) => !!record && isV2(record) && record.remote?.provider === "vaultline").map((record) => record.id);
287
+ let managedRows = null;
288
+ if (managedIds.length > 0 && !client) {
289
+ const { listCloudArchives } = await import("./cloud.js");
290
+ managedRows = new Map((await listCloudArchives(dataDir)).map((row) => [row.vault_ref, Number(row.bytes) || 0]));
291
+ }
292
+ for (const id of ids) {
293
+ const record = archives.find((item) => item.id === id);
294
+ if (!record || !isV2(record) || !record.remote?.verifiedAt) {
295
+ results.push({ ok: false, provider: "none", bytes: 0, checkedAt, detail: "No verified cloud copy on record for this archive." });
296
+ continue;
297
+ }
298
+ if (record.remote.provider === "vaultline" && managedRows) {
299
+ results.push(await managedCopyCheck(dataDir, record, managedRows, checkedAt));
300
+ continue;
301
+ }
302
+ try {
303
+ results.push(await checkRemoteCopy(dataDir, id, client));
304
+ }
305
+ catch (error) {
306
+ results.push({ ok: false, provider: record.remote.provider, bytes: 0, checkedAt, detail: error instanceof Error ? error.message : "the check itself failed" });
307
+ }
308
+ }
309
+ return results;
310
+ }
311
+ /**
312
+ * Gives back a local path holding this archive's ciphertext, fetching it first
313
+ * if the local copy was offloaded.
314
+ *
315
+ * Restoring reads the object twice — once to hash it, once to decrypt it — so
316
+ * it needs a file, not a buffer. An offloaded archive is written to a temporary
317
+ * one and removed afterwards, which keeps the disk saving intact: the bytes are
318
+ * on this machine only for as long as the restore takes.
319
+ */
320
+ export async function materialiseArchive(dataDir, record, client) {
321
+ if (!isV2(record) || !record.offloaded)
322
+ return { path: record.objectPath, release: async () => { } };
323
+ const ciphertext = await fetchCiphertext(dataDir, record, client);
324
+ if (sha256(ciphertext) !== record.cipher.ciphertextSha256) {
325
+ fail("ciphertext_integrity_failed", `The copy of archive ${record.id} in ${record.offloaded.bucket} is not the archive that was stored there`, { archiveId: record.id });
326
+ }
327
+ const scratch = await mkdtemp(join(tmpdir(), "vaultline-fetch-"));
328
+ const path = join(scratch, `${record.id}.vlarchive`);
329
+ await writeFile(path, ciphertext, { mode: 0o600 });
330
+ return { path, release: async () => { await rm(scratch, { recursive: true, force: true }); } };
331
+ }
@@ -0,0 +1,10 @@
1
+ import { detectAgents } from "./adapters.js";
2
+ export type SetupResult = {
3
+ initialized: boolean;
4
+ vaultId: string;
5
+ recoveryPhrase?: string;
6
+ detectedAgents: Awaited<ReturnType<typeof detectAgents>>;
7
+ files: string[];
8
+ };
9
+ /** Creates only Sealkeep-owned artifacts. Agent settings are never modified. */
10
+ export declare function setupVault(dataDir: string, providedPhrase?: string): Promise<SetupResult>;
@@ -0,0 +1,44 @@
1
+ import { access, mkdir, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { detectAgents, hookConfig } from "./adapters.js";
5
+ import { initialize, vaultStatus } from "./vault.js";
6
+ async function exists(path) { try {
7
+ await access(path, constants.F_OK);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ } }
13
+ async function writeJson(path, value) { await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); }
14
+ /** Creates only Sealkeep-owned artifacts. Agent settings are never modified. */
15
+ export async function setupVault(dataDir, providedPhrase) {
16
+ const configPath = join(dataDir, "config.json");
17
+ let initialized = false;
18
+ let recoveryPhrase;
19
+ if (!(await exists(configPath))) {
20
+ const created = await initialize(dataDir, providedPhrase);
21
+ initialized = true;
22
+ recoveryPhrase = created.phrase;
23
+ }
24
+ const status = await vaultStatus(dataDir);
25
+ const detectedAgents = await detectAgents();
26
+ const integrations = join(dataDir, "integrations");
27
+ const files = [];
28
+ for (const agent of ["codex", "claude"]) {
29
+ const path = join(integrations, `${agent}-hooks.json`);
30
+ await writeJson(path, hookConfig(agent, "vaultline", dataDir));
31
+ files.push(path);
32
+ }
33
+ // Written by `sealkeep mcp install`, which knows how to register with each
34
+ // agent rather than leaving a fragment and hoping. This used to emit a JSON
35
+ // fragment here naming `sealkeep mcp` — a command that did not exist until
36
+ // that install path was built, so anyone who merged it got an agent that
37
+ // failed to start its server.
38
+ const { installMcpServer } = await import("./mcp-install.js");
39
+ const mcp = await installMcpServer(dataDir);
40
+ for (const entry of mcp.agents)
41
+ if (entry.fragmentPath)
42
+ files.push(entry.fragmentPath);
43
+ return { initialized, vaultId: status.vaultId, recoveryPhrase, detectedAgents, files };
44
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What a customer actually buys.
3
+ *
4
+ * A package is a number of gigabytes and nothing else. Which provider holds the
5
+ * bytes is our problem, not the customer's — we pick whichever of B2 or R2 is
6
+ * cheapest and can move between them without anyone needing to know, because
7
+ * the ciphertext is opaque and the envelope travels with it. A customer who
8
+ * wants to name their own provider, region and storage class is not buying a
9
+ * package at all; they point Sealkeep at their own bucket and pay their own
10
+ * storage bill.
11
+ *
12
+ * Prices are monthly, in US dollars, and match what the site publishes — a
13
+ * visitor who has already read the pricing page must not be quoted something
14
+ * else by the CLI.
15
+ *
16
+ * On margin, and the mistake this comment used to make: compression does *not*
17
+ * halve what we store per GB of quota sold. Quota is metered on ciphertext —
18
+ * `src/upload.ts:42` sends `bytes: ciphertext.length` and the control plane
19
+ * bills against exactly that — so at full fill, stored bytes equal quota 1:1.
20
+ * Compression buys the *customer* more sessions per GB; it buys us nothing.
21
+ *
22
+ * Costed correctly, at Backblaze B2's $0.00695/GB-mo with free egress, and
23
+ * noting that `GB` below is 1024^3 while B2 bills decimal GB (+7.4%):
24
+ * gb100 ~$0.75, gb500 ~$3.73, gb2000 ~$14.92 per month at full quota.
25
+ *
26
+ * That is comfortable on B2 and not on the alternatives: at R2 Standard's
27
+ * $0.015 the gb2000 tier costs ~$32 against $30 of revenue, and on the GCS
28
+ * that managed storage still runs on today it costs ~$43. Finishing the B2
29
+ * migration is what makes the top tier viable, not a cost optimisation.
30
+ * See STORAGE.md.
31
+ */
32
+ export type PackageId = "free" | "pro_lite" | "gb100" | "gb500" | "gb2000";
33
+ export type StoragePackage = {
34
+ id: PackageId;
35
+ name: string;
36
+ /** Gigabytes of managed storage. 0 means the customer runs their own bucket. */
37
+ gigabytes: number;
38
+ /** US dollars per month. 0 is free. */
39
+ monthly: number;
40
+ managed: boolean;
41
+ blurb: string;
42
+ };
43
+ export declare const PACKAGES: readonly StoragePackage[];
44
+ export declare function findPackage(id: string): StoragePackage | undefined;
45
+ export declare function quotaBytes(id: PackageId): number;
46
+ /**
47
+ * Which package an account is on, worked out from what the control plane
48
+ * actually granted rather than from anything the client asked for.
49
+ *
50
+ * Quota is an entitlement: it is set server-side when a subscription is paid,
51
+ * never by the machine doing the uploading. This maps a granted quota back to
52
+ * the nearest package so the panel and the CLI can name it, and falls back to
53
+ * a plain byte count when a quota does not match the catalogue — a custom or
54
+ * grandfathered grant should be reported honestly, not rounded into a tier.
55
+ */
56
+ export declare function packageForAccount(account: {
57
+ plan?: string;
58
+ quota_bytes?: number;
59
+ }): StoragePackage | {
60
+ id: "custom";
61
+ name: string;
62
+ gigabytes: number;
63
+ monthly: null;
64
+ managed: true;
65
+ blurb: string;
66
+ };
67
+ /** Managed storage providers we may put a package on. Never customer-visible. */
68
+ export type ManagedProvider = "gcs" | "b2" | "r2";
69
+ /**
70
+ * Quota visibility.
71
+ *
72
+ * Today a customer who fills their package gets a bare 402 on their next
73
+ * upload — no warning beforehand, no dashboard number, nothing, even though
74
+ * `CloudAccount` has carried `quota_bytes` and `used_bytes` the whole time.
75
+ * This is the client-side surface for that data: where an account sits
76
+ * against its quota, and the plain-language line that goes with it, shared by
77
+ * `cloud status`, `status`, `doctor`, and the rewritten 402 so all four agree
78
+ * on the same numbers and the same next step.
79
+ *
80
+ * This is groundwork only. It warns and it points at `sealkeep plans`; it
81
+ * does not charge anyone or decide how overage should work, because that
82
+ * product decision has not been made yet.
83
+ */
84
+ export type QuotaLevel = "ok" | "warn" | "critical" | "over";
85
+ export type QuotaState = {
86
+ usedBytes: number;
87
+ quotaBytes: number;
88
+ /** Floored, and not clamped to 100 — an account genuinely over quota reads
89
+ * as more than 100%, not as a suspiciously round 100. */
90
+ percent: number;
91
+ remainingBytes: number;
92
+ level: QuotaLevel;
93
+ };
94
+ /**
95
+ * Where an account sits against its managed-storage quota.
96
+ *
97
+ * The displayed percentage is floored, not rounded, and the level is compared
98
+ * against the same floored figure, so the two can never disagree — 79.6% reads
99
+ * as "79%" and "ok", never a "80%" that is not flagged as a warning. A quota
100
+ * of zero or less means there is no managed entitlement to speak of — a pure
101
+ * bring-your-own-bucket account — so it reads as "ok" rather than a division
102
+ * by zero.
103
+ */
104
+ export declare function quotaState(usedBytes: number, quotaBytes: number): QuotaState;
105
+ /**
106
+ * One sentence, always carrying the real numbers.
107
+ *
108
+ * A customer who hits this has no dashboard to cross-check it against — this
109
+ * line, wherever it is shown, is the only accounting they get. `warn` and
110
+ * `critical` both name what to do; `over` says plainly that uploads will fail,
111
+ * because at 100% they will.
112
+ */
113
+ export declare function quotaMessage(state: QuotaState): string;
114
+ /**
115
+ * Rewrites a bare quota rejection into something a customer can act on.
116
+ *
117
+ * `call()` in cloud.ts maps a 402 straight to a VaultlineError coded
118
+ * "forbidden" whose message is whatever the control plane said — historically
119
+ * just "Quota exceeded", with no numbers and no next step. This asks the same
120
+ * question `cloud status` would have answered in advance: where the account
121
+ * actually stands, and what to do about it.
122
+ */
123
+ export declare function quotaExceededMessage(account: {
124
+ used_bytes: number;
125
+ quota_bytes: number;
126
+ }): string;
@@ -0,0 +1,114 @@
1
+ import { bytes } from "./ui.js";
2
+ const GB = 1024 ** 3;
3
+ export const PACKAGES = [
4
+ {
5
+ id: "free", name: "Your own bucket", gigabytes: 0, monthly: 0, managed: false,
6
+ blurb: "Point Sealkeep at storage you already pay for. Your provider, your region, your retention. We never hold the bytes and never see a key."
7
+ },
8
+ {
9
+ id: "pro_lite", name: "Pro Lite", gigabytes: 0, monthly: 2, managed: false,
10
+ blurb: "Your storage, our safety net: a sealed copy of your key in escrow, and every machine signed in. We still hold no bytes."
11
+ },
12
+ {
13
+ id: "gb100", name: "100 GB", gigabytes: 100, monthly: 6, managed: true,
14
+ blurb: "Enough for years of transcripts on one machine. Nothing to create, nothing to configure."
15
+ },
16
+ {
17
+ id: "gb500", name: "500 GB", gigabytes: 500, monthly: 12, managed: true,
18
+ blurb: "A whole team's history, or one machine that never deletes anything."
19
+ },
20
+ {
21
+ id: "gb2000", name: "2 TB", gigabytes: 2000, monthly: 30, managed: true,
22
+ blurb: "Every agent on every machine, kept indefinitely."
23
+ }
24
+ ];
25
+ export function findPackage(id) {
26
+ return PACKAGES.find((item) => item.id === id);
27
+ }
28
+ export function quotaBytes(id) {
29
+ return (findPackage(id)?.gigabytes ?? 0) * GB;
30
+ }
31
+ /**
32
+ * Which package an account is on, worked out from what the control plane
33
+ * actually granted rather than from anything the client asked for.
34
+ *
35
+ * Quota is an entitlement: it is set server-side when a subscription is paid,
36
+ * never by the machine doing the uploading. This maps a granted quota back to
37
+ * the nearest package so the panel and the CLI can name it, and falls back to
38
+ * a plain byte count when a quota does not match the catalogue — a custom or
39
+ * grandfathered grant should be reported honestly, not rounded into a tier.
40
+ */
41
+ export function packageForAccount(account) {
42
+ if (account.plan === "pro_lite")
43
+ return findPackage("pro_lite") ?? PACKAGES[0];
44
+ if (account.plan !== "pro")
45
+ return PACKAGES[0];
46
+ const gigabytes = Math.round((account.quota_bytes ?? 0) / GB);
47
+ const exact = PACKAGES.find((item) => item.managed && item.gigabytes === gigabytes);
48
+ if (exact)
49
+ return exact;
50
+ return {
51
+ id: "custom", name: `${gigabytes} GB`, gigabytes, monthly: null, managed: true,
52
+ blurb: "A custom allocation, not one of the standard packages."
53
+ };
54
+ }
55
+ const WARN_AT = 0.8;
56
+ const CRITICAL_AT = 0.9;
57
+ /**
58
+ * Where an account sits against its managed-storage quota.
59
+ *
60
+ * The displayed percentage is floored, not rounded, and the level is compared
61
+ * against the same floored figure, so the two can never disagree — 79.6% reads
62
+ * as "79%" and "ok", never a "80%" that is not flagged as a warning. A quota
63
+ * of zero or less means there is no managed entitlement to speak of — a pure
64
+ * bring-your-own-bucket account — so it reads as "ok" rather than a division
65
+ * by zero.
66
+ */
67
+ export function quotaState(usedBytes, quotaBytes) {
68
+ const used = Math.max(0, usedBytes || 0);
69
+ const quota = Math.max(0, quotaBytes || 0);
70
+ if (quota === 0)
71
+ return { usedBytes: used, quotaBytes: 0, percent: 0, remainingBytes: 0, level: "ok" };
72
+ const percent = Math.floor((used / quota) * 100);
73
+ const level = percent >= 100 ? "over" : percent >= CRITICAL_AT * 100 ? "critical" : percent >= WARN_AT * 100 ? "warn" : "ok";
74
+ return { usedBytes: used, quotaBytes: quota, percent, remainingBytes: Math.max(0, quota - used), level };
75
+ }
76
+ /**
77
+ * One sentence, always carrying the real numbers.
78
+ *
79
+ * A customer who hits this has no dashboard to cross-check it against — this
80
+ * line, wherever it is shown, is the only accounting they get. `warn` and
81
+ * `critical` both name what to do; `over` says plainly that uploads will fail,
82
+ * because at 100% they will.
83
+ */
84
+ export function quotaMessage(state) {
85
+ const used = bytes(state.usedBytes);
86
+ const quota = bytes(state.quotaBytes);
87
+ const left = bytes(state.remainingBytes);
88
+ switch (state.level) {
89
+ // No command in these. This string reaches the dashboard's Health tab
90
+ // through doctor, and the one place a paying customer will definitely see
91
+ // it is the moment their uploads stop — telling them to open a terminal to
92
+ // see prices, right then, is the wrong answer. The CLI adds its own hint.
93
+ case "over":
94
+ return `${used} of ${quota} used (${state.percent}%). New archives are being held on this machine until there is room — free some space, or move to a bigger package.`;
95
+ case "critical":
96
+ return `${used} of ${quota} used (${state.percent}%), ${left} left before uploads pause.`;
97
+ case "warn":
98
+ return `${used} of ${quota} used (${state.percent}%), ${left} left.`;
99
+ default:
100
+ return `${used} of ${quota} used (${state.percent}%), ${left} left.`;
101
+ }
102
+ }
103
+ /**
104
+ * Rewrites a bare quota rejection into something a customer can act on.
105
+ *
106
+ * `call()` in cloud.ts maps a 402 straight to a VaultlineError coded
107
+ * "forbidden" whose message is whatever the control plane said — historically
108
+ * just "Quota exceeded", with no numbers and no next step. This asks the same
109
+ * question `cloud status` would have answered in advance: where the account
110
+ * actually stands, and what to do about it.
111
+ */
112
+ export function quotaExceededMessage(account) {
113
+ return `Quota exceeded: ${quotaMessage(quotaState(account.used_bytes, account.quota_bytes))}`;
114
+ }
@@ -0,0 +1,26 @@
1
+ export declare const PRF_SECRET_BYTES = 32;
2
+ /** A fresh per-vault salt, evaluated by the authenticator to produce the secret. */
3
+ export declare function generatePrfSalt(): string;
4
+ /**
5
+ * The X25519 identity that a given passkey secret always produces.
6
+ *
7
+ * Deterministic on purpose: enrolment registers the public half, and every
8
+ * later unlock re-derives the private half from the same finger. If this were
9
+ * random, a passkey could enrol but never open anything.
10
+ */
11
+ export declare function keysFromPrfSecret(secret: Buffer): {
12
+ publicKeyBase64: string;
13
+ privateKeyRaw: Buffer;
14
+ };
15
+ /** What a vault remembers about an enrolled passkey. Never the secret, never the key. */
16
+ export type PasskeyRecord = {
17
+ /** The WebAuthn credential id, so a later unlock asks for the right passkey. */
18
+ credentialId: string;
19
+ /** The salt the authenticator evaluates. Public by design: it is useless alone. */
20
+ prfSalt: string;
21
+ label: string;
22
+ addedAt: string;
23
+ /** The recipient this passkey opens, so the two can be removed together. */
24
+ recipientId: string;
25
+ };
26
+ export declare function isPasskeyRecord(value: unknown): value is PasskeyRecord;