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
@@ -0,0 +1,1043 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { Writable } from "node:stream";
5
+ import { execFile } from "node:child_process";
6
+ import { openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, validateEnvelope, wrapAll, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw, zeroize, } from "../packages/sealkeep-crypto/src/index.js";
7
+ import { getTeamRoster, listTeamArchives, pullTeamCiphertextStream, pushFramedArchive, registerTeamArchive, } from "./cloud.js";
8
+ import { managedIndexStore, managedTeamIndexStore } from "./index-sync.js";
9
+ import { mergeTeamProjectShard, openVerifiedManagedIndex, writeContentIndexCheckpoint, } from "./search.js";
10
+ import { MAX_FRAME_METADATA_BYTES, writeChunksToFile } from "./byte-stream.js";
11
+ import { fail, isSealkeepError } from "./errors.js";
12
+ import { ensureTeamIdentity } from "./team-presence.js";
13
+ import { createProgressDeadline } from "./progress-deadline.js";
14
+ import { backgroundUploadBytesPerSecond, readLocalSettings } from "./machine-settings.js";
15
+ import { paceBackgroundByteStream } from "./background-bandwidth.js";
16
+ import { freeBytes, resolveReserveBytes } from "./disk.js";
17
+ import { listArchives, MAX_DELTA_CHAIN_LINKS, readConfig, teamSpaceOf, writeRecord } from "./vault.js";
18
+ import { isV2 } from "./types.js";
19
+ const journalDirectory = (dataDir, scope) => join(dataDir, scope.transition === "invitation" ? "team-invitations" : "team-offboarding", createHash("sha256")
20
+ .update(JSON.stringify([scope.spaceKey, scope.targetAccountId])).digest("hex"));
21
+ const emptyIndex = () => ({
22
+ version: 2, builtAt: new Date().toISOString(),
23
+ archives: Object.create(null), tokens: Object.create(null), meta: Object.create(null),
24
+ });
25
+ const sameRoster = (roster) => JSON.stringify(roster.members
26
+ .map((member) => [member.account_id, member.public_key, member.role]).sort((a, b) => Number(a[0]) - Number(b[0])));
27
+ const MAX_HANDOFF_INDEX_BYTES = 64 * 1024 * 1024;
28
+ async function remote(operation, action) {
29
+ const deadline = createProgressDeadline({ operation, idleMs: 2 * 60_000 });
30
+ try {
31
+ return await deadline.wait(action({ signal: deadline.signal, onProgress: () => deadline.progress() }));
32
+ }
33
+ finally {
34
+ deadline.close();
35
+ }
36
+ }
37
+ async function atomicJson(path, value) {
38
+ const temporary = `${path}.${randomUUID()}.tmp`;
39
+ const handle = await open(temporary, "wx", 0o600);
40
+ try {
41
+ await handle.writeFile(JSON.stringify(value));
42
+ await handle.sync();
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ await rename(temporary, path);
48
+ // The membership commit is remote. Persist directory entries as well as
49
+ // file contents before allowing it to depend on this local recovery state.
50
+ const directory = await open(dirname(path), "r").catch((error) => {
51
+ if (["EISDIR", "EPERM", "EACCES", "ENOTSUP"].includes(error.code ?? ""))
52
+ return null;
53
+ throw error;
54
+ });
55
+ if (!directory)
56
+ return; // Directory descriptors are unavailable on some platforms.
57
+ try {
58
+ await directory.sync();
59
+ }
60
+ catch (error) {
61
+ if (!["EINVAL", "EPERM", "ENOTSUP", "EBADF"].includes(error.code ?? ""))
62
+ throw error;
63
+ }
64
+ finally {
65
+ await directory.close();
66
+ }
67
+ }
68
+ async function requireStagingRoom(dataDir, directory, bytes) {
69
+ if (!Number.isSafeInteger(bytes) || bytes < 0)
70
+ fail("ciphertext_integrity_failed", "Shared history has no valid staging size");
71
+ const available = await freeBytes(directory);
72
+ const reserve = await resolveReserveBytes(dataDir);
73
+ if (available !== undefined && available < bytes + reserve) {
74
+ fail("insufficient_disk_space", "There is not enough free space to preserve team history before this departure", {
75
+ availableBytes: available, stagingBytes: bytes, reserveBytes: reserve,
76
+ });
77
+ }
78
+ }
79
+ async function discardCapturedFiles(directory, journal) {
80
+ const files = Object.values(journal.archives).map((archive) => archive.file);
81
+ if (journal.snapshot)
82
+ files.push(journal.snapshot.file);
83
+ for (const file of files) {
84
+ if (!/^[a-f0-9-]{36}\.(?:ciphertext|index)$/.test(file)) {
85
+ fail("ciphertext_integrity_failed", "The saved team handoff has an invalid cleanup filename");
86
+ }
87
+ await rm(join(directory, file), { force: true });
88
+ }
89
+ }
90
+ async function cleanupCompleted(directory, journal) {
91
+ if (!journal.completed || !journal.completedReceipt)
92
+ return;
93
+ await discardCapturedFiles(directory, journal);
94
+ journal.archives = Object.create(null);
95
+ delete journal.snapshot;
96
+ delete journal.sizes;
97
+ delete journal.generation;
98
+ delete journal.custody;
99
+ await atomicJson(join(directory, "journal.json"), journal);
100
+ }
101
+ async function readJournal(directory, scope) {
102
+ const value = await readFile(join(directory, "journal.json"), "utf8").catch((error) => {
103
+ if (error?.code === "ENOENT")
104
+ return null;
105
+ throw error;
106
+ });
107
+ if (value === null)
108
+ return null;
109
+ const journal = JSON.parse(value);
110
+ if (journal.version !== 1 || journal.scope?.spaceKey !== scope.spaceKey
111
+ || journal.scope?.targetAccountId !== scope.targetAccountId
112
+ || journal.scope?.transition !== scope.transition
113
+ || !Array.isArray(journal.refs) || !journal.archives
114
+ || !Number.isSafeInteger(journal.membershipVersion) || journal.membershipVersion < 1) {
115
+ fail("ciphertext_integrity_failed", "The saved team departure handoff is not valid");
116
+ }
117
+ if ((journal.snapshot && !/^[a-f0-9-]{36}\.index$/.test(journal.snapshot.file))
118
+ || Object.values(journal.archives).some((archive) => !/^[a-f0-9-]{36}\.ciphertext$/.test(archive.file))) {
119
+ fail("ciphertext_integrity_failed", "The saved departure references a path outside its bounded staging files");
120
+ }
121
+ return journal;
122
+ }
123
+ /** A live process owns one short phase; a crashed process leaves a recoverable lock. */
124
+ const PROCESS_INSTANCE = randomUUID();
125
+ const processStart = (pid) => new Promise((resolve) => {
126
+ execFile("ps", ["-p", String(pid), "-o", "lstart="], { timeout: 1_000, maxBuffer: 4_096 }, (error, stdout) => resolve(error ? null : stdout.trim() || null));
127
+ });
128
+ async function withJournal(directory, work) {
129
+ await mkdir(directory, { recursive: true, mode: 0o700 });
130
+ const path = join(directory, "lock");
131
+ const token = randomUUID();
132
+ const candidate = join(directory, `lock.${token}.tmp`);
133
+ const handle = await open(candidate, "wx", 0o600);
134
+ try {
135
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token, instance: PROCESS_INSTANCE,
136
+ processStart: await processStart(process.pid) }));
137
+ await handle.sync();
138
+ }
139
+ finally {
140
+ await handle.close();
141
+ }
142
+ try {
143
+ for (let attempt = 0;; attempt += 1) {
144
+ try {
145
+ // Link only a fully written owner record: another process never observes
146
+ // an empty lock left between exclusive creation and the first write.
147
+ await link(candidate, path);
148
+ break;
149
+ }
150
+ catch (error) {
151
+ if (error.code !== "EEXIST")
152
+ throw error;
153
+ const previous = JSON.parse(await readFile(path, "utf8"));
154
+ let alive = true;
155
+ try {
156
+ process.kill(previous.pid, 0);
157
+ }
158
+ catch (failure) {
159
+ if (failure.code === "ESRCH")
160
+ alive = false;
161
+ }
162
+ if (alive && previous.processStart) {
163
+ const actualStart = await processStart(previous.pid);
164
+ if (actualStart && actualStart !== previous.processStart)
165
+ alive = false;
166
+ }
167
+ if (alive && previous.pid === process.pid && previous.instance && previous.instance !== PROCESS_INSTANCE)
168
+ alive = false;
169
+ if (alive || attempt > 0)
170
+ fail("team_state_changed", "A team departure handoff is already running; retry when it finishes");
171
+ await rm(path);
172
+ }
173
+ }
174
+ }
175
+ finally {
176
+ await rm(candidate, { force: true });
177
+ }
178
+ try {
179
+ // Only this locked handoff directory owns these temporary files. Recover
180
+ // plaintext/compressor scratch left by a process exit during sealing.
181
+ const saved = await readFile(join(directory, "journal.json"), "utf8").then((raw) => JSON.parse(raw))
182
+ .catch((error) => { if (error?.code === "ENOENT")
183
+ return null; throw error; });
184
+ const referenced = new Set([...(saved?.snapshot ? [saved.snapshot.file] : []),
185
+ ...Object.values(saved?.archives ?? {}).map((archive) => archive.file)]);
186
+ for (const name of await readdir(directory)) {
187
+ if (/^(?:[a-f0-9-]{36}\.temporary\.(?:jsonl|index)|\.vlbody-[a-f0-9-]{36}|journal\.json\.[a-f0-9-]{36}\.tmp)$/.test(name)
188
+ || (/^[a-f0-9-]{36}\.(?:ciphertext|index)$/.test(name) && !referenced.has(name))) {
189
+ await rm(join(directory, name), { force: true });
190
+ }
191
+ }
192
+ return await work();
193
+ }
194
+ finally {
195
+ const current = JSON.parse(await readFile(path, "utf8"));
196
+ if (current.token === token)
197
+ await rm(path);
198
+ }
199
+ }
200
+ /** Extract a capped envelope while ciphertext continues directly to disk. */
201
+ async function* archiveBody(source, captured, maximumBytes) {
202
+ let prefix = Buffer.alloc(0);
203
+ let parsed = false;
204
+ let bytes = 0;
205
+ const admit = (chunk) => {
206
+ bytes += chunk.length;
207
+ if (bytes > maximumBytes)
208
+ fail("ciphertext_integrity_failed", "A shared archive exceeds its authenticated ciphertext size");
209
+ return chunk;
210
+ };
211
+ for await (const chunk of source) {
212
+ if (parsed) {
213
+ yield admit(chunk);
214
+ continue;
215
+ }
216
+ prefix = Buffer.concat([prefix, chunk]);
217
+ if (prefix.length < 8)
218
+ continue;
219
+ if (prefix.subarray(0, 4).toString("ascii") !== "VLA1") {
220
+ fail("ciphertext_integrity_failed", "A shared archive has no authenticated envelope");
221
+ }
222
+ const size = prefix.readUInt32BE(4);
223
+ if (size > MAX_FRAME_METADATA_BYTES)
224
+ fail("ciphertext_integrity_failed", "A shared archive envelope exceeds the bounded metadata limit");
225
+ if (prefix.length < 8 + size)
226
+ continue;
227
+ captured.envelope = JSON.parse(prefix.subarray(8, 8 + size).toString("utf8"));
228
+ parsed = true;
229
+ const rest = prefix.subarray(8 + size);
230
+ prefix = Buffer.alloc(0);
231
+ if (rest.length)
232
+ yield admit(rest);
233
+ }
234
+ if (!parsed)
235
+ fail("ciphertext_integrity_failed", "A shared archive envelope is truncated");
236
+ }
237
+ /** Every live grant must be restorable, not merely present in a token index. */
238
+ function validateCapturedCatalog(index, journal) {
239
+ const refs = new Set(journal.refs);
240
+ if (refs.size !== journal.refs.length || Object.keys(index.archives).length !== refs.size) {
241
+ fail("ciphertext_integrity_failed", "Every current team grant requires a complete authenticated scoped catalog row before departure");
242
+ }
243
+ const ordered = [];
244
+ const done = new Set();
245
+ const visiting = new Set();
246
+ const depth = new Map();
247
+ const check = (ref) => {
248
+ const row = index.archives[ref];
249
+ const facts = index.meta?.[ref]?.archiveRecord;
250
+ if (!refs.has(ref) || !row || row[3] !== journal.scope.spaceKey || !facts || facts.v !== 1
251
+ || !Number.isSafeInteger(facts.sourceBytes) || facts.sourceBytes < 0
252
+ || !Number.isSafeInteger(facts.storedBytes) || facts.storedBytes < 0
253
+ || !Number.isSafeInteger(facts.chunks) || facts.chunks < 0
254
+ || !/^[a-f0-9]{64}$/.test(facts.sourceSha256) || !/^[a-f0-9]{64}$/.test(facts.ciphertextSha256)) {
255
+ fail("ciphertext_integrity_failed", "A current team grant lacks verified scoped archive facts; departure was withheld");
256
+ }
257
+ if (facts.delta) {
258
+ const base = index.meta?.[facts.delta.baseArchiveId]?.archiveRecord;
259
+ if (!Number.isSafeInteger(facts.delta.baseBytes) || facts.delta.baseBytes <= 0
260
+ || facts.delta.baseBytes >= facts.sourceBytes || base?.sourceBytes !== facts.delta.baseBytes) {
261
+ fail("ciphertext_integrity_failed", "A shared delta does not match its authenticated base size");
262
+ }
263
+ return facts.delta.baseArchiveId;
264
+ }
265
+ return undefined;
266
+ };
267
+ for (const start of journal.refs) {
268
+ const path = [];
269
+ let ref = start;
270
+ while (ref !== undefined && !done.has(ref)) {
271
+ if (visiting.has(ref))
272
+ fail("ciphertext_integrity_failed", "The shared history catalog contains a delta cycle");
273
+ visiting.add(ref);
274
+ path.push(ref);
275
+ ref = check(ref);
276
+ }
277
+ for (const member of path.reverse()) {
278
+ const base = index.meta[member].archiveRecord.delta?.baseArchiveId;
279
+ const links = base ? depth.get(base) + 1 : 1;
280
+ if (links > MAX_DELTA_CHAIN_LINKS) {
281
+ fail("ciphertext_integrity_failed", "A shared delta chain exceeds the native recovery depth; consolidate it before departure");
282
+ }
283
+ depth.set(member, links);
284
+ visiting.delete(member);
285
+ done.add(member);
286
+ ordered.push(member);
287
+ }
288
+ }
289
+ return ordered;
290
+ }
291
+ async function verifyCapturedArchives(directory, journal, index, ordered, privateKey) {
292
+ // Copy incremental hash states at branches. Every ciphertext opens once,
293
+ // plaintext only crosses a bounded writable sink, and each delta proves its
294
+ // full source hash (a tail hash cannot stand in for the complete transcript).
295
+ const hashes = new Map();
296
+ for (const ref of ordered) {
297
+ const facts = index.meta[ref].archiveRecord;
298
+ const captured = journal.archives[ref];
299
+ const envelope = captured.envelope;
300
+ validateEnvelope(envelope, facts.storedBytes);
301
+ if (envelope.chunks.some((chunk) => !Number.isSafeInteger(chunk.storedBytes) || chunk.storedBytes > 16 * 1024 * 1024)) {
302
+ fail("ciphertext_integrity_failed", "Shared history exceeds the bounded departure verification chunk size");
303
+ }
304
+ const expectedBytes = facts.sourceBytes - (facts.delta?.baseBytes ?? 0);
305
+ if (captured.bytes !== facts.storedBytes || captured.sha256 !== facts.ciphertextSha256
306
+ || envelope.archiveId !== (facts.envelopeArchiveId ?? ref) || envelope.suite !== facts.algorithm
307
+ || envelope.chunks.length !== facts.chunks
308
+ || (envelope.manifest.originalBytes ?? envelope.manifest.plaintextBytes) !== expectedBytes) {
309
+ fail("ciphertext_integrity_failed", "A retained envelope does not match its authenticated archive facts");
310
+ }
311
+ const hash = facts.delta ? hashes.get(facts.delta.baseArchiveId).copy() : createHash("sha256");
312
+ let bytes = 0;
313
+ const sink = new Writable({ write(chunk, _encoding, callback) {
314
+ bytes += chunk.length;
315
+ if (bytes > expectedBytes) {
316
+ callback(new Error("Shared history exceeds its authenticated plaintext size"));
317
+ return;
318
+ }
319
+ hash.update(chunk);
320
+ callback();
321
+ } });
322
+ await openArchiveToFile(envelope, join(directory, captured.file), sink, { privateKey }, {
323
+ maxStoredChunkBytes: 16 * 1024 * 1024,
324
+ maxOriginalBytes: expectedBytes,
325
+ maxExpandedChunkBytes: 64 * 1024 * 1024,
326
+ streamChunkDecompression: true,
327
+ });
328
+ if (bytes !== expectedBytes || hash.copy().digest("hex") !== facts.sourceSha256) {
329
+ fail("ciphertext_integrity_failed", "Shared history cannot reproduce its authenticated source hash");
330
+ }
331
+ hashes.set(ref, hash);
332
+ }
333
+ }
334
+ function filtered(index, scope, allowed) {
335
+ const result = emptyIndex();
336
+ mergeTeamProjectShard(result, index, scope.project, scope.spaceKey, new Map(), scope.projectKey);
337
+ for (const ref of Object.keys(result.archives)) {
338
+ if (allowed.has(ref))
339
+ continue;
340
+ delete result.archives[ref];
341
+ delete result.meta?.[ref];
342
+ }
343
+ for (const token of Object.keys(result.tokens)) {
344
+ result.tokens[token] = result.tokens[token].filter((posting) => allowed.has(posting.split("@")[0]));
345
+ if (!result.tokens[token].length)
346
+ delete result.tokens[token];
347
+ }
348
+ return result;
349
+ }
350
+ async function openBoundedIndex(dataDir, directory, envelope, ciphertext, opener, coverage) {
351
+ const originalBytes = envelope.manifest.originalBytes ?? envelope.manifest.plaintextBytes;
352
+ if (ciphertext.length > MAX_HANDOFF_INDEX_BYTES || !Number.isSafeInteger(originalBytes)
353
+ || originalBytes < 0 || originalBytes > MAX_HANDOFF_INDEX_BYTES) {
354
+ fail("provider_unsupported", "Departure history exceeds the bounded 64 MiB index checkpoint limit");
355
+ }
356
+ if (envelope.chunks.some((chunk) => !Number.isSafeInteger(chunk.storedBytes) || chunk.storedBytes > 16 * 1024 * 1024)) {
357
+ fail("ciphertext_integrity_failed", "The departure index exceeds the bounded verification chunk size");
358
+ }
359
+ await requireStagingRoom(dataDir, directory, ciphertext.length);
360
+ const path = join(directory, `${randomUUID()}.temporary.index`);
361
+ const handle = await open(path, "wx", 0o600);
362
+ try {
363
+ await handle.writeFile(ciphertext);
364
+ }
365
+ finally {
366
+ await handle.close();
367
+ }
368
+ try {
369
+ let bytes = 0;
370
+ const sink = new Writable({ write(chunk, _encoding, callback) {
371
+ bytes += chunk.length;
372
+ callback(bytes > originalBytes ? new Error("The departure index exceeds its bounded plaintext size") : undefined);
373
+ } });
374
+ // Prove actual expansion before the buffered index parser sees the same
375
+ // immutable ciphertext. A small declared size alone is not a zip-bomb gate.
376
+ await openArchiveToFile(envelope, path, sink, opener, {
377
+ maxStoredChunkBytes: 16 * 1024 * 1024,
378
+ maxOriginalBytes: originalBytes,
379
+ maxExpandedChunkBytes: MAX_HANDOFF_INDEX_BYTES,
380
+ streamChunkDecompression: true,
381
+ });
382
+ return openVerifiedManagedIndex(envelope, ciphertext, opener, coverage);
383
+ }
384
+ finally {
385
+ await rm(path, { force: true });
386
+ }
387
+ }
388
+ async function capturedIndex(dataDir, directory, journal, privateKey) {
389
+ if (!journal.snapshot)
390
+ return emptyIndex();
391
+ if (journal.snapshot.receipt.blob.bytes > MAX_HANDOFF_INDEX_BYTES) {
392
+ fail("provider_unsupported", "Departure history exceeds the bounded 64 MiB index checkpoint limit");
393
+ }
394
+ if ((await stat(join(directory, journal.snapshot.file))).size !== journal.snapshot.receipt.blob.bytes) {
395
+ fail("ciphertext_integrity_failed", "The saved departure index has changed size");
396
+ }
397
+ const ciphertext = await readFile(join(directory, journal.snapshot.file));
398
+ if (ciphertext.length !== journal.snapshot.receipt.blob.bytes
399
+ || createHash("sha256").update(ciphertext).digest("hex") !== journal.snapshot.receipt.blob.sha256) {
400
+ fail("ciphertext_integrity_failed", "The saved team index changed during its departure handoff");
401
+ }
402
+ return filtered(await openBoundedIndex(dataDir, directory, journal.snapshot.envelope, ciphertext, { privateKey }, journal.snapshot.receipt.coverageJson), journal.scope, new Set(journal.refs));
403
+ }
404
+ /**
405
+ * Pin already-shared history before the membership commit. Only ciphertext,
406
+ * wrapped keys and opaque references survive a process exit; transcript and
407
+ * searchable index plaintext are never written to the durable journal.
408
+ */
409
+ export async function prepareTeamOffboarding(dataDir, scope, phrase, preferred) {
410
+ const directory = journalDirectory(dataDir, scope);
411
+ return withJournal(directory, async () => {
412
+ let journal = await readJournal(directory, scope);
413
+ let retainedCustody;
414
+ const roster = await remote("Reading the departure roster", (io) => getTeamRoster(dataDir, scope.spaceKey, preferred, io));
415
+ const targetPresent = roster.members.some((member) => Number(member.account_id) === scope.targetAccountId);
416
+ const transitioned = scope.transition === "invitation" ? targetPresent : !targetPresent;
417
+ if (journal?.prepared && transitioned)
418
+ return {
419
+ membershipVersion: journal.membershipVersion, keyVersion: journal.keyVersion,
420
+ indexRevision: journal.indexRevision ?? journal.snapshot?.receipt.revision ?? 0, archiveRefs: [...journal.refs],
421
+ };
422
+ if ((journal?.completed || journal?.obsolete) && !transitioned) {
423
+ await discardCapturedFiles(directory, journal);
424
+ journal = null;
425
+ }
426
+ if (scope.transition === "invitation" && !journal && transitioned) {
427
+ fail("team_state_changed", "This accepted invitation has no prior captured history handoff");
428
+ }
429
+ if (scope.transition === "invitation" && journal && !transitioned) {
430
+ // Pending invitations can wait while contributors keep writing. Refresh
431
+ // their complete snapshot, never merely its membership/key version.
432
+ const cas = (await managedTeamIndexStore(dataDir, scope.spaceKey)).cas;
433
+ if (!cas)
434
+ fail("provider_unsupported", "Invitations require immutable team history support");
435
+ const head = await remote("Checking pending invitation history", (io) => cas.head(io));
436
+ const refs = (await remote("Checking pending invitation grants", (io) => listTeamArchives(dataDir, scope.spaceKey, preferred, io)))
437
+ .filter((grant) => grant.membership_version === roster.membership_version).map((grant) => grant.vault_ref).sort();
438
+ const unchanged = head.status === "ok" && journal.membershipVersion === roster.membership_version
439
+ && journal.capturedRoster === sameRoster(roster) && journal.scope.targetPublicKey === scope.targetPublicKey
440
+ && (head.head?.receipt.revision ?? 0) === journal.indexRevision
441
+ && JSON.stringify(refs) === JSON.stringify(journal.refs);
442
+ if (unchanged && journal.prepared && (!journal.refs.length || !!journal.custodyReceipt)) {
443
+ return { membershipVersion: journal.membershipVersion, keyVersion: journal.keyVersion,
444
+ indexRevision: journal.indexRevision ?? 0, archiveRefs: [...journal.refs] };
445
+ }
446
+ if (!unchanged) {
447
+ retainedCustody = journal.custody;
448
+ await discardCapturedFiles(directory, journal);
449
+ journal = null;
450
+ }
451
+ }
452
+ if (journal && journal.membershipVersion !== roster.membership_version) {
453
+ fail("team_state_changed", "Team membership changed during history capture; finish the saved handoff before another departure");
454
+ }
455
+ const identity = await ensureTeamIdentity(dataDir, phrase, preferred);
456
+ const privateRaw = Buffer.from(identity.privateKey, "base64");
457
+ const privateKey = x25519PrivateKeyFromRaw(privateRaw);
458
+ const maxBytesPerSecond = backgroundUploadBytesPerSecond(await readLocalSettings(dataDir));
459
+ try {
460
+ if (!journal) {
461
+ const grants = await remote("Reading shared departure history", (io) => listTeamArchives(dataDir, scope.spaceKey, preferred, io));
462
+ const cas = (await managedTeamIndexStore(dataDir, scope.spaceKey, { maxBytesPerSecond })).cas;
463
+ if (!cas)
464
+ fail("provider_unsupported", "Team departure requires immutable team index support");
465
+ const pulled = await remote("Capturing the departure index", (io) => cas.pullSnapshot({ ...io, maxBytes: MAX_HANDOFF_INDEX_BYTES }));
466
+ if (pulled.status === "unsupported")
467
+ fail("provider_unsupported", "Team departure requires immutable team index support");
468
+ if (pulled.snapshot && pulled.snapshot.receipt.membershipVersion !== roster.membership_version) {
469
+ fail("team_state_changed", "Publish the current team history generation before removing another member");
470
+ }
471
+ journal = {
472
+ version: 1, scope, membershipVersion: roster.membership_version, capturedRoster: sameRoster(roster), keyVersion: roster.key_version,
473
+ indexRevision: pulled.snapshot?.receipt.revision ?? 0,
474
+ refs: grants.filter((grant) => grant.membership_version === roster.membership_version).map((grant) => grant.vault_ref).sort(),
475
+ sizes: Object.fromEntries(grants.filter((grant) => grant.membership_version === roster.membership_version)
476
+ .map((grant) => [grant.vault_ref, Number(grant.bytes)])),
477
+ archives: Object.create(null), prepared: false,
478
+ };
479
+ if (retainedCustody)
480
+ journal.custody = Object.fromEntries(Object.entries(retainedCustody)
481
+ .filter(([ref]) => journal.refs.includes(ref)));
482
+ await requireStagingRoom(dataDir, directory, Object.values(journal.sizes).reduce((sum, bytes) => sum + bytes, 0)
483
+ + (pulled.snapshot?.ciphertext.length ?? 0));
484
+ if (pulled.snapshot) {
485
+ const snapshot = pulled.snapshot;
486
+ await openBoundedIndex(dataDir, directory, snapshot.envelope, snapshot.ciphertext, { privateKey }, snapshot.receipt.coverageJson);
487
+ const file = `${randomUUID()}.index`;
488
+ const handle = await open(join(directory, file), "wx", 0o600);
489
+ try {
490
+ await handle.writeFile(snapshot.ciphertext);
491
+ await handle.sync();
492
+ }
493
+ finally {
494
+ await handle.close();
495
+ }
496
+ journal.snapshot = { file, envelope: snapshot.envelope, receipt: snapshot.receipt };
497
+ }
498
+ await atomicJson(join(directory, "journal.json"), journal);
499
+ }
500
+ const index = await capturedIndex(dataDir, directory, journal, privateKey);
501
+ const ordered = validateCapturedCatalog(index, journal);
502
+ for (const ref of journal.refs) {
503
+ if (journal.archives[ref])
504
+ continue;
505
+ const file = `${randomUUID()}.ciphertext`;
506
+ const path = join(directory, file);
507
+ const captured = {};
508
+ try {
509
+ await requireStagingRoom(dataDir, directory, index.meta[ref].archiveRecord.storedBytes);
510
+ const digest = await remote("Capturing a shared departure archive", async (io) => {
511
+ const source = await pullTeamCiphertextStream(dataDir, scope.spaceKey, ref, preferred, io);
512
+ return writeChunksToFile(path, archiveBody(paceBackgroundByteStream(dataDir, source, maxBytesPerSecond, io), captured, index.meta[ref].archiveRecord.storedBytes), { ...io, expectedBytes: index.meta[ref].archiveRecord.storedBytes });
513
+ });
514
+ const envelope = captured.envelope;
515
+ validateEnvelope(envelope, digest.bytes);
516
+ const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, { privateKey });
517
+ zeroize(archiveKey);
518
+ const facts = index.meta?.[ref]?.archiveRecord;
519
+ if (facts && (facts.storedBytes !== digest.bytes || facts.ciphertextSha256 !== digest.sha256Hex
520
+ || (facts.envelopeArchiveId ?? ref) !== envelope.archiveId)) {
521
+ fail("ciphertext_integrity_failed", "A shared archive does not match its authenticated team catalog");
522
+ }
523
+ journal.archives[ref] = { file, envelope, bytes: digest.bytes, sha256: digest.sha256Hex };
524
+ await atomicJson(join(directory, "journal.json"), journal);
525
+ }
526
+ catch (error) {
527
+ await rm(path, { force: true });
528
+ throw error;
529
+ }
530
+ }
531
+ await verifyCapturedArchives(directory, journal, index, ordered, privateKey);
532
+ const custodyRecords = await persistOwnerCustody(dataDir, directory, journal, index, privateKey, phrase, preferred, maxBytesPerSecond, false);
533
+ await publishOwnerCustody(dataDir, directory, journal, index, custodyRecords, phrase, maxBytesPerSecond);
534
+ const current = await remote("Confirming the departure roster", (io) => getTeamRoster(dataDir, scope.spaceKey, preferred, io));
535
+ const cas = (await managedTeamIndexStore(dataDir, scope.spaceKey, { maxBytesPerSecond })).cas;
536
+ const head = await remote("Confirming the captured team index", (io) => cas.head(io));
537
+ const currentRefs = (await remote("Confirming captured history grants", (io) => listTeamArchives(dataDir, scope.spaceKey, preferred, io)))
538
+ .filter((grant) => grant.membership_version === current.membership_version).map((grant) => grant.vault_ref).sort();
539
+ if (current.membership_version !== journal.membershipVersion || sameRoster(current) !== sameRoster(roster)
540
+ || head.status === "unsupported" || (head.head?.receipt.revision ?? 0) !== (journal.snapshot?.receipt.revision ?? 0)
541
+ || JSON.stringify(currentRefs) !== JSON.stringify(journal.refs)) {
542
+ journal.obsolete = true;
543
+ journal.prepared = false;
544
+ await atomicJson(join(directory, "journal.json"), journal);
545
+ fail("team_state_changed", "Team history changed while it was being captured; retry the departure with a fresh snapshot");
546
+ }
547
+ journal.prepared = true;
548
+ await atomicJson(join(directory, "journal.json"), journal);
549
+ return {
550
+ membershipVersion: journal.membershipVersion, keyVersion: journal.keyVersion,
551
+ indexRevision: journal.indexRevision ?? journal.snapshot?.receipt.revision ?? 0, archiveRefs: [...journal.refs],
552
+ };
553
+ }
554
+ finally {
555
+ zeroize(privateRaw);
556
+ }
557
+ });
558
+ }
559
+ function remappedIndex(source, journal, aliases = journal.generation.aliases) {
560
+ const index = emptyIndex();
561
+ for (const [oldRef, line] of Object.entries(source.archives)) {
562
+ const alias = aliases[oldRef];
563
+ if (!alias)
564
+ continue;
565
+ index.archives[alias.ref] = [...line];
566
+ const oldMeta = source.meta?.[oldRef];
567
+ if (oldMeta) {
568
+ const meta = structuredClone(oldMeta);
569
+ if (meta.archiveRecord) {
570
+ const facts = meta.archiveRecord;
571
+ facts.envelopeArchiveId = journal.archives[oldRef].envelope.archiveId;
572
+ // New wraps are a new authenticated descriptor even though the
573
+ // underlying ciphertext/source is retained byte-for-byte.
574
+ facts.envelopeSha256 = createHash("sha256").update(JSON.stringify(alias.envelope)).digest("hex");
575
+ if (facts.delta) {
576
+ const base = aliases[facts.delta.baseArchiveId];
577
+ if (!base)
578
+ fail("ciphertext_integrity_failed", "A retained team delta has no retained base");
579
+ facts.delta.baseArchiveId = base.ref;
580
+ }
581
+ if (facts.supersedes) {
582
+ const previous = aliases[facts.supersedes.archiveId];
583
+ if (previous)
584
+ facts.supersedes.archiveId = previous.ref;
585
+ else
586
+ delete facts.supersedes;
587
+ }
588
+ }
589
+ index.meta[alias.ref] = meta;
590
+ }
591
+ }
592
+ for (const [token, postings] of Object.entries(source.tokens)) {
593
+ const remapped = [];
594
+ for (const posting of postings) {
595
+ const separator = posting.indexOf("@");
596
+ const ref = separator < 0 ? posting : posting.slice(0, separator);
597
+ const alias = aliases[ref];
598
+ if (alias && index.archives[alias.ref])
599
+ remapped.push(alias.ref + (separator < 0 ? "" : posting.slice(separator)));
600
+ }
601
+ if (remapped.length)
602
+ index.tokens[token] = remapped;
603
+ }
604
+ return index;
605
+ }
606
+ /**
607
+ * Team aliases authorize one roster. A surviving owner's separate private
608
+ * custody copy supplies the stable local archive inventory which ordinary
609
+ * backfill needs when another invitation advances that roster again. Its
610
+ * phrase wrap is never uploaded as a team grant or used as a shared envelope.
611
+ */
612
+ async function persistOwnerCustody(dataDir, directory, journal, source, privateKey, phrase, preferred, maxBytesPerSecond, installRecords) {
613
+ const config = await readConfig(dataDir);
614
+ const custody = journal.custody ??= Object.create(null);
615
+ const owned = (await listArchives(dataDir)).filter(isV2);
616
+ const ownedById = new Map(owned.map((record) => [record.id, record]));
617
+ const reusable = new Map(owned.filter((record) => record.teamCustody?.spaceKey === journal.scope.spaceKey
618
+ && record.remote?.verifiedAt && record.envelope.wrappedKeys.length === 1 && record.envelope.wrappedKeys[0].type === "phrase")
619
+ .map((record) => [JSON.stringify([record.envelope.archiveId, record.source.bytes, record.source.sha256,
620
+ record.cipher.storedBytes, record.cipher.ciphertextSha256, record.delta?.baseBytes ?? null,
621
+ record.delta ? ownedById.get(record.delta.baseArchiveId)?.envelope.archiveId : null]), record]));
622
+ for (const ref of journal.refs) {
623
+ if (custody[ref])
624
+ continue;
625
+ const original = journal.archives[ref].envelope;
626
+ const key = unwrapArchiveKey(original.wrappedKeys, original.suite, original.archiveId, { privateKey });
627
+ try {
628
+ const facts = source.meta[ref].archiveRecord;
629
+ const reusableCopy = reusable.get(JSON.stringify([original.archiveId, facts.sourceBytes, facts.sourceSha256,
630
+ facts.storedBytes, facts.ciphertextSha256, facts.delta?.baseBytes ?? null,
631
+ facts.delta ? journal.archives[facts.delta.baseArchiveId].envelope.archiveId : null]));
632
+ if (reusableCopy) {
633
+ let opened;
634
+ try {
635
+ opened = unwrapArchiveKey(reusableCopy.envelope.wrappedKeys, reusableCopy.envelope.suite, reusableCopy.envelope.archiveId, { phrase });
636
+ if (opened.equals(key)) {
637
+ custody[ref] = { ref: reusableCopy.id, envelope: reusableCopy.envelope, remote: reusableCopy.remote, installed: true };
638
+ continue;
639
+ }
640
+ }
641
+ catch { /* A different phrase/key cannot authorize custody reuse. */ }
642
+ finally {
643
+ if (opened)
644
+ zeroize(opened);
645
+ }
646
+ }
647
+ custody[ref] = {
648
+ ref: randomUUID(),
649
+ envelope: { ...original, wrappedKeys: wrapAll(key, [{ kind: "phrase", phrase }], original.suite, original.archiveId) },
650
+ };
651
+ }
652
+ finally {
653
+ zeroize(key);
654
+ }
655
+ }
656
+ await atomicJson(join(directory, "journal.json"), journal);
657
+ const finished = new Set();
658
+ const visiting = new Set();
659
+ const records = [];
660
+ const sourceRoots = new Map();
661
+ const sourceRoot = (ref) => {
662
+ const base = source.meta?.[ref]?.archiveRecord?.delta?.baseArchiveId;
663
+ const root = base ? sourceRoots.get(base) : ref;
664
+ sourceRoots.set(ref, root);
665
+ return root;
666
+ };
667
+ const install = async (ref) => {
668
+ if (finished.has(ref))
669
+ return;
670
+ if (visiting.has(ref))
671
+ fail("ciphertext_integrity_failed", "A custody delta chain contains a cycle");
672
+ visiting.add(ref);
673
+ const facts = source.meta?.[ref]?.archiveRecord;
674
+ if (facts?.delta)
675
+ await install(facts.delta.baseArchiveId);
676
+ const copy = custody[ref];
677
+ const archive = journal.archives[ref];
678
+ if (!copy.remote) {
679
+ const uploaded = await remote("Saving owner custody of shared history", (io) => pushFramedArchive(dataDir, {
680
+ vaultRef: copy.ref, envelope: copy.envelope, ciphertextPath: join(directory, archive.file),
681
+ ciphertextSha256: archive.sha256, ciphertextBytes: archive.bytes,
682
+ }, preferred, { ...io, maxBytesPerSecond }));
683
+ if (!uploaded.durable)
684
+ fail("ciphertext_integrity_failed", "The owner custody copy has no verified managed-storage receipt");
685
+ copy.remote = {
686
+ provider: "vaultline", bucket: "vaultline-managed", objectKey: uploaded.objectKey,
687
+ bytes: uploaded.bytes, checksum: uploaded.checksum ?? "", verifiedAt: new Date().toISOString(),
688
+ };
689
+ await atomicJson(join(directory, "journal.json"), journal);
690
+ }
691
+ {
692
+ const sealed = archive.envelope;
693
+ const record = {
694
+ version: 2, id: copy.ref, createdAt: facts?.createdAt ?? sealed.manifest.createdAt,
695
+ source: {
696
+ // The same proven delta chain receives one synthetic nonexistent
697
+ // path. Never inherit another machine's absolute source path or
698
+ // point retention at an unrelated file on this owner's machine.
699
+ path: join(dataDir, "team-custody-source", `${custody[sourceRoot(ref)].ref}.jsonl`),
700
+ agent: source.archives[ref]?.[0] ?? sealed.manifest.adapter?.agent ?? "shared-history",
701
+ bytes: facts?.sourceBytes ?? sealed.manifest.originalBytes ?? sealed.manifest.plaintextBytes,
702
+ sha256: facts?.sourceSha256 ?? sealed.manifest.originalSha256 ?? sealed.manifest.plaintextSha256,
703
+ project: journal.scope.project,
704
+ ...(journal.scope.projectKey ? { projectKey: journal.scope.projectKey } : {}),
705
+ projectScope: journal.scope.spaceKey,
706
+ },
707
+ cipher: { algorithm: sealed.suite, ciphertextSha256: archive.sha256, storedBytes: archive.bytes, chunks: sealed.chunks.length },
708
+ envelope: copy.envelope,
709
+ teamCustody: { version: 1, spaceKey: journal.scope.spaceKey, envelopeArchiveId: sealed.archiveId },
710
+ objectPath: join(config.storage.root, `${copy.ref}.sealed`),
711
+ remote: copy.remote,
712
+ offloaded: {
713
+ at: copy.remote.verifiedAt, provider: copy.remote.provider, bucket: copy.remote.bucket,
714
+ objectKey: copy.remote.objectKey, localBytes: archive.bytes, freedBytes: 0,
715
+ },
716
+ ...(facts?.delta ? { delta: { ...facts.delta, baseArchiveId: custody[facts.delta.baseArchiveId].ref } } : {}),
717
+ ...(facts?.supersedes && custody[facts.supersedes.archiveId]
718
+ ? { supersedes: { ...facts.supersedes, archiveId: custody[facts.supersedes.archiveId].ref } } : {}),
719
+ };
720
+ records.push(record);
721
+ if (installRecords && !copy.installed) {
722
+ await writeRecord(config.storage.root, record);
723
+ copy.installed = true;
724
+ await atomicJson(join(directory, "journal.json"), journal);
725
+ }
726
+ }
727
+ visiting.delete(ref);
728
+ finished.add(ref);
729
+ };
730
+ for (const ref of validateCapturedCatalog(source, journal)) {
731
+ sourceRoot(ref);
732
+ await install(ref);
733
+ }
734
+ return records;
735
+ }
736
+ /** Publish only the owner's private account index; never advance a team fence. */
737
+ async function publishOwnerCustody(dataDir, directory, journal, source, records, phrase, maxBytesPerSecond) {
738
+ if (!journal.refs.length)
739
+ return;
740
+ const cas = (await managedIndexStore(dataDir, { maxBytesPerSecond })).cas;
741
+ if (!cas?.publishFile)
742
+ fail("provider_unsupported", "Departure requires a durable immutable owner custody catalog");
743
+ const retained = remappedIndex(source, journal, journal.custody);
744
+ // Match the safe local custody provenance. Team readers never receive these
745
+ // owner-only locators or phrase wraps; ordinary backfill derives new ones.
746
+ const { archiveCatalogFacts } = await import("./search.js");
747
+ for (const copy of Object.values(journal.custody)) {
748
+ const record = records.find((record) => record.id === copy.ref);
749
+ if (!record || !copy.remote)
750
+ fail("ciphertext_integrity_failed", "Owner custody is not durable");
751
+ retained.archives[copy.ref] = [record.source.agent, record.source.path, journal.scope.project,
752
+ journal.scope.spaceKey, journal.scope.projectKey ?? ""];
753
+ retained.meta[copy.ref].archiveRecord = archiveCatalogFacts(record);
754
+ }
755
+ for (let attempt = 0; attempt < 5; attempt += 1) {
756
+ const pulled = await remote("Reading the owner custody catalog", (io) => cas.pullSnapshot({ ...io, maxBytes: MAX_HANDOFF_INDEX_BYTES }));
757
+ if (pulled.status === "unsupported")
758
+ fail("provider_unsupported", "Departure requires a durable immutable owner custody catalog");
759
+ const index = pulled.snapshot
760
+ ? await openBoundedIndex(dataDir, directory, pulled.snapshot.envelope, pulled.snapshot.ciphertext, { phrase }, pulled.snapshot.receipt.coverageJson)
761
+ : emptyIndex();
762
+ Object.assign(index.archives, retained.archives);
763
+ Object.assign(index.meta ??= Object.create(null), retained.meta);
764
+ for (const [token, postings] of Object.entries(retained.tokens)) {
765
+ index.tokens[token] = [...new Set([...(index.tokens[token] ?? []), ...postings])];
766
+ }
767
+ index.builtAt = new Date().toISOString();
768
+ const plaintext = join(directory, `${randomUUID()}.temporary.jsonl`);
769
+ const ciphertext = join(directory, `${randomUUID()}.temporary.index`);
770
+ try {
771
+ let checkpointBytes = 1_024;
772
+ for (const [ref, line] of Object.entries(index.archives))
773
+ checkpointBytes += Buffer.byteLength(JSON.stringify(["a", ref, ...line])) + 1;
774
+ for (const [token, postings] of Object.entries(index.tokens))
775
+ checkpointBytes += Buffer.byteLength(JSON.stringify(["t", token, postings])) + 1;
776
+ for (const [ref, meta] of Object.entries(index.meta ?? {}))
777
+ checkpointBytes += Buffer.byteLength(JSON.stringify(["m", ref, meta])) + 1;
778
+ if (checkpointBytes > MAX_HANDOFF_INDEX_BYTES)
779
+ fail("provider_unsupported", "Owner custody exceeds the bounded 64 MiB index checkpoint limit");
780
+ await requireStagingRoom(dataDir, directory, checkpointBytes * 3 + 8 * 1024 * 1024);
781
+ const coverage = await writeContentIndexCheckpoint(plaintext, index);
782
+ const sealed = await sealArchiveToFile({ path: plaintext }, ciphertext, {
783
+ recipients: [{ kind: "phrase", phrase }], compression: "gzip",
784
+ adapter: { agent: "sealkeep-index", version: "1" }, scratchDir: directory,
785
+ });
786
+ await rm(plaintext, { force: true });
787
+ const published = await remote("Publishing durable owner custody", (io) => cas.publishFile(sealed.envelope, ciphertext, {
788
+ ...io, maxBytesPerSecond, expectedRevision: pulled.snapshot?.receipt.revision ?? 0, coverageJson: coverage.publication,
789
+ }));
790
+ if (published.status === "unsupported")
791
+ fail("provider_unsupported", "Departure requires a durable immutable owner custody catalog");
792
+ if (published.status === "conflict")
793
+ continue;
794
+ if (!published.verifiedReadBack)
795
+ fail("ciphertext_integrity_failed", "Owner custody catalog has no verified immutable read-back receipt");
796
+ journal.custodyReceipt = published.receipt;
797
+ await atomicJson(join(directory, "journal.json"), journal);
798
+ return;
799
+ }
800
+ finally {
801
+ await rm(plaintext, { force: true });
802
+ await rm(ciphertext, { force: true });
803
+ }
804
+ }
805
+ fail("team_state_changed", "Concurrent owner catalog publication delayed departure; retry it");
806
+ }
807
+ /**
808
+ * Resume after the authoritative removal, including a lost removal response.
809
+ * The surviving uploader owns new immutable aliases; old author-owned grants
810
+ * and old membership receipts are never promoted to the new epoch.
811
+ */
812
+ export async function completeTeamOffboarding(dataDir, scope, phrase, preferred) {
813
+ const directory = journalDirectory(dataDir, scope);
814
+ await withJournal(directory, async () => {
815
+ const journal = await readJournal(directory, scope);
816
+ if (!journal?.prepared)
817
+ fail("team_state_changed", "The departure has no complete saved history handoff");
818
+ if (journal.completed) {
819
+ await cleanupCompleted(directory, journal);
820
+ return;
821
+ }
822
+ const roster = await remote("Reading the surviving team roster", (io) => getTeamRoster(dataDir, scope.spaceKey, preferred, io));
823
+ const target = roster.members.find((member) => Number(member.account_id) === scope.targetAccountId);
824
+ if (scope.transition === "invitation") {
825
+ const previousMembers = roster.members.filter((member) => Number(member.account_id) !== scope.targetAccountId);
826
+ if (!target || roster.membership_version !== journal.membershipVersion + 1
827
+ || target.public_key !== journal.scope.targetPublicKey
828
+ || sameRoster({ ...roster, members: previousMembers }) !== journal.capturedRoster) {
829
+ fail("team_state_changed", "The invitation history handoff does not match this accepted roster");
830
+ }
831
+ }
832
+ else if (target || roster.membership_version <= journal.membershipVersion) {
833
+ fail("team_state_changed", "Remove the departing account before completing its history handoff");
834
+ }
835
+ const privateRaw = Buffer.from((await ensureTeamIdentity(dataDir, phrase, preferred)).privateKey, "base64");
836
+ const privateKey = x25519PrivateKeyFromRaw(privateRaw);
837
+ const maxBytesPerSecond = backgroundUploadBytesPerSecond(await readLocalSettings(dataDir));
838
+ try {
839
+ const source = await capturedIndex(dataDir, directory, journal, privateKey);
840
+ const recipients = roster.members.map((member) => ({
841
+ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(member.public_key, "base64")),
842
+ }));
843
+ if (!journal.generation || journal.generation.membershipVersion !== roster.membership_version
844
+ || journal.generation.roster !== sameRoster(roster)) {
845
+ const aliases = Object.create(null);
846
+ for (const ref of journal.refs) {
847
+ const original = journal.archives[ref].envelope;
848
+ const key = unwrapArchiveKey(original.wrappedKeys, original.suite, original.archiveId, { privateKey });
849
+ try {
850
+ aliases[ref] = { ref: randomUUID(), envelope: {
851
+ ...original, wrappedKeys: wrapAll(key, recipients, original.suite, original.archiveId),
852
+ } };
853
+ }
854
+ finally {
855
+ zeroize(key);
856
+ }
857
+ }
858
+ journal.generation = { membershipVersion: roster.membership_version, roster: sameRoster(roster), aliases };
859
+ await atomicJson(join(directory, "journal.json"), journal);
860
+ }
861
+ const aliases = journal.generation.aliases;
862
+ const granted = new Set((await remote("Reading surviving history grants", (io) => listTeamArchives(dataDir, scope.spaceKey, preferred, io)))
863
+ .filter((grant) => grant.membership_version === roster.membership_version).map((grant) => grant.vault_ref));
864
+ const visiting = new Set();
865
+ const finished = new Set();
866
+ const transfer = async (ref) => {
867
+ if (finished.has(ref))
868
+ return;
869
+ if (visiting.has(ref))
870
+ fail("ciphertext_integrity_failed", "A team history delta chain contains a cycle");
871
+ visiting.add(ref);
872
+ const base = source.meta?.[ref]?.archiveRecord?.delta?.baseArchiveId;
873
+ if (base)
874
+ await transfer(base);
875
+ const alias = aliases[ref];
876
+ if (!granted.has(alias.ref)) {
877
+ const archive = journal.archives[ref];
878
+ const uploaded = await remote("Preserving a shared departure archive", (io) => pushFramedArchive(dataDir, {
879
+ vaultRef: alias.ref, envelope: alias.envelope, ciphertextPath: join(directory, archive.file),
880
+ ciphertextSha256: archive.sha256, ciphertextBytes: archive.bytes,
881
+ }, preferred, { ...io, maxBytesPerSecond }));
882
+ if (!uploaded.durable)
883
+ fail("ciphertext_integrity_failed", "The retained team archive was not verified in managed storage");
884
+ await remote("Granting preserved team history", (io) => registerTeamArchive(dataDir, scope.spaceKey, alias.ref, roster.membership_version, preferred, io));
885
+ granted.add(alias.ref);
886
+ alias.registered = true;
887
+ await atomicJson(join(directory, "journal.json"), journal);
888
+ }
889
+ visiting.delete(ref);
890
+ finished.add(ref);
891
+ };
892
+ for (const ref of validateCapturedCatalog(source, journal))
893
+ await transfer(ref);
894
+ if (journal.refs.length && !journal.custodyReceipt) {
895
+ fail("ciphertext_integrity_failed", "The saved departure has no pre-removal durable owner custody catalog");
896
+ }
897
+ await persistOwnerCustody(dataDir, directory, journal, source, privateKey, phrase, preferred, maxBytesPerSecond, true);
898
+ const cas = (await managedTeamIndexStore(dataDir, scope.spaceKey, { maxBytesPerSecond })).cas;
899
+ if (!cas?.publishFile)
900
+ fail("provider_unsupported", "The team history handoff requires streaming index publication");
901
+ let usedCapturedRevision = false;
902
+ for (let attempt = 0; attempt < 5; attempt += 1) {
903
+ let pulled;
904
+ let capturedRevision;
905
+ try {
906
+ pulled = await remote("Reading the surviving team index", (io) => cas.pullSnapshot({ ...io, maxBytes: MAX_HANDOFF_INDEX_BYTES }));
907
+ }
908
+ catch (error) {
909
+ // Hosted HEAD deliberately vends no download capabilities for the
910
+ // prior roster. The removal transaction fenced this exact revision,
911
+ // so one replacement CAS can use the saved snapshot without reading
912
+ // stale data. Every other failure and every later conflict remains a
913
+ // normal hard failure/current-head merge, never a guessed revision.
914
+ const savedRevision = journal.indexRevision ?? journal.snapshot?.receipt.revision;
915
+ if (usedCapturedRevision || !isSealkeepError(error) || error.code !== "team_state_changed"
916
+ || error.message !== "Team membership changed; publish a new search index"
917
+ || !journal.snapshot || !Number.isSafeInteger(savedRevision) || Number(savedRevision) < 1
918
+ || savedRevision !== journal.snapshot.receipt.revision
919
+ || roster.membership_version !== journal.membershipVersion + 1)
920
+ throw error;
921
+ const current = await remote("Confirming the fenced departure epoch", (io) => getTeamRoster(dataDir, scope.spaceKey, preferred, io));
922
+ if (current.membership_version !== roster.membership_version || sameRoster(current) !== sameRoster(roster)
923
+ || (current.members.some((member) => Number(member.account_id) === scope.targetAccountId) !== (scope.transition === "invitation")))
924
+ throw error;
925
+ usedCapturedRevision = true;
926
+ capturedRevision = savedRevision;
927
+ pulled = { status: "ok", snapshot: null };
928
+ }
929
+ if (pulled.status === "unsupported")
930
+ fail("provider_unsupported", "Immutable team index publication is unavailable");
931
+ const index = remappedIndex(source, journal);
932
+ if (pulled.snapshot?.receipt.membershipVersion === roster.membership_version) {
933
+ const currentGrants = new Set((await remote("Confirming surviving history grants", (io) => listTeamArchives(dataDir, scope.spaceKey, preferred, io)))
934
+ .filter((grant) => grant.membership_version === roster.membership_version).map((grant) => grant.vault_ref));
935
+ const current = filtered(await openBoundedIndex(dataDir, directory, pulled.snapshot.envelope, pulled.snapshot.ciphertext, { privateKey }, pulled.snapshot.receipt.coverageJson), scope, currentGrants);
936
+ mergeTeamProjectShard(index, current, scope.project, scope.spaceKey, new Map(), scope.projectKey);
937
+ }
938
+ else if (pulled.snapshot && pulled.snapshot.receipt.membershipVersion !== journal.membershipVersion) {
939
+ fail("team_state_changed", "Another roster transition changed the team history handoff");
940
+ }
941
+ const plaintext = join(directory, `${randomUUID()}.temporary.jsonl`);
942
+ const ciphertext = join(directory, `${randomUUID()}.temporary.index`);
943
+ try {
944
+ // JSONL, compressor scratch, and sealed output can coexist briefly.
945
+ // Measure per row so admission itself never builds one index-sized
946
+ // JSON string. The margin covers framing and compression overhead.
947
+ let checkpointBytes = 1_024;
948
+ for (const [ref, line] of Object.entries(index.archives))
949
+ checkpointBytes += Buffer.byteLength(JSON.stringify(["a", ref, ...line])) + 1;
950
+ for (const [token, postings] of Object.entries(index.tokens))
951
+ checkpointBytes += Buffer.byteLength(JSON.stringify(["t", token, postings])) + 1;
952
+ for (const [ref, meta] of Object.entries(index.meta ?? {}))
953
+ checkpointBytes += Buffer.byteLength(JSON.stringify(["m", ref, meta])) + 1;
954
+ if (checkpointBytes > MAX_HANDOFF_INDEX_BYTES)
955
+ fail("provider_unsupported", "Retained team history exceeds the bounded 64 MiB index checkpoint limit");
956
+ await requireStagingRoom(dataDir, directory, checkpointBytes * 3 + 8 * 1024 * 1024);
957
+ const coverage = await writeContentIndexCheckpoint(plaintext, index);
958
+ const sealed = await sealArchiveToFile({ path: plaintext }, ciphertext, {
959
+ recipients, compression: "gzip", adapter: { agent: "sealkeep-index", version: "1" }, scratchDir: directory,
960
+ });
961
+ await rm(plaintext, { force: true });
962
+ const published = await remote("Publishing preserved team history", (io) => cas.publishFile(sealed.envelope, ciphertext, {
963
+ ...io, maxBytesPerSecond,
964
+ expectedRevision: capturedRevision ?? pulled.snapshot?.receipt.revision ?? 0,
965
+ expectedMembershipVersion: roster.membership_version, coverageJson: coverage.publication,
966
+ }));
967
+ if (published.status === "unsupported")
968
+ fail("provider_unsupported", "Immutable team index publication is unavailable");
969
+ if (published.status === "conflict")
970
+ continue;
971
+ if (!published.verifiedReadBack || published.receipt.membershipVersion !== roster.membership_version) {
972
+ fail("ciphertext_integrity_failed", "The retained team index has no verified current membership receipt");
973
+ }
974
+ const current = await remote("Confirming preserved team membership", (io) => getTeamRoster(dataDir, scope.spaceKey, preferred, io));
975
+ if (current.membership_version !== roster.membership_version || sameRoster(current) !== sameRoster(roster)) {
976
+ fail("team_state_changed", "Team membership changed while retained history was published; retry its handoff");
977
+ }
978
+ journal.completed = true;
979
+ journal.completedReceipt = published.receipt;
980
+ await atomicJson(join(directory, "journal.json"), journal);
981
+ await cleanupCompleted(directory, journal);
982
+ return;
983
+ }
984
+ finally {
985
+ await rm(plaintext, { force: true });
986
+ await rm(ciphertext, { force: true });
987
+ }
988
+ }
989
+ fail("team_state_changed", "Concurrent team history publication delayed the saved departure handoff; retry it");
990
+ }
991
+ finally {
992
+ zeroize(privateRaw);
993
+ }
994
+ });
995
+ }
996
+ export async function prepareTeamInvitationHistory(dataDir, scope, phrase, preferred) {
997
+ if (!Number.isSafeInteger(scope.targetAccountId) || scope.targetAccountId <= 0
998
+ || Buffer.from(scope.targetPublicKey, "base64").length !== 32) {
999
+ fail("invalid_argument", "A history invitation requires an identified recipient encryption key");
1000
+ }
1001
+ return prepareTeamOffboarding(dataDir, { ...scope, transition: "invitation" }, phrase, preferred);
1002
+ }
1003
+ /** Owner-side background continuation; pending invitations grant no access. */
1004
+ export async function reconcileTeamInvitationHandoffs(dataDir, phrase, preferred) {
1005
+ const root = join(dataDir, "team-invitations");
1006
+ const names = await readdir(root).catch((error) => {
1007
+ if (error?.code === "ENOENT")
1008
+ return [];
1009
+ throw error;
1010
+ });
1011
+ if (!names.length)
1012
+ return 0;
1013
+ const config = await readConfig(dataDir);
1014
+ let completed = 0;
1015
+ for (const name of names.sort()) {
1016
+ if (!/^[a-f0-9]{64}$/.test(name))
1017
+ continue;
1018
+ const directory = join(root, name);
1019
+ const raw = await readFile(join(directory, "journal.json"), "utf8").catch((error) => {
1020
+ if (error?.code === "ENOENT")
1021
+ return null;
1022
+ throw error;
1023
+ });
1024
+ if (!raw)
1025
+ continue;
1026
+ const scope = JSON.parse(raw).scope;
1027
+ if (scope?.transition !== "invitation" || journalDirectory(dataDir, scope) !== directory) {
1028
+ fail("ciphertext_integrity_failed", "The saved invitation handoff has an invalid scope");
1029
+ }
1030
+ const journal = await readJournal(directory, scope);
1031
+ if (!journal?.prepared || journal.completed)
1032
+ continue;
1033
+ const binding = teamSpaceOf(config, scope.project, scope.projectKey);
1034
+ if (!binding || binding.role !== "owner" || binding.spaceKey !== scope.spaceKey)
1035
+ continue;
1036
+ const roster = await remote("Checking accepted invitation history", (io) => getTeamRoster(dataDir, scope.spaceKey, preferred, io));
1037
+ if (!roster.members.some((member) => Number(member.account_id) === scope.targetAccountId))
1038
+ continue;
1039
+ await completeTeamOffboarding(dataDir, scope, phrase, preferred);
1040
+ completed += 1;
1041
+ }
1042
+ return completed;
1043
+ }