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,446 @@
1
+ import { readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isSourceActive } from "./activity.js";
4
+ import { fail, isVaultlineError } from "./errors.js";
5
+ import { recordAudit } from "./audit.js";
6
+ import { moveToTrash } from "./trash.js";
7
+ import { isV2 } from "./types.js";
8
+ import { listArchives, readConfig, writeConfig, writeRecord } from "./vault.js";
9
+ /** For blocker text a person reads. Whole numbers below 10 units, one decimal above. */
10
+ function humanBytes(value) {
11
+ const units = ["B", "KB", "MB", "GB", "TB"];
12
+ let size = value, unit = 0;
13
+ while (size >= 1024 && unit < units.length - 1) {
14
+ size /= 1024;
15
+ unit += 1;
16
+ }
17
+ return `${unit === 0 ? size : size < 10 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
18
+ }
19
+ export const DEFAULT_RETENTION = {
20
+ policy: "local-only", olderThanDays: 30, graceDays: 7, deleteAfterDays: null,
21
+ sourceIdleDays: null, minSourceBytes: null
22
+ };
23
+ export const POLICIES = ["local-only", "sync-only", "archive-and-reclaim", "manual-approval"];
24
+ export function retentionSettings(config, overrides = {}) {
25
+ return { ...DEFAULT_RETENTION, ...config.retention, ...overrides };
26
+ }
27
+ const approvalsPath = (dataDir) => join(dataDir, "retention-approvals.json");
28
+ export async function readApprovals(dataDir) {
29
+ try {
30
+ return JSON.parse(await readFile(approvalsPath(dataDir), "utf8"));
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ /** Manual-approval policy: an operator names the archives whose sources may be reclaimed. */
37
+ export async function approveReclamation(dataDir, archiveIds) {
38
+ const known = new Set((await listArchives(dataDir)).map((record) => record.id));
39
+ for (const id of archiveIds)
40
+ if (!known.has(id))
41
+ fail("archive_not_found", `Archive not found: ${id}`, { archiveId: id });
42
+ const merged = [...new Set([...(await readApprovals(dataDir)), ...archiveIds])];
43
+ await writeFile(approvalsPath(dataDir), JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
44
+ await recordAudit(dataDir, "retention.approve", "allowed", { approved: archiveIds.length, total: merged.length });
45
+ return merged;
46
+ }
47
+ /**
48
+ * Decides, per archive, whether its local source may be reclaimed.
49
+ *
50
+ * Every gate is evaluated and all failures are reported, so an operator sees the
51
+ * complete reason a file is protected rather than the first one that matched.
52
+ */
53
+ export async function evaluateRetention(dataDir, options = {}) {
54
+ const config = await readConfig(dataDir);
55
+ const settings = retentionSettings(config, { ...options.settings, ...(options.olderThanDays === undefined ? {} : { olderThanDays: options.olderThanDays }) });
56
+ if (!Number.isFinite(settings.olderThanDays) || settings.olderThanDays < 0)
57
+ fail("invalid_argument", "olderThanDays must be a non-negative number");
58
+ if (settings.sourceIdleDays !== null && (!Number.isFinite(settings.sourceIdleDays) || settings.sourceIdleDays < 0))
59
+ fail("invalid_argument", "sourceIdleDays must be a non-negative number of days, or null to ignore how long a session has been idle");
60
+ if (settings.minSourceBytes !== null && (!Number.isFinite(settings.minSourceBytes) || settings.minSourceBytes < 0))
61
+ fail("invalid_argument", "minSourceBytes must be a non-negative number of bytes, or null to reclaim any size");
62
+ const now = options.now ?? Date.now();
63
+ const approvals = new Set(await readApprovals(dataDir));
64
+ const candidates = [];
65
+ // Whether this vault is on the managed tier, which has no remoteStorage of
66
+ // its own. Best effort and cheap: any archive that has been verified into
67
+ // managed storage proves it, and a fresh vault with none reads as unmanaged,
68
+ // which only changes wording.
69
+ const allRecords = await listArchives(dataDir);
70
+ const managed = allRecords.some((item) => isV2(item) && item.remote?.provider === "vaultline");
71
+ for (const record of allRecords) {
72
+ const current = await stat(record.source.path).catch(() => null);
73
+ if (!current)
74
+ continue;
75
+ if (isV2(record) && record.reclaimed)
76
+ continue;
77
+ const blockers = [];
78
+ // The archive covers the bytes that existed when it was written. A session
79
+ // that kept being appended to has content in no archive at all, and
80
+ // reclaiming it would delete exactly that content — so refuse, and say so
81
+ // in terms that name the fix. Size is compared rather than hashed because
82
+ // agent transcripts grow by appending and this runs over every record.
83
+ if (current.size !== record.source.bytes)
84
+ blockers.push(`source changed since it was archived (${record.source.bytes} bytes archived, ${current.size} now); re-archive it first`);
85
+ // These render straight into a dashboard table cell, so they are sentences
86
+ // a customer reads, not slugs a maintainer greps. The retention page already
87
+ // names the four policies in plain words; the same words are used here.
88
+ //
89
+ // Order matters: `reason` is the first of these, and it is what the table
90
+ // shows in a narrow column. The one a person can act on leads — "no
91
+ // storage is set up" is a thing to go and change, while "set to keep
92
+ // everything" is quite possibly what they meant.
93
+ const remote = isV2(record) ? record.remote : undefined;
94
+ // A managed-tier vault has no `remoteStorage` of its own — the bucket is
95
+ // ours — so "no remote target configured" was told to paying customers about
96
+ // storage they were paying for. The honest state is the same either way:
97
+ // this archive has not been uploaded and verified yet.
98
+ if (!remote?.verifiedAt)
99
+ blockers.push(config.remoteStorage || managed ? "not yet uploaded and verified" : "no storage is set up, so there is no second copy to reclaim against");
100
+ // A delta archive restores through its whole chain, so "there is a second
101
+ // copy" must be true of every link, not just this head. One unverified
102
+ // base and reclaiming the source deletes bytes only this disk holds.
103
+ if (remote?.verifiedAt) {
104
+ const { deltaOf, resolveDeltaChain } = await import("./vault.js");
105
+ if (deltaOf(record)) {
106
+ try {
107
+ const chain = resolveDeltaChain(allRecords, record);
108
+ const unverified = chain.filter((link) => !(isV2(link) && link.remote?.verifiedAt)).length;
109
+ if (unverified > 0)
110
+ blockers.push(`this snapshot builds on ${chain.length - 1} earlier ${chain.length === 2 ? "archive" : "archives"}, and ${unverified} of the chain ${unverified === 1 ? "is" : "are"} not uploaded and verified yet`);
111
+ }
112
+ catch {
113
+ blockers.push("this snapshot builds on an earlier archive that is missing, so the transcript cannot be restored from the cloud alone");
114
+ }
115
+ }
116
+ }
117
+ if (settings.policy === "local-only")
118
+ blockers.push("retention is set to keep everything");
119
+ if (settings.policy === "sync-only")
120
+ blockers.push("retention is set to upload but keep the original");
121
+ if (!isV2(record))
122
+ blockers.push("this archive is in an older format and needs updating before its source can be reclaimed");
123
+ const ageMs = now - Date.parse(record.createdAt);
124
+ if (ageMs < settings.olderThanDays * 86_400_000)
125
+ blockers.push(`archived ${Math.floor(ageMs / 86_400_000)} days ago, younger than the ${settings.olderThanDays}-day threshold`);
126
+ // The source's own age, read from the file rather than from our records, so
127
+ // "I have not touched this in months" is answerable without trusting that
128
+ // we archived it at a useful moment.
129
+ if (settings.sourceIdleDays !== null) {
130
+ const idleDays = (now - current.mtimeMs) / 86_400_000;
131
+ if (idleDays < settings.sourceIdleDays)
132
+ blockers.push(`last modified ${Math.floor(idleDays)} days ago, sooner than the ${settings.sourceIdleDays}-day idle threshold`);
133
+ }
134
+ if (settings.minSourceBytes !== null && current.size < settings.minSourceBytes)
135
+ blockers.push(`${humanBytes(current.size)} is under your ${humanBytes(settings.minSourceBytes)} floor`);
136
+ if (remote?.verifiedAt) {
137
+ const graceRemainingMs = settings.graceDays * 86_400_000 - (now - Date.parse(remote.verifiedAt));
138
+ // Days, because that is the unit the person set it in; "168h" for a
139
+ // seven-day grace period is a conversion nobody asked for.
140
+ if (graceRemainingMs > 0) {
141
+ const hours = Math.ceil(graceRemainingMs / 3_600_000);
142
+ blockers.push(hours < 48 ? `grace period ends in ${hours}h` : `grace period ends in ${Math.ceil(hours / 24)} days`);
143
+ }
144
+ }
145
+ // The activity probe shells out to lsof, so it is the one expensive gate —
146
+ // and it only changes the outcome when every other gate already passed.
147
+ // Probing unconditionally put ~90ms × every archive inside each evaluation,
148
+ // which every /v1/status call awaited: 49 archives made the dashboard's
149
+ // first paint 4 seconds, and a big library would make it a minute.
150
+ if (blockers.length === 0) {
151
+ const activity = await isSourceActive(record.source.path, { ...options.activity, now });
152
+ if (activity.active)
153
+ blockers.push(`source looks active: ${activity.reason}`);
154
+ }
155
+ if (settings.policy === "manual-approval" && !approvals.has(record.id))
156
+ blockers.push("awaiting manual approval");
157
+ // Two gates can reach the same conclusion in the same words; a cell that
158
+ // reads "no storage set up · no storage set up" looks like a bug, not a
159
+ // reason.
160
+ const distinct = [...new Set(blockers)];
161
+ candidates.push({
162
+ id: record.id, agent: record.source.agent, path: record.source.path, bytes: record.source.bytes, createdAt: record.createdAt,
163
+ eligible: distinct.length === 0, blockers: distinct, reason: distinct[0] ?? "eligible for reclamation",
164
+ remote: remote?.verifiedAt ? { provider: remote.provider, verifiedAt: remote.verifiedAt } : null
165
+ });
166
+ }
167
+ const reclaimableBytes = candidates.filter((candidate) => candidate.eligible).reduce((total, candidate) => total + candidate.bytes, 0);
168
+ return {
169
+ mode: "preview", policy: settings.policy, olderThanDays: settings.olderThanDays, graceDays: settings.graceDays,
170
+ sourceIdleDays: settings.sourceIdleDays, minSourceBytes: settings.minSourceBytes,
171
+ // Returned so the page that sets it can show what is set. Without it the
172
+ // field rendered blank on every load, which read as "not configured" even
173
+ // when it was.
174
+ deleteAfterDays: settings.deleteAfterDays,
175
+ reclaimableBytes, candidates
176
+ };
177
+ }
178
+ /**
179
+ * Reclaims eligible sources. Dry-run is the default: without `confirm`, nothing on
180
+ * disk changes. Reclaimed files go to the OS trash, never to `unlink`.
181
+ */
182
+ export async function applyRetention(dataDir, options = {}) {
183
+ const config = await readConfig(dataDir);
184
+ const evaluation = await evaluateRetention(dataDir, options);
185
+ const eligible = evaluation.candidates.filter((candidate) => candidate.eligible);
186
+ if (!options.confirm) {
187
+ return { mode: "dry-run", policy: evaluation.policy, wouldReclaim: eligible.length, wouldFreeBytes: evaluation.reclaimableBytes, candidates: evaluation.candidates };
188
+ }
189
+ const records = new Map((await listArchives(dataDir)).map((record) => [record.id, record]));
190
+ const reclaimed = [];
191
+ const failed = [];
192
+ for (const candidate of eligible) {
193
+ try {
194
+ const result = await moveToTrash(candidate.path, { dataDir, home: options.home, strategy: options.strategy });
195
+ const record = records.get(candidate.id);
196
+ if (isV2(record)) {
197
+ const next = { ...record, reclaimed: { at: new Date(options.now ?? Date.now()).toISOString(), trashPath: result.movedTo, strategy: result.strategy } };
198
+ await writeRecord(config.storage.root, next);
199
+ }
200
+ await recordAudit(dataDir, "source.reclaim", "allowed", { archiveId: candidate.id, path: candidate.path, bytes: candidate.bytes, movedTo: result.movedTo, strategy: result.strategy });
201
+ reclaimed.push({ id: candidate.id, path: candidate.path, bytes: candidate.bytes, movedTo: result.movedTo, strategy: result.strategy });
202
+ }
203
+ catch (error) {
204
+ await recordAudit(dataDir, "source.reclaim", "denied", { archiveId: candidate.id, path: candidate.path });
205
+ failed.push({ id: candidate.id, error: error instanceof Error ? error.message : "reclamation failed" });
206
+ }
207
+ }
208
+ return { mode: "apply", policy: evaluation.policy, reclaimed, failed, freedBytes: reclaimed.reduce((total, item) => total + item.bytes, 0), protected: evaluation.candidates.length - eligible.length };
209
+ }
210
+ /**
211
+ * Reclaims ONE source, because a person pointed at it.
212
+ *
213
+ * The automatic pass (`applyRetention`) obeys every rule: policy, age, grace,
214
+ * idle, size floor, manual approval. Those rules exist so the machine acting
215
+ * on its own is conservative. A person clicking one row after checking its
216
+ * cloud copy IS the approval — so this path keeps only the gates that protect
217
+ * against actual data loss, and says which one refused when it refuses:
218
+ *
219
+ * - the archive must have a verified remote copy (and a delta head's whole
220
+ * chain must be verified — one unverified base and the cloud cannot
221
+ * reproduce the file this is about to trash);
222
+ * - the source must be byte-for-byte the file that was archived — a session
223
+ * that grew since has content in no archive at all;
224
+ * - nothing may have the file open right now.
225
+ *
226
+ * Policy, age and grace are deliberately not consulted. Trash, never unlink,
227
+ * same as everywhere.
228
+ */
229
+ export async function reclaimOne(dataDir, archiveId, options = {}) {
230
+ const config = await readConfig(dataDir);
231
+ const archives = await listArchives(dataDir);
232
+ const record = archives.find((item) => item.id === archiveId);
233
+ if (!record)
234
+ fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
235
+ if (!isV2(record))
236
+ fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
237
+ if (record.reclaimed)
238
+ fail("invalid_argument", "This source was already reclaimed — it is in your trash, not on this list.");
239
+ const current = await stat(record.source.path).catch(() => null);
240
+ if (!current)
241
+ fail("source_unreadable", "The source file is no longer at its recorded path, so there is nothing to reclaim.", { path: record.source.path });
242
+ if (current.size !== record.source.bytes) {
243
+ fail("invalid_argument", `The session changed since it was archived (${record.source.bytes} bytes archived, ${current.size} now) — re-archive it first, or reclaiming would delete exactly the part no archive holds.`, { archiveId });
244
+ }
245
+ if (!record.remote?.verifiedAt) {
246
+ fail("invalid_argument", "No verified cloud copy exists for this archive, so this disk holds the only bytes. Upload it first.", { archiveId });
247
+ }
248
+ const { deltaOf, resolveDeltaChain } = await import("./vault.js");
249
+ if (deltaOf(record)) {
250
+ try {
251
+ const chain = resolveDeltaChain(archives, record);
252
+ const unverified = chain.filter((link) => !(isV2(link) && link.remote?.verifiedAt)).length;
253
+ if (unverified > 0)
254
+ fail("invalid_argument", `This snapshot builds on earlier archives and ${unverified} of the chain ${unverified === 1 ? "is" : "are"} not verified in the cloud yet — the cloud cannot reproduce this file without them.`, { archiveId });
255
+ }
256
+ catch (error) {
257
+ if (isVaultlineError(error))
258
+ throw error;
259
+ fail("invalid_argument", "This snapshot builds on an earlier archive that is missing, so the cloud cannot reproduce the file. Keep the source.", { archiveId });
260
+ }
261
+ }
262
+ const activity = await isSourceActive(record.source.path, { ...options.activity, now: options.now ?? Date.now() });
263
+ if (activity.active)
264
+ fail("invalid_argument", `The file looks in use right now: ${activity.reason}. Close it and try again.`, { archiveId });
265
+ if (!options.confirm)
266
+ fail("invalid_argument", "Pass confirm to move this source to the trash.");
267
+ const result = await moveToTrash(record.source.path, { dataDir, home: options.home, strategy: options.strategy });
268
+ const next = { ...record, reclaimed: { at: new Date(options.now ?? Date.now()).toISOString(), trashPath: result.movedTo, strategy: result.strategy } };
269
+ await writeRecord(config.storage.root, next);
270
+ await recordAudit(dataDir, "source.reclaim", "allowed", { archiveId, path: record.source.path, bytes: record.source.bytes, movedTo: result.movedTo, strategy: result.strategy, manual: true });
271
+ return { id: archiveId, path: record.source.path, bytes: record.source.bytes, movedTo: result.movedTo, strategy: result.strategy };
272
+ }
273
+ /**
274
+ * `reclaimOne` over a selection. Each row passes or is kept with its reason —
275
+ * one refusal never blocks the rest, and the reasons come back in the same
276
+ * sentences the single path speaks.
277
+ */
278
+ export async function reclaimBatch(dataDir, ids, options = {}) {
279
+ const reclaimed = [];
280
+ const kept = [];
281
+ for (const id of ids) {
282
+ try {
283
+ reclaimed.push(await reclaimOne(dataDir, id, options));
284
+ }
285
+ catch (error) {
286
+ kept.push({ id, reason: error instanceof Error ? error.message : "could not reclaim" });
287
+ }
288
+ }
289
+ return { reclaimed, kept, freedBytes: reclaimed.reduce((total, item) => total + item.bytes, 0) };
290
+ }
291
+ export async function setRetentionPolicy(dataDir, settings) {
292
+ if (settings.policy && !POLICIES.includes(settings.policy))
293
+ fail("invalid_argument", `Policy must be one of: ${POLICIES.join(", ")}`);
294
+ for (const key of ["olderThanDays", "graceDays"]) {
295
+ const value = settings[key];
296
+ if (value !== undefined && (!Number.isFinite(value) || value < 0))
297
+ fail("invalid_argument", `${key} must be a non-negative number`);
298
+ }
299
+ // These two are nullable — null is "do not consider this at all", which is a
300
+ // different statement from zero, and zero would mean "reclaim everything".
301
+ for (const key of ["sourceIdleDays", "minSourceBytes", "deleteAfterDays"]) {
302
+ const value = settings[key];
303
+ if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 0))
304
+ fail("invalid_argument", `${key} must be a non-negative number, or null to ignore it`);
305
+ }
306
+ const config = await readConfig(dataDir);
307
+ const next = { ...config, retention: retentionSettings(config, settings) };
308
+ await writeConfig(dataDir, next);
309
+ return next;
310
+ }
311
+ /**
312
+ * Snapshots whose every byte is already inside a newer archive of the same file.
313
+ *
314
+ * Agent transcripts are append-only, so a session archived while it was still
315
+ * growing leaves a trail: a 1 GB snapshot, then a 2 GB one, then the finished
316
+ * 3.8 GB. Each is complete and standalone — that is what makes them safe — but
317
+ * it also makes the earlier ones pure duplication, because the newest archive
318
+ * already reproduces every byte they hold. `supersedes` recorded this from the
319
+ * beginning and nothing ever read it, so the trail was kept forever and, on
320
+ * metered storage, paid for forever.
321
+ *
322
+ * Nothing is deleted here. Deleting an archive is the one action that cannot be
323
+ * undone from somewhere else, and the product's promise is that it reports what
324
+ * could go and never acts alone. Two conditions are required before a snapshot
325
+ * is even *offered*: the superseding archive's object must still exist, and,
326
+ * where remote storage is configured, it must be verified there. A redundant
327
+ * copy is only redundant while the copy that replaced it is real.
328
+ */
329
+ export async function redundantArchives(dataDir) {
330
+ const { readConfig, listArchives } = await import("./vault.js");
331
+ const config = await readConfig(dataDir);
332
+ const archives = await listArchives(dataDir);
333
+ const byId = new Map(archives.map((record) => [record.id, record]));
334
+ // Which archives are pointed at as a base, and by whom. Only the *newest*
335
+ // claimant matters, but any claimant proves the bytes live on elsewhere.
336
+ const supersededBy = new Map();
337
+ for (const record of archives) {
338
+ if (!isV2(record) || !record.supersedes)
339
+ continue;
340
+ supersededBy.set(record.supersedes.archiveId, record.id);
341
+ }
342
+ // Delta archives NEED their base: a delta holds only the appended tail, so
343
+ // every link under a live delta is load-bearing. Deltas deliberately write
344
+ // no `supersedes`, which keeps the common path safe — this set closes the
345
+ // contrived one, a full snapshot superseding an archive that some other
346
+ // delta still bases on.
347
+ const { deltaOf } = await import("./vault.js");
348
+ const deltaBases = new Set();
349
+ for (const record of archives) {
350
+ let base = deltaOf(record)?.baseArchiveId;
351
+ for (let hops = 0; base && hops < archives.length; hops += 1) {
352
+ deltaBases.add(base);
353
+ const next = byId.get(base);
354
+ base = next ? deltaOf(next)?.baseArchiveId : undefined;
355
+ }
356
+ }
357
+ const redundant = [];
358
+ for (const record of archives) {
359
+ if (deltaBases.has(record.id))
360
+ continue;
361
+ const heirId = supersededBy.get(record.id);
362
+ if (!heirId)
363
+ continue;
364
+ const heir = byId.get(heirId);
365
+ if (!heir)
366
+ continue;
367
+ // The heir has to actually be there. An archive whose replacement has been
368
+ // deleted is not redundant, it is the only copy left.
369
+ const heirObject = await stat(heir.objectPath).catch(() => null);
370
+ if (!heirObject)
371
+ continue;
372
+ if (config.remoteStorage && !(isV2(heir) && heir.remote?.verifiedAt))
373
+ continue;
374
+ const own = await stat(record.objectPath).catch(() => null);
375
+ if (!own)
376
+ continue;
377
+ redundant.push({
378
+ id: record.id, agent: record.source.agent, path: record.source.path,
379
+ storedBytes: own.size, createdAt: record.createdAt, supersededBy: heirId, objectPath: record.objectPath
380
+ });
381
+ }
382
+ return {
383
+ redundant,
384
+ reclaimableBytes: redundant.reduce((total, item) => total + item.storedBytes, 0),
385
+ kept: archives.length - redundant.length
386
+ };
387
+ }
388
+ /**
389
+ * Removes snapshots that `redundantArchives` has already proved duplicate.
390
+ *
391
+ * A dry run unless confirmed, like every other destructive path here. The list
392
+ * is recomputed rather than accepted from a caller, so a stale page cannot ask
393
+ * for the deletion of something that has since become the last copy.
394
+ */
395
+ export async function pruneRedundantArchives(dataDir, options = {}) {
396
+ const { redundant, reclaimableBytes } = await redundantArchives(dataDir);
397
+ if (!options.confirm)
398
+ return { mode: "preview", removed: [], wouldRemove: redundant, freedBytes: reclaimableBytes };
399
+ const removed = [];
400
+ const failed = [];
401
+ for (const item of redundant) {
402
+ try {
403
+ await rm(item.objectPath, { force: true });
404
+ await rm(join(dataDir, "archives", `${item.id}.json`), { force: true });
405
+ await recordAudit(dataDir, "archive.prune", "allowed", { archiveId: item.id, supersededBy: item.supersededBy, storedBytes: item.storedBytes });
406
+ removed.push(item);
407
+ }
408
+ catch (error) {
409
+ await recordAudit(dataDir, "archive.prune", "denied", { archiveId: item.id });
410
+ failed.push({ id: item.id, error: error instanceof Error ? error.message : "could not remove" });
411
+ }
412
+ }
413
+ return { mode: "apply", removed, failed, freedBytes: removed.reduce((total, item) => total + item.storedBytes, 0) };
414
+ }
415
+ /**
416
+ * Archives whose retention period has run out.
417
+ *
418
+ * This only ever *reports*. Deleting an archive is the one action in Sealkeep
419
+ * that cannot be undone by restoring from somewhere else — the archive is the
420
+ * last copy once the source has been reclaimed — so the decision to act on this
421
+ * list stays with an explicit command, and `deleteAfterDays: null` (never) is
422
+ * the default.
423
+ */
424
+ export async function expiredArchives(dataDir, options = {}) {
425
+ const { readConfig, listArchives } = await import("./vault.js");
426
+ const config = await readConfig(dataDir);
427
+ const settings = retentionSettings(config, options.settings);
428
+ const now = options.now ?? Date.now();
429
+ const archives = await listArchives(dataDir);
430
+ if (settings.deleteAfterDays === null)
431
+ return { settings, expired: [], keptForever: archives.length };
432
+ if (!Number.isFinite(settings.deleteAfterDays) || settings.deleteAfterDays < 0) {
433
+ fail("invalid_argument", "deleteAfterDays must be a non-negative number of days, or null for never");
434
+ }
435
+ const expired = archives.flatMap((archive) => {
436
+ const ageDays = (now - Date.parse(archive.createdAt)) / 86_400_000;
437
+ if (!(ageDays >= settings.deleteAfterDays))
438
+ return [];
439
+ return [{
440
+ id: archive.id, agent: archive.source.agent, path: archive.source.path,
441
+ bytes: archive.source.bytes, createdAt: archive.createdAt,
442
+ ageDays: Math.floor(ageDays), objectPath: archive.objectPath
443
+ }];
444
+ });
445
+ return { settings, expired, keptForever: 0 };
446
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Rotates the vault's recovery phrase without re-encrypting a single byte of
3
+ * content. Every archive's DEK gains a wrap under the NEW phrase while keeping
4
+ * its existing wraps — both phrases open everything locally until the old
5
+ * slots are retired, so there is no moment where a crash strands data behind
6
+ * a phrase nobody holds.
7
+ *
8
+ * What rotation can and cannot do, said plainly because it is the whole point:
9
+ * it protects every archive an attacker holding the old phrase has NOT copied,
10
+ * and everything sealed from now on. It cannot un-leak an object someone
11
+ * already downloaded while the old phrase was theirs — that is mathematics,
12
+ * not a product gap. And a copy in cloud storage embeds the envelope it was
13
+ * pushed with: until it is pushed again, the CLOUD copy opens only with the
14
+ * wraps it left with. The report names how many such copies exist, and the
15
+ * standard upload path re-pushes rewrapped envelopes as archives change.
16
+ */
17
+ export type RotationReport = {
18
+ vaultId: string;
19
+ rewrapped: number;
20
+ skipped: {
21
+ id: string;
22
+ reason: string;
23
+ }[];
24
+ /** Cloud copies whose embedded envelope predates this rotation — they still honor the old phrase until re-pushed. */
25
+ staleCloudCopies: number;
26
+ keystoreUpdated: boolean;
27
+ passwordLockRewrapped: boolean;
28
+ phraseFile: string | null;
29
+ };
30
+ export declare function rotateVaultPhrase(dataDir: string, options: {
31
+ /** The current phrase, already resolved by the caller (keystore, flag, or prompt). */
32
+ currentPhrase: string;
33
+ /** Where the NEW phrase is written (0600). Deliberately a file: a phrase printed to a terminal lands in scrollback, transcripts, and logs — which is how the last one burned. */
34
+ phraseFile?: string;
35
+ /** The vault password when a password lock exists, so it can be re-pointed at the new phrase. */
36
+ password?: string;
37
+ }): Promise<{
38
+ report: RotationReport;
39
+ newPhrase: string;
40
+ }>;
41
+ /**
42
+ * Changes the vault password. The phrase never changes and never appears:
43
+ * the old password proves itself by unwrapping phrase.lock, and the same
44
+ * phrase is wrapped again under the new one. Instant, no archives touched.
45
+ */
46
+ export declare function changeVaultPassword(dataDir: string, currentPassword: string, newPassword: string): Promise<void>;
47
+ export declare const rotatedPhrasePath: (dataDir: string) => string;
@@ -0,0 +1,95 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { equalHex, phraseCheck } from "./crypto.js";
4
+ import { fail } from "./errors.js";
5
+ import { canonicalPhrase, generateRecoveryPhrase } from "./mnemonic.js";
6
+ import { recordAudit } from "./audit.js";
7
+ import { hasPasswordLock, unwrapPhrase, wrapPhrase } from "./password-lock.js";
8
+ import { rememberRecoveryPhrase } from "./secrets.js";
9
+ import { isV2 } from "./types.js";
10
+ import { configuredRecipients, listArchives, readConfig, writeConfig, writeRecord } from "./vault.js";
11
+ import { rewrapArchive } from "../packages/vaultline-crypto/src/index.js";
12
+ export async function rotateVaultPhrase(dataDir, options) {
13
+ const config = await readConfig(dataDir);
14
+ const current = canonicalPhrase(options.currentPhrase);
15
+ if (!equalHex(config.recovery.phraseCheck, phraseCheck(current))) {
16
+ fail("recovery_phrase_mismatch", "The current recovery phrase does not match this vault. Nothing has changed.");
17
+ }
18
+ if (config.storageMode === "plain") {
19
+ fail("invalid_argument", "This vault stores archives unencrypted; there is no phrase to rotate.");
20
+ }
21
+ const passwordLocked = await hasPasswordLock(dataDir);
22
+ if (passwordLocked && !options.password) {
23
+ fail("invalid_argument", "This vault opens with a password. Provide it (--password or the prompt) so the lock can be re-pointed at the new phrase. Nothing has changed.");
24
+ }
25
+ if (passwordLocked) {
26
+ const held = await unwrapPhrase(dataDir, options.password);
27
+ if (held === null)
28
+ fail("recovery_phrase_mismatch", "That password does not open this vault. Nothing has changed.");
29
+ }
30
+ const newPhrase = generateRecoveryPhrase();
31
+ // Rewrap every record's key slots FIRST, under both phrases. Only after the
32
+ // last record carries the new wrap do the vault-level pointers move — a
33
+ // crash mid-loop leaves a vault that still opens exactly as before.
34
+ const rewrapped = [];
35
+ const skipped = [];
36
+ let staleCloudCopies = 0;
37
+ const records = await listArchives(dataDir);
38
+ for (const record of records) {
39
+ if (!isV2(record)) {
40
+ skipped.push({ id: record.id, reason: "v1 archive; run `sealkeep migrate` first" });
41
+ continue;
42
+ }
43
+ const next = {
44
+ ...record,
45
+ envelope: rewrapArchive(record.envelope, { phrase: current }, configuredRecipients(config, newPhrase), { mode: "add" })
46
+ };
47
+ await writeRecord(config.storage.root, next);
48
+ rewrapped.push(record.id);
49
+ if (record.remote?.verifiedAt || record.offloaded)
50
+ staleCloudCopies += 1;
51
+ }
52
+ // The pointers move together, new phrase last-writer-wins everywhere a
53
+ // machine looks for it: the check that verifies typed phrases, the keystore
54
+ // that lets archiving run unattended, and the password lock if one exists.
55
+ await writeConfig(dataDir, { ...config, recovery: { ...config.recovery, phraseCheck: phraseCheck(canonicalPhrase(newPhrase)) } });
56
+ await rememberRecoveryPhrase(dataDir, config.vaultId, newPhrase);
57
+ if (passwordLocked)
58
+ await wrapPhrase(dataDir, newPhrase, options.password);
59
+ let phraseFile = null;
60
+ if (options.phraseFile) {
61
+ phraseFile = options.phraseFile;
62
+ await writeFile(phraseFile, `${newPhrase}\n`, { mode: 0o600 });
63
+ }
64
+ await recordAudit(dataDir, "phrase.rotate", "allowed", { rewrapped: rewrapped.length, skipped: skipped.length, staleCloudCopies });
65
+ return {
66
+ newPhrase,
67
+ report: {
68
+ vaultId: config.vaultId,
69
+ rewrapped: rewrapped.length,
70
+ skipped,
71
+ staleCloudCopies,
72
+ keystoreUpdated: true,
73
+ passwordLockRewrapped: passwordLocked,
74
+ phraseFile
75
+ }
76
+ };
77
+ }
78
+ /**
79
+ * Changes the vault password. The phrase never changes and never appears:
80
+ * the old password proves itself by unwrapping phrase.lock, and the same
81
+ * phrase is wrapped again under the new one. Instant, no archives touched.
82
+ */
83
+ export async function changeVaultPassword(dataDir, currentPassword, newPassword) {
84
+ if (!(await hasPasswordLock(dataDir))) {
85
+ fail("invalid_argument", "This vault has no password lock. `sealkeep setup --password` creates one.");
86
+ }
87
+ if (newPassword.length < 12)
88
+ fail("invalid_argument", "A vault password needs at least 12 characters.");
89
+ const phrase = await unwrapPhrase(dataDir, currentPassword);
90
+ if (phrase === null)
91
+ fail("recovery_phrase_mismatch", "That password does not open this vault. Nothing has changed.");
92
+ await wrapPhrase(dataDir, phrase, newPassword);
93
+ await recordAudit(dataDir, "password.change", "allowed", {});
94
+ }
95
+ export const rotatedPhrasePath = (dataDir) => join(dataDir, "rotated-phrase.txt");