sealkeep 0.9.0 → 0.11.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 (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/vault.js CHANGED
@@ -1,42 +1,413 @@
1
- import { mkdir, readFile, writeFile, rename, access, rm, stat } from "node:fs/promises";
2
- import { constants, createReadStream, createWriteStream, existsSync } from "node:fs";
3
- import { basename, dirname, join, resolve } from "node:path";
1
+ import { mkdir, open, readFile, writeFile, rename, access, rm, stat } from "node:fs/promises";
2
+ import { constants, createReadStream, createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { createGzip, createGunzip, gunzipSync } from "node:zlib";
6
6
  import { pipeline } from "node:stream/promises";
7
+ import { setImmediate as yieldToEventLoop } from "node:timers/promises";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
7
9
  import { decryptLegacyArchive, equalHex, isLegacyPhraseCheck, phraseCheck, sha256, upgradeLegacyPhraseCheck } from "./crypto.js";
8
10
  import { canonicalPhrase, generateRecoveryPhrase } from "./mnemonic.js";
9
- import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, x25519PrivateKeyFromRaw, zeroize } from "../packages/vaultline-crypto/src/index.js";
10
- import { fail, VaultlineError } from "./errors.js";
11
+ import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, x25519PrivateKeyFromRaw, rawPublicKey, zeroize } from "../packages/sealkeep-crypto/src/index.js";
12
+ import { fail, SealkeepError } from "./errors.js";
11
13
  import { recordAudit } from "./audit.js";
12
- import { isV2 } from "./types.js";
14
+ import { isV2, teamCustodyRecordIsValid } from "./types.js";
13
15
  import { envVar } from "./env.js";
14
- /** Bumped when the adapter's preservation behaviour changes, recorded in every envelope. */
16
+ import { hasCompleteVerifiedCopyChain, hasVerifiedCopy } from "./archive-copies.js";
17
+ import { acquireDurableTicketLock } from "./durable-ticket-lock.js";
18
+ // Agent hook stdout/stderr is a protocol boundary. A damaged historical
19
+ // sidecar must remain visible in Doctor/Health, but the low-level warning that
20
+ // `listArchives` emits for an interactive process must not make an otherwise
21
+ // successful SessionStart look noisy or malformed. AsyncLocalStorage keeps the
22
+ // exception scoped to the hook operation even when search imports and awaits
23
+ // several layers below it; unrelated work in the same process still warns.
24
+ const archiveWarningSilence = new AsyncLocalStorage();
25
+ const warnedArchiveDamage = new Map();
26
+ export function withoutArchiveRecordWarnings(operation) {
27
+ return archiveWarningSilence.run(true, operation);
28
+ }
29
+ async function syncRegularFile(path) {
30
+ // Write access, not read: Windows refuses FlushFileBuffers on a read-only
31
+ // handle with EPERM, so every record write failed there. POSIX is happy
32
+ // either way. A file that cannot be opened for writing (a restored archive
33
+ // made read-only) still gets its flush attempt through the read handle.
34
+ const handle = await open(path, "r+").catch(async (error) => {
35
+ if (error.code === "EACCES" || error.code === "EPERM" || error.code === "EROFS")
36
+ return open(path, "r");
37
+ throw error;
38
+ });
39
+ try {
40
+ const info = await handle.stat();
41
+ if (!info.isFile())
42
+ throw new Error(`Archive record temporary path is not a regular file: ${path}`);
43
+ await handle.sync();
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
48
+ }
49
+ async function syncRecordDirectory(path) {
50
+ let handle;
51
+ try {
52
+ handle = await open(path, "r");
53
+ await handle.sync();
54
+ }
55
+ catch (error) {
56
+ // Windows does not support opening a directory for fsync. The temporary
57
+ // file itself is still synced before rename, which is the strongest
58
+ // durability primitive Node exposes on that platform.
59
+ if (process.platform !== "win32")
60
+ throw error;
61
+ }
62
+ finally {
63
+ await handle?.close();
64
+ }
65
+ }
15
66
  /**
16
67
  * Writes one archive record atomically.
17
68
  *
18
- * Written to a temporary file beside the target and renamed into place, so a
19
- * crash never leaves a half-written record that would strand an archive. This
20
- * lived as three byte-identical private copies in upload, migrate and
21
- * retention; a fourth was about to be added for the managed push path.
69
+ * The complete temporary file is fsynced before rename, then the archive
70
+ * directory is fsynced after rename. Atomic visibility alone is not enough for
71
+ * source reclaim: the purge journal must never become more durable than the
72
+ * record saying where the recoverable archive lives.
22
73
  */
23
- export async function writeRecord(root, record) {
74
+ export async function writeRecord(root, record, operations = {}) {
24
75
  const path = join(root, `${record.id}.json`);
25
76
  const temp = `${path}.${randomUUID()}.tmp`;
26
- await writeFile(temp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
27
- await rename(temp, path);
77
+ const syncFile = operations.syncFile ?? syncRegularFile;
78
+ const renameRecord = operations.rename ?? rename;
79
+ const syncDirectory = operations.syncDirectory ?? syncRecordDirectory;
80
+ try {
81
+ await writeFile(temp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600, flag: "wx" });
82
+ await syncFile(temp);
83
+ await renameRecord(temp, path);
84
+ await syncDirectory(root);
85
+ }
86
+ finally {
87
+ // Before rename this clears an unpublished temporary; after rename it is a
88
+ // no-op. Never remove the target to disguise an fsync error.
89
+ await rm(temp, { force: true }).catch(() => undefined);
90
+ }
91
+ }
92
+ const ARCHIVE_RECORD_LOCK_WAIT_MS = 30_000;
93
+ /**
94
+ * Serialize a read/modify/write of one archive record across the daemon, CLI,
95
+ * dashboard and hooks. Atomic rename prevents torn JSON, but by itself it
96
+ * still lets two valid stale documents overwrite one another (for example an
97
+ * upload adding a remote copy while retention marks the source reclaimed).
98
+ */
99
+ export async function mutateArchiveRecord(root, archiveId, change, options = {}) {
100
+ const lockDir = join(root, ".record-locks");
101
+ const lockKey = createHash("sha256").update(archiveId).digest("hex");
102
+ const legacyLockPath = join(lockDir, `${lockKey}.lock`);
103
+ const ticketQueuePath = join(lockDir, `${lockKey}.claims`);
104
+ await mkdir(lockDir, { recursive: true, mode: 0o700 });
105
+ // Never compare-then-unlink a stale legacy lock: another process could
106
+ // publish a new owner in that gap. Old-format leftovers require deliberate
107
+ // singleton maintenance; current processes use immutable FIFO tickets.
108
+ if (await stat(legacyLockPath).then(() => true, (error) => {
109
+ if (error.code === "ENOENT")
110
+ return false;
111
+ throw error;
112
+ })) {
113
+ return fail("internal", `Archive ${archiveId} has a legacy update lock. Stop Sealkeep services and repair that lock before retrying; no record was changed.`);
114
+ }
115
+ const recordLock = await acquireDurableTicketLock(ticketQueuePath, `archive record ${archiveId}`, {
116
+ timeoutMs: ARCHIVE_RECORD_LOCK_WAIT_MS,
117
+ pollMs: 25,
118
+ }).catch((error) => {
119
+ if (error.code === "DURABLE_LOCK_TIMEOUT") {
120
+ return fail("internal", `Timed out waiting for another Sealkeep process to finish updating archive ${archiveId}`);
121
+ }
122
+ throw error;
123
+ });
124
+ try {
125
+ const path = join(root, `${archiveId}.json`);
126
+ let current;
127
+ try {
128
+ current = JSON.parse(await readFile(path, "utf8"));
129
+ }
130
+ catch (error) {
131
+ // Streamed seals do not have a record until the provider has verified
132
+ // the bytes. Let their first verified write use this same lock rather
133
+ // than falling back to a check-then-create race. Never treat malformed
134
+ // existing JSON as absent: `initial` is only safe for a true ENOENT.
135
+ if (error.code === "ENOENT" && options.initial)
136
+ current = options.initial;
137
+ else
138
+ return fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
139
+ }
140
+ if (current.id !== archiveId)
141
+ fail("invalid_argument", "An initial archive record must use the requested id", { archiveId, currentId: current.id });
142
+ const next = await change(current);
143
+ if (next.id !== archiveId)
144
+ fail("invalid_argument", "An archive record update cannot change its id", { archiveId, nextId: next.id });
145
+ await writeRecord(root, next);
146
+ return next;
147
+ }
148
+ finally {
149
+ await recordLock.release();
150
+ }
28
151
  }
29
152
  export const ADAPTER_VERSION = "1";
30
153
  export const CONFIG_VERSION = 1;
154
+ const MAX_INTEGRATION_CONFIG_BYTES = 4 * 1024 * 1024;
155
+ function ownedByCurrentUid(info) {
156
+ return typeof process.getuid !== "function" || info.uid === process.getuid();
157
+ }
158
+ function smallTextFile(path) {
159
+ try {
160
+ // These files are treated as durable statements made by this user's own
161
+ // setup. Following another user's file (including through a symlink) would
162
+ // turn a readable system/shared config into authority to choose our vault.
163
+ if (!ownedByCurrentUid(lstatSync(path)))
164
+ return null;
165
+ const info = statSync(path);
166
+ if (!ownedByCurrentUid(info) || !info.isFile() || info.size > MAX_INTEGRATION_CONFIG_BYTES)
167
+ return null;
168
+ return readFileSync(path, "utf8");
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ function normalizedAbsoluteDataDir(value, home) {
175
+ const expanded = value === "~" ? home : value.startsWith("~/") || value.startsWith("~\\") ? join(home, value.slice(2)) : value;
176
+ if (!expanded || expanded.includes("\0") || !isAbsolute(expanded))
177
+ return null;
178
+ return resolve(expanded);
179
+ }
180
+ function jsonString(value) {
181
+ try {
182
+ const parsed = JSON.parse(value);
183
+ return typeof parsed === "string" ? parsed : null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ function codexMcpDataDirs(text) {
190
+ const values = [];
191
+ const headers = [...text.matchAll(/^\s*\[([^\]\r\n]+)\]\s*$/gm)];
192
+ for (let index = 0; index < headers.length; index += 1) {
193
+ const match = headers[index];
194
+ if (!/^mcp_servers\.(?:sealkeep|vaultline)\.env$/i.test(match[1]))
195
+ continue;
196
+ const start = match.index + match[0].length;
197
+ const end = headers[index + 1]?.index ?? text.length;
198
+ const body = text.slice(start, end);
199
+ const assignment = body.match(/^\s*SEALKEEP_DATA_DIR\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/mi)
200
+ ?? body.match(/^\s*VAULTLINE_DATA_DIR\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/mi);
201
+ if (!assignment)
202
+ continue;
203
+ const literal = assignment[1];
204
+ const value = literal.startsWith('"') ? jsonString(literal) : literal.slice(1, -1);
205
+ if (value)
206
+ values.push(value);
207
+ }
208
+ return values;
209
+ }
210
+ function shellDataDirs(command) {
211
+ // Sealkeep writes a single-quoted argument. Double-quoted and unquoted forms
212
+ // are accepted for older owned units, but only beside Sealkeep's own command
213
+ // and `--data-dir` marker.
214
+ if (!/(?:^|[\s/])(?:sealkeep|vaultline)(?:[\s'"/]|$)/i.test(command))
215
+ return [];
216
+ const values = [];
217
+ const argument = /(?:^|\s)["']?--data-dir["']?(?:=|\s+)(?:'((?:[^']|'"'"')*)'|"((?:\\.|[^"\\])*)"|([^\s<]+))/gi;
218
+ for (const match of command.matchAll(argument)) {
219
+ if (match[1] !== undefined)
220
+ values.push(match[1].replaceAll(`'"'"'`, "'"));
221
+ else if (match[2] !== undefined)
222
+ values.push(jsonString(`"${match[2]}"`) ?? match[2]);
223
+ else if (match[3] !== undefined)
224
+ values.push(match[3]);
225
+ }
226
+ return values;
227
+ }
228
+ function parsedJson(text) {
229
+ try {
230
+ return JSON.parse(text);
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ function jsonMcpDataDirs(text) {
237
+ const document = parsedJson(text);
238
+ if (document === null)
239
+ return [];
240
+ const found = [];
241
+ const visit = (value) => {
242
+ if (Array.isArray(value)) {
243
+ for (const item of value)
244
+ visit(item);
245
+ return;
246
+ }
247
+ if (!value || typeof value !== "object")
248
+ return;
249
+ const record = value;
250
+ const servers = record.mcpServers;
251
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
252
+ for (const name of ["sealkeep", "vaultline"]) {
253
+ const server = servers[name];
254
+ if (!server || typeof server !== "object" || Array.isArray(server))
255
+ continue;
256
+ const env = server.env;
257
+ const dataDir = env && typeof env === "object" && !Array.isArray(env)
258
+ ? env.SEALKEEP_DATA_DIR ?? env.VAULTLINE_DATA_DIR
259
+ : undefined;
260
+ if (typeof dataDir === "string")
261
+ found.push(dataDir);
262
+ }
263
+ }
264
+ for (const child of Object.values(record))
265
+ visit(child);
266
+ };
267
+ visit(document);
268
+ return found;
269
+ }
270
+ function jsonHookDataDirs(text) {
271
+ const document = parsedJson(text);
272
+ if (!document || typeof document !== "object" || Array.isArray(document))
273
+ return [];
274
+ const hooks = document.hooks;
275
+ if (!hooks || typeof hooks !== "object")
276
+ return [];
277
+ const found = [];
278
+ const visit = (value) => {
279
+ if (Array.isArray(value)) {
280
+ for (const item of value)
281
+ visit(item);
282
+ return;
283
+ }
284
+ if (!value || typeof value !== "object")
285
+ return;
286
+ const record = value;
287
+ if (typeof record.command === "string")
288
+ found.push(...shellDataDirs(record.command));
289
+ for (const [name, child] of Object.entries(record))
290
+ if (name !== "command")
291
+ visit(child);
292
+ };
293
+ visit(hooks);
294
+ return found;
295
+ }
296
+ function recognizableVaultConfig(text) {
297
+ let document;
298
+ try {
299
+ document = JSON.parse(text);
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ if (!document || typeof document !== "object" || Array.isArray(document))
305
+ return false;
306
+ const config = document;
307
+ const recovery = config.recovery;
308
+ const storage = config.storage;
309
+ return Number.isSafeInteger(config.version) && Number(config.version) > 0
310
+ && typeof config.vaultId === "string" && config.vaultId.trim().length > 0
311
+ && !!recovery && typeof recovery === "object" && !Array.isArray(recovery)
312
+ && typeof recovery.phraseCheck === "string"
313
+ && recovery.phraseCheck.length > 0
314
+ && !!storage && typeof storage === "object" && !Array.isArray(storage)
315
+ && typeof storage.provider === "string"
316
+ && typeof storage.root === "string";
317
+ }
318
+ function xmlText(value) {
319
+ return value.replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
320
+ }
321
+ function serviceDataDirs(text) {
322
+ const decoded = xmlText(text);
323
+ const values = shellDataDirs(decoded);
324
+ const plist = /<string>--data-dir<\/string>\s*<string>([^<]+)<\/string>/gi;
325
+ for (const match of decoded.matchAll(plist))
326
+ if (match[1])
327
+ values.push(match[1]);
328
+ return values;
329
+ }
330
+ function ownedDataDirReferences(home, xdgConfigHome) {
331
+ // XDG_CONFIG_HOME is authoritative only for a normal process-wide lookup.
332
+ // An explicit `home` supplied by a caller/test must remain a complete
333
+ // boundary and never inherit a service unit from an unrelated real account.
334
+ const systemdConfigHome = xdgConfigHome && isAbsolute(xdgConfigHome) ? resolve(xdgConfigHome) : join(home, ".config");
335
+ const files = [
336
+ { path: join(home, ".codex", "config.toml"), source: "Codex MCP", extract: codexMcpDataDirs },
337
+ { path: join(home, ".codex", "hooks.json"), source: "Codex hooks", extract: jsonHookDataDirs },
338
+ { path: join(home, ".claude", "settings.json"), source: "Claude hooks", extract: jsonHookDataDirs },
339
+ { path: join(home, ".claude.json"), source: "Claude MCP", extract: jsonMcpDataDirs },
340
+ { path: join(home, "Library", "LaunchAgents", "ai.sealkeep.agent.plist"), source: "Sealkeep background service", extract: serviceDataDirs },
341
+ { path: join(home, "Library", "LaunchAgents", "ai.vaultline.agent.plist"), source: "legacy background service", extract: serviceDataDirs },
342
+ { path: join(systemdConfigHome, "systemd", "user", "sealkeep.service"), source: "Sealkeep background service", extract: serviceDataDirs },
343
+ { path: join(systemdConfigHome, "systemd", "user", "vaultline.service"), source: "legacy background service", extract: serviceDataDirs },
344
+ ];
345
+ const references = [];
346
+ for (const file of files) {
347
+ const text = smallTextFile(file.path);
348
+ if (text === null)
349
+ continue;
350
+ for (const value of file.extract(text)) {
351
+ const dataDir = normalizedAbsoluteDataDir(value, home);
352
+ if (dataDir)
353
+ references.push({ dataDir, source: file.source });
354
+ }
355
+ }
356
+ return references;
357
+ }
358
+ function initializedVaultIdentity(path) {
359
+ const config = smallTextFile(join(path, "config.json"));
360
+ if (config === null || !recognizableVaultConfig(config))
361
+ return null;
362
+ try {
363
+ return realpathSync(path);
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ function existingOwnedDirectory(path) {
370
+ try {
371
+ const info = statSync(path);
372
+ return ownedByCurrentUid(lstatSync(path)) && ownedByCurrentUid(info) && info.isDirectory();
373
+ }
374
+ catch {
375
+ return false;
376
+ }
377
+ }
31
378
  /**
32
- * ~/.sealkeep for new vaults; an existing ~/.Sealkeep keeps working forever —
33
- * the rebrand must never strand a vault that predates it.
379
+ * ~/.sealkeep for new vaults; an existing ~/.vaultline keeps working forever.
380
+ * A unique initialized vault named by owned integrations is the same vault a
381
+ * bare CLI/UI/MCP process must use. Multiple initialized candidates are never
382
+ * guessed between: explicit `--data-dir` is then required.
34
383
  */
35
- export const defaultDataDir = () => {
36
- const home = process.env.HOME ?? ".";
37
- const legacy = join(home, ".vaultline");
38
- const current = join(home, ".sealkeep");
39
- if (!existsSync(current) && existsSync(legacy))
384
+ export const defaultDataDir = (home, environment = process.env) => {
385
+ const resolvedHome = resolve(home ?? environment.HOME ?? environment.USERPROFILE ?? ".");
386
+ const legacy = resolve(join(resolvedHome, ".vaultline"));
387
+ const current = resolve(join(resolvedHome, ".sealkeep"));
388
+ const byPath = new Map();
389
+ const add = (dataDir, source) => {
390
+ const identity = initializedVaultIdentity(dataDir);
391
+ if (identity === null)
392
+ return;
393
+ const candidate = byPath.get(identity) ?? { dataDir, sources: new Set() };
394
+ candidate.sources.add(source);
395
+ byPath.set(identity, candidate);
396
+ };
397
+ add(current, "~/.sealkeep");
398
+ add(legacy, "~/.vaultline");
399
+ for (const reference of ownedDataDirReferences(resolvedHome, home === undefined ? environment.XDG_CONFIG_HOME : undefined))
400
+ add(reference.dataDir, reference.source);
401
+ if (byPath.size === 1)
402
+ return byPath.values().next().value.dataDir;
403
+ if (byPath.size > 1) {
404
+ const candidates = [...byPath.values()].map(({ dataDir, sources }) => ({ dataDir, sources: [...sources].sort() }));
405
+ fail("invalid_argument", `Sealkeep found more than one initialized vault on this machine and will not guess which is current: ${candidates.map((candidate) => candidate.dataDir).join(", ")}. Re-run with --data-dir <path>.`, { candidates });
406
+ }
407
+ // Preserve the historical empty-directory fallback too: an unfinished old
408
+ // install should continue in ~/.vaultline, while a genuinely fresh machine
409
+ // starts in ~/.sealkeep.
410
+ if (!existsSync(current) && existingOwnedDirectory(legacy))
40
411
  return legacy;
41
412
  return current;
42
413
  };
@@ -49,12 +420,82 @@ async function exists(path) { try {
49
420
  catch {
50
421
  return false;
51
422
  } }
52
- async function writeJson(path, value) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); }
53
423
  /**
54
- * Reads and version-gates the vault config. A config written by a newer build is
55
- * refused rather than silently reinterpreted; migrations attach here when v2 lands.
424
+ * Config updates that move a team binding and its sharing mode must land as
425
+ * one file replacement. A crash may leave the old config or the new config,
426
+ * never a half-written JSON document or a team binding without its mode.
56
427
  */
57
- export async function readConfig(dataDir) {
428
+ async function writeJsonAtomic(path, value) {
429
+ await mkdir(dirname(path), { recursive: true });
430
+ const temporary = `${path}.${randomUUID()}.tmp`;
431
+ try {
432
+ await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
433
+ await rename(temporary, path);
434
+ }
435
+ catch (error) {
436
+ await rm(temporary, { force: true }).catch(() => undefined);
437
+ throw error;
438
+ }
439
+ }
440
+ const configMutationQueues = new Map();
441
+ const CONFIG_LOCK_STALE_MS = 10 * 60_000;
442
+ const CONFIG_LOCK_WAIT_MS = 30_000;
443
+ function processIsAlive(pid) {
444
+ if (!Number.isSafeInteger(pid) || pid <= 0)
445
+ return false;
446
+ try {
447
+ process.kill(pid, 0);
448
+ return true;
449
+ }
450
+ catch (error) {
451
+ return error.code === "EPERM";
452
+ }
453
+ }
454
+ /**
455
+ * Serialize config read/modify/write cycles across the daemon, CLI, local API,
456
+ * and agent-hook processes. Atomic rename prevents torn JSON; this lock also
457
+ * prevents a perfectly valid but stale whole document from winning later and
458
+ * resurrecting a revoked team binding.
459
+ */
460
+ async function withConfigLock(dataDir, work) {
461
+ const lockDir = join(dataDir, "locks");
462
+ const lockPath = join(lockDir, "config.lock");
463
+ await mkdir(lockDir, { recursive: true, mode: 0o700 });
464
+ const started = Date.now();
465
+ for (;;) {
466
+ try {
467
+ const { open } = await import("node:fs/promises");
468
+ const handle = await open(lockPath, "wx", 0o600);
469
+ await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`);
470
+ await handle.close();
471
+ break;
472
+ }
473
+ catch (error) {
474
+ if (error.code !== "EEXIST")
475
+ throw error;
476
+ const [held, owner] = await Promise.all([
477
+ stat(lockPath).catch(() => null),
478
+ readFile(lockPath, "utf8").catch(() => ""),
479
+ ]);
480
+ const pid = Number(owner.trim().split(/\s+/, 1)[0]);
481
+ if (held && Date.now() - held.mtimeMs > CONFIG_LOCK_STALE_MS && !processIsAlive(pid)) {
482
+ await rm(lockPath, { force: true });
483
+ continue;
484
+ }
485
+ if (Date.now() - started >= CONFIG_LOCK_WAIT_MS) {
486
+ return fail("internal", "Timed out waiting for another Sealkeep process to finish updating the vault configuration");
487
+ }
488
+ await new Promise((resolve) => setTimeout(resolve, 25));
489
+ }
490
+ }
491
+ try {
492
+ return await work();
493
+ }
494
+ finally {
495
+ await rm(lockPath, { force: true }).catch(() => undefined);
496
+ }
497
+ }
498
+ async function readConfigFile(dataDir) {
58
499
  let raw;
59
500
  try {
60
501
  raw = await readFile(configPath(dataDir), "utf8");
@@ -65,6 +506,38 @@ export async function readConfig(dataDir) {
65
506
  const config = JSON.parse(raw);
66
507
  if (config.version !== CONFIG_VERSION)
67
508
  fail("config_unsupported_version", `Vault config version ${String(config.version)} is not supported by this build (expected ${CONFIG_VERSION})`, { found: config.version, expected: CONFIG_VERSION });
509
+ return config;
510
+ }
511
+ function withCurrentPhraseCheck(config) {
512
+ if (!isLegacyPhraseCheck(config.recovery.phraseCheck))
513
+ return config;
514
+ return { ...config, recovery: { ...config.recovery, phraseCheck: upgradeLegacyPhraseCheck(config.recovery.phraseCheck) } };
515
+ }
516
+ /** Serialize every config mutation around a cross-process atomic replace. */
517
+ async function mutateConfigAtomically(dataDir, change) {
518
+ const path = configPath(dataDir);
519
+ const prior = configMutationQueues.get(path) ?? Promise.resolve();
520
+ const operation = prior.catch(() => undefined).then(() => withConfigLock(dataDir, async () => {
521
+ const stored = await readConfigFile(dataDir);
522
+ const current = withCurrentPhraseCheck(stored);
523
+ const next = change(current);
524
+ if (next !== stored)
525
+ await writeJsonAtomic(path, next);
526
+ return next;
527
+ }));
528
+ const tracked = operation.then(() => undefined, () => undefined).finally(() => {
529
+ if (configMutationQueues.get(path) === tracked)
530
+ configMutationQueues.delete(path);
531
+ });
532
+ configMutationQueues.set(path, tracked);
533
+ return operation;
534
+ }
535
+ /**
536
+ * Reads and version-gates the vault config. A config written by a newer build is
537
+ * refused rather than silently reinterpreted; migrations attach here when v2 lands.
538
+ */
539
+ export async function readConfig(dataDir) {
540
+ const config = await readConfigFile(dataDir);
68
541
  // Defect #49: builds before this one stored `recovery.phraseCheck` as a
69
542
  // bare, unsalted SHA-256 -- a cheap offline oracle. upgradeLegacyPhraseCheck
70
543
  // re-derives the cost-matched value from that stored digest alone, with no
@@ -72,34 +545,121 @@ export async function readConfig(dataDir) {
72
545
  // first time anything reads its config -- nobody has to run a migration or
73
546
  // even unlock the vault first for this to happen.
74
547
  if (isLegacyPhraseCheck(config.recovery.phraseCheck)) {
75
- config.recovery = { ...config.recovery, phraseCheck: upgradeLegacyPhraseCheck(config.recovery.phraseCheck) };
548
+ const upgraded = withCurrentPhraseCheck(config);
76
549
  // Best effort: an unwritable dataDir still gets the upgraded value for
77
550
  // this call (below), and simply repeats the upgrade on its next read.
78
- await writeJson(configPath(dataDir), config).catch(() => { });
551
+ return withConfigLock(dataDir, async () => {
552
+ const latest = await readConfigFile(dataDir);
553
+ const settled = withCurrentPhraseCheck(latest);
554
+ if (settled !== latest)
555
+ await writeJsonAtomic(configPath(dataDir), settled);
556
+ return settled;
557
+ }).catch(() => upgraded);
79
558
  }
80
559
  return config;
81
560
  }
82
561
  export async function vaultStatus(dataDir) {
83
562
  const config = await readConfig(dataDir);
84
563
  const archives = await listArchives(dataDir);
564
+ const byId = new Map(archives.map((archive) => [archive.id, archive]));
565
+ // A delta is a storage link, not another agent session. Its `source.bytes`
566
+ // describes the whole transcript so the next append can prove its prefix;
567
+ // summing that value for every link would count the base again at every
568
+ // snapshot (100 + 120 + 130 instead of 130). Only trust a delta as a link
569
+ // when its direct base is present and has the promised length. A damaged or
570
+ // orphaned pointer remains visible as a standalone record rather than making
571
+ // history silently disappear from Overview.
572
+ const logicalDelta = (archive) => {
573
+ const delta = deltaOf(archive);
574
+ if (!delta || delta.baseBytes >= archive.source.bytes)
575
+ return null;
576
+ return byId.get(delta.baseArchiveId)?.source.bytes === delta.baseBytes ? delta : null;
577
+ };
578
+ const logicalArchives = archives.filter((archive) => !logicalDelta(archive));
579
+ // `archiveCount` remains the logical sealed-object count for compatibility:
580
+ // two whole snapshots of one transcript are still two archive roots. The UI
581
+ // folds every snapshot/delta carrying the same exact source path into one
582
+ // session row, so expose that user-facing count separately instead of
583
+ // labelling archive roots as sessions.
584
+ const sessionCount = new Set(archives.map((archive) => archive.source.path || archive.id)).size;
585
+ const archivedBytes = archives.reduce((total, archive) => {
586
+ const delta = logicalDelta(archive);
587
+ return total + (delta ? archive.source.bytes - delta.baseBytes : archive.source.bytes);
588
+ }, 0);
589
+ // Physical encrypted/compressed bytes are a different fact from the amount
590
+ // of logical source history represented above. Keeping both prevents every
591
+ // dashboard consumer from calling (for example) 130 GB of source history
592
+ // "130 GB sealed" when the actual archive objects occupy about 10 GB.
593
+ const sealedBytes = archives.reduce((total, archive) => total + ("storedBytes" in archive.cipher ? archive.cipher.storedBytes : archive.source.bytes), 0);
594
+ // "Off this machine" is a logical-session count too. A delta-backed session
595
+ // is restorable away from this Mac only when every object in its chain has a
596
+ // verified copy; counting each verified link would otherwise display e.g.
597
+ // "3 of 1" for one three-link session.
598
+ const rootOf = (archive) => {
599
+ let cursor = archive;
600
+ const seen = new Set();
601
+ for (;;) {
602
+ const delta = logicalDelta(cursor);
603
+ if (!delta || seen.has(cursor.id))
604
+ return cursor.id;
605
+ seen.add(cursor.id);
606
+ cursor = byId.get(delta.baseArchiveId);
607
+ }
608
+ };
609
+ const groups = new Map();
610
+ for (const archive of archives) {
611
+ const root = rootOf(archive);
612
+ const chain = groups.get(root);
613
+ if (chain)
614
+ chain.push(archive);
615
+ else
616
+ groups.set(root, [archive]);
617
+ }
618
+ const cloudArchiveCount = [...groups.values()].filter((chain) => {
619
+ if (!chain.every(isV2))
620
+ return false;
621
+ return hasCompleteVerifiedCopyChain(chain);
622
+ }).length;
623
+ // A chain with individually verified links is still pending until one
624
+ // destination contains every link. Otherwise split copies (base on Drive,
625
+ // delta on Cloud) made the Queue read empty while no remote restore existed.
626
+ // Count every physical link in such a chain conservatively: the uploader may
627
+ // choose any one eligible common destination, so the status must not claim
628
+ // less work than the chain can require.
629
+ const incompleteChains = [...groups.values()].filter((chain) => !chain.every(isV2) || !hasCompleteVerifiedCopyChain(chain));
630
+ const incompleteIds = new Set(incompleteChains.flatMap((chain) => chain.map((archive) => archive.id)));
631
+ // V1 cannot be uploaded by the current provider path, but hiding it here
632
+ // made an old local-only archive look durable. Keep it visible as blocked
633
+ // pending work; callers can present the required migration separately.
634
+ const pendingUploads = archives.filter((archive) => !isV2(archive) || incompleteIds.has(archive.id) || !hasVerifiedCopy(archive));
635
+ const blockedUploadCount = archives.filter((archive) => !isV2(archive)).length;
85
636
  // Resolved to "sealed" rather than passed through raw, so no consumer ever
86
637
  // has to know that absence and sealed are the same thing.
87
- return { vaultId: config.vaultId, provider: config.storage.provider, storageMode: config.storageMode ?? "sealed", remoteStorage: config.remoteStorage, archiveCount: archives.length, archivedBytes: archives.reduce((total, archive) => total + archive.source.bytes, 0) };
638
+ return {
639
+ vaultId: config.vaultId,
640
+ provider: config.storage.provider,
641
+ storageMode: config.storageMode ?? "sealed",
642
+ remoteStorage: config.remoteStorage,
643
+ archiveCount: logicalArchives.length,
644
+ sessionCount,
645
+ archivedBytes,
646
+ sealedBytes,
647
+ cloudArchiveCount,
648
+ incompleteChainCount: incompleteChains.filter((chain) => chain.every(isV2)).length,
649
+ blockedUploadCount,
650
+ pendingUploadCount: pendingUploads.length,
651
+ pendingUploadBytes: pendingUploads.reduce((total, archive) => total + ("storedBytes" in archive.cipher ? archive.cipher.storedBytes : archive.source.bytes), 0)
652
+ };
88
653
  }
89
654
  export async function configureRemoteStorage(dataDir, remoteStorage) {
90
655
  if (!remoteStorage.bucket.trim() || !remoteStorage.prefix.trim())
91
656
  fail("invalid_argument", "--bucket and --prefix are required");
92
- const config = await readConfig(dataDir);
93
657
  const normalized = { ...remoteStorage, bucket: remoteStorage.bucket.trim(), prefix: remoteStorage.prefix.replace(/^\/+|\/+$/g, "") };
94
658
  if (!normalized.prefix)
95
659
  fail("invalid_argument", "--prefix cannot be empty");
96
- const next = { ...config, remoteStorage: normalized };
97
- await writeJson(configPath(dataDir), next);
98
- return next;
660
+ return mutateConfigAtomically(dataDir, (config) => ({ ...config, remoteStorage: normalized }));
99
661
  }
100
662
  export async function initialize(dataDir, providedPhrase, opts = {}) {
101
- if (await exists(configPath(dataDir)))
102
- fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
103
663
  const phrase = canonicalPhrase(providedPhrase ?? generateRecoveryPhrase());
104
664
  // Defect #49: `??` only falls back to a generated phrase on null/undefined,
105
665
  // so `setup --recovery-phrase ""` sailed through with an empty phrase and no
@@ -118,7 +678,11 @@ export async function initialize(dataDir, providedPhrase, opts = {}) {
118
678
  ...(opts.storageMode === "plain" ? { storageMode: "plain" } : {}),
119
679
  recovery: { phraseCheck: phraseCheck(phrase) }
120
680
  };
121
- await writeJson(configPath(dataDir), config);
681
+ await withConfigLock(dataDir, async () => {
682
+ if (await exists(configPath(dataDir)))
683
+ fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
684
+ await writeJsonAtomic(configPath(dataDir), config);
685
+ });
122
686
  await mkdir(config.storage.root, { recursive: true });
123
687
  return { config, phrase };
124
688
  }
@@ -154,13 +718,13 @@ function assertArchivable(absolute, size) {
154
718
  * 3 MB reports the 3 MB archive. Nothing is returned when the file was
155
719
  * rewritten, truncated, or has never been archived.
156
720
  */
157
- export async function provenAppendOffset(archives, sourcePath, currentBytes) {
721
+ export async function provenAppendOffset(archives, sourcePath, currentBytes, onActivity, signal, onYield) {
158
722
  const candidates = archives
159
723
  .filter((record) => record.source.path === sourcePath && record.source.bytes > 0 && record.source.bytes <= currentBytes)
160
724
  .sort((a, b) => b.source.bytes - a.source.bytes);
161
725
  if (candidates.length === 0)
162
726
  return null;
163
- const digests = await hashFilePrefixes(sourcePath, candidates.map((record) => record.source.bytes));
727
+ const digests = await hashFilePrefixes(sourcePath, candidates.map((record) => record.source.bytes), onActivity, signal, onYield);
164
728
  for (const record of candidates) {
165
729
  const prefix = digests.get(record.source.bytes);
166
730
  if (prefix && equalHex(prefix, record.source.sha256))
@@ -168,6 +732,9 @@ export async function provenAppendOffset(archives, sourcePath, currentBytes) {
168
732
  }
169
733
  return null;
170
734
  }
735
+ /** Bound replay work for an agent that appends forever. The next snapshot
736
+ * after this many physical links is sealed whole and becomes a new base. */
737
+ export const MAX_DELTA_CHAIN_LINKS = 16;
171
738
  /**
172
739
  * The delta pointer on a record, when the record is a delta archive.
173
740
  *
@@ -194,26 +761,39 @@ export function deltaOf(record) {
194
761
  * is removed for the same reason sealArchiveToFile removes its destination — a
195
762
  * crash must never leave a .partial that could be mistaken for an archive.
196
763
  */
197
- async function writePlainArchive(sourcePath, staged, onProgress) {
764
+ async function writePlainArchive(sourcePath, staged, snapshotBytes, onProgress, signal) {
198
765
  const originalHash = createHash("sha256");
199
766
  const storedHash = createHash("sha256");
200
767
  let originalBytes = 0;
201
768
  let storedBytes = 0;
202
769
  try {
203
- await pipeline(createReadStream(sourcePath), async function* (chunks) {
770
+ await pipeline(
771
+ // A transcript may still be receiving turns while the background worker
772
+ // archives it. Read exactly the size captured before the source lock was
773
+ // entered, just like the sealed path does. `end` is inclusive; for an
774
+ // empty snapshot we let the stream open/read at most byte zero and drop
775
+ // it below, preserving both source-error reporting and a real empty gzip.
776
+ createReadStream(sourcePath, { start: 0, end: Math.max(0, snapshotBytes - 1), signal }), async function* (chunks) {
777
+ let remaining = snapshotBytes;
204
778
  for await (const piece of chunks) {
205
- originalHash.update(piece);
206
- originalBytes += piece.length;
779
+ signal?.throwIfAborted();
780
+ if (remaining <= 0)
781
+ continue;
782
+ const slice = piece.subarray(0, remaining);
783
+ originalHash.update(slice);
784
+ originalBytes += slice.length;
785
+ remaining -= slice.length;
207
786
  onProgress?.(originalBytes);
208
- yield piece;
787
+ yield slice;
209
788
  }
210
789
  }, createGzip({ level: 6 }), async function* (chunks) {
211
790
  for await (const piece of chunks) {
791
+ signal?.throwIfAborted();
212
792
  storedHash.update(piece);
213
793
  storedBytes += piece.length;
214
794
  yield piece;
215
795
  }
216
- }, createWriteStream(staged, { mode: 0o600 }));
796
+ }, createWriteStream(staged, { mode: 0o600, signal }), { signal });
217
797
  }
218
798
  catch (error) {
219
799
  await rm(staged, { force: true });
@@ -235,70 +815,290 @@ async function writePlainArchive(sourcePath, staged, onProgress) {
235
815
  * queue worker turns it on, because re-sealing a growing session whole on
236
816
  * every snapshot is exactly how 4 GB of transcript became 40 GB of storage.
237
817
  */
818
+ /**
819
+ * One seal of a given source at a time, across processes.
820
+ *
821
+ * Deduplication is a check followed by an act: look for an archive of these
822
+ * exact bytes, and seal one if there is none. Two callers arriving together
823
+ * both looked, both found nothing, and both sealed — two archives of one file,
824
+ * two records, twice the bytes. Not hypothetical: the daemon and a hand-run
825
+ * `sealkeep archive` reach for the same session constantly, and the queue runs
826
+ * seals in parallel by design.
827
+ *
828
+ * A lock file rather than an in-process mutex, because the racing callers are
829
+ * usually different processes. A dead owner's lock is reclaimed immediately;
830
+ * a malformed legacy lock has no PID to prove dead, so it is reclaimed only
831
+ * after the staleness window. Age alone never overrides a live owner: sealing a
832
+ * multi-gigabyte transcript can legitimately take longer than either window.
833
+ */
834
+ const SEAL_LOCK_STALE_MS = 30 * 60_000;
835
+ function cancellationError(signal) {
836
+ if (signal.reason instanceof Error)
837
+ return signal.reason;
838
+ const error = new Error("The seal was cancelled");
839
+ error.name = "AbortError";
840
+ return error;
841
+ }
842
+ async function cancellablePause(milliseconds, signal) {
843
+ if (!signal) {
844
+ await new Promise((resolvePause) => setTimeout(resolvePause, milliseconds));
845
+ return;
846
+ }
847
+ if (signal.aborted)
848
+ throw cancellationError(signal);
849
+ await new Promise((resolvePause, reject) => {
850
+ const timer = setTimeout(done, milliseconds);
851
+ const cancel = () => { clearTimeout(timer); cleanup(); reject(cancellationError(signal)); };
852
+ function cleanup() { signal.removeEventListener("abort", cancel); }
853
+ function done() { cleanup(); resolvePause(); }
854
+ signal.addEventListener("abort", cancel, { once: true });
855
+ });
856
+ }
857
+ async function withSourceLock(dataDir, absolute, work, signal) {
858
+ const { mkdir: makeDir, rm: remove, stat: statFile, open } = await import("node:fs/promises");
859
+ const dir = join(dataDir, "locks");
860
+ await makeDir(dir, { recursive: true, mode: 0o700 });
861
+ const lock = join(dir, `${createHash("sha256").update(absolute).digest("hex").slice(0, 32)}.lock`);
862
+ const ownership = `${process.pid} ${randomUUID()} ${new Date().toISOString()}\n`;
863
+ const abandoned = (held, owner) => {
864
+ const pid = Number(owner.trim().split(/\s+/, 1)[0]);
865
+ const hasPid = Number.isSafeInteger(pid) && pid > 0;
866
+ return (hasPid && !processIsAlive(pid))
867
+ || (!hasPid && held !== null && Date.now() - Number(held.mtimeMs) > SEAL_LOCK_STALE_MS);
868
+ };
869
+ /**
870
+ * Only one waiter may remove an abandoned source lock at a time. Without this
871
+ * small recovery guard, two processes can both observe the same dead PID;
872
+ * the slower one's `rm` can then delete the faster one's newly acquired live
873
+ * lock and both proceed to seal. The guard is held through the second
874
+ * read/check/remove, never through the seal itself.
875
+ */
876
+ const recoverAbandonedLock = async () => {
877
+ const recoveryLock = `${lock}.recovery`;
878
+ const recoveryOwnership = `${process.pid} ${randomUUID()} ${new Date().toISOString()}\n`;
879
+ for (;;) {
880
+ signal?.throwIfAborted();
881
+ try {
882
+ const handle = await open(recoveryLock, "wx", 0o600);
883
+ try {
884
+ await handle.writeFile(recoveryOwnership);
885
+ }
886
+ catch (error) {
887
+ await remove(recoveryLock, { force: true }).catch(() => undefined);
888
+ throw error;
889
+ }
890
+ finally {
891
+ await handle.close().catch(() => undefined);
892
+ }
893
+ break;
894
+ }
895
+ catch (error) {
896
+ if (error.code !== "EEXIST")
897
+ throw error;
898
+ const [held, owner] = await Promise.all([
899
+ statFile(recoveryLock).catch(() => null),
900
+ readFile(recoveryLock, "utf8").catch(() => ""),
901
+ ]);
902
+ if (abandoned(held, owner)) {
903
+ // A token check prevents a delayed dead-guard observer from deleting
904
+ // a successor guard that appeared at the same path meanwhile.
905
+ const current = await readFile(recoveryLock, "utf8").catch(() => null);
906
+ if (current === owner)
907
+ await remove(recoveryLock, { force: true }).catch(() => undefined);
908
+ continue;
909
+ }
910
+ await cancellablePause(100, signal);
911
+ }
912
+ }
913
+ try {
914
+ const [held, owner] = await Promise.all([
915
+ statFile(lock).catch(() => null),
916
+ readFile(lock, "utf8").catch(() => ""),
917
+ ]);
918
+ if (abandoned(held, owner))
919
+ await remove(lock, { force: true });
920
+ }
921
+ finally {
922
+ const current = await readFile(recoveryLock, "utf8").catch(() => null);
923
+ if (current === recoveryOwnership)
924
+ await remove(recoveryLock, { force: true }).catch(() => undefined);
925
+ }
926
+ };
927
+ for (;;) {
928
+ signal?.throwIfAborted();
929
+ try {
930
+ const handle = await open(lock, "wx", 0o600);
931
+ try {
932
+ await handle.writeFile(ownership);
933
+ }
934
+ catch (error) {
935
+ await remove(lock, { force: true }).catch(() => undefined);
936
+ throw error;
937
+ }
938
+ finally {
939
+ await handle.close().catch(() => undefined);
940
+ }
941
+ break;
942
+ }
943
+ catch (error) {
944
+ if (error.code !== "EEXIST")
945
+ throw error;
946
+ const [held, owner] = await Promise.all([
947
+ statFile(lock).catch(() => null),
948
+ readFile(lock, "utf8").catch(() => ""),
949
+ ]);
950
+ if (abandoned(held, owner)) {
951
+ await recoverAbandonedLock();
952
+ continue;
953
+ }
954
+ await cancellablePause(100, signal);
955
+ }
956
+ }
957
+ try {
958
+ return await work();
959
+ }
960
+ finally {
961
+ // Never unlink a successor's lock. This matters after stale-lock recovery:
962
+ // an unusually delayed former owner can still reach its `finally` after a
963
+ // new process has acquired the same path.
964
+ const current = await readFile(lock, "utf8").catch(() => null);
965
+ if (current === ownership)
966
+ await remove(lock, { force: true }).catch(() => undefined);
967
+ }
968
+ }
238
969
  export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custom", hooks = {}) {
970
+ hooks.signal?.throwIfAborted();
239
971
  const phrase = canonicalPhrase(rawPhrase);
240
972
  const config = await readConfig(dataDir);
973
+ const projectScope = teamSpaceOf(config, hooks.project, hooks.projectKey)?.spaceKey;
241
974
  if (!equalHex(config.recovery.phraseCheck, phraseCheck(phrase)))
242
975
  fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
243
- // The crypto has always taken a chunk size and nothing above it could set
244
- // one, so exercising a multi-chunk archive meant writing tens of megabytes.
245
- // A caller — or SEALKEEP_CHUNK_BYTES — can span chunks with kilobytes now.
246
- const chunkBytes = hooks.chunkBytes ?? (() => {
247
- const override = Number(envVar("CHUNK_BYTES"));
248
- return Number.isInteger(override) && override > 0 ? override : undefined;
249
- })();
976
+ // The typed seam lets deterministic tests exercise boundaries without a
977
+ // hidden environment variable changing production archive layout.
978
+ const chunkBytes = hooks.chunkBytes;
250
979
  const absolute = resolve(sourcePath);
251
980
  const source = await stat(absolute).catch(() => null);
252
981
  if (!source?.isFile())
253
982
  return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
254
983
  assertArchivable(absolute, source.size);
255
- // Hashing the source to answer "have I already archived exactly this?" is a
256
- // whole extra read of a file that may be gigabytes, so it is only worth doing
257
- // when some archive of this path already claims the same length. Anything of
258
- // a different length has certainly changed.
259
- const archives = await listArchives(dataDir);
260
- const sameLength = archives.filter((item) => item.source.path === absolute && item.source.bytes === source.size);
261
- if (sameLength.length > 0) {
262
- const { sha256: digest } = await hashFileRange(absolute);
263
- const existing = sameLength.find((item) => equalHex(item.source.sha256, digest));
264
- if (existing)
265
- return { ...existing, deduplicated: true };
266
- }
267
- // What this snapshot adds to the newest one that is still a prefix of the
268
- // file. Recorded, not acted on: the archive below is a complete, standalone
269
- // archive of the whole transcript. See the note on `supersedes` in types.ts.
270
- const base = await provenAppendOffset(archives, absolute, source.size);
271
- // The delta path: the caller asked for it, the prefix is proven, and there
272
- // is genuinely a tail to seal. Plain vaults are excluded — a bare gzip has
273
- // no ranged layout, and "portable, readable memory" should stay whole files.
274
- // (base.bytes === size never reaches here: the dedupe above already returned
275
- // that archive.)
276
- if (hooks.delta === true && config.storageMode !== "plain" && base && base.bytes < source.size) {
277
- return sealDeltaArchive(dataDir, config, absolute, agent, phrase, source.size, base, hooks);
278
- }
279
- const id = randomUUID();
280
- const objectPath = join(config.storage.root, `${id}.vlarchive`);
281
- const staged = `${objectPath}.${randomUUID()}.partial`;
282
- if (config.storageMode === "plain") {
283
- const plain = await writePlainArchive(absolute, staged, hooks.onProgress).catch((error) => {
984
+ // Everything from here — the "have I already got this?" question and the seal
985
+ // that answers it — happens under one lock per source, so two callers cannot
986
+ // both decide the answer is no.
987
+ return withSourceLock(dataDir, absolute, async () => {
988
+ hooks.signal?.throwIfAborted();
989
+ // Hashing the source to answer "have I already archived exactly this?" is a
990
+ // whole extra read of a file that may be gigabytes, so it is only worth doing
991
+ // when some archive of this path already claims the same length. Anything of
992
+ // a different length has certainly changed.
993
+ const archives = await listArchives(dataDir);
994
+ const sameLength = archives.filter((item) => item.source.path === absolute && item.source.bytes === source.size);
995
+ if (sameLength.length > 0) {
996
+ // The source may be a live transcript. Hash the size captured above,
997
+ // which is also the range the seal records; an unbounded read can chase an
998
+ // appending agent indefinitely and can never equal a same-length archive.
999
+ const { sha256: digest } = await hashFileRange(absolute, 0, source.size, hooks.onActivity, hooks.signal, hooks.onYield);
1000
+ const existing = sameLength.find((item) => equalHex(item.source.sha256, digest));
1001
+ if (existing)
1002
+ return { ...existing, deduplicated: true };
1003
+ }
1004
+ // What this snapshot adds to the newest one that is still a prefix of the
1005
+ // file. Recorded, not acted on: the archive below is a complete, standalone
1006
+ // archive of the whole transcript. See the note on `supersedes` in types.ts.
1007
+ const base = await provenAppendOffset(archives, absolute, source.size, hooks.onActivity, hooks.signal, hooks.onYield);
1008
+ // The delta path: the caller asked for it, the prefix is proven, and there
1009
+ // is genuinely a tail to seal. Plain vaults are excluded — a bare gzip has
1010
+ // no ranged layout, and "portable, readable memory" should stay whole files.
1011
+ // (base.bytes === size never reaches here: the dedupe above already returned
1012
+ // that archive.)
1013
+ if (hooks.delta === true && config.storageMode !== "plain" && base && base.bytes < source.size) {
1014
+ // `provenAppendOffset` deliberately keeps its public result small, but the
1015
+ // delta writer also needs the hash it just proved. Re-checking that hash
1016
+ // after the tail is sealed closes the window where an in-place rewrite of
1017
+ // the prefix could otherwise publish a chain that can never restore.
1018
+ const baseRecord = archives.find((record) => record.id === base.archiveId);
1019
+ if (!baseRecord)
1020
+ return fail("internal", `The proven base archive disappeared before ${absolute} could be sealed`, { archiveId: base.archiveId, sourcePath: absolute });
1021
+ // Replaying an unbounded append chain on every incremental index pass is
1022
+ // quadratic over a long-running session. Once the chain reaches the fixed
1023
+ // limit, fall through to the ordinary full-snapshot writer. That snapshot
1024
+ // is independently restorable, and later appends start a short new chain.
1025
+ if (resolveDeltaChain(archives, baseRecord).length < MAX_DELTA_CHAIN_LINKS) {
1026
+ return sealDeltaArchive(dataDir, config, absolute, agent, phrase, source.size, { ...base, sha256: baseRecord.source.sha256 }, hooks);
1027
+ }
1028
+ }
1029
+ const id = randomUUID();
1030
+ const objectPath = join(config.storage.root, `${id}.skarchive`);
1031
+ const staged = `${objectPath}.${randomUUID()}.partial`;
1032
+ if (config.storageMode === "plain") {
1033
+ const plain = await writePlainArchive(absolute, staged, source.size, hooks.onProgress, hooks.signal).catch((error) => {
1034
+ const code = error.code;
1035
+ if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
1036
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
1037
+ throw error;
1038
+ });
1039
+ // The record keeps the sealed shape minus the envelope, because everything
1040
+ // downstream — dedupe, verify, offload, push — reads `cipher` as "the
1041
+ // stored object's summary" and never as proof of encryption. In particular
1042
+ // `ciphertextSha256` keeps its name: it has always meant "hash of the
1043
+ // bytes on disk", which here is the gzip, and renaming it would fork every
1044
+ // consumer into two field names for one meaning. No envelope is written
1045
+ // because inventing one would claim key wrapping that never happened.
1046
+ const record = {
1047
+ version: 2, id, createdAt: new Date().toISOString(),
1048
+ // Stored, not re-derived: a project was knowable only by parsing the source
1049
+ // path, which works for an agent's own directories and nowhere else. Extending
1050
+ // a project's membership needs the answer the seal already had.
1051
+ source: { path: absolute, agent, bytes: plain.originalBytes, sha256: plain.originalSha256, ...(hooks.project ? { project: hooks.project } : {}), ...(hooks.projectKey ? { projectKey: hooks.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
1052
+ cipher: { algorithm: "none", ciphertextSha256: plain.storedSha256, storedBytes: plain.storedBytes, chunks: 0 },
1053
+ objectPath, ...(base ? { supersedes: base } : {})
1054
+ };
1055
+ try {
1056
+ await rename(staged, objectPath);
1057
+ }
1058
+ catch (error) {
1059
+ await rm(staged, { force: true });
1060
+ throw error;
1061
+ }
1062
+ await writeRecord(config.storage.root, record);
1063
+ await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: plain.originalBytes, recipients: 0, supersedes: base?.archiveId ?? null });
1064
+ return { ...record, deduplicated: false };
1065
+ }
1066
+ // Compression happens before the seal because ciphertext is incompressible
1067
+ // by construction. How much it buys was assumed here to be about 2x; measured
1068
+ // on a real 1.6 GB Codex rollout it is 1.5x, because these files carry a lot
1069
+ // of base64 and tool output that has already been compressed once. The
1070
+ // difference describes expected storage, not safe admission: the disk guard
1071
+ // uses the codec's incompressible upper bound before these bytes are seen.
1072
+ const tokenCollector = await inlineIndexWanted(dataDir, hooks, source.size)
1073
+ ? (await import("./search.js")).createSealTokenCollector(source.size)
1074
+ : null;
1075
+ const sealed = await sealArchiveToFile({ path: absolute, end: source.size }, staged, {
1076
+ ...(tokenCollector ? { onPlaintext: tokenCollector.onPlaintext } : {}),
1077
+ // gzip-chunk: compression lives inside each chunk, so sealing is a single
1078
+ // pass with no staged body — the ×2 disk window is gone — and every chunk
1079
+ // can later be fetched and opened alone. Archives sealed under the legacy
1080
+ // whole-body layout stay readable forever; this changes what is WRITTEN.
1081
+ // Members of this project are wrapped in; members of other projects are not.
1082
+ recipients: mergeRecipients(configuredRecipients(config, phrase, { project: hooks.project ?? undefined, projectKey: hooks.projectKey ?? undefined }), hooks.teamRecipients ?? []), archiveId: id, compression: hooks.compression ?? "gzip-chunk",
1083
+ adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
1084
+ ...(chunkBytes ? { chunkBytes } : {}),
1085
+ ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {}),
1086
+ ...(hooks.onYield ? { onYield: hooks.onYield } : {}),
1087
+ ...(hooks.signal ? { signal: hooks.signal } : {}),
1088
+ }).catch((error) => {
284
1089
  const code = error.code;
285
1090
  if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
286
1091
  return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
287
1092
  throw error;
288
1093
  });
289
- // The record keeps the sealed shape minus the envelope, because everything
290
- // downstream — dedupe, verify, offload, push — reads `cipher` as "the
291
- // stored object's summary" and never as proof of encryption. In particular
292
- // `ciphertextSha256` keeps its name: it has always meant "hash of the
293
- // bytes on disk", which here is the gzip, and renaming it would fork every
294
- // consumer into two field names for one meaning. No envelope is written
295
- // because inventing one would claim key wrapping that never happened.
296
1094
  const record = {
297
- version: 2, id, createdAt: new Date().toISOString(),
298
- source: { path: absolute, agent, bytes: plain.originalBytes, sha256: plain.originalSha256 },
299
- cipher: { algorithm: "none", ciphertextSha256: plain.storedSha256, storedBytes: plain.storedBytes, chunks: 0 },
300
- objectPath, ...(base ? { supersedes: base } : {})
1095
+ version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
1096
+ source: { path: absolute, agent, bytes: sealed.originalBytes, sha256: sealed.originalSha256, ...(hooks.project ? { project: hooks.project } : {}), ...(hooks.projectKey ? { projectKey: hooks.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
1097
+ cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
1098
+ envelope: sealed.envelope, objectPath, ...(base ? { supersedes: base } : {})
301
1099
  };
1100
+ // The object only becomes an archive once it is whole: the manifest hashes are
1101
+ // proved by the last chunk, so a crash mid-seal leaves a .partial nobody reads.
302
1102
  try {
303
1103
  await rename(staged, objectPath);
304
1104
  }
@@ -306,50 +1106,57 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
306
1106
  await rm(staged, { force: true });
307
1107
  throw error;
308
1108
  }
309
- await writeJson(join(config.storage.root, `${id}.json`), record);
310
- await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: plain.originalBytes, recipients: 0, supersedes: base?.archiveId ?? null });
1109
+ await writeRecord(config.storage.root, record);
1110
+ await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: base?.archiveId ?? null });
1111
+ if (tokenCollector)
1112
+ await indexSealInline(dataDir, phrase, record, tokenCollector.finish(sealed.envelope.chunks.length), hooks.signal);
311
1113
  return { ...record, deduplicated: false };
312
- }
313
- // Compression happens before the seal because ciphertext is incompressible
314
- // by construction. How much it buys was assumed here to be about 2x; measured
315
- // on a real 1.6 GB Codex rollout it is 1.5x, because these files carry a lot
316
- // of base64 and tool output that has already been compressed once. The
317
- // difference matters wherever space is planned rather than merely hoped for —
318
- // see spaceNeededFor in src/disk.ts, which uses the measured figure.
319
- const sealed = await sealArchiveToFile({ path: absolute, end: source.size }, staged, {
320
- // gzip-chunk: compression lives inside each chunk, so sealing is a single
321
- // pass with no staged body — the ×2 disk window is gone — and every chunk
322
- // can later be fetched and opened alone. Archives sealed under the legacy
323
- // whole-body layout stay readable forever; this changes what is WRITTEN.
324
- // Members of this project are wrapped in; members of other projects are not.
325
- recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
326
- adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
327
- ...(chunkBytes ? { chunkBytes } : {}),
328
- ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
329
- }).catch((error) => {
330
- const code = error.code;
331
- if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
332
- return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
333
- throw error;
334
- });
335
- const record = {
336
- version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
337
- source: { path: absolute, agent, bytes: sealed.originalBytes, sha256: sealed.originalSha256 },
338
- cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
339
- envelope: sealed.envelope, objectPath, ...(base ? { supersedes: base } : {})
340
- };
341
- // The object only becomes an archive once it is whole: the manifest hashes are
342
- // proved by the last chunk, so a crash mid-seal leaves a .partial nobody reads.
1114
+ }, hooks.signal);
1115
+ }
1116
+ /** Above this a seal is a whale: the catch-up indexes it last, deliberately, and the seal itself must not carry that cost. */
1117
+ const INLINE_INDEX_MAX_BYTES = 256 * 1024 * 1024;
1118
+ /** A blob index is merged whole, in this process, on every inline seal: fine while it is small, an out-of-memory crash inside the daemon once it is not. */
1119
+ const INLINE_INDEX_MAX_BLOB_ARCHIVES = 2000;
1120
+ async function inlineIndexWanted(dataDir, hooks, sourceBytes) {
1121
+ if (!hooks.indexInline || (hooks.compression ?? "gzip-chunk") === "zstd" || sourceBytes > INLINE_INDEX_MAX_BYTES)
1122
+ return false;
1123
+ const { segmentsEnabled } = await import("./index-store.js");
1124
+ // Segments append one small sealed file per seal, whatever the vault's size,
1125
+ // under the manifest lock — safe beside a running catch-up.
1126
+ if (await segmentsEnabled(dataDir))
1127
+ return true;
1128
+ // The blob has one writer at a time, and while the catch-up is building it
1129
+ // is that writer: a merge from here would race its checkpoint and one of
1130
+ // the two would be lost (the test "cannot bypass the migration guard").
1131
+ const lane = await readFile(join(dataDir, "daemon-state.json"), "utf8")
1132
+ .then((raw) => JSON.parse(raw))
1133
+ .catch(() => null);
1134
+ if (lane?.indexing?.state === "building")
1135
+ return false;
1136
+ // Past this the whole-index merge belongs to the catch-up lane (or to a
1137
+ // migration: `sealkeep index migrate` gives a big vault seal-time indexing).
1138
+ const coverage = await readFile(join(dataDir, "index", "coverage.json"), "utf8")
1139
+ .then((raw) => JSON.parse(raw))
1140
+ .catch(() => null);
1141
+ return (coverage?.indexed?.length ?? 0) <= INLINE_INDEX_MAX_BLOB_ARCHIVES;
1142
+ }
1143
+ /**
1144
+ * The seal is durable before this runs and stays durable if this fails: an
1145
+ * index entry that cannot be written is named (warning + audit) and the next
1146
+ * catch-up pass indexes the archive from its sealed bytes — the contract the
1147
+ * cloud seal path keeps in chunk-store.ts.
1148
+ */
1149
+ async function indexSealInline(dataDir, phrase, record, tokensByChunk, signal) {
343
1150
  try {
344
- await rename(staged, objectPath);
1151
+ const { indexArchiveTokens } = await import("./search.js");
1152
+ await indexArchiveTokens(dataDir, phrase, record, tokensByChunk, { ...(signal ? { signal } : {}) });
345
1153
  }
346
1154
  catch (error) {
347
- await rm(staged, { force: true });
348
- throw error;
1155
+ const code = error?.code;
1156
+ const named = typeof code === "string" ? code : error instanceof Error ? error.message.split("\n")[0] : String(error);
1157
+ process.emitWarning(`index merge deferred for ${record.id.slice(0, 8)}: ${named} — the next index pass indexes it from the sealed bytes`, "SealkeepIndexDeferred");
1158
+ await recordAudit(dataDir, "index.merge", "denied", { archiveId: record.id, reason: named, preserved: false }).catch(() => undefined);
349
1159
  }
350
- await writeJson(join(config.storage.root, `${id}.json`), record);
351
- await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: base?.archiveId ?? null });
352
- return { ...record, deduplicated: false };
353
1160
  }
354
1161
  /**
355
1162
  * Seals only what an appended transcript added since its proven base archive.
@@ -370,33 +1177,97 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
370
1177
  * *needs* it — so writing `supersedes` here would offer the base for pruning
371
1178
  * and turn a storage saving into data loss.
372
1179
  */
1180
+ async function hashDeltaSnapshot(sourcePath, size, prefixBytes, onActivity, signal, onYield) {
1181
+ const whole = createHash("sha256");
1182
+ const prefix = createHash("sha256");
1183
+ const tail = createHash("sha256");
1184
+ let bytes = 0;
1185
+ let nextYield = 16 * 1024 * 1024;
1186
+ // One bounded pass is important here. Three independent reads could each
1187
+ // describe a different instant of a live transcript; hashing all three
1188
+ // views from the same chunks proves that `whole` is exactly base + sealed
1189
+ // tail whenever both component hashes match.
1190
+ for await (const chunk of createReadStream(sourcePath, { start: 0, end: size - 1, signal })) {
1191
+ signal?.throwIfAborted();
1192
+ const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1193
+ whole.update(piece);
1194
+ const prefixRemaining = Math.max(0, prefixBytes - bytes);
1195
+ const split = Math.min(prefixRemaining, piece.length);
1196
+ if (split > 0)
1197
+ prefix.update(piece.subarray(0, split));
1198
+ if (split < piece.length)
1199
+ tail.update(piece.subarray(split));
1200
+ bytes += piece.length;
1201
+ onActivity?.(bytes);
1202
+ if (bytes >= nextYield) {
1203
+ nextYield = bytes + 16 * 1024 * 1024;
1204
+ if (onYield)
1205
+ await onYield();
1206
+ else
1207
+ await yieldToEventLoop();
1208
+ }
1209
+ }
1210
+ return {
1211
+ bytes,
1212
+ wholeSha256: whole.digest("hex"),
1213
+ prefixSha256: prefix.digest("hex"),
1214
+ tailSha256: tail.digest("hex")
1215
+ };
1216
+ }
373
1217
  async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size, base, hooks) {
374
1218
  const id = randomUUID();
375
- const objectPath = join(config.storage.root, `${id}.vlarchive`);
1219
+ const objectPath = join(config.storage.root, `${id}.skarchive`);
376
1220
  const staged = `${objectPath}.${randomUUID()}.partial`;
1221
+ const projectScope = teamSpaceOf(config, hooks.project, hooks.projectKey)?.spaceKey;
1222
+ // Same inline-indexing gate a full seal uses, sized to what THIS seal
1223
+ // actually covers: the delta's own appended range, not the whole
1224
+ // transcript. That range is exactly what the delta's own chunk numbers
1225
+ // describe, which is what lets a later query ranged-read this object
1226
+ // directly instead of decrypting the whole chain for one quote.
1227
+ const appendedBytes = size - base.bytes;
1228
+ const tokenCollector = await inlineIndexWanted(dataDir, hooks, appendedBytes)
1229
+ ? (await import("./search.js")).createSealTokenCollector(appendedBytes)
1230
+ : null;
377
1231
  const sealed = await sealArchiveToFile({ path: absolute, start: base.bytes, end: size }, staged, {
378
- recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
1232
+ recipients: mergeRecipients(configuredRecipients(config, phrase, { project: hooks.project ?? undefined, projectKey: hooks.projectKey ?? undefined }), hooks.teamRecipients ?? []), archiveId: id, compression: "gzip-chunk",
379
1233
  adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
380
- ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
1234
+ ...(tokenCollector ? { onPlaintext: tokenCollector.onPlaintext } : {}),
1235
+ ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {}),
1236
+ ...(hooks.onYield ? { onYield: hooks.onYield } : {}),
1237
+ ...(hooks.signal ? { signal: hooks.signal } : {}),
381
1238
  }).catch((error) => {
382
1239
  const code = error.code;
383
1240
  if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
384
1241
  return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
385
1242
  throw error;
386
1243
  });
387
- // The whole-file identity this record answers for. Hashed after the seal so
388
- // a file that grew meanwhile is still described exactly as sealed: the range
389
- // ends at `size` either way. A file that *shrank* mid-seal is not an append
390
- // at all — refuse rather than record a chain that cannot reproduce anything;
391
- // the queued retry re-proves the prefix and archives it whole.
392
- const whole = await hashFileRange(absolute, 0, size);
393
- if (sealed.originalBytes !== size - base.bytes || whole.bytes !== size) {
1244
+ // Validate the source snapshot after sealing and before publishing. Length
1245
+ // alone is not proof: an agent (or editor) can rewrite already-proved bytes
1246
+ // in place without changing the file size. The prefix must still be the base
1247
+ // archive and the tail must still be the bytes in the staged delta. The
1248
+ // queued retry will re-prove the source and choose a whole archive if either
1249
+ // component changed.
1250
+ let snapshot;
1251
+ try {
1252
+ snapshot = await hashDeltaSnapshot(absolute, size, base.bytes, hooks.onActivity, hooks.signal, hooks.onYield);
1253
+ }
1254
+ catch (error) {
1255
+ await rm(staged, { force: true });
1256
+ const code = error.code;
1257
+ if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
1258
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
1259
+ throw error;
1260
+ }
1261
+ if (sealed.originalBytes !== size - base.bytes ||
1262
+ snapshot.bytes !== size ||
1263
+ !equalHex(snapshot.prefixSha256, base.sha256) ||
1264
+ !equalHex(snapshot.tailSha256, sealed.originalSha256)) {
394
1265
  await rm(staged, { force: true });
395
1266
  fail("source_unreadable", `${absolute} changed while its append was being sealed; it will re-archive whole on the next pass`, { sourcePath: absolute, bytes: size });
396
1267
  }
397
1268
  const record = {
398
1269
  version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
399
- source: { path: absolute, agent, bytes: size, sha256: whole.sha256 },
1270
+ source: { path: absolute, agent, bytes: size, sha256: snapshot.wholeSha256, ...(hooks.project ? { project: hooks.project } : {}), ...(hooks.projectKey ? { projectKey: hooks.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
400
1271
  cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
401
1272
  envelope: sealed.envelope, objectPath,
402
1273
  delta: { baseArchiveId: base.archiveId, baseBytes: base.bytes }
@@ -408,8 +1279,12 @@ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size,
408
1279
  await rm(staged, { force: true });
409
1280
  throw error;
410
1281
  }
411
- await writeJson(join(config.storage.root, `${id}.json`), record);
1282
+ await writeRecord(config.storage.root, record);
412
1283
  await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: size, appendedBytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: null, deltaBase: base.archiveId });
1284
+ // The seal is durable either way; indexing it is advisory (see
1285
+ // indexSealInline) and, unlike a full seal, covers only the appended range.
1286
+ if (tokenCollector)
1287
+ await indexSealInline(dataDir, phrase, record, tokenCollector.finish(sealed.envelope.chunks.length), hooks.signal);
413
1288
  return { ...record, deduplicated: false };
414
1289
  }
415
1290
  /**
@@ -431,21 +1306,653 @@ export function configuredRecipients(config, rawPhrase, scope) {
431
1306
  const asked = typeof scope === "string" ? { group: scope } : (scope ?? {});
432
1307
  const keys = (config.recipients ?? [])
433
1308
  .filter((recipient) => (asked.group ? recipient.group === asked.group : true))
434
- .filter((recipient) => !recipient.project || recipient.project === asked.project)
1309
+ .filter((recipient) => {
1310
+ if (!recipient.project && !recipient.projectKey)
1311
+ return true;
1312
+ // Once a stable key is known, a display label is never sufficient proof
1313
+ // of scope. This is the fail-closed boundary that keeps org-a/api from
1314
+ // receiving org-b/api's memory.
1315
+ if (asked.projectKey)
1316
+ return recipient.projectKey === asked.projectKey;
1317
+ if (recipient.projectKey)
1318
+ return false;
1319
+ return recipient.project === asked.project;
1320
+ })
435
1321
  .map((recipient) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(recipient.publicKey, "base64")) }));
436
1322
  return [{ kind: "phrase", phrase }, ...keys];
437
1323
  }
1324
+ /** Accepted managed team members that should open future project archives. */
1325
+ export async function teamRecipientsForProject(dataDir, project, projectKey) {
1326
+ if (!project && !projectKey)
1327
+ return [];
1328
+ const config = await readConfig(dataDir);
1329
+ const binding = teamSpaceOf(config, project, projectKey);
1330
+ if (!binding)
1331
+ return [];
1332
+ try {
1333
+ const { listTeamMembers } = await import("./cloud.js");
1334
+ return (await listTeamMembers(dataDir, binding.spaceKey)).filter((member) => typeof member.public_key === "string").map((member) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(member.public_key, "base64")) }));
1335
+ }
1336
+ catch {
1337
+ // A transient control-plane outage must not prevent local preservation; the
1338
+ // normal recovery phrase recipient still seals the archive safely.
1339
+ return [];
1340
+ }
1341
+ }
1342
+ function mergeRecipients(base, extra) {
1343
+ const identity = (item) => item.kind === "phrase"
1344
+ ? `phrase:${item.phrase.normalize("NFKD")}`
1345
+ : `x25519:${keyRecipientId(Buffer.isBuffer(item.publicKey) ? item.publicKey : rawPublicKey(item.publicKey))}`;
1346
+ const seen = new Set(base.map(identity));
1347
+ return [...base, ...extra.filter((item) => {
1348
+ const key = identity(item);
1349
+ if (seen.has(key))
1350
+ return false;
1351
+ seen.add(key);
1352
+ return true;
1353
+ })];
1354
+ }
1355
+ /**
1356
+ * Every archive this vault knows about.
1357
+ *
1358
+ * One unreadable record used to take the whole list with it: `JSON.parse`
1359
+ * threw inside a `Promise.all`, so a single truncated file — a crash mid-write,
1360
+ * a disk that filled between `open` and `close` — made `status`, `search`,
1361
+ * `retention` and every other command that lists archives fail outright. A
1362
+ * vault of two hundred good records became a vault of none, over one bad byte.
1363
+ *
1364
+ * A record that will not parse is skipped and NAMED, not swallowed: it may be
1365
+ * the only trace of an archive, and quietly dropping it would turn a loud
1366
+ * problem into a missing one. The archives that do parse still answer.
1367
+ */
438
1368
  export async function listArchives(dataDir) {
439
1369
  const config = await readConfig(dataDir);
1370
+ const scan = await currentArchiveLedgerScan(config.storage.root);
1371
+ reportUnreadableArchiveRecords(config.storage.root, scan.unreadable);
1372
+ // Each caller owns the collection it receives even when several concurrent
1373
+ // dashboard requests shared the underlying filesystem scan.
1374
+ return scan.records.slice();
1375
+ }
1376
+ // The dashboard loads several archive-derived endpoints together. A per-call
1377
+ // batch bound alone still multiplied 32 opens by every endpoint. Share both an
1378
+ // in-flight scan and a completed, generation-validated scan. Archive record
1379
+ // writers publish with atomic rename, which changes the root generation. A
1380
+ // malformed file is the exception: a person may repair it in place, so its own
1381
+ // stat fingerprint is revalidated before the completed scan can be reused.
1382
+ const inFlightArchiveLedgerScans = new Map();
1383
+ const completedArchiveLedgerScans = new Map();
1384
+ const MAX_COMPLETED_ARCHIVE_LEDGER_SCANS = 32;
1385
+ async function archiveDirectoryGeneration(storageRoot) {
1386
+ try {
1387
+ const value = await stat(storageRoot, { bigint: true });
1388
+ return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
1389
+ }
1390
+ catch {
1391
+ return null;
1392
+ }
1393
+ }
1394
+ async function archiveSidecarFingerprint(path) {
1395
+ try {
1396
+ const value = await stat(path, { bigint: true });
1397
+ return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
1398
+ }
1399
+ catch {
1400
+ return null;
1401
+ }
1402
+ }
1403
+ async function completedArchiveLedgerScanIsCurrent(storageRoot, scan) {
1404
+ if (!scan.cacheable || !scan.directoryGeneration
1405
+ || await archiveDirectoryGeneration(storageRoot) !== scan.directoryGeneration)
1406
+ return false;
1407
+ for (const [file, fingerprint] of scan.damaged) {
1408
+ if (await archiveSidecarFingerprint(join(storageRoot, file)) !== fingerprint)
1409
+ return false;
1410
+ }
1411
+ return true;
1412
+ }
1413
+ function rememberCompletedArchiveLedgerScan(storageRoot, scan) {
1414
+ completedArchiveLedgerScans.delete(storageRoot);
1415
+ completedArchiveLedgerScans.set(storageRoot, scan);
1416
+ if (completedArchiveLedgerScans.size <= MAX_COMPLETED_ARCHIVE_LEDGER_SCANS)
1417
+ return;
1418
+ const oldest = completedArchiveLedgerScans.keys().next().value;
1419
+ if (oldest !== undefined)
1420
+ completedArchiveLedgerScans.delete(oldest);
1421
+ }
1422
+ async function currentArchiveLedgerScan(storageRoot) {
1423
+ const key = resolve(storageRoot);
1424
+ const cached = completedArchiveLedgerScans.get(key);
1425
+ if (cached) {
1426
+ if (await completedArchiveLedgerScanIsCurrent(storageRoot, cached)) {
1427
+ // Refresh insertion order so the small process-wide map behaves as an LRU.
1428
+ completedArchiveLedgerScans.delete(key);
1429
+ completedArchiveLedgerScans.set(key, cached);
1430
+ return cached;
1431
+ }
1432
+ if (completedArchiveLedgerScans.get(key) === cached)
1433
+ completedArchiveLedgerScans.delete(key);
1434
+ }
1435
+ // Another caller may have started the replacement scan while this caller
1436
+ // awaited generation/fingerprint validation above.
1437
+ const existing = inFlightArchiveLedgerScans.get(key);
1438
+ if (existing)
1439
+ return existing;
1440
+ let tracked;
1441
+ tracked = scanArchiveLedger(storageRoot).then((scan) => {
1442
+ if (scan.cacheable)
1443
+ rememberCompletedArchiveLedgerScan(key, scan);
1444
+ else
1445
+ completedArchiveLedgerScans.delete(key);
1446
+ return scan;
1447
+ }).finally(() => {
1448
+ if (inFlightArchiveLedgerScans.get(key) === tracked)
1449
+ inFlightArchiveLedgerScans.delete(key);
1450
+ });
1451
+ inFlightArchiveLedgerScans.set(key, tracked);
1452
+ return tracked;
1453
+ }
1454
+ function objectValue(value) {
1455
+ return value !== null && typeof value === "object" && !Array.isArray(value)
1456
+ ? value
1457
+ : null;
1458
+ }
1459
+ function requiredString(value) {
1460
+ return typeof value === "string" && value.length > 0;
1461
+ }
1462
+ function nonNegativeNumber(value) {
1463
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
1464
+ }
1465
+ function archiveSourceIsValid(value) {
1466
+ const source = objectValue(value);
1467
+ return source !== null
1468
+ && requiredString(source.path)
1469
+ && requiredString(source.agent)
1470
+ && nonNegativeNumber(source.bytes)
1471
+ && typeof source.sha256 === "string";
1472
+ }
1473
+ function legacyCipherIsValid(value) {
1474
+ const cipher = objectValue(value);
1475
+ return cipher !== null
1476
+ && cipher.algorithm === "aes-256-gcm"
1477
+ && requiredString(cipher.nonce)
1478
+ && requiredString(cipher.authTag)
1479
+ && typeof cipher.ciphertextSha256 === "string"
1480
+ && cipher.compression === "none";
1481
+ }
1482
+ function legacyWrappedKeyIsValid(value) {
1483
+ const wrapped = objectValue(value);
1484
+ return wrapped !== null
1485
+ && wrapped.algorithm === "scrypt-aes-256-gcm"
1486
+ && requiredString(wrapped.salt)
1487
+ && requiredString(wrapped.nonce)
1488
+ && requiredString(wrapped.authTag)
1489
+ && requiredString(wrapped.ciphertext);
1490
+ }
1491
+ function currentCipherIsValid(value) {
1492
+ const cipher = objectValue(value);
1493
+ return cipher !== null
1494
+ && (cipher.algorithm === "none" || cipher.algorithm === "aes-256-gcm" || cipher.algorithm === "chacha20-poly1305")
1495
+ && typeof cipher.ciphertextSha256 === "string"
1496
+ && nonNegativeNumber(cipher.storedBytes)
1497
+ && nonNegativeNumber(cipher.chunks)
1498
+ && Number.isInteger(cipher.chunks);
1499
+ }
1500
+ function currentEnvelopeIsValid(value, archiveId, suite) {
1501
+ const envelope = objectValue(value);
1502
+ const manifest = objectValue(envelope?.manifest);
1503
+ return envelope !== null
1504
+ && envelope.version === 2
1505
+ && envelope.archiveId === archiveId
1506
+ && envelope.suite === suite
1507
+ && nonNegativeNumber(envelope.chunkBytes)
1508
+ && Array.isArray(envelope.chunks)
1509
+ && manifest !== null
1510
+ && typeof manifest.plaintextSha256 === "string"
1511
+ && nonNegativeNumber(manifest.plaintextBytes)
1512
+ && requiredString(manifest.createdAt)
1513
+ && Array.isArray(envelope.wrappedKeys);
1514
+ }
1515
+ function archiveRecordIsValid(value) {
1516
+ const record = objectValue(value);
1517
+ if (record === null
1518
+ || !requiredString(record.id)
1519
+ || !requiredString(record.createdAt)
1520
+ || !archiveSourceIsValid(record.source)
1521
+ || !requiredString(record.objectPath))
1522
+ return false;
1523
+ if (record.version === 1) {
1524
+ return record.teamCustody === undefined && legacyCipherIsValid(record.cipher) && legacyWrappedKeyIsValid(record.wrappedKey);
1525
+ }
1526
+ if (record.version !== 2 || !currentCipherIsValid(record.cipher))
1527
+ return false;
1528
+ let sealedIdentity = record.id;
1529
+ if (record.teamCustody !== undefined) {
1530
+ if (!teamCustodyRecordIsValid(record))
1531
+ return false;
1532
+ sealedIdentity = record.teamCustody.envelopeArchiveId;
1533
+ }
1534
+ // Plain archives deliberately have no encryption envelope. Every encrypted
1535
+ // V2 archive must carry the envelope facts needed to interpret its cipher.
1536
+ return record.cipher.algorithm === "none"
1537
+ ? record.envelope === undefined
1538
+ : currentEnvelopeIsValid(record.envelope, sealedIdentity, record.cipher.algorithm);
1539
+ }
1540
+ function parseArchiveSidecar(raw) {
1541
+ try {
1542
+ const record = JSON.parse(raw);
1543
+ return archiveRecordIsValid(record) ? record : null;
1544
+ }
1545
+ catch {
1546
+ return null;
1547
+ }
1548
+ }
1549
+ async function readArchiveSidecar(storageRoot, file) {
1550
+ const path = join(storageRoot, file);
1551
+ let raw;
1552
+ try {
1553
+ raw = await readFile(path, "utf8");
1554
+ }
1555
+ catch {
1556
+ return { kind: "transient", file };
1557
+ }
1558
+ const record = parseArchiveSidecar(raw);
1559
+ if (record)
1560
+ return { kind: "record", file, record };
1561
+ // A malformed sidecar is safe to remember only after proving the exact file
1562
+ // stayed unchanged across a second read. This closes the race where an
1563
+ // in-place repair lands between the failed parse and the fingerprint stat.
1564
+ const before = await archiveSidecarFingerprint(path);
1565
+ if (!before)
1566
+ return { kind: "transient", file };
1567
+ let confirmedRaw;
1568
+ try {
1569
+ confirmedRaw = await readFile(path, "utf8");
1570
+ }
1571
+ catch {
1572
+ return { kind: "transient", file };
1573
+ }
1574
+ const after = await archiveSidecarFingerprint(path);
1575
+ if (!after || before !== after)
1576
+ return { kind: "transient", file };
1577
+ const repaired = parseArchiveSidecar(confirmedRaw);
1578
+ return repaired
1579
+ ? { kind: "record", file, record: repaired }
1580
+ : { kind: "damaged", file, fingerprint: after };
1581
+ }
1582
+ async function scanArchiveLedger(storageRoot) {
440
1583
  const { readdir } = await import("node:fs/promises");
441
- const files = await readdir(config.storage.root);
442
- const records = await Promise.all(files.filter((file) => file.endsWith(".json")).map(async (file) => JSON.parse(await readFile(join(config.storage.root, file), "utf8"))));
443
- return records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1584
+ const before = await archiveDirectoryGeneration(storageRoot);
1585
+ const files = (await readdir(storageRoot)).filter((file) => file.endsWith(".json"));
1586
+ const records = [];
1587
+ const unreadable = [];
1588
+ const damaged = new Map();
1589
+ let transientFailure = before === null;
1590
+ // A real long-lived vault has thousands of sidecars, and the dashboard asks
1591
+ // several archive-derived questions at once. Opening every file in one
1592
+ // Promise.all submitted thousands of filesystem requests per caller, then
1593
+ // ran all of their JSON.parse continuations in one dense microtask wave. The
1594
+ // bytes were small, but the shared local API event loop stopped answering
1595
+ // lightweight health/UI requests while that wave drained. Bound both the fd
1596
+ // fan-out and the synchronous parse burst, and explicitly return to the event
1597
+ // loop between batches. The completed cache above is guarded by the root's
1598
+ // durable generation, so a later request cannot miss a record another
1599
+ // Sealkeep process atomically replaced.
1600
+ const batchSize = 32;
1601
+ for (let offset = 0; offset < files.length; offset += batchSize) {
1602
+ const batch = await Promise.all(files.slice(offset, offset + batchSize)
1603
+ .map((file) => readArchiveSidecar(storageRoot, file)));
1604
+ for (const result of batch) {
1605
+ if (result.kind === "record")
1606
+ records.push(result.record);
1607
+ else {
1608
+ unreadable.push(result.file);
1609
+ if (result.kind === "damaged")
1610
+ damaged.set(result.file, result.fingerprint);
1611
+ else
1612
+ transientFailure = true;
1613
+ }
1614
+ }
1615
+ if (offset + batchSize < files.length)
1616
+ await yieldToEventLoop();
1617
+ }
1618
+ const after = await archiveDirectoryGeneration(storageRoot);
1619
+ const stable = before !== null && before === after;
1620
+ return {
1621
+ records: records.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
1622
+ unreadable,
1623
+ directoryGeneration: stable ? after : null,
1624
+ damaged,
1625
+ cacheable: stable && !transientFailure,
1626
+ };
1627
+ }
1628
+ function reportUnreadableArchiveRecords(storageRoot, unreadable) {
1629
+ if (!archiveWarningSilence.getStore()) {
1630
+ // Sorting a copy keeps the shared scan immutable for concurrent callers.
1631
+ const named = [...unreadable].sort();
1632
+ if (named.length === 0) {
1633
+ // If an operator repairs the metadata and damage later recurs, that is a
1634
+ // new incident and deserves a new warning.
1635
+ warnedArchiveDamage.delete(storageRoot);
1636
+ }
1637
+ else {
1638
+ const fingerprint = named.join("\0");
1639
+ if (warnedArchiveDamage.get(storageRoot) !== fingerprint) {
1640
+ warnedArchiveDamage.set(storageRoot, fingerprint);
1641
+ process.emitWarning(`${named.length} archive record${named.length === 1 ? "" : "s"} could not be read and were skipped: ${named.slice(0, 5).join(", ")}${named.length > 5 ? " …" : ""}`, "SealkeepDamagedRecord");
1642
+ }
1643
+ }
1644
+ }
444
1645
  }
445
1646
  /**
446
1647
  * Registers an X25519 public key that may open future archives. Only the public
447
1648
  * key is stored; the matching private key stays on its own device.
448
1649
  */
1650
+ export function projectSharingOf(config, project, projectKey) {
1651
+ const lookup = projectKey || project;
1652
+ if (!lookup)
1653
+ return "passive";
1654
+ return config.projectSharing?.[lookup] ?? "passive";
1655
+ }
1656
+ export async function setProjectSharing(dataDir, project, mode, projectKey) {
1657
+ if (projectKey && !PROJECT_KEY_RE.test(projectKey))
1658
+ fail("invalid_argument", "A stable project key is malformed");
1659
+ let resolvedProjectKey = projectKey ?? null;
1660
+ if (!resolvedProjectKey) {
1661
+ const { projectIdentityOfRecord } = await import("./adapters.js");
1662
+ const keys = new Set();
1663
+ for (const record of await listArchives(dataDir)) {
1664
+ const identity = await projectIdentityOfRecord(record).catch(() => null);
1665
+ if (identity?.project === project && identity.projectKey)
1666
+ keys.add(identity.projectKey);
1667
+ }
1668
+ if (keys.size > 1) {
1669
+ fail("invalid_argument", `More than one stable project is named ${project}; choose the exact folder so sharing cannot cross repositories`);
1670
+ }
1671
+ resolvedProjectKey = [...keys][0] ?? null;
1672
+ }
1673
+ const bindingKey = resolvedProjectKey || project;
1674
+ const next = await mutateConfigAtomically(dataDir, (config) => ({
1675
+ ...config, projectSharing: { ...(config.projectSharing ?? {}), [bindingKey]: mode },
1676
+ }));
1677
+ await recordAudit(dataDir, "project.sharing", "allowed", { project, ...(resolvedProjectKey ? { projectKey: resolvedProjectKey } : {}), mode });
1678
+ return next;
1679
+ }
1680
+ const TEAM_SPACE_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
1681
+ const PROJECT_KEY_RE = /^(?:g1|p1):[a-f0-9]{64}$/;
1682
+ function assertTeamBinding(binding) {
1683
+ if (!TEAM_SPACE_KEY_RE.test(binding.spaceKey))
1684
+ fail("invalid_argument", "A team space key must be a 43-character base64url value");
1685
+ if (binding.realtimeKey !== undefined && !TEAM_SPACE_KEY_RE.test(binding.realtimeKey)) {
1686
+ fail("invalid_argument", "A team realtime key must be a 43-character base64url value");
1687
+ }
1688
+ if (binding.keyVersion !== undefined && (!Number.isSafeInteger(binding.keyVersion) || binding.keyVersion <= 0)) {
1689
+ fail("invalid_argument", "A team key version must be a positive integer");
1690
+ }
1691
+ if (binding.role !== undefined && binding.role !== "owner" && binding.role !== "member") {
1692
+ fail("invalid_argument", "A team role must be owner or member");
1693
+ }
1694
+ }
1695
+ export function teamSpaceOf(config, project, projectKey) {
1696
+ // A caller that knows the stable key has supplied all the identity evidence
1697
+ // available. Falling back to a same-named legacy entry would reintroduce the
1698
+ // collision this key exists to prevent, so a miss is deliberately final.
1699
+ const lookup = projectKey || project;
1700
+ if (!lookup)
1701
+ return null;
1702
+ const value = config.teamSpaces?.[lookup];
1703
+ return value && typeof value.spaceKey === "string" ? value : null;
1704
+ }
1705
+ /**
1706
+ * Adds keyed aliases for old label-keyed bindings only when local discovery
1707
+ * proves that label names exactly one stable project. The legacy entry stays
1708
+ * in place for older Sealkeep builds; duplicate labels stay untouched and are
1709
+ * therefore invisible to new exact-key access decisions.
1710
+ */
1711
+ export async function migrateLegacyTeamSpaceBindings(dataDir, identities) {
1712
+ const keysByLabel = new Map();
1713
+ for (const identity of identities) {
1714
+ if (!identity.project || !identity.projectKey || !PROJECT_KEY_RE.test(identity.projectKey))
1715
+ continue;
1716
+ const set = keysByLabel.get(identity.project) ?? new Set();
1717
+ set.add(identity.projectKey);
1718
+ keysByLabel.set(identity.project, set);
1719
+ }
1720
+ const initial = await readConfig(dataDir);
1721
+ const canMigrate = (legacyLabel, target) => {
1722
+ if (PROJECT_KEY_RE.test(legacyLabel))
1723
+ return false;
1724
+ const matches = [...(keysByLabel.get(legacyLabel) ?? [])];
1725
+ return matches.length === 1 && !target?.[matches[0]];
1726
+ };
1727
+ const needsMigration = Object.keys(initial.teamSpaces ?? {}).some((legacyLabel) => canMigrate(legacyLabel, initial.teamSpaces))
1728
+ || Object.keys(initial.projectSharing ?? {}).some((legacyLabel) => canMigrate(legacyLabel, initial.projectSharing));
1729
+ if (!needsMigration)
1730
+ return initial;
1731
+ return mutateConfigAtomically(dataDir, (config) => {
1732
+ const teamSpaces = { ...(config.teamSpaces ?? {}) };
1733
+ const projectSharing = { ...(config.projectSharing ?? {}) };
1734
+ let changed = false;
1735
+ for (const [legacyLabel, binding] of Object.entries(config.teamSpaces ?? {})) {
1736
+ if (PROJECT_KEY_RE.test(legacyLabel))
1737
+ continue;
1738
+ const matches = [...(keysByLabel.get(legacyLabel) ?? [])];
1739
+ if (matches.length !== 1 || teamSpaces[matches[0]])
1740
+ continue;
1741
+ teamSpaces[matches[0]] = { ...binding, localProject: legacyLabel };
1742
+ if (config.projectSharing?.[legacyLabel])
1743
+ projectSharing[matches[0]] = config.projectSharing[legacyLabel];
1744
+ changed = true;
1745
+ }
1746
+ // Sharing mode existed before stable project identity and may have no team
1747
+ // binding at all. Copy it independently, but only when local discovery
1748
+ // proves the display label names exactly one key. Ambiguous labels remain
1749
+ // legacy-only and are invisible to keyed callers.
1750
+ for (const [legacyLabel, mode] of Object.entries(config.projectSharing ?? {})) {
1751
+ if (PROJECT_KEY_RE.test(legacyLabel))
1752
+ continue;
1753
+ const matches = [...(keysByLabel.get(legacyLabel) ?? [])];
1754
+ if (matches.length !== 1 || projectSharing[matches[0]])
1755
+ continue;
1756
+ projectSharing[matches[0]] = mode;
1757
+ changed = true;
1758
+ }
1759
+ return changed ? { ...config, teamSpaces, projectSharing } : config;
1760
+ });
1761
+ }
1762
+ /**
1763
+ * Records the dashboard-visible choice that this checkout participates in a
1764
+ * team space. It stores only the opaque space id; encryption keys never enter
1765
+ * config.json. Binding also enables the automatic live lane for that project.
1766
+ */
1767
+ export async function bindTeamSpace(dataDir, project, binding, projectKey) {
1768
+ if (!project.trim())
1769
+ fail("invalid_argument", "A project is required to join a team space");
1770
+ if (projectKey && !PROJECT_KEY_RE.test(projectKey))
1771
+ fail("invalid_argument", "A stable project key is malformed");
1772
+ assertTeamBinding(binding);
1773
+ const bindingKey = projectKey || project;
1774
+ const storedBinding = projectKey ? { ...binding, localProject: project } : { ...binding };
1775
+ const next = await mutateConfigAtomically(dataDir, (config) => ({
1776
+ ...config,
1777
+ // An explicit folder selection is also an explicit reconnect on this
1778
+ // machine, so it clears any earlier local disconnect tombstone.
1779
+ teamSpaceOptOuts: (config.teamSpaceOptOuts ?? []).filter((spaceKey) => spaceKey !== binding.spaceKey),
1780
+ projectSharing: { ...(config.projectSharing ?? {}), [bindingKey]: "active" },
1781
+ teamSpaces: { ...(config.teamSpaces ?? {}), [bindingKey]: storedBinding },
1782
+ // A folder can be a doorway into only one logical project. Once it has a
1783
+ // verified binding it is no longer pending in any project-creation draft.
1784
+ teamProjectDrafts: (config.teamProjectDrafts ?? [])
1785
+ .map((draft) => {
1786
+ if (projectKey && draft.projectSelections) {
1787
+ const projectSelections = draft.projectSelections.filter((item) => item.projectKey !== projectKey);
1788
+ return { ...draft, projectSelections, projects: [...new Set(projectSelections.map((item) => item.project))] };
1789
+ }
1790
+ return { ...draft, projects: draft.projects.filter((name) => name !== project) };
1791
+ })
1792
+ .filter((draft) => draft.projectSelections ? draft.projectSelections.length > 0 : draft.projects.length > 0),
1793
+ }));
1794
+ await recordAudit(dataDir, "project.sharing", "allowed", {
1795
+ project,
1796
+ ...(projectKey ? { projectKey } : {}),
1797
+ mode: "active",
1798
+ teamSpace: createHash("sha256").update(binding.spaceKey).digest("hex").slice(0, 12),
1799
+ });
1800
+ return next;
1801
+ }
1802
+ /**
1803
+ * Remember which detected local folders should become one logical project.
1804
+ * No path or project label is sent to the hosted plane here. The account page
1805
+ * creates the logical project, then normal daemon reconciliation consumes this
1806
+ * local draft when the matching project appears.
1807
+ */
1808
+ export async function stageTeamProjectDraft(dataDir, projectName, projects) {
1809
+ const name = projectName.trim();
1810
+ const keyed = projects.length > 0 && typeof projects[0] !== "string";
1811
+ const projectSelections = keyed
1812
+ ? [...new Map(projects.map((item) => [item.projectKey, { projectKey: item.projectKey.trim(), project: item.project.trim() }])).values()]
1813
+ : undefined;
1814
+ const localProjects = projectSelections
1815
+ ? [...new Set(projectSelections.map((item) => item.project).filter(Boolean))]
1816
+ : [...new Set(projects.map((project) => project.trim()).filter(Boolean))];
1817
+ if (!name || name.length > 120)
1818
+ fail("invalid_argument", "A project name between 1 and 120 characters is required");
1819
+ if (!localProjects.length || localProjects.length > 50 || localProjects.some((project) => project.length > 240)
1820
+ || projectSelections?.some((item) => !item.project || !PROJECT_KEY_RE.test(item.projectKey))) {
1821
+ fail("invalid_argument", "Choose between 1 and 50 detected folders for this project");
1822
+ }
1823
+ const draft = {
1824
+ projectName: name,
1825
+ projects: localProjects,
1826
+ ...(projectSelections ? { projectSelections } : {}),
1827
+ createdAt: new Date().toISOString(),
1828
+ };
1829
+ await mutateConfigAtomically(dataDir, (config) => {
1830
+ const selected = new Set(localProjects);
1831
+ const selectedKeys = new Set(projectSelections?.map((item) => item.projectKey) ?? []);
1832
+ const sameName = name.toLocaleLowerCase();
1833
+ // Selecting a folder for a new logical project moves it out of any older
1834
+ // unfinished draft. It never creates two copies or two access scopes.
1835
+ const retained = (config.teamProjectDrafts ?? [])
1836
+ .filter((item) => item.projectName.trim().toLocaleLowerCase() !== sameName)
1837
+ .map((item) => {
1838
+ if (selectedKeys.size > 0 && item.projectSelections) {
1839
+ const remaining = item.projectSelections.filter((selection) => !selectedKeys.has(selection.projectKey));
1840
+ return { ...item, projectSelections: remaining, projects: [...new Set(remaining.map((selection) => selection.project))] };
1841
+ }
1842
+ if (!projectSelections)
1843
+ return { ...item, projects: item.projects.filter((project) => !selected.has(project)) };
1844
+ return item;
1845
+ })
1846
+ .filter((item) => item.projectSelections ? item.projectSelections.length > 0 : item.projects.length > 0);
1847
+ return { ...config, teamProjectDrafts: [...retained, draft] };
1848
+ });
1849
+ await recordAudit(dataDir, "project.draft", "allowed", { projectName: name, folders: localProjects.length });
1850
+ return draft;
1851
+ }
1852
+ export async function removeTeamProjectDraft(dataDir, projectName) {
1853
+ const wanted = projectName.trim().toLocaleLowerCase();
1854
+ const next = await mutateConfigAtomically(dataDir, (config) => ({
1855
+ ...config,
1856
+ teamProjectDrafts: (config.teamProjectDrafts ?? []).filter((draft) => draft.projectName.trim().toLocaleLowerCase() !== wanted),
1857
+ }));
1858
+ await recordAudit(dataDir, "project.draft", "allowed", { projectName: projectName.trim(), cancelled: true });
1859
+ return next;
1860
+ }
1861
+ export async function unbindTeamSpace(dataDir, project, projectKey) {
1862
+ const bindingKey = projectKey || project;
1863
+ const next = await mutateConfigAtomically(dataDir, (config) => {
1864
+ const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).filter(([name]) => name !== bindingKey));
1865
+ return { ...config, teamSpaces, projectSharing: { ...(config.projectSharing ?? {}), [bindingKey]: "passive" } };
1866
+ });
1867
+ await recordAudit(dataDir, "project.sharing", "allowed", { project, ...(projectKey ? { projectKey } : {}), mode: "passive", teamSpace: null });
1868
+ return next;
1869
+ }
1870
+ /**
1871
+ * Disconnect one hosted project from this machine without changing cloud
1872
+ * membership. The tombstone is deliberate: daemon reconciliation must not
1873
+ * silently recreate a binding the person just removed.
1874
+ */
1875
+ export async function disconnectTeamSpaceOnMachine(dataDir, spaceKey) {
1876
+ if (!TEAM_SPACE_KEY_RE.test(spaceKey))
1877
+ fail("invalid_argument", "A team space key must be a 43-character base64url value");
1878
+ let disconnected = [];
1879
+ const next = await mutateConfigAtomically(dataDir, (config) => {
1880
+ disconnected = Object.entries(config.teamSpaces ?? {})
1881
+ .filter(([, binding]) => binding.spaceKey === spaceKey)
1882
+ .map(([key, binding]) => ({ key, project: binding.localProject ?? key }));
1883
+ const removed = new Set(disconnected.map((item) => item.key));
1884
+ const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).filter(([key]) => !removed.has(key)));
1885
+ const projectSharing = { ...(config.projectSharing ?? {}) };
1886
+ for (const item of disconnected)
1887
+ projectSharing[item.key] = "passive";
1888
+ const teamSpaceOptOuts = [...new Set([...(config.teamSpaceOptOuts ?? []), spaceKey])];
1889
+ return { ...config, teamSpaces, projectSharing, teamSpaceOptOuts };
1890
+ });
1891
+ await Promise.all(disconnected.map(({ key, project }) => recordAudit(dataDir, "project.sharing", "allowed", {
1892
+ project,
1893
+ ...(key === project ? {} : { projectKey: key }),
1894
+ mode: "passive",
1895
+ teamSpace: null,
1896
+ reason: "machine-disconnected",
1897
+ })));
1898
+ return next;
1899
+ }
1900
+ /**
1901
+ * Apply a hosted key/version/route update to every local checkout bound to one
1902
+ * logical space. `undefined` patch members are ignored so an older hosted
1903
+ * response cannot erase a newer route already learned by this machine.
1904
+ */
1905
+ export async function updateTeamSpaceBindings(dataDir, spaceKey, patch) {
1906
+ if (!TEAM_SPACE_KEY_RE.test(spaceKey))
1907
+ fail("invalid_argument", "A team space key must be a 43-character base64url value");
1908
+ const defined = {
1909
+ ...(patch.role !== undefined ? { role: patch.role } : {}),
1910
+ ...(patch.keyVersion !== undefined ? { keyVersion: patch.keyVersion } : {}),
1911
+ ...(patch.realtimeKey !== undefined ? { realtimeKey: patch.realtimeKey } : {}),
1912
+ };
1913
+ assertTeamBinding({ spaceKey, ...defined });
1914
+ return mutateConfigAtomically(dataDir, (config) => {
1915
+ let changed = false;
1916
+ const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).map(([project, binding]) => {
1917
+ if (binding.spaceKey !== spaceKey)
1918
+ return [project, binding];
1919
+ const nextBinding = { ...binding, ...defined };
1920
+ if (nextBinding.role !== binding.role
1921
+ || nextBinding.keyVersion !== binding.keyVersion
1922
+ || nextBinding.realtimeKey !== binding.realtimeKey)
1923
+ changed = true;
1924
+ return [project, nextBinding];
1925
+ }));
1926
+ return changed ? { ...config, teamSpaces } : config;
1927
+ });
1928
+ }
1929
+ /**
1930
+ * Revoke every checkout bound to a removed hosted membership in one atomic
1931
+ * config replacement. The logical space disappears and all affected projects
1932
+ * become passive together; encrypted archives and project files are untouched.
1933
+ */
1934
+ export async function revokeTeamSpaceBindings(dataDir, spaceKey) {
1935
+ if (!TEAM_SPACE_KEY_RE.test(spaceKey))
1936
+ fail("invalid_argument", "A team space key must be a 43-character base64url value");
1937
+ let revokedProjects = [];
1938
+ const next = await mutateConfigAtomically(dataDir, (config) => {
1939
+ revokedProjects = Object.entries(config.teamSpaces ?? {})
1940
+ .filter(([, binding]) => binding.spaceKey === spaceKey)
1941
+ .map(([project]) => project);
1942
+ if (!revokedProjects.length)
1943
+ return config;
1944
+ const revoked = new Set(revokedProjects);
1945
+ const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).filter(([project]) => !revoked.has(project)));
1946
+ const projectSharing = { ...(config.projectSharing ?? {}) };
1947
+ for (const project of revokedProjects)
1948
+ projectSharing[project] = "passive";
1949
+ return { ...config, teamSpaces, projectSharing };
1950
+ });
1951
+ await Promise.all(revokedProjects.map((project) => recordAudit(dataDir, "project.sharing", "allowed", {
1952
+ project, mode: "passive", teamSpace: null, reason: "membership-revoked",
1953
+ })));
1954
+ return next;
1955
+ }
449
1956
  export async function addRecipient(dataDir, label, publicKeyBase64, group,
450
1957
  /**
451
1958
  * Tie this key to one project and it becomes a member of that project only:
@@ -453,36 +1960,100 @@ export async function addRecipient(dataDir, label, publicKeyBase64, group,
453
1960
  * seals. A colleague on `checkout-rewrite` never receives the rest of your
454
1961
  * work, which is the difference between sharing a folder and handing over a
455
1962
  * drive.
456
- */
457
- project) {
1963
+ */
1964
+ project, projectKey) {
458
1965
  if (!label.trim())
459
1966
  fail("invalid_argument", "A recipient label is required");
460
1967
  const raw = Buffer.from(publicKeyBase64, "base64");
461
1968
  if (raw.length !== 32)
462
1969
  fail("invalid_argument", "An X25519 public key must be 32 bytes, base64 encoded");
463
- const config = await readConfig(dataDir);
1970
+ if (projectKey && !PROJECT_KEY_RE.test(projectKey))
1971
+ fail("invalid_argument", "A stable project key is malformed");
464
1972
  const id = keyRecipientId(raw);
465
- const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
466
- recipients.push({ id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}), ...(project ? { project } : {}) });
467
- const next = { ...config, recipients };
468
- await writeJson(configPath(dataDir), next);
469
- await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null, project: project ?? null });
1973
+ const recipient = { id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}), ...(project ? { project } : {}), ...(projectKey ? { projectKey } : {}) };
1974
+ const next = await mutateConfigAtomically(dataDir, (config) => ({
1975
+ ...config,
1976
+ recipients: [...(config.recipients ?? []).filter((value) => value.id !== id), recipient],
1977
+ }));
1978
+ await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null, project: project ?? null, projectKey: projectKey ?? null });
470
1979
  return next;
471
1980
  }
472
1981
  /** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
473
1982
  export async function removeRecipient(dataDir, id) {
474
- const config = await readConfig(dataDir);
475
- const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
476
- if (recipients.length === (config.recipients ?? []).length)
477
- fail("invalid_argument", `No recipient with id ${id}`, { recipientId: id });
478
- const next = { ...config, recipients };
479
- await writeJson(configPath(dataDir), next);
1983
+ const next = await mutateConfigAtomically(dataDir, (config) => {
1984
+ const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
1985
+ if (recipients.length === (config.recipients ?? []).length)
1986
+ fail("invalid_argument", `No recipient with id ${id}`, { recipientId: id });
1987
+ return { ...config, recipients };
1988
+ });
1989
+ // The search index is sealed per project to the members that project had at
1990
+ // build time. Removing someone here without touching those shards left the
1991
+ // revoked key able to open one indefinitely — the documented flow
1992
+ // (`recipients remove` then `rewrap`) rebuilds archives, never the index. So
1993
+ // revocation drops the shards outright; the next `index build` writes fresh
1994
+ // ones for whoever remains.
1995
+ const { invalidateScopedShards } = await import("./search.js");
1996
+ await invalidateScopedShards(dataDir);
480
1997
  await recordAudit(dataDir, "recipient.remove", "allowed", { recipientId: id });
481
1998
  return next;
482
1999
  }
483
- /** Persists a full config document after validation by the caller. */
2000
+ /**
2001
+ * Persists a full config document after validation by the caller. Team state
2002
+ * is deliberately retained from the lock-protected current file: bindings and
2003
+ * revocations are changed only through their dedicated atomic helpers.
2004
+ */
484
2005
  export async function writeConfig(dataDir, config) {
485
- await writeJson(configPath(dataDir), config);
2006
+ await mutateConfigAtomically(dataDir, (current) => ({
2007
+ ...config,
2008
+ teamSpaces: current.teamSpaces,
2009
+ projectSharing: current.projectSharing,
2010
+ }));
2011
+ }
2012
+ /**
2013
+ * Installs a vault identity that was authenticated and decrypted by a transfer
2014
+ * protocol before this call. Unlike `initialize`, this must not mint a new id
2015
+ * or phrase; unlike `writeConfig`, it must work before config.json exists.
2016
+ * The storage root is always local to this data directory, regardless of what
2017
+ * the sending machine used, and the same cross-process lock/refusal protects
2018
+ * against replacing a vault that appeared while approval was in flight.
2019
+ */
2020
+ export async function initializeTransferredVault(dataDir, transferred) {
2021
+ if (transferred.version !== CONFIG_VERSION || !transferred.vaultId || !transferred.recovery?.phraseCheck) {
2022
+ return fail("invalid_argument", "The transferred vault identity is not supported by this SealKeep build");
2023
+ }
2024
+ const config = {
2025
+ ...transferred,
2026
+ version: CONFIG_VERSION,
2027
+ storage: { provider: "local", root: archiveDir(dataDir) },
2028
+ };
2029
+ await mkdir(config.storage.root, { recursive: true });
2030
+ await withConfigLock(dataDir, async () => {
2031
+ if (await exists(configPath(dataDir)))
2032
+ fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
2033
+ await writeJsonAtomic(configPath(dataDir), config);
2034
+ });
2035
+ return config;
2036
+ }
2037
+ /** Merge a top-level patch into the latest lock-protected config document. */
2038
+ export async function updateConfigFields(dataDir, patch) {
2039
+ return mutateConfigAtomically(dataDir, (current) => ({ ...current, ...patch }));
2040
+ }
2041
+ /**
2042
+ * Compare and update top-level config fields while holding the same
2043
+ * cross-process lock as every other supported config mutation. This is the
2044
+ * config-file equivalent of compare-and-set: callers which obtained a value
2045
+ * before an asynchronous network read can refuse to replace a newer local
2046
+ * choice which landed while that read was in flight.
2047
+ */
2048
+ export async function updateConfigFieldsIf(dataDir, predicate, patch) {
2049
+ let matched = false;
2050
+ const config = await mutateConfigAtomically(dataDir, (current) => {
2051
+ if (!predicate(current))
2052
+ return current;
2053
+ matched = true;
2054
+ return { ...current, ...patch };
2055
+ });
2056
+ return { matched, config };
486
2057
  }
487
2058
  /** A non-mutating preview. Policy evaluation lives in `src/retention.ts`. */
488
2059
  export async function previewRetention(dataDir, olderThanDays = 30) {
@@ -540,21 +2111,21 @@ export function assertPhraseOpens(config, record, rawPhrase) {
540
2111
  */
541
2112
  function openFailure(archiveId, error) {
542
2113
  if (!(error instanceof Error))
543
- return new VaultlineError("internal", `Unable to open archive ${archiveId}`, { archiveId });
2114
+ return new SealkeepError("internal", `Unable to open archive ${archiveId}`, { archiveId });
544
2115
  const code = error.code;
545
2116
  const detail = { archiveId };
546
2117
  switch (code) {
547
2118
  case "destination_write_failed":
548
- return new VaultlineError("destination_unwritable", error.message, detail);
2119
+ return new SealkeepError("destination_unwritable", error.message, detail);
549
2120
  case "chunk_auth_failed":
550
2121
  case "chunk_hash_mismatch":
551
2122
  case "envelope_malformed":
552
- return new VaultlineError("ciphertext_integrity_failed", `Archive ${archiveId} is damaged: ${error.message}`, detail);
2123
+ return new SealkeepError("ciphertext_integrity_failed", `Archive ${archiveId} is damaged: ${error.message}`, detail);
553
2124
  case "plaintext_hash_mismatch":
554
2125
  case "plaintext_length_mismatch":
555
- return new VaultlineError("plaintext_integrity_failed", `Archive ${archiveId} did not reproduce the bytes it recorded: ${error.message}`, detail);
2126
+ return new SealkeepError("plaintext_integrity_failed", `Archive ${archiveId} did not reproduce the bytes it recorded: ${error.message}`, detail);
556
2127
  default:
557
- return new VaultlineError("recovery_phrase_mismatch", `Unable to open archive ${archiveId}: ${error.message}`, detail);
2128
+ return new SealkeepError("recovery_phrase_mismatch", `Unable to open archive ${archiveId}: ${error.message}`, detail);
558
2129
  }
559
2130
  }
560
2131
  /**
@@ -573,7 +2144,14 @@ export async function restoreRecordToFile(dataDir, record,
573
2144
  * shared for it to work. Until this existed, a registered recipient could be
574
2145
  * wrapped in and still had no way to decrypt anything.
575
2146
  */
576
- rawPhrase, destination,
2147
+ rawPhrase,
2148
+ /**
2149
+ * A path, or a Writable to stream the plaintext into. The stream form exists
2150
+ * so an archive can be PROVED to decrypt without spending its own size in
2151
+ * free disk; the delta and plain-mode paths below still need a real file and
2152
+ * say so rather than silently writing somewhere unexpected.
2153
+ */
2154
+ destination,
577
2155
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
578
2156
  options = {}) {
579
2157
  const withKey = typeof rawPhrase === "object";
@@ -597,15 +2175,22 @@ options = {}) {
597
2175
  if (deltaOf(record)) {
598
2176
  if (withKey)
599
2177
  fail("invalid_argument", "This archive is an append onto an earlier one, and the chain is opened with the phrase. Restore it with --recovery-phrase.");
2178
+ if (typeof destination !== "string")
2179
+ fail("invalid_argument", "A delta chain is rebuilt through files and cannot stream to a sink");
600
2180
  const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
601
2181
  return { bytes };
602
2182
  }
603
- if (config.storageMode === "plain")
2183
+ if (config.storageMode === "plain") {
2184
+ if (typeof destination !== "string")
2185
+ fail("invalid_argument", "A plain-mode vault restores through files and cannot stream to a sink");
604
2186
  return restorePlainRecordToFile(dataDir, config, record, phrase, destination, options);
2187
+ }
605
2188
  if (!isV2(record)) {
606
2189
  // v1 predates chunking: there is one AEAD frame over the whole archive, so
607
2190
  // there is nothing to stream and nothing to gain by pretending otherwise.
608
2191
  const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
2192
+ if (typeof destination !== "string")
2193
+ fail("invalid_argument", "This archive restores through a file and cannot stream to a sink");
609
2194
  await writeFile(destination, plaintext, { mode: 0o600 });
610
2195
  return { bytes: plaintext.length };
611
2196
  }
@@ -615,7 +2200,10 @@ options = {}) {
615
2200
  // the space saving real. An archive that was never offloaded resolves to its
616
2201
  // own path and releases to nothing.
617
2202
  const { materialiseArchive } = await import("./offload.js");
618
- const source = await materialiseArchive(dataDir, record, options.client);
2203
+ const source = await materialiseArchive(dataDir, record, options.client, {
2204
+ signal: options.signal,
2205
+ scratchWaitMs: options.scratchWaitMs,
2206
+ });
619
2207
  try {
620
2208
  const { sha256: stored } = await hashFileRange(source.path);
621
2209
  if (!equalHex(stored, record.cipher.ciphertextSha256))
@@ -646,8 +2234,16 @@ options = {}) {
646
2234
  * base whose recorded length is not what the delta sealed from is refused
647
2235
  * here, before any restore starts writing.
648
2236
  */
649
- export function resolveDeltaChain(archives, target) {
650
- const byId = new Map(archives.map((record) => [record.id, record]));
2237
+ export function resolveDeltaChain(archives, target,
2238
+ /**
2239
+ * Callers resolving many heads from one ledger may supply its shared index.
2240
+ * Rebuilding the same thousands-entry map once per visible session turned a
2241
+ * linear dashboard question into quadratic work. The optional argument does
2242
+ * not change resolution semantics; it only lets that immutable lookup be
2243
+ * built once for the batch.
2244
+ */
2245
+ archiveIndex) {
2246
+ const byId = archiveIndex ?? new Map(archives.map((record) => [record.id, record]));
651
2247
  const chain = [target];
652
2248
  const seen = new Set([target.id]);
653
2249
  let cursor = target;
@@ -770,24 +2366,12 @@ async function restorePlainRecordToFile(dataDir, config, record, phrase, destina
770
2366
  await source.release();
771
2367
  }
772
2368
  }
773
- /**
774
- * Verifies then decrypts one archive in memory. Ciphertext integrity is checked
775
- * before the key touches it, and plaintext integrity after.
776
- *
777
- * This holds the whole transcript at once; `restoreRecordToFile` is the
778
- * constant-memory path and is what restoring uses. Callers that genuinely need
779
- * the bytes in hand — indexing, the v1 migration — still come through here.
780
- */
781
- /**
782
- * The sealed bytes of a record, wherever they live. A local blob reads
783
- * straight from disk; an OFFLOADED archive — sealed copy in the cloud, local
784
- * blob reclaimed — is fetched hash-checked and held only for the read. Found
785
- * by the live value drill: `sealkeep index build` died with ENOENT on the
786
- * first offloaded legacy archive it met, because this read assumed the blob.
787
- */
788
- async function readSealedObject(dataDir, record) {
2369
+ async function readSealedObject(dataDir, record, options = {}) {
789
2370
  const { materialiseArchive } = await import("./offload.js");
790
- const materialised = await materialiseArchive(dataDir, record);
2371
+ const materialised = await materialiseArchive(dataDir, record, options.client, {
2372
+ signal: options.signal,
2373
+ scratchWaitMs: options.scratchWaitMs,
2374
+ });
791
2375
  try {
792
2376
  return await readFile(materialised.path);
793
2377
  }
@@ -795,7 +2379,7 @@ async function readSealedObject(dataDir, record) {
795
2379
  await materialised.release();
796
2380
  }
797
2381
  }
798
- export async function decryptRecord(dataDir, id, rawPhrase) {
2382
+ export async function decryptRecord(dataDir, id, rawPhrase, options = {}) {
799
2383
  const phrase = canonicalPhrase(rawPhrase);
800
2384
  const config = await readConfig(dataDir);
801
2385
  const record = await findArchive(dataDir, id);
@@ -805,11 +2389,11 @@ export async function decryptRecord(dataDir, id, rawPhrase) {
805
2389
  // decrypt a tail and correctly refuse it as not matching the record.
806
2390
  if (deltaOf(record) && config.storageMode !== "plain") {
807
2391
  const chain = resolveDeltaChain(await listArchives(dataDir), record);
808
- const pieces = [(await decryptRecord(dataDir, chain[0].id, phrase)).plaintext];
2392
+ const pieces = [(await decryptRecord(dataDir, chain[0].id, phrase, options)).plaintext];
809
2393
  for (const link of chain.slice(1)) {
810
2394
  if (!isV2(link))
811
2395
  fail("ciphertext_integrity_failed", `Delta ${link.id} is not a v2 archive and cannot hold a sealed range`, { archiveId: link.id });
812
- const sealedRange = await readSealedObject(dataDir, link);
2396
+ const sealedRange = await readSealedObject(dataDir, link, options);
813
2397
  if (!equalHex(sha256(sealedRange), link.cipher.ciphertextSha256))
814
2398
  fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: link.id });
815
2399
  try {
@@ -824,7 +2408,7 @@ export async function decryptRecord(dataDir, id, rawPhrase) {
824
2408
  fail("plaintext_integrity_failed", "Plaintext integrity check failed after decryption", { archiveId: id });
825
2409
  return { record, plaintext: assembled };
826
2410
  }
827
- const ciphertext = await readSealedObject(dataDir, record);
2411
+ const ciphertext = await readSealedObject(dataDir, record, options);
828
2412
  if (!equalHex(sha256(ciphertext), record.cipher.ciphertextSha256))
829
2413
  fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: id });
830
2414
  // In a plain vault the stored object is a gzip, so "decrypt" is gunzip — but