sealkeep 0.8.1 → 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 (275) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +244 -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 +1746 -166
  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.d.ts +1 -0
  150. package/dist/src/migrate.js +33 -5
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5897 -290
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +235 -29
  249. package/dist/src/vault.js +1783 -190
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  275. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { fail } from "../errors.js";
3
3
  import { presign } from "./sigv4.js";
4
+ import { collectChunks, responseBodyChunks } from "../byte-stream.js";
4
5
  export const DEFAULT_PART_BYTES = 8 * 1024 * 1024;
5
6
  export const MIN_PART_BYTES = 5 * 1024 * 1024;
6
7
  const sha256b64 = (body) => createHash("sha256").update(body).digest("base64");
@@ -88,14 +89,16 @@ export class S3UploadClient {
88
89
  endpoint;
89
90
  credentials;
90
91
  partBytes;
92
+ fetchImpl;
91
93
  constructor(kind, endpoint, credentials, partBytes = DEFAULT_PART_BYTES,
92
94
  /** S3 requires every part except the last to be at least 5 MiB. Local emulators and
93
95
  * MinIO do not, so this is the deliberate opt-out for those targets only. */
94
- allowSmallParts = false) {
96
+ allowSmallParts = false, fetchImpl = fetch) {
95
97
  this.kind = kind;
96
98
  this.endpoint = endpoint;
97
99
  this.credentials = credentials;
98
100
  this.partBytes = partBytes;
101
+ this.fetchImpl = fetchImpl;
99
102
  if (partBytes < MIN_PART_BYTES && !allowSmallParts)
100
103
  fail("invalid_argument", `S3 multipart parts must be at least ${MIN_PART_BYTES} bytes`, { partBytes });
101
104
  if (partBytes <= 0)
@@ -105,10 +108,10 @@ export class S3UploadClient {
105
108
  const { host, path } = target(this.endpoint, key);
106
109
  return presign({ method, host, port: this.endpoint.port, protocol: this.endpoint.protocol, path, region: this.endpoint.region, credentials: this.credentials, query, headers, expiresInSeconds });
107
110
  }
108
- async put(key, body, query = {}) {
111
+ async put(key, body, query = {}, signal) {
109
112
  const checksum = sha256b64(body);
110
113
  const { url } = this.sign("PUT", key, query, { "x-amz-checksum-sha256": checksum });
111
- const response = await fetch(url, { method: "PUT", body: new Uint8Array(body), headers: { "x-amz-checksum-sha256": checksum } });
114
+ const response = await this.fetchImpl(url, { method: "PUT", body: new Uint8Array(body), headers: { "x-amz-checksum-sha256": checksum }, signal });
112
115
  if (!response.ok)
113
116
  fail("internal", `Upload failed for ${key}: ${await readError(response)}`, { key, status: response.status });
114
117
  return { etag: (response.headers.get("etag") ?? "").replace(/"/g, ""), checksum };
@@ -119,44 +122,51 @@ export class S3UploadClient {
119
122
  * a plain checksummed PUT does everything multipart machinery did — which
120
123
  * is exactly why the chunk layout needs none of it.
121
124
  */
122
- async putObject(key, body) {
123
- const { checksum } = await this.put(key, body);
125
+ async putObject(key, body, options = {}) {
126
+ const { checksum } = await this.put(key, body, {}, options.signal);
124
127
  return { checksum };
125
128
  }
126
- async headObject(key) {
129
+ async headObject(key, options = {}) {
127
130
  const { url } = this.sign("HEAD", key, {}, { "x-amz-checksum-mode": "ENABLED" });
128
- const response = await fetch(url, { method: "HEAD", headers: { "x-amz-checksum-mode": "ENABLED" } });
131
+ const response = await this.fetchImpl(url, { method: "HEAD", headers: { "x-amz-checksum-mode": "ENABLED" }, signal: options.signal });
129
132
  if (response.status === 404)
130
133
  return { exists: false, bytes: 0 };
131
134
  if (!response.ok)
132
135
  fail("internal", `Could not read ${key}: ${response.status} ${response.statusText}`, { key, status: response.status });
133
136
  return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: response.headers.get("x-amz-checksum-sha256") ?? undefined };
134
137
  }
135
- async getObject(key) {
138
+ async getObject(key, options = {}) {
139
+ return collectChunks(await this.downloadKeyStream(key, options.signal), Number.MAX_SAFE_INTEGER);
140
+ }
141
+ /** Object-key streaming counterpart used by the background index store. */
142
+ async getObjectStream(key, options = {}) {
143
+ return this.downloadKeyStream(key, options.signal);
144
+ }
145
+ async downloadKeyStream(key, signal) {
136
146
  const { url } = this.sign("GET", key, {});
137
- const response = await fetch(url);
147
+ const response = await this.fetchImpl(url, { signal });
138
148
  if (!response.ok)
139
149
  fail("internal", `Could not download ${key}: ${await readError(response)}`, { key, status: response.status });
140
- return Buffer.from(await response.arrayBuffer());
150
+ return responseBodyChunks(response);
141
151
  }
142
152
  async deleteObject(key) {
143
153
  const { url } = this.sign("DELETE", key, {});
144
- const response = await fetch(url, { method: "DELETE" });
154
+ const response = await this.fetchImpl(url, { method: "DELETE" });
145
155
  if (!response.ok && response.status !== 404)
146
156
  fail("internal", `Could not delete ${key}: ${await readError(response)}`, { key, status: response.status });
147
157
  }
148
- async initiateMultipart(key) {
158
+ async initiateMultipart(key, signal) {
149
159
  const { url } = this.sign("POST", key, { uploads: "" });
150
- const response = await fetch(url, { method: "POST" });
160
+ const response = await this.fetchImpl(url, { method: "POST", signal });
151
161
  if (!response.ok)
152
162
  fail("internal", `Could not start a multipart upload for ${key}: ${await readError(response)}`, { key });
153
163
  const uploadId = xmlValue(await response.text(), "UploadId");
154
164
  return uploadId ?? fail("internal", `Multipart initiation for ${key} returned no UploadId`, { key });
155
165
  }
156
- async completeMultipart(key, uploadId, parts) {
166
+ async completeMultipart(key, uploadId, parts, signal) {
157
167
  const body = Buffer.from(`<CompleteMultipartUpload>${parts.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>"${part.etag}"</ETag><ChecksumSHA256>${part.checksum}</ChecksumSHA256></Part>`).join("")}</CompleteMultipartUpload>`);
158
168
  const { url } = this.sign("POST", key, { uploadId });
159
- const response = await fetch(url, { method: "POST", body: new Uint8Array(body) });
169
+ const response = await this.fetchImpl(url, { method: "POST", body: new Uint8Array(body), signal });
160
170
  if (!response.ok)
161
171
  fail("internal", `Could not complete the multipart upload for ${key}: ${await readError(response)}`, { key });
162
172
  const text = await response.text();
@@ -173,7 +183,7 @@ export class S3UploadClient {
173
183
  */
174
184
  async listParts(key, uploadId) {
175
185
  const { url } = this.sign("GET", key, { uploadId });
176
- const response = await fetch(url);
186
+ const response = await this.fetchImpl(url);
177
187
  if (!response.ok)
178
188
  fail("internal", `Cannot resume this upload: the provider would not list its parts (${await readError(response)})`, { key, uploadId });
179
189
  const text = await response.text();
@@ -190,9 +200,9 @@ export class S3UploadClient {
190
200
  parts.sort((a, b) => a.partNumber - b.partNumber);
191
201
  return parts;
192
202
  }
193
- async abortMultipart(key, uploadId) {
203
+ async abortMultipart(key, uploadId, signal) {
194
204
  const { url } = this.sign("DELETE", key, { uploadId });
195
- await fetch(url, { method: "DELETE" }).catch(() => undefined);
205
+ await this.fetchImpl(url, { method: "DELETE", signal }).catch(() => undefined);
196
206
  }
197
207
  /** Uploads ciphertext and returns the checksum the provider confirmed. */
198
208
  async upload(lease, ciphertext) {
@@ -270,6 +280,7 @@ export class S3UploadClient {
270
280
  };
271
281
  try {
272
282
  for await (const chunk of chunks) {
283
+ options.signal?.throwIfAborted();
273
284
  if (completionOnly && chunk.length > 0) {
274
285
  fail("invalid_argument", "Cannot resume this upload with more data: the seeded parts already end in the object's final short part, so the only thing left is completion.", { key });
275
286
  }
@@ -281,8 +292,8 @@ export class S3UploadClient {
281
292
  const part = buffered.subarray(0, this.partBytes);
282
293
  held = part.length < buffered.length ? [buffered.subarray(this.partBytes)] : [];
283
294
  heldBytes = buffered.length - part.length;
284
- uploadId = uploadId ?? await this.initiateMultipart(key);
285
- const { etag, checksum } = await this.put(key, part, { partNumber: String(parts.length + 1), uploadId });
295
+ uploadId = uploadId ?? await this.initiateMultipart(key, options.signal);
296
+ const { etag, checksum } = await this.put(key, part, { partNumber: String(parts.length + 1), uploadId }, options.signal);
286
297
  parts.push({ partNumber: parts.length + 1, etag, checksum, bytes: part.length });
287
298
  await journal(uploadId);
288
299
  }
@@ -292,15 +303,15 @@ export class S3UploadClient {
292
303
  // The whole object fit inside one part: a single PUT stores it with a
293
304
  // whole-object checksum, exactly like the buffered small-object path.
294
305
  // (Never reached on resume — a seeded uploadId always exists there.)
295
- const { checksum } = await this.put(key, tail);
306
+ const { checksum } = await this.put(key, tail, {}, options.signal);
296
307
  return { remoteChecksum: checksum, bytes: total };
297
308
  }
298
309
  if (tail.length > 0 || parts.length === 0) {
299
- const { etag, checksum } = await this.put(key, tail, { partNumber: String(parts.length + 1), uploadId });
310
+ const { etag, checksum } = await this.put(key, tail, { partNumber: String(parts.length + 1), uploadId }, options.signal);
300
311
  parts.push({ partNumber: parts.length + 1, etag, checksum, bytes: tail.length });
301
312
  await journal(uploadId);
302
313
  }
303
- await this.completeMultipart(key, uploadId, parts);
314
+ await this.completeMultipart(key, uploadId, parts, options.signal);
304
315
  return { remoteChecksum: compositeChecksum(parts.map((part) => part.checksum)), bytes: total };
305
316
  }
306
317
  catch (error) {
@@ -317,12 +328,12 @@ export class S3UploadClient {
317
328
  * GET with the short-lived credential it was vended, exactly as it does for
318
329
  * the upload.
319
330
  */
320
- async download(lease) {
321
- const { url } = this.sign("GET", lease.objectKey, {});
322
- const response = await fetch(url, { method: "GET" });
323
- if (!response.ok)
324
- fail("internal", `Could not read ${lease.objectKey}: ${await readError(response)}`, { key: lease.objectKey, status: response.status });
325
- return Buffer.from(await response.arrayBuffer());
331
+ async download(lease, options = {}) {
332
+ return collectChunks(await this.downloadStream(lease, options), Number.MAX_SAFE_INTEGER);
333
+ }
334
+ /** The production restore/transfer door: response chunks flow straight to disk. */
335
+ async downloadStream(lease, options = {}) {
336
+ return this.downloadKeyStream(lease.objectKey, options.signal);
326
337
  }
327
338
  /**
328
339
  * Confirms the object exists remotely and reports what the provider stored.
@@ -335,7 +346,7 @@ export class S3UploadClient {
335
346
  async head(lease) {
336
347
  const mode = { "x-amz-checksum-mode": "ENABLED" };
337
348
  const { url } = this.sign("HEAD", lease.objectKey, {}, mode);
338
- const response = await fetch(url, { method: "HEAD", headers: mode });
349
+ const response = await this.fetchImpl(url, { method: "HEAD", headers: mode });
339
350
  if (response.status === 404)
340
351
  return { exists: false, bytes: 0 };
341
352
  if (!response.ok)
@@ -0,0 +1,12 @@
1
+ import { type StorageAddressResolver } from "../storage-endpoint.js";
2
+ export type StorageFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
3
+ /**
4
+ * Fetches only from one already-parsed storage origin. DNS policy executes in
5
+ * Undici's connector lookup, so the addresses it approves are the exact ones
6
+ * handed to the socket. The URL hostname remains untouched: signed Host,
7
+ * HTTPS SNI, and certificate hostname verification continue to use it.
8
+ */
9
+ export declare function createPinnedStorageFetch(endpoint: string | URL, options?: {
10
+ allowPrivate?: boolean;
11
+ resolver?: StorageAddressResolver;
12
+ }): StorageFetch;
@@ -0,0 +1,72 @@
1
+ import { isIP } from "node:net";
2
+ import { Agent, fetch as undiciFetch } from "undici";
3
+ import { fail } from "../errors.js";
4
+ import { allowedStorageAddresses, parseStorageEndpoint, systemStorageAddressResolver, } from "../storage-endpoint.js";
5
+ function guardedLookup(allowPrivate, resolver) {
6
+ return (hostname, options, callback) => {
7
+ void allowedStorageAddresses(hostname, { allowPrivate, resolver }).then((answers) => {
8
+ const family = options.family === 4 || options.family === 6 ? options.family : 0;
9
+ const eligible = family ? answers.filter((answer) => answer.family === family) : answers;
10
+ if (eligible.length === 0) {
11
+ const error = new Error(`Storage endpoint ${hostname} has no address in the requested network family.`);
12
+ error.code = "EAI_ADDRFAMILY";
13
+ callback(error, "", 0);
14
+ return;
15
+ }
16
+ if (options.all)
17
+ callback(null, eligible);
18
+ else
19
+ callback(null, eligible[0].address, eligible[0].family);
20
+ }, (error) => callback(error instanceof Error ? error : new Error("Storage endpoint DNS validation failed."), "", 0));
21
+ };
22
+ }
23
+ const dispatchers = new WeakMap();
24
+ function dispatcherFor(resolver, allowPrivate) {
25
+ let pair = dispatchers.get(resolver);
26
+ if (!pair) {
27
+ pair = {
28
+ public: new Agent({ connect: { lookup: guardedLookup(false, resolver) } }),
29
+ private: new Agent({ connect: { lookup: guardedLookup(true, resolver) } }),
30
+ };
31
+ dispatchers.set(resolver, pair);
32
+ }
33
+ return allowPrivate ? pair.private : pair.public;
34
+ }
35
+ /**
36
+ * Fetches only from one already-parsed storage origin. DNS policy executes in
37
+ * Undici's connector lookup, so the addresses it approves are the exact ones
38
+ * handed to the socket. The URL hostname remains untouched: signed Host,
39
+ * HTTPS SNI, and certificate hostname verification continue to use it.
40
+ */
41
+ export function createPinnedStorageFetch(endpoint, options = {}) {
42
+ const expected = parseStorageEndpoint(String(endpoint));
43
+ const allowPrivate = options.allowPrivate === true;
44
+ const resolver = options.resolver ?? systemStorageAddressResolver;
45
+ const dispatcher = dispatcherFor(resolver, allowPrivate);
46
+ return async (input, init = {}) => {
47
+ let requested;
48
+ try {
49
+ requested = input instanceof URL ? new URL(input.href) : new URL(input);
50
+ }
51
+ catch {
52
+ return fail("invalid_argument", "Storage request did not contain a valid URL.");
53
+ }
54
+ if (requested.origin !== expected.origin) {
55
+ fail("invalid_argument", "Storage request tried to leave its configured endpoint; cross-origin storage requests are refused.");
56
+ }
57
+ // net.connect may bypass lookup for a literal. Apply the same rule before
58
+ // Undici can open that socket.
59
+ const literal = requested.hostname.toLowerCase().replace(/^\[|\]$/g, "");
60
+ if (isIP(literal))
61
+ await allowedStorageAddresses(literal, { allowPrivate, resolver });
62
+ const requestInit = {
63
+ ...init,
64
+ // A provider redirect can otherwise escape the origin check internally.
65
+ // Signed storage endpoints should already be final; fail closed on 3xx.
66
+ redirect: "manual",
67
+ dispatcher,
68
+ };
69
+ const response = await undiciFetch(requested, requestInit);
70
+ return response;
71
+ };
72
+ }
@@ -46,13 +46,29 @@ export type EnqueueRequest = {
46
46
  event: string;
47
47
  sessionId?: string;
48
48
  };
49
+ export type EnqueueResult = {
50
+ job: ArchiveJob;
51
+ deduped: boolean;
52
+ superseded: string[];
53
+ };
54
+ export type BatchEnqueueOutcome = ({
55
+ ok: true;
56
+ request: EnqueueRequest;
57
+ } & EnqueueResult) | {
58
+ ok: false;
59
+ request: EnqueueRequest;
60
+ error: unknown;
61
+ };
49
62
  export type QueueOptions = {
50
63
  now?: () => number;
51
64
  leaseMs?: number;
52
65
  maxAttempts?: number;
53
66
  backoffMs?: number;
54
67
  maxBackoffMs?: number;
68
+ /** Real-time bound for one tiny queue-record state transition. */
69
+ lockAcquireMs?: number;
55
70
  };
71
+ export type ClaimEligibility = (job: ArchiveJob) => boolean;
56
72
  export declare function queueDir(dataDir: string): string;
57
73
  /**
58
74
  * Two lifecycle events describing the same unchanged transcript produce the same
@@ -76,14 +92,53 @@ export declare class ArchiveQueue {
76
92
  private readonly maxAttempts;
77
93
  private readonly backoffMs;
78
94
  private readonly maxBackoffMs;
95
+ private readonly lockAcquireMs;
96
+ /**
97
+ * `hasClaimable` already opened and parsed this record. Keep that answer for
98
+ * the immediately following claim so a mature queue does not enumerate and
99
+ * parse every completed job a second time merely to lease the work it just
100
+ * found. The hint is deliberately in-memory: the job file remains the
101
+ * durable source of truth and is revalidated before a lease is written.
102
+ */
103
+ private claimableHint?;
104
+ /** One startup reconciliation absorbs queue files written by older builds. */
105
+ private reconciledBacklog;
79
106
  constructor(dataDir: string, options?: QueueOptions);
80
107
  private path;
108
+ private lockPath;
81
109
  private stamp;
110
+ /**
111
+ * Serialises one job's read-check-write transition across every Sealkeep
112
+ * process on this machine. Atomic rename protects readers from partial JSON,
113
+ * but it is not compare-and-swap: without this lock, a delayed renewal can
114
+ * overwrite a newer worker's reclaimed lease, and a delayed progress write
115
+ * can resurrect a job after completion.
116
+ */
117
+ private withJobLock;
82
118
  private writeAtomic;
83
119
  /** Creates the file only if the id is unseen; the content is complete before it becomes visible. */
84
120
  private createExclusive;
85
121
  get(id: string): Promise<ArchiveJob | null>;
86
122
  private require;
123
+ private readQueueDirectory;
124
+ private readQueueFile;
125
+ private scanJobs;
126
+ /**
127
+ * Atomic job replacement changes the queue directory's mtime/ctime. Reading
128
+ * those at nanosecond precision gives all Sealkeep processes a cheap durable
129
+ * generation check: sequential dashboard endpoints can reuse parsed jobs,
130
+ * while a hook/worker in another process invalidates that snapshot merely by
131
+ * committing its ordinary rename.
132
+ */
133
+ private directoryGeneration;
134
+ private startSharedScan;
135
+ private readJobsSnapshot;
136
+ /**
137
+ * Returns rows only when one durable directory generation covered the whole
138
+ * scan. `enqueueMany` uses this as a read-only fast path for settled ids; an
139
+ * unstable snapshot is never evidence that a job already exists.
140
+ */
141
+ private stableJobsSnapshot;
87
142
  list(filter?: {
88
143
  status?: JobStatus;
89
144
  }): Promise<ArchiveJob[]>;
@@ -92,16 +147,29 @@ export declare class ArchiveQueue {
92
147
  ready: number;
93
148
  leased: number;
94
149
  done: number;
150
+ completed: number;
151
+ superseded: number;
95
152
  failed: number;
96
153
  }>;
154
+ /**
155
+ * Answers the worker's startup question from the same generation-validated
156
+ * snapshot used by the local API. A mature vault can have thousands of
157
+ * completed job files; independently reading every one merely to learn that
158
+ * one ready job exists made preservation compete with the status UI.
159
+ *
160
+ * Expired leases count too: after a crash they are work the next worker can
161
+ * reclaim, even though a plain `stats().ready` check would call the queue
162
+ * empty and wait for discovery first.
163
+ */
164
+ hasClaimable(eligible?: ClaimEligibility): Promise<boolean>;
97
165
  /**
98
166
  * Whether `candidate` describes a later state of the transcript than `over`.
99
167
  *
100
168
  * A total order over snapshots of one file, so two hook processes enqueueing
101
169
  * at once reach the same verdict and the most advanced snapshot is never the
102
- * one superseded. Bytes lead because a transcript that has grown has strictly
103
- * more in it; mtime settles a same-length rewrite. Equal on all three means
104
- * the same content address, which is the same job.
170
+ * one superseded. Modification time leads because compaction can make a later
171
+ * transcript smaller; byte length settles the rare same-time observation.
172
+ * Equal on both means the same content address, which is the same job.
105
173
  */
106
174
  private advances;
107
175
  /**
@@ -117,16 +185,46 @@ export declare class ArchiveQueue {
117
185
  *
118
186
  * Nothing is lost by dropping the earlier job: the bytes it would have
119
187
  * archived are the bytes still on disk, and the surviving job archives those.
120
- * Only `ready` jobs are retired — a leased job may already be mid-archive, and
121
- * a done or failed job is the record of something that actually happened.
188
+ * Ready jobs and abandoned expired leases may be retired; an active lease may
189
+ * already be mid-archive, and a done or failed job records something that
190
+ * actually happened.
122
191
  */
123
- private supersede;
192
+ private sourceKey;
193
+ /**
194
+ * Retires older ready snapshots for a whole discovery pass with one queue read.
195
+ *
196
+ * `enqueue()` used to call `list()` once per discovered transcript. A startup
197
+ * scan of N transcripts against Q queue records therefore parsed N*Q JSON
198
+ * files before it could seal anything. All prospective jobs are made visible
199
+ * first, then this method indexes the queue once and applies the same
200
+ * source-local ordering rule.
201
+ *
202
+ * A hook in another process remains safe: job creation is still exclusive. A
203
+ * hook that lands before this snapshot is included here; one that lands after
204
+ * it sees every batch job in its own reconciliation. Re-read before writing so
205
+ * a job claimed by a worker since the snapshot is never knowingly retired.
206
+ */
207
+ private supersedeMany;
208
+ /**
209
+ * Settles obsolete observations after a worker archives the live path.
210
+ *
211
+ * Queue rows contain metadata and a path, not a private copy of those old
212
+ * bytes. If an older row was already leased when discovery observed a newer
213
+ * state, that worker reads the newer live state and its completion covers
214
+ * the newer ready row too. Active *other* leases remain untouched; they may
215
+ * already have captured their own bounded snapshot.
216
+ */
217
+ private settleCoveredSourceSnapshots;
218
+ private describe;
219
+ private persist;
124
220
  /** Records intent to archive a transcript. It never reads transcript contents. */
125
- enqueue(request: EnqueueRequest): Promise<{
126
- job: ArchiveJob;
127
- deduped: boolean;
128
- superseded: string[];
129
- }>;
221
+ enqueue(request: EnqueueRequest): Promise<EnqueueResult>;
222
+ /**
223
+ * Batch form used by startup/rescan discovery. Individual unreadable sources
224
+ * are reported without aborting the rest of the pass, matching the watcher's
225
+ * previous per-file failure isolation.
226
+ */
227
+ enqueueMany(requests: readonly EnqueueRequest[]): Promise<BatchEnqueueOutcome[]>;
130
228
  private claimable;
131
229
  /** Claims from this instance run one at a time; see `claim`. */
132
230
  private claimTurn;
@@ -140,18 +238,38 @@ export declare class ArchiveQueue {
140
238
  * several workers against the same instance, and two claims listing the
141
239
  * directory at the same moment would pick the same candidate and both write
142
240
  * a lease on it — the loser then failing its own job with a lease error.
143
- * Serialising costs nothing next to the seal that follows, and cross-process
144
- * claims keep the protection they always had: whoever renames second wins,
145
- * and archiving is idempotent by source bytes.
241
+ * Serialising costs nothing next to the seal that follows. The per-job
242
+ * filesystem lock inside `claimNext` extends that same exclusivity across
243
+ * hook, daemon and manual-command processes; atomic rename alone cannot act
244
+ * as compare-and-swap and previously let both callers believe they won.
146
245
  */
147
- claim(owner: string, excluding?: ReadonlySet<string>): Promise<ArchiveJob | null>;
246
+ claim(owner: string, excluding?: ReadonlySet<string>, eligible?: ClaimEligibility): Promise<ArchiveJob | null>;
148
247
  private claimNext;
248
+ private assertLeaseOwner;
149
249
  private assertLease;
250
+ /**
251
+ * How often a worker should prove it is still alive while holding a lease.
252
+ * Kept below one third of the lease, with a one-minute ceiling for the
253
+ * ordinary five-minute lease. Tests and small embedded queues can shorten
254
+ * the lease without having to guess a separate heartbeat setting.
255
+ */
256
+ leaseHeartbeatMs(): number;
257
+ /**
258
+ * Extends an unchanged lease without changing its owner, attempt count or
259
+ * original acquisition time. A delayed event loop may reach this write just
260
+ * after the deadline. The per-job lock makes that safe: if another worker
261
+ * reclaimed first, its new lease id wins and this renewal is refused; if the
262
+ * id is still ours, nobody took the work and renewal prevents a false orphan.
263
+ */
264
+ renew(id: string, leaseId: string): Promise<ArchiveJob>;
150
265
  complete(id: string, leaseId: string, result: {
151
266
  archiveId: string;
152
267
  ciphertextSha256: string;
153
268
  bytes: number;
154
269
  deduplicated?: boolean;
270
+ }, sourceSnapshot?: {
271
+ bytes: number;
272
+ modifiedAt: string;
155
273
  }): Promise<ArchiveJob>;
156
274
  /**
157
275
  * Puts a claimed job back without spending an attempt.
@@ -165,10 +283,9 @@ export declare class ArchiveQueue {
165
283
  /** Progress on the job being worked — visible, not merely true. */
166
284
  progress(id: string, leaseId: string, bytes: number, of: number): Promise<void>;
167
285
  /**
168
- * Takes a waiting job back out of the queue. Only `ready` and `failed` jobs
169
- * can go: a leased one is being sealed this moment and will finish or fail
170
- * on its own, and a done one is not in the queue anymore — it is an archive,
171
- * and removing that is a different, bigger decision made elsewhere.
286
+ * Takes a waiting job back out of the queue. Ready, failed, and expired-lease
287
+ * jobs can go. An unexpired lease is still owned by a worker and must finish
288
+ * or fail on its own; a done row is an archive and belongs to the vault.
172
289
  */
173
290
  remove(id: string): Promise<ArchiveJob>;
174
291
  release(id: string, leaseId: string, reason?: {