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
@@ -4,17 +4,38 @@ import { join, resolve } from "node:path";
4
4
  import { fail } from "./errors.js";
5
5
  import { recordAudit } from "./audit.js";
6
6
  import { canonicalPhrase } from "./mnemonic.js";
7
- import { ADAPTER_VERSION, configuredRecipients, readConfig, writeRecord } from "./vault.js";
7
+ import { isV2 } from "./types.js";
8
+ import { ADAPTER_VERSION, configuredRecipients, mutateArchiveRecord, readConfig, teamSpaceOf } from "./vault.js";
9
+ import { appendArchiveCopy } from "./archive-copies.js";
8
10
  import { uploadClientFromStore } from "./providers/index.js";
9
11
  import { acquireSpoolLock, createSpool, findSpoolForSource, shredSpool, unwrapSpoolKey, updateSpool } from "./spool.js";
10
- import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrapAll, zeroize } from "../packages/vaultline-crypto/src/index.js";
12
+ import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrapAll, zeroize } from "../packages/sealkeep-crypto/src/index.js";
11
13
  import { frameObject } from "./cloud.js";
12
- import { aeadCipher, aeadDecipher } from "../packages/vaultline-crypto/src/aead.js";
13
- import { envVar } from "./env.js";
14
+ import { aeadCipher, aeadDecipher } from "../packages/sealkeep-crypto/src/aead.js";
15
+ import { STORAGE_PREFIX_DEFAULT } from "./branding.js";
16
+ import { oneChunk } from "./byte-stream.js";
17
+ import { paceBackgroundByteStream } from "./background-bandwidth.js";
14
18
  const SEAL_SUITE = "chacha20-poly1305";
15
19
  const sha256hex = (input) => createHash("sha256").update(input).digest("hex");
20
+ async function waitForAbort(work, signal) {
21
+ if (!signal)
22
+ return work;
23
+ signal.throwIfAborted();
24
+ let cancel;
25
+ const aborted = new Promise((_resolve, reject) => {
26
+ cancel = () => reject(signal.reason instanceof Error ? signal.reason : new DOMException("The seal was cancelled", "AbortError"));
27
+ signal.addEventListener("abort", cancel, { once: true });
28
+ });
29
+ try {
30
+ return await Promise.race([work, aborted]);
31
+ }
32
+ finally {
33
+ if (cancel)
34
+ signal.removeEventListener("abort", cancel);
35
+ }
36
+ }
16
37
  export const chunkObjectName = (index) => `chunk-${String(index).padStart(6, "0")}`;
17
- export const ENVELOPE_OBJECT = "envelope.vlmeta";
38
+ export const ENVELOPE_OBJECT = "envelope.skmeta";
18
39
  /** One path segment a person can read in a bucket console, made key-safe. */
19
40
  function slug(raw, fallback) {
20
41
  const cleaned = raw.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
@@ -75,7 +96,7 @@ async function clientFor(dataDir, target) {
75
96
  // remoteStorage entry. The live drill on a real vault caught the gap:
76
97
  // gating on config.remoteStorage alone meant no production managed vault
77
98
  // could ever stream. A routed TARGET (multi-target vaults) overrides both.
78
- const remoteStorage = (target ? { provider: target.provider, bucket: target.bucket ?? "vaultline-managed", prefix: target.prefix ?? "vaultline", region: target.region } : undefined)
99
+ const remoteStorage = (target ? { provider: target.provider, bucket: target.bucket ?? "vaultline-managed", prefix: target.prefix ?? STORAGE_PREFIX_DEFAULT, region: target.region } : undefined)
79
100
  ?? config.remoteStorage
80
101
  ?? await (async () => {
81
102
  const { cloudToken } = await import("./cloud.js");
@@ -96,7 +117,13 @@ async function clientFor(dataDir, target) {
96
117
  const { managedChunkClient } = await import("./managed-chunks.js");
97
118
  return { client: managedChunkClient(dataDir), remoteStorage: { provider: "vaultline", bucket: "vaultline-managed", prefix: "" } };
98
119
  }
99
- const raw = await uploadClientFromStore(dataDir, config.vaultId, remoteStorage);
120
+ const resolvedTarget = target ?? { id: "primary", ...remoteStorage };
121
+ const { credentialConfigIdForTarget, endpointOverridesForResolvedTarget } = await import("./storage-targets.js");
122
+ const credentialId = await credentialConfigIdForTarget(dataDir, resolvedTarget);
123
+ if (!credentialId) {
124
+ fail("signer_not_configured", `Storage target "${resolvedTarget.id}" is not connected on this machine. Connect it in Settings before streaming.`);
125
+ }
126
+ const raw = await uploadClientFromStore(dataDir, credentialId, remoteStorage, await endpointOverridesForResolvedTarget(dataDir, resolvedTarget));
100
127
  const client = raw;
101
128
  if (typeof client.putObject !== "function" || typeof client.headObject !== "function" || typeof client.getObject !== "function" || typeof client.deleteObject !== "function") {
102
129
  fail("invalid_argument", `The ${remoteStorage.provider} client cannot store chunk folders`);
@@ -111,6 +138,7 @@ async function clientFor(dataDir, target) {
111
138
  * AND the provider's stored checksum, then skipped.
112
139
  */
113
140
  export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, options = {}) {
141
+ options.signal?.throwIfAborted();
114
142
  const config = await readConfig(dataDir);
115
143
  if (config.storageMode === "plain") {
116
144
  fail("invalid_argument", "This vault stores archives as plain readable files; chunking them into a bucket would upload your transcripts unencrypted. Use a sealed vault for cloud archiving.");
@@ -119,10 +147,16 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
119
147
  // byte limits are the rules; chunk streaming excludes Drive (single-object
120
148
  // by design). Injected clients (tests) bypass routing entirely.
121
149
  let routedTarget;
150
+ let destination;
151
+ let destinationFingerprint;
122
152
  if (!options.client) {
123
153
  const preSize = await stat(resolve(sourcePath)).then((info) => info.size).catch(() => 0);
124
- const { chooseTarget } = await import("./storage-targets.js");
154
+ const { chooseTarget, destinationIdentityForTarget, storageDestinationFingerprint } = await import("./storage-targets.js");
125
155
  routedTarget = await chooseTarget(dataDir, { project: options.project ?? null, bytes: preSize, requireChunkCapable: true, targetId: options.targetId });
156
+ if (routedTarget.provider !== "vaultline") {
157
+ destination = await destinationIdentityForTarget(dataDir, routedTarget);
158
+ destinationFingerprint = storageDestinationFingerprint(destination);
159
+ }
126
160
  }
127
161
  const bundled = options.client ? null : await clientFor(dataDir, routedTarget);
128
162
  const remoteStorage = bundled?.remoteStorage ?? config.remoteStorage ?? { provider: "vaultline", bucket: "vaultline-managed", prefix: "" };
@@ -135,8 +169,10 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
135
169
  const totalBytes = source.size;
136
170
  // Same override the local seal path honours, for the same reason: proving the
137
171
  // streamed layout works should not cost tens of megabytes of disk per run.
138
- const chunkOverride = Number(envVar("CHUNK_BYTES"));
139
- const chunkBytes = Number.isInteger(chunkOverride) && chunkOverride > 0 ? chunkOverride : DEFAULT_CHUNK_BYTES;
172
+ const chunkOverride = options.chunkBytes;
173
+ const chunkBytes = typeof chunkOverride === "number" && Number.isInteger(chunkOverride) && chunkOverride > 0
174
+ ? chunkOverride
175
+ : DEFAULT_CHUNK_BYTES;
140
176
  const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
141
177
  // Journal triage, chunk-layout edition. Same drain-the-stack shape as the
142
178
  // streaming path: resume the newest matching journal or abandon it with the
@@ -163,7 +199,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
163
199
  }
164
200
  const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
165
201
  try {
166
- return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, now: options.now }, journal);
202
+ return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield }, journal);
167
203
  }
168
204
  catch (error) {
169
205
  if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
@@ -195,7 +231,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
195
231
  naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
196
232
  });
197
233
  // Members of this project are wrapped in here, and nowhere else.
198
- const recipients = configuredRecipients(config, phrase, { project: options.project ?? undefined });
234
+ const recipients = [...configuredRecipients(config, phrase, { project: options.project ?? undefined, projectKey: options.projectKey ?? undefined }), ...(options.teamRecipients ?? [])];
199
235
  const archiveKey = randomBytes(KEY_BYTES);
200
236
  const noncePrefix = randomBytes(4);
201
237
  const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
@@ -211,7 +247,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
211
247
  hashState: { plaintext: "", stored: "" },
212
248
  providerState: { kind: "chunk-folder", folder, uploaded: 0 }
213
249
  }, { now: options.now });
214
- return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, now: options.now }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
250
+ return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id ?? options.targetId, destination, destinationFingerprint, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, projectKey: options.projectKey ?? null, teamRecipients: options.teamRecipients ?? [], now: options.now, signal: options.signal, maxBytesPerSecond: options.maxBytesPerSecond, onYield: options.onYield }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
215
251
  }
216
252
  finally {
217
253
  zeroize(archiveKey);
@@ -239,23 +275,29 @@ async function abandonFolderJournal(dataDir, client, journal, reason) {
239
275
  */
240
276
  async function runChunkSeal(ctx, args) {
241
277
  const config = await readConfig(ctx.dataDir);
242
- const recipients = configuredRecipients(config, ctx.phrase, { project: ctx.project ?? undefined });
243
- const { tokenize, indexableToken, tokenCapFor } = await import("./search.js");
244
- const perChunkTokenCap = Math.max(4000, Math.floor(tokenCapFor(ctx.totalBytes) / 4));
278
+ const projectScope = teamSpaceOf(config, ctx.project, ctx.projectKey)?.spaceKey;
279
+ const recipients = [...configuredRecipients(config, ctx.phrase, { project: ctx.project ?? undefined, projectKey: ctx.projectKey ?? undefined }), ...ctx.teamRecipients];
280
+ const { createSealTokenCollector } = await import("./search.js");
245
281
  const hashers = { plaintext: new StreamingSha256(), stored: new StreamingSha256() };
246
282
  const headers = [];
247
283
  let storedBytes = 0;
248
284
  let heldAtMostBytes = 0;
249
- // Uploads ride a small pool: the seal loop stays sequential (it is the CPU
250
- // and the nonce discipline), but a sealed chunk does not wait for the wire
251
- // before the next one seals. Four in flight bounds peak memory at four
252
- // chunks and roughly quarters the wall clock of a lease-per-chunk upload.
253
- const UPLOAD_CONCURRENCY = 4;
285
+ // User-triggered uploads ride a small pool: the seal loop stays sequential
286
+ // (it is the CPU and nonce discipline), while four in-flight PUTs overlap
287
+ // lease latency without holding more than four chunks. Automatic work is
288
+ // identified by its mandatory byte-rate ceiling and stays one-wide: disk
289
+ // pressure must not create a 4-chunk RAM/network burst in the background.
290
+ const uploadConcurrency = ctx.maxBytesPerSecond ? 1 : 4;
254
291
  const pending = new Map();
255
292
  const completedUploads = new Set();
256
293
  let watermark = args.startAt;
257
294
  let uploadError = null;
258
295
  let inFlightBytes = 0;
296
+ const paceUpload = async (body) => {
297
+ if (!ctx.maxBytesPerSecond)
298
+ return;
299
+ for await (const _ of paceBackgroundByteStream(ctx.dataDir, oneChunk(body), ctx.maxBytesPerSecond, { signal: ctx.signal })) { /* reservation only */ }
300
+ };
259
301
  // Journal writes are serialized: two concurrent read-modify-write updates
260
302
  // of the spool would lose one of them.
261
303
  let spoolChain = Promise.resolve();
@@ -267,7 +309,7 @@ async function runChunkSeal(ctx, args) {
267
309
  // Seal-time indexing: the seal holds every plaintext slice anyway, so the
268
310
  // tokens are collected here for free — the alternative was uploading the
269
311
  // archive and then downloading it back just to read it again.
270
- const tokensByChunk = new Map();
312
+ const tokenCollector = createSealTokenCollector(ctx.totalBytes);
271
313
  const sink = {
272
314
  write: async (stored) => {
273
315
  const index = headers.length;
@@ -302,15 +344,16 @@ async function runChunkSeal(ctx, args) {
302
344
  await journal({ chunkHeaders: [...headers] });
303
345
  if (uploadError)
304
346
  throw uploadError;
305
- while (pending.size >= UPLOAD_CONCURRENCY) {
306
- await Promise.race(pending.values());
347
+ while (pending.size >= uploadConcurrency) {
348
+ await waitForAbort(Promise.race(pending.values()), ctx.signal);
307
349
  if (uploadError)
308
350
  throw uploadError;
309
351
  }
310
352
  inFlightBytes += stored.length;
311
353
  heldAtMostBytes = Math.max(heldAtMostBytes, inFlightBytes);
312
354
  const task = (async () => {
313
- await ctx.client.putObject(`${args.folder}/${chunkObjectName(index)}`, stored);
355
+ await paceUpload(stored);
356
+ await ctx.client.putObject(`${args.folder}/${chunkObjectName(index)}`, stored, { signal: ctx.signal });
314
357
  completedUploads.add(index);
315
358
  while (completedUploads.has(watermark))
316
359
  watermark += 1;
@@ -330,27 +373,14 @@ async function runChunkSeal(ctx, args) {
330
373
  recipients, archiveId: args.archiveId, adapter: { agent: ctx.agent, version: ADAPTER_VERSION },
331
374
  suite: SEAL_SUITE, chunkBytes: ctx.chunkBytes, archiveKey: args.archiveKey, noncePrefix: args.noncePrefix,
332
375
  hashers, createdAt: args.createdAt,
333
- onPlaintext: (chunkIndex, slice) => {
334
- let bucket = tokensByChunk.get(chunkIndex);
335
- if (!bucket) {
336
- bucket = new Set();
337
- tokensByChunk.set(chunkIndex, bucket);
338
- }
339
- // Filtered and budgeted at collection: junk (hex digests, base64
340
- // runs) never takes a slot, and the budget scales with the archive —
341
- // the flat 4000 starved everything past chunk zero of a 1.7 GB seal.
342
- for (const token of tokenize(slice.toString("utf8"))) {
343
- if (bucket.size >= perChunkTokenCap)
344
- break;
345
- if (!indexableToken(token))
346
- continue;
347
- bucket.add(token);
348
- }
349
- }
376
+ onPlaintext: tokenCollector.onPlaintext,
377
+ ...(ctx.onYield ? { onYield: ctx.onYield } : {}),
378
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
350
379
  });
380
+ const tokensByChunk = tokenCollector.finish(ctx.totalChunks);
351
381
  // Drain the pool before anything downstream trusts the folder.
352
382
  while (pending.size > 0)
353
- await Promise.race(pending.values());
383
+ await waitForAbort(Promise.race(pending.values()), ctx.signal);
354
384
  await spoolChain;
355
385
  if (uploadError)
356
386
  throw uploadError;
@@ -361,15 +391,16 @@ async function runChunkSeal(ctx, args) {
361
391
  // readable hierarchy back; a provider holding the bucket alone gets noise.
362
392
  const envelope = sealed.envelope;
363
393
  const identity = sealIdentity(args.archiveKey, args.archiveId, {
364
- v: 1, project: args.project, sourcePath: ctx.absolute, agent: ctx.agent, createdAt: args.createdAt
394
+ v: 1, project: args.project, ...(ctx.projectKey ? { projectKey: ctx.projectKey } : {}), projectScope, sourcePath: ctx.absolute, agent: ctx.agent, createdAt: args.createdAt
365
395
  });
366
- await ctx.client.putObject(`${args.folder}/${ENVELOPE_OBJECT}`, frameObject(envelope, identity));
396
+ const sidecar = frameObject(envelope, identity);
397
+ await ctx.client.putObject(`${args.folder}/${ENVELOPE_OBJECT}`, sidecar, { signal: ctx.signal });
367
398
  // Verification, per chunk, in the provider's own answers: every chunk
368
399
  // present at its recorded size. Byte-level trust is the chunk hash in the
369
400
  // envelope — restore proves it, AEAD enforces it.
370
401
  const problems = [];
371
402
  for (let index = 0; index < envelope.chunks.length; index += 1) {
372
- const head = await ctx.client.headObject(`${args.folder}/${chunkObjectName(index)}`);
403
+ const head = await ctx.client.headObject(`${args.folder}/${chunkObjectName(index)}`, { signal: ctx.signal });
373
404
  if (!head.exists)
374
405
  problems.push(`chunk ${index} is not in the bucket`);
375
406
  else if (head.bytes !== envelope.chunks[index].storedBytes)
@@ -377,6 +408,13 @@ async function runChunkSeal(ctx, args) {
377
408
  if (problems.length >= 3)
378
409
  break;
379
410
  }
411
+ if (problems.length < 3) {
412
+ const sidecarHead = await ctx.client.headObject(`${args.folder}/${ENVELOPE_OBJECT}`, { signal: ctx.signal });
413
+ if (!sidecarHead.exists)
414
+ problems.push("the envelope sidecar is not in the bucket");
415
+ else if (sidecarHead.bytes !== sidecar.length)
416
+ problems.push(`the envelope sidecar is ${sidecarHead.bytes} bytes; ${sidecar.length} were sent`);
417
+ }
380
418
  if (problems.length > 0) {
381
419
  await recordAudit(ctx.dataDir, "upload.verify", "denied", { archiveId: args.archiveId, objectKey: args.folder, problems: problems.length });
382
420
  fail("ciphertext_integrity_failed", `Refusing to record the chunked archive as durable: ${problems.join("; ")}. The journal was kept; re-running resumes what is already there.`, { archiveId: args.archiveId, objectKey: args.folder });
@@ -386,32 +424,73 @@ async function runChunkSeal(ctx, args) {
386
424
  provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder,
387
425
  bytes: sealed.storedBytes, checksum: hashers.stored.digestBase64(), verifiedAt: now,
388
426
  layout: { kind: "chunks", chunkCount: envelope.chunks.length },
389
- ...(ctx.targetId ? { targetId: ctx.targetId } : {})
427
+ ...(ctx.targetId ? { targetId: ctx.targetId } : {}),
428
+ ...(ctx.destination && ctx.destinationFingerprint
429
+ ? { destination: ctx.destination, destinationFingerprint: ctx.destinationFingerprint }
430
+ : {})
390
431
  };
391
- const record = {
432
+ const completed = {
392
433
  version: 2, id: args.archiveId, createdAt: envelope.manifest.createdAt,
393
- source: { path: ctx.absolute, agent: ctx.agent, bytes: ctx.totalBytes, sha256: sealed.originalSha256 },
434
+ source: { path: ctx.absolute, agent: ctx.agent, bytes: ctx.totalBytes, sha256: sealed.originalSha256, ...(ctx.project ? { project: ctx.project } : {}), ...(ctx.projectKey ? { projectKey: ctx.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
394
435
  cipher: { algorithm: envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: envelope.chunks.length },
395
436
  envelope,
396
- objectPath: join(config.storage.root, `${args.archiveId}.vlarchive`),
437
+ objectPath: join(config.storage.root, `${args.archiveId}.skarchive`),
397
438
  remote,
398
439
  offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder }
399
440
  };
400
- await writeRecord(config.storage.root, record);
441
+ const record = await mutateArchiveRecord(config.storage.root, args.archiveId, (current) => {
442
+ if (!isV2(current))
443
+ fail("invalid_argument", `Archive ${args.archiveId} uses format v1 and cannot receive a streamed copy`, { archiveId: args.archiveId });
444
+ // Completion owns the sealed archive facts, while the latest record owns
445
+ // any bookkeeping another process added. Most importantly, do not spread
446
+ // `completed.remote` over the current copy set: append/promote makes this
447
+ // verified destination the legacy primary and retains every older copy.
448
+ const { remote: _remote, copies: _copies, ...completedFacts } = completed;
449
+ return appendArchiveCopy({ ...current, ...completedFacts }, remote, { makePrimary: true });
450
+ }, { initial: completed });
401
451
  await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId: args.archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: envelope.wrappedKeys.length, chunked: true, reused: args.reusedChunks });
402
452
  await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: args.archiveId, objectKey: args.folder, bytes: sealed.storedBytes, provider: ctx.remoteStorage.provider, chunked: true });
403
453
  await shredSpool(ctx.dataDir, args.archiveId);
404
454
  // The tokens collected during the seal become the archive's index entry —
405
- // search knows this session the moment the seal returns, and no machine
406
- // ever downloads it back just to read it. Advisory by design: an index
407
- // hiccup must never turn a durable archive into a failure.
455
+ // search knows this session the moment the seal returns, other machines
456
+ // learn it from the published index, and nobody downloads it back just to
457
+ // read it. First they are made durable beside the record: the merge below
458
+ // needs the whole index and the cloud, and when it failed (silently, for
459
+ // 2,300 seals in one week on a real vault) the tokens were lost with it and
460
+ // the archive was re-queued for a full decrypt. With the sidecar, a failed
461
+ // merge costs nothing: the next index pass merges it from the file. Both
462
+ // steps stay advisory — a hiccup must never turn a durable archive into a
463
+ // failure — but each is named, because "not indexed" used to be
464
+ // indistinguishable from "nothing there".
465
+ const failureName = (error) => {
466
+ const code = error?.code;
467
+ return typeof code === "string" ? code : error instanceof Error ? error.message.split("\n")[0] : String(error);
468
+ };
469
+ let preserved = false;
470
+ try {
471
+ const { writeArchiveTokenSidecar } = await import("./search.js");
472
+ await writeArchiveTokenSidecar(ctx.dataDir, ctx.phrase, args.archiveId, tokensByChunk);
473
+ preserved = true;
474
+ }
475
+ catch (error) {
476
+ const named = failureName(error);
477
+ process.emitWarning(`index tokens not preserved for ${args.archiveId.slice(0, 8)}: ${named}`, "SealkeepIndexDeferred");
478
+ await recordAudit(ctx.dataDir, "index.tokens", "denied", { archiveId: args.archiveId, reason: named }).catch(() => undefined);
479
+ }
408
480
  let indexed = false;
409
481
  try {
410
482
  const { indexArchiveTokens } = await import("./search.js");
411
- await indexArchiveTokens(ctx.dataDir, ctx.phrase, record, tokensByChunk);
483
+ await indexArchiveTokens(ctx.dataDir, ctx.phrase, record, tokensByChunk, {
484
+ signal: ctx.signal,
485
+ maxBytesPerSecond: ctx.maxBytesPerSecond,
486
+ });
412
487
  indexed = true;
413
488
  }
414
- catch { /* the next `index build` re-indexes this archive */ }
489
+ catch (error) {
490
+ const named = failureName(error);
491
+ process.emitWarning(`index merge deferred for ${args.archiveId.slice(0, 8)}: ${named}${preserved ? " — its tokens are preserved; the next index pass merges them without decrypting the archive" : ""}`, "SealkeepIndexDeferred");
492
+ await recordAudit(ctx.dataDir, "index.merge", "denied", { archiveId: args.archiveId, reason: named, preserved }).catch(() => undefined);
493
+ }
415
494
  // The account learns what each destination holds from the machines that
416
495
  // write to them — refresh the stamps now that this archive is recorded.
417
496
  {
@@ -442,7 +521,7 @@ async function resumeChunkFolder(ctx, journal) {
442
521
  let present = 0;
443
522
  const verifiedHeaders = [];
444
523
  while (present < journal.chunkHeaders.length) {
445
- const head = await ctx.client.headObject(`${journal.objectKey}/${chunkObjectName(present)}`);
524
+ const head = await ctx.client.headObject(`${journal.objectKey}/${chunkObjectName(present)}`, { signal: ctx.signal });
446
525
  if (!head.exists)
447
526
  break;
448
527
  if (head.bytes !== journal.chunkHeaders[present].storedBytes) {
@@ -456,7 +535,7 @@ async function resumeChunkFolder(ctx, journal) {
456
535
  await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: journal.archiveId, objectKey: journal.objectKey, resumedAtChunk: present });
457
536
  return await runChunkSeal(ctx, {
458
537
  archiveId: journal.archiveId, createdAt: journal.createdAt, folder: journal.objectKey,
459
- archiveKey, noncePrefix, project: null, startAt: present, verifiedHeaders, reusedChunks: present
538
+ archiveKey, noncePrefix, project: ctx.project ?? null, startAt: present, verifiedHeaders, reusedChunks: present
460
539
  });
461
540
  }
462
541
  finally {
@@ -487,20 +566,37 @@ export function openIdentity(archiveKey, archiveId, blob) {
487
566
  return fail("invalid_argument", "The sidecar's identity does not open for this archive — wrong key, or a sidecar moved between folders.");
488
567
  }
489
568
  }
490
- /** Fetches a chunked remote back into one ciphertext buffer, hash-checked per chunk. */
491
- export async function fetchChunkFolder(client, remote, expected) {
569
+ /** Fetches a chunked remote one bounded object at a time, hash-checking before yield. */
570
+ export async function* fetchChunkFolderStream(client, remote, expected, options = {}) {
492
571
  if (remote.layout?.kind !== "chunks")
493
572
  fail("invalid_argument", "This remote is not a chunk folder");
494
- const parts = [];
573
+ // The record's headers are the authority on what this archive is made of, not
574
+ // the bucket's own count. Trusting the remote count meant any chunk past the
575
+ // end of the recorded headers had no hash to check against and was folded
576
+ // into the ciphertext unverified — "every chunk is hash-checked" has to mean
577
+ // every chunk, or it is not a guarantee at all.
578
+ if (expected.chunks.length !== remote.layout.chunkCount) {
579
+ fail("ciphertext_integrity_failed", `This archive records ${expected.chunks.length} chunks but the bucket holds ${remote.layout.chunkCount}`, { objectKey: remote.objectKey });
580
+ }
495
581
  for (let index = 0; index < remote.layout.chunkCount; index += 1) {
496
- const body = await client.getObject(`${remote.objectKey}/${chunkObjectName(index)}`);
582
+ options.signal?.throwIfAborted();
583
+ const body = await client.getObject(`${remote.objectKey}/${chunkObjectName(index)}`, { signal: options.signal });
497
584
  const header = expected.chunks[index];
498
- if (header && sha256hex(body) !== header.sha256) {
585
+ if (!header || sha256hex(body) !== header.sha256) {
499
586
  fail("ciphertext_integrity_failed", `Chunk ${index} fetched from the bucket does not match its recorded hash`, { objectKey: remote.objectKey });
500
587
  }
501
- parts.push(body);
588
+ yield body;
589
+ }
590
+ }
591
+ /** Compatibility API for callers that explicitly need the complete Buffer. */
592
+ export async function fetchChunkFolder(client, remote, expected) {
593
+ const parts = [];
594
+ let bytes = 0;
595
+ for await (const part of fetchChunkFolderStream(client, remote, expected)) {
596
+ parts.push(part);
597
+ bytes += part.length;
502
598
  }
503
- return Buffer.concat(parts);
599
+ return Buffer.concat(parts, bytes);
504
600
  }
505
601
  /** Deletes a chunked remote: every chunk object plus the envelope sidecar. */
506
602
  export async function deleteChunkFolder(client, remote) {