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,8 +1,9 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
- import { fail, VaultlineError } from "../errors.js";
4
+ import { fail, SealkeepError } from "../errors.js";
5
5
  import { envVar } from "../env.js";
6
+ import { collectChunks, oneChunk, responseBodyChunks } from "../byte-stream.js";
6
7
  /**
7
8
  * Google Drive as a bring-your-own storage provider (Drive API v3).
8
9
  *
@@ -21,7 +22,7 @@ import { envVar } from "../env.js";
21
22
  */
22
23
  export const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
23
24
  /** Placeholder until a first-party client id ships. Point SEALKEEP_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
24
- export const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
25
+ export const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-sealkeep-placeholder.apps.googleusercontent.com";
25
26
  export function gdriveClientId(env = process.env) {
26
27
  return envVar("GDRIVE_CLIENT_ID", env)?.trim() || GDRIVE_CLIENT_ID_PLACEHOLDER;
27
28
  }
@@ -69,6 +70,34 @@ export async function exchangeCodeForTokens(input) {
69
70
  ?? fail("unauthorized", "Google returned no refresh token. Remove Sealkeep at https://myaccount.google.com/permissions and connect again.");
70
71
  return { refreshToken, accessToken: data.access_token ?? "", expiresIn: data.expires_in ?? 0 };
71
72
  }
73
+ /**
74
+ * Resolve the stable Drive identity represented by an access token.
75
+ *
76
+ * `about.get(fields=user(permissionId))` is available under the existing
77
+ * `drive.file` grant. A permission id is an opaque grantee identifier, not a
78
+ * credential and not personally-readable account metadata, so it is safe to
79
+ * retain beside the refresh token and include in destination fingerprints.
80
+ */
81
+ export async function gdriveAccountId(accessToken, options = {}) {
82
+ const token = accessToken.trim();
83
+ if (!token)
84
+ fail("unauthorized", "Google returned no access token, so the connected Drive account could not be identified.");
85
+ const url = new URL("/drive/v3/about", options.apiBase ?? GDRIVE_API_BASE);
86
+ url.searchParams.set("fields", "user(permissionId)");
87
+ const response = await (options.fetchImpl ?? fetch)(url, {
88
+ headers: { authorization: `Bearer ${token}` },
89
+ });
90
+ if (!response.ok) {
91
+ const code = response.status === 401 || response.status === 403 ? "unauthorized" : "internal";
92
+ fail(code, `Google Drive could not identify the connected account: ${response.status} ${response.statusText}`, { status: response.status });
93
+ }
94
+ const data = await response.json();
95
+ const accountId = typeof data.user?.permissionId === "string" ? data.user.permissionId.trim() : "";
96
+ if (!accountId || accountId.length > 512 || /[\u0000-\u001f\u007f]/.test(accountId)) {
97
+ fail("unauthorized", "Google Drive returned no usable account identity. Disconnect and reconnect the Drive target.");
98
+ }
99
+ return accountId;
100
+ }
72
101
  /**
73
102
  * Fire-and-forget platform opener. Failing to open a browser is never failing
74
103
  * to connect — the consent URL is reported to the caller either way — so this
@@ -108,7 +137,7 @@ export async function connectGdrive(options = {}) {
108
137
  const consentUrl = gdriveConsentUrl({ clientId, redirectUri, challenge, state, authUrl: options.authUrl });
109
138
  let timer;
110
139
  const code = new Promise((resolve, reject) => {
111
- timer = setTimeout(() => reject(new VaultlineError("unauthorized", `No response from the browser within ${Math.round((options.timeoutMs ?? 300_000) / 1000)}s. Run the connect command again.`)), options.timeoutMs ?? 300_000);
140
+ timer = setTimeout(() => reject(new SealkeepError("unauthorized", `No response from the browser within ${Math.round((options.timeoutMs ?? 300_000) / 1000)}s. Run the connect command again.`)), options.timeoutMs ?? 300_000);
112
141
  server.on("request", (request, response) => {
113
142
  const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
114
143
  if (url.pathname !== "/callback") {
@@ -119,7 +148,7 @@ export async function connectGdrive(options = {}) {
119
148
  const deny = (message) => {
120
149
  response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
121
150
  response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
122
- reject(new VaultlineError("unauthorized", message));
151
+ reject(new SealkeepError("unauthorized", message));
123
152
  };
124
153
  if (url.searchParams.get("error"))
125
154
  return deny(`Google reported: ${url.searchParams.get("error")}`);
@@ -145,7 +174,8 @@ export async function connectGdrive(options = {}) {
145
174
  await Promise.resolve((options.opener ?? launchBrowser)(consentUrl)).catch(() => { });
146
175
  const authorizationCode = await code;
147
176
  const tokens = await exchangeCodeForTokens({ code: authorizationCode, verifier, clientId, redirectUri, tokenUrl: options.tokenUrl, fetchImpl: options.fetchImpl });
148
- return { kind: "gdrive", refreshToken: tokens.refreshToken, clientId };
177
+ const accountId = await (options.accountIdResolver ?? gdriveAccountId)(tokens.accessToken);
178
+ return { kind: "gdrive", refreshToken: tokens.refreshToken, clientId, accountId };
149
179
  }
150
180
  finally {
151
181
  if (timer)
@@ -159,7 +189,7 @@ const FOLDER_MIME = "application/vnd.google-apps.folder";
159
189
  /**
160
190
  * Drive upload client.
161
191
  *
162
- * A lease's objectKey (e.g. `vaultline/<id>.vlarchive`) has no path meaning in
192
+ * A lease's objectKey (e.g. `vaultline/<id>.skarchive`) has no path meaning in
163
193
  * Drive, so the mapping is: files live flat in one folder (or the hidden
164
194
  * appDataFolder space), named by the key's basename, and each carries
165
195
  * `appProperties.vaultlineKey = <full objectKey>`. head/download locate by that
@@ -198,7 +228,7 @@ export class GdriveUploadClient {
198
228
  tokenUrl() { return this.endpoint.host ? `${this.base()}/token` : GDRIVE_TOKEN_URL; }
199
229
  space() { return this.endpoint.appData ? "appDataFolder" : "drive"; }
200
230
  /** Access token minted from the refresh token, cached until shortly before expiry so it never dies mid-chunk. */
201
- async token() {
231
+ async token(signal) {
202
232
  if (this.accessToken && this.now() < this.accessToken.expiresAt)
203
233
  return this.accessToken.token;
204
234
  // A cloud-exchanged credential refreshes through the account's server —
@@ -211,7 +241,8 @@ export class GdriveUploadClient {
211
241
  const response = await this.fetchImpl(this.tokenUrl(), {
212
242
  method: "POST",
213
243
  headers: { "content-type": "application/x-www-form-urlencoded" },
214
- body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.credentials.refreshToken, client_id: this.credentials.clientId }).toString()
244
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.credentials.refreshToken, client_id: this.credentials.clientId }).toString(),
245
+ signal,
215
246
  });
216
247
  if (!response.ok)
217
248
  fail("unauthorized", `Google Drive refused to mint an access token (${response.status}). Reconnect with: sealkeep storage connect gdrive`, { status: response.status });
@@ -221,40 +252,55 @@ export class GdriveUploadClient {
221
252
  return token;
222
253
  }
223
254
  async api(path, init = {}) {
224
- const token = await this.token();
255
+ const token = await this.token(init.signal);
225
256
  return this.fetchImpl(`${this.base()}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, ...init.headers } });
226
257
  }
227
- async list(query) {
258
+ async list(query, signal) {
228
259
  const params = new URLSearchParams({ q: query, spaces: this.space(), fields: "files(id,name,size,md5Checksum)", pageSize: "10" });
229
- const response = await this.api(`/drive/v3/files?${params.toString()}`);
260
+ const response = await this.api(`/drive/v3/files?${params.toString()}`, { signal });
230
261
  if (!response.ok)
231
262
  fail("internal", `Google Drive file lookup failed: ${response.status} ${response.statusText}`, { status: response.status });
232
263
  const data = await response.json();
233
264
  return data.files ?? [];
234
265
  }
235
- /** Exact lookup by the appProperties key. Two files claiming one key is a state we refuse to guess about. */
236
- async findByKey(objectKey) {
237
- const files = await this.list(`appProperties has { key = 'vaultlineKey' and value = '${escapeQuery(objectKey)}' } and trashed = false`);
266
+ /**
267
+ * Exact lookup inside this configured physical destination. Object keys are
268
+ * intentionally reusable across Drive folders, so the immutable parent id
269
+ * and Drive space are part of every lookup rather than merely creation
270
+ * metadata.
271
+ */
272
+ async findByKey(objectKey, signal, knownParentId) {
273
+ const parentId = knownParentId ?? await this.parentId(signal, false);
274
+ if (!parentId)
275
+ return null;
276
+ const files = await this.list(`appProperties has { key = 'vaultlineKey' and value = '${escapeQuery(objectKey)}' } and '${escapeQuery(parentId)}' in parents and trashed = false`, signal);
238
277
  if (files.length > 1)
239
278
  fail("internal", `Google Drive holds ${files.length} files claiming ${objectKey}; refusing to pick one`, { objectKey, count: files.length });
240
279
  return files[0] ?? null;
241
280
  }
242
- /** Finds or creates the archive folder; the id is cached per instance. */
243
- async parentId() {
281
+ /** Resolves the archive folder to its immutable Drive id, creating it only for uploads. */
282
+ async parentId(signal, create = true) {
244
283
  if (this.endpoint.appData)
245
284
  return "appDataFolder";
246
285
  if (this.folderId)
247
286
  return this.folderId;
248
287
  const name = this.endpoint.folderName;
249
- const existing = (await this.list(`name = '${escapeQuery(name)}' and mimeType = '${FOLDER_MIME}' and trashed = false`))[0]?.id;
288
+ const folders = await this.list(`name = '${escapeQuery(name)}' and mimeType = '${FOLDER_MIME}' and trashed = false`, signal);
289
+ if (folders.length > 1) {
290
+ fail("internal", `Google Drive holds ${folders.length} folders named "${name}"; refusing to guess which configured destination owns the archive`, { folderName: name, count: folders.length });
291
+ }
292
+ const existing = folders[0]?.id;
250
293
  if (existing) {
251
294
  this.folderId = existing;
252
295
  return existing;
253
296
  }
297
+ if (!create)
298
+ return null;
254
299
  const response = await this.api("/drive/v3/files?fields=id", {
255
300
  method: "POST",
256
301
  headers: { "content-type": "application/json; charset=UTF-8" },
257
- body: JSON.stringify({ name, mimeType: FOLDER_MIME })
302
+ body: JSON.stringify({ name, mimeType: FOLDER_MIME }),
303
+ signal,
258
304
  });
259
305
  if (!response.ok)
260
306
  fail("internal", `Could not create the "${name}" folder in Google Drive: ${response.status} ${response.statusText}`, { status: response.status });
@@ -268,42 +314,147 @@ export class GdriveUploadClient {
268
314
  * appProperties lookup stops being exact). The session URI is a bearer
269
315
  * capability: used immediately, never persisted, never logged.
270
316
  */
271
- async initiateResumable(objectKey, existingFileId, totalBytes) {
317
+ async initiateResumable(objectKey, parentId, existingFileId, totalBytes, signal) {
272
318
  const metadata = existingFileId
273
319
  ? { appProperties: { vaultlineKey: objectKey } }
274
- : { name: objectKey.split("/").pop() ?? objectKey, parents: [await this.parentId()], appProperties: { vaultlineKey: objectKey } };
320
+ : { name: objectKey.split("/").pop() ?? objectKey, parents: [parentId], appProperties: { vaultlineKey: objectKey } };
275
321
  const response = await this.api(existingFileId ? `/upload/drive/v3/files/${existingFileId}?uploadType=resumable` : "/upload/drive/v3/files?uploadType=resumable", {
276
322
  method: existingFileId ? "PATCH" : "POST",
277
- headers: { "content-type": "application/json; charset=UTF-8", "x-upload-content-length": String(totalBytes) },
278
- body: JSON.stringify(metadata)
323
+ headers: {
324
+ "content-type": "application/json; charset=UTF-8",
325
+ "x-upload-content-length": String(totalBytes),
326
+ "x-upload-content-type": "application/octet-stream",
327
+ },
328
+ body: JSON.stringify(metadata),
329
+ signal,
279
330
  });
280
331
  if (!response.ok)
281
332
  fail("internal", `Could not start a Drive resumable upload for ${objectKey}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
282
333
  return response.headers.get("location") ?? fail("internal", `Drive's resumable initiation for ${objectKey} returned no session URI`, { objectKey });
283
334
  }
284
335
  async upload(lease, ciphertext) {
285
- const objectKey = lease.objectKey;
286
- const existing = await this.findByKey(objectKey);
287
- const session = await this.initiateResumable(objectKey, existing?.id ?? null, ciphertext.length);
288
- for (let offset = 0; offset < Math.max(ciphertext.length, 1); offset += this.chunkBytes) {
289
- const slice = ciphertext.subarray(offset, Math.min(offset + this.chunkBytes, ciphertext.length));
290
- const last = offset + slice.length >= ciphertext.length;
291
- const range = ciphertext.length === 0 ? "bytes */0" : `bytes ${offset}-${offset + slice.length - 1}/${ciphertext.length}`;
292
- const response = await this.fetchImpl(session, { method: "PUT", body: new Uint8Array(slice), headers: { "content-range": range } });
293
- // 308 is "resume incomplete", the expected status between chunks.
294
- if (!response.ok && !(response.status === 308 && !last)) {
295
- fail("internal", `Drive resumable upload for ${objectKey} failed at ${range}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
336
+ return this.uploadStream(lease.objectKey, oneChunk(ciphertext), { bytes: ciphertext.length });
337
+ }
338
+ /**
339
+ * Uploads a known-length object through Drive's resumable protocol while
340
+ * retaining at most one configured resumable part. The archive pipeline
341
+ * measures a file in a bounded first pass, then this second pass computes
342
+ * Drive's whole-object MD5 as bytes are copied into fixed-size PUTs.
343
+ *
344
+ * Drive requires every non-final chunk to be a positive multiple of 256 KiB.
345
+ * The configured `chunkBytes` already enforces that rule; the last PUT may be
346
+ * short. A 308 response is accepted only for a non-final PUT, and the final
347
+ * PUT must be a success, so an early-finalized or half-accepted session can
348
+ * never be reported as complete.
349
+ */
350
+ async uploadStream(objectKey, chunks, options = {}) {
351
+ const totalBytes = options.bytes;
352
+ if (!Number.isSafeInteger(totalBytes) || (totalBytes ?? -1) < 0) {
353
+ fail("invalid_argument", `Google Drive needs the archive's exact byte length before a resumable upload can start`, { objectKey, bytes: totalBytes });
354
+ }
355
+ const total = totalBytes;
356
+ options.signal?.throwIfAborted();
357
+ const parentId = await this.parentId(options.signal, true)
358
+ ?? fail("internal", `Could not resolve the configured Drive destination for ${objectKey}`, { objectKey });
359
+ const existing = await this.findByKey(objectKey, options.signal, parentId);
360
+ options.signal?.throwIfAborted();
361
+ let session = await this.initiateResumable(objectKey, parentId, existing?.id ?? null, total, options.signal);
362
+ const md5 = createHash("md5");
363
+ // One fixed part, reused only after fetch has consumed the preceding body.
364
+ // Input chunks are copied piecemeal, so even a badly shaped producer cannot
365
+ // make this client concatenate the whole archive internally.
366
+ const part = Buffer.allocUnsafe(Math.min(this.chunkBytes, Math.max(total, 1)));
367
+ let partBytes = 0;
368
+ let consumed = 0;
369
+ let sent = 0;
370
+ const putPart = async (piece, final) => {
371
+ const partStart = sent;
372
+ const partEnd = partStart + piece.length;
373
+ let attemptsWithoutProgress = 0;
374
+ do {
375
+ options.signal?.throwIfAborted();
376
+ const bodyOffset = sent - partStart;
377
+ const body = piece.subarray(bodyOffset);
378
+ const range = total === 0 ? "bytes */0" : `bytes ${sent}-${partEnd - 1}/${total}`;
379
+ const response = await this.fetchImpl(session, {
380
+ method: "PUT",
381
+ // This is a view over the fixed part buffer, not another part-sized copy.
382
+ body: new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
383
+ headers: { "content-length": String(body.length), "content-range": range },
384
+ redirect: "manual",
385
+ signal: options.signal,
386
+ });
387
+ const movedSession = response.headers.get("location");
388
+ if (movedSession)
389
+ session = movedSession;
390
+ if (response.ok) {
391
+ if (!final) {
392
+ fail("internal", `Drive finalized the resumable upload for ${objectKey} before its declared final byte`, { objectKey, status: response.status, sentBytes: partEnd, expectedBytes: total });
393
+ }
394
+ sent = partEnd;
395
+ return;
396
+ }
397
+ if (response.status !== 308) {
398
+ fail("internal", `Drive resumable upload for ${objectKey} failed at ${range}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
399
+ }
400
+ // Drive's 308 Range is the authoritative committed prefix. It can be
401
+ // shorter than the attempted PUT after a connection interruption, in
402
+ // which case the still-retained suffix is sent again from that offset.
403
+ const acknowledged = /^bytes=0-(\d+)$/.exec(response.headers.get("range") ?? "");
404
+ const confirmed = acknowledged ? Number(acknowledged[1]) + 1 : 0;
405
+ if (!Number.isSafeInteger(confirmed) || confirmed < sent || confirmed > partEnd) {
406
+ fail("internal", `Drive returned an invalid committed-byte range while uploading ${objectKey}`, { objectKey, range: response.headers.get("range"), sentBytes: sent, partEnd });
407
+ }
408
+ if (confirmed === sent)
409
+ attemptsWithoutProgress += 1;
410
+ else
411
+ attemptsWithoutProgress = 0;
412
+ if (attemptsWithoutProgress >= 3) {
413
+ fail("internal", `Drive made no progress after three resumable PUT attempts for ${objectKey}`, { objectKey, sentBytes: sent });
414
+ }
415
+ sent = confirmed;
416
+ if (sent === partEnd) {
417
+ if (final) {
418
+ fail("internal", `Drive kept the final resumable PUT for ${objectKey} incomplete after acknowledging every byte`, { objectKey, sentBytes: sent });
419
+ }
420
+ return;
421
+ }
422
+ } while (sent < partEnd || (final && total === 0));
423
+ };
424
+ for await (const chunk of chunks) {
425
+ options.signal?.throwIfAborted();
426
+ if (chunk.length === 0)
427
+ continue;
428
+ consumed += chunk.length;
429
+ if (consumed > total) {
430
+ fail("ciphertext_integrity_failed", `The stream for ${objectKey} produced more than its declared ${total} bytes`, { objectKey, expectedBytes: total, consumedBytes: consumed });
296
431
  }
297
- if (last)
298
- break;
432
+ md5.update(chunk);
433
+ let offset = 0;
434
+ while (offset < chunk.length) {
435
+ // A full part is held until another source byte proves it is non-final.
436
+ // That prevents a generator which later throws or overruns its declared
437
+ // length from finalizing a truncated object first.
438
+ if (partBytes === part.length) {
439
+ await putPart(part.subarray(0, partBytes), false);
440
+ partBytes = 0;
441
+ }
442
+ const copied = Math.min(part.length - partBytes, chunk.length - offset);
443
+ chunk.copy(part, partBytes, offset, offset + copied);
444
+ partBytes += copied;
445
+ offset += copied;
446
+ }
447
+ }
448
+ if (consumed !== total) {
449
+ fail("ciphertext_integrity_failed", `The stream for ${objectKey} ended after ${consumed} of its declared ${total} bytes`, { objectKey, expectedBytes: total, consumedBytes: consumed });
450
+ }
451
+ await putPart(part.subarray(0, partBytes), true);
452
+ if (sent !== total) {
453
+ fail("ciphertext_integrity_failed", `Drive accepted ${sent} of ${total} bytes for ${objectKey}`, { objectKey, expectedBytes: total, sentBytes: sent });
299
454
  }
300
- // The whole-object proof Drive reports back is `md5Checksum` in file
301
- // metadata, as lowercase hex — it computes no SHA-256, and unlike GCS's
302
- // base64 header this one is hex. The verifier in upload.ts compares this
303
- // value against head()'s, so both sides must speak that same hex dialect;
304
- // MD5 here is transport proof, not trust — the ciphertext is
305
- // AEAD-authenticated and restore re-checks its SHA-256 independently.
306
- return { remoteChecksum: createHash("md5").update(ciphertext).digest("hex"), bytes: ciphertext.length };
455
+ // Drive reports binary-object MD5 as lowercase hex. This is transport proof,
456
+ // not trust: ciphertext is AEAD-authenticated and restore verifies SHA-256.
457
+ return { remoteChecksum: md5.digest("hex"), bytes: consumed };
307
458
  }
308
459
  async head(lease) {
309
460
  const file = await this.findByKey(lease.objectKey);
@@ -315,22 +466,44 @@ export class GdriveUploadClient {
315
466
  return { exists: true, bytes: Number(file.size ?? "0"), checksum: file.md5Checksum };
316
467
  }
317
468
  /** Reads an object back for restore or offload-proof: locate by key, then `alt=media`. */
318
- /** Removes the file holding this object key. Absent already means done. */
469
+ /**
470
+ * Removes the file holding this object key in this exact destination. An
471
+ * empty successful scoped listing proves absence. A DELETE 404 is rechecked
472
+ * in the same scope before callers may discard their ledger metadata.
473
+ */
319
474
  async deleteByKey(objectKey) {
320
- const existing = await this.findByKey(objectKey);
475
+ const parentId = await this.parentId(undefined, false);
476
+ if (!parentId) {
477
+ fail("internal", `The configured Drive folder "${this.endpoint.folderName}" cannot be found, so absence of ${objectKey} cannot be proven. Nothing was changed here.`, { objectKey, folderName: this.endpoint.folderName });
478
+ }
479
+ const existing = await this.findByKey(objectKey, undefined, parentId);
321
480
  if (!existing)
322
- return;
481
+ return "absent";
323
482
  const response = await this.api(`/drive/v3/files/${existing.id}`, { method: "DELETE" });
324
- if (!response.ok && response.status !== 404) {
483
+ if (response.ok) {
484
+ if (!await this.findByKey(objectKey, undefined, parentId))
485
+ return "deleted";
486
+ fail("internal", `Drive accepted deletion of ${objectKey}, but an object with that key is still present in the configured destination. Nothing was changed here.`, { objectKey });
487
+ }
488
+ if (response.status === 404) {
489
+ if (!await this.findByKey(objectKey, undefined, parentId))
490
+ return "absent";
491
+ fail("internal", `Drive reported ${objectKey} missing during deletion, but it is still present in the configured destination. Nothing was changed here.`, { objectKey });
492
+ }
493
+ if (!response.ok) {
325
494
  fail("internal", `Drive refused the delete: ${response.status} ${response.statusText}. The stored copy may still be there.`);
326
495
  }
496
+ return "deleted";
497
+ }
498
+ async download(lease, options = {}) {
499
+ return collectChunks(await this.downloadStream(lease, options), Number.MAX_SAFE_INTEGER);
327
500
  }
328
- async download(lease) {
329
- const file = await this.findByKey(lease.objectKey)
501
+ async downloadStream(lease, options = {}) {
502
+ const file = await this.findByKey(lease.objectKey, options.signal)
330
503
  ?? fail("archive_not_found", `Google Drive holds no file for ${lease.objectKey}`, { key: lease.objectKey });
331
- const response = await this.api(`/drive/v3/files/${file.id}?alt=media`);
504
+ const response = await this.api(`/drive/v3/files/${file.id}?alt=media`, { signal: options.signal });
332
505
  if (!response.ok)
333
506
  fail("internal", `Could not download ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
334
- return Buffer.from(await response.arrayBuffer());
507
+ return responseBodyChunks(response);
335
508
  }
336
509
  }
@@ -3,6 +3,7 @@ import { type ProviderConfig, type ProviderKind, type ProviderUploadClient, type
3
3
  import { S3UploadClient } from "./s3.js";
4
4
  import { GcsUploadClient } from "./gcs.js";
5
5
  import { GdriveUploadClient } from "./gdrive.js";
6
+ import { type StorageFetch } from "./safe-storage-fetch.js";
6
7
  export type EndpointOverrides = {
7
8
  host?: string;
8
9
  port?: number;
@@ -10,6 +11,8 @@ export type EndpointOverrides = {
10
11
  pathStyle?: boolean;
11
12
  partBytes?: number;
12
13
  allowSmallParts?: boolean;
14
+ /** Runtime-only test/transport seam. Never persisted with routing metadata. */
15
+ fetchImpl?: StorageFetch;
13
16
  };
14
17
  /**
15
18
  * "gdrive" rides alongside control-plane's ProviderKind rather than inside it:
@@ -21,10 +24,15 @@ export type UploadProviderKind = ProviderKind | "gdrive";
21
24
  export type UploadProviderConfig = Omit<ProviderConfig, "provider"> & {
22
25
  provider: UploadProviderKind;
23
26
  };
27
+ /** One canonical rule for selecting Drive's hidden per-app storage space. */
28
+ export declare function isGdriveAppDataPrefix(prefix: string | undefined): boolean;
29
+ /** The one origin a custom endpoint client may contact. */
30
+ export declare function storageFetchForOverrides(overrides: EndpointOverrides, env?: NodeJS.ProcessEnv): StorageFetch | undefined;
24
31
  /**
25
- * Builds the client for a configured provider. This is the only place a credential
26
- * is handed to a network client, and the feature flag is checked here rather than
27
- * at import time so an operator can turn uploads off without a restart.
32
+ * Builds the client for a configured provider. This is the only place a
33
+ * credential is handed to a network client, and capability is checked per
34
+ * call: an ordinary CLI needs explicit environment permission while a daemon
35
+ * may use only the grant held for its own lifetime.
28
36
  */
29
37
  export declare function createUploadClient(config: UploadProviderConfig, credentials: ProviderCredentials, overrides?: EndpointOverrides, env?: NodeJS.ProcessEnv): ProviderUploadClient;
30
38
  export declare function uploadClientFromStore(dataDir: string, storageConfigId: string, config: UploadProviderConfig, overrides?: EndpointOverrides, backend?: BackendName): Promise<ProviderUploadClient>;
@@ -5,36 +5,56 @@ import { DEFAULT_LEASE_TTL_MS, signerEnabled } from "../control-plane.js";
5
5
  import { S3UploadClient } from "./s3.js";
6
6
  import { GcsUploadClient } from "./gcs.js";
7
7
  import { GdriveUploadClient } from "./gdrive.js";
8
+ import { envFlag } from "../env.js";
9
+ import { createPinnedStorageFetch } from "./safe-storage-fetch.js";
10
+ /** One canonical rule for selecting Drive's hidden per-app storage space. */
11
+ export function isGdriveAppDataPrefix(prefix) {
12
+ return (prefix ?? "").replace(/^\/+|\/+$/g, "") === "appdata";
13
+ }
8
14
  function isAwsStyle(credentials) {
9
15
  return "accessKeyId" in credentials;
10
16
  }
11
17
  function isGdriveCredentials(credentials) {
12
18
  return "kind" in credentials && credentials.kind === "gdrive";
13
19
  }
20
+ /** The one origin a custom endpoint client may contact. */
21
+ export function storageFetchForOverrides(overrides, env = process.env) {
22
+ if (overrides.fetchImpl)
23
+ return overrides.fetchImpl;
24
+ if (!overrides.host)
25
+ return undefined;
26
+ const host = overrides.host.includes(":") && !overrides.host.startsWith("[")
27
+ ? `[${overrides.host}]`
28
+ : overrides.host;
29
+ const origin = `${overrides.protocol ?? "https"}://${host}${overrides.port ? `:${overrides.port}` : ""}`;
30
+ return createPinnedStorageFetch(origin, { allowPrivate: envFlag("ALLOW_PRIVATE_STORAGE_ENDPOINT", env) });
31
+ }
14
32
  /**
15
- * Builds the client for a configured provider. This is the only place a credential
16
- * is handed to a network client, and the feature flag is checked here rather than
17
- * at import time so an operator can turn uploads off without a restart.
33
+ * Builds the client for a configured provider. This is the only place a
34
+ * credential is handed to a network client, and capability is checked per
35
+ * call: an ordinary CLI needs explicit environment permission while a daemon
36
+ * may use only the grant held for its own lifetime.
18
37
  */
19
- export function createUploadClient(config, credentials, overrides = {}, env = process.env) {
38
+ export function createUploadClient(config, credentials, overrides = {}, env) {
20
39
  if (!signerEnabled(env))
21
40
  fail("signer_not_configured", "Uploads are disabled. Set SEALKEEP_ENABLE_SIGNER=1 to enable a configured provider client.", { provider: config.provider });
41
+ const fetchImpl = storageFetchForOverrides(overrides, env ?? process.env);
22
42
  if (config.provider === "gdrive") {
23
43
  if (!isGdriveCredentials(credentials))
24
44
  fail("invalid_argument", "Google Drive needs an OAuth credential. Run: sealkeep storage connect gdrive");
25
45
  // Drive has no buckets: the configured bucket names the archive folder, and
26
46
  // a prefix of "appdata" selects the hidden per-app appDataFolder space.
27
- return new GdriveUploadClient({ folderName: config.bucket, appData: config.prefix.replace(/^\/+|\/+$/g, "") === "appdata", host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials);
47
+ return new GdriveUploadClient({ folderName: config.bucket, appData: isGdriveAppDataPrefix(config.prefix), host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials, fetchImpl ? { fetchImpl } : {});
28
48
  }
29
49
  if (config.provider === "gcs") {
30
50
  if (isAwsStyle(credentials) || isGdriveCredentials(credentials))
31
51
  fail("invalid_argument", "GCS needs a service-account credential, not an access key or OAuth token");
32
- return new GcsUploadClient({ bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials);
52
+ return new GcsUploadClient({ bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials, undefined, fetchImpl);
33
53
  }
34
54
  if (!isAwsStyle(credentials))
35
55
  fail("invalid_argument", `${config.provider} needs an access key credential, not a service account or OAuth token`);
36
56
  const endpoint = { provider: config.provider === "vaultline" ? "s3" : config.provider, region: config.region ?? "auto", bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol, pathStyle: overrides.pathStyle };
37
- return new S3UploadClient(config.provider, endpoint, credentials, overrides.partBytes, overrides.allowSmallParts);
57
+ return new S3UploadClient(config.provider, endpoint, credentials, overrides.partBytes, overrides.allowSmallParts, fetchImpl);
38
58
  }
39
59
  export async function uploadClientFromStore(dataDir, storageConfigId, config, overrides = {}, backend) {
40
60
  const credentials = await loadProviderCredentials(dataDir, storageConfigId, backend);
@@ -52,7 +72,7 @@ export async function uploadClientFromStore(dataDir, storageConfigId, config, ov
52
72
  * The object key is derived from the configured prefix and the archive id; a caller
53
73
  * cannot choose it, which keeps one account's objects inside its own prefix.
54
74
  */
55
- export function createActiveLease(config, input, env = process.env) {
75
+ export function createActiveLease(config, input, env) {
56
76
  if (!signerEnabled(env))
57
77
  fail("signer_not_configured", "Active leases require an enabled signer", { provider: config.provider });
58
78
  if (!config.bucket || !config.prefix)
@@ -63,10 +83,10 @@ export function createActiveLease(config, input, env = process.env) {
63
83
  // UploadLease.provider is still control-plane's narrower union; the cast
64
84
  // goes away with the UploadProviderKind widening above.
65
85
  archiveId, provider: config.provider,
66
- objectKey: `${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.vlarchive`,
86
+ objectKey: `${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.skarchive`,
67
87
  expiresAt: new Date(issuedAt + (input.ttlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString(),
68
88
  method: config.provider === "gcs" || config.provider === "gdrive" ? "RESUMABLE" : "MULTIPART",
69
- uploadUrl: `${config.provider}://${config.bucket}/${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.vlarchive`,
89
+ uploadUrl: `${config.provider}://${config.bucket}/${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.skarchive`,
70
90
  requiredHeaders: { "content-type": "application/vnd.vaultline.ciphertext", "x-vaultline-ciphertext-sha256": input.ciphertextSha256, "content-length": String(input.bytes) },
71
91
  status: "active", uploadable: true
72
92
  };
@@ -1,5 +1,7 @@
1
1
  import { type Credentials } from "./sigv4.js";
2
2
  import type { ProviderKind, ProviderUploadClient, UploadLease } from "../control-plane.js";
3
+ import { type ByteStream } from "../byte-stream.js";
4
+ import type { StorageFetch } from "./safe-storage-fetch.js";
3
5
  export type S3Endpoint = {
4
6
  region: string;
5
7
  bucket: string;
@@ -46,6 +48,8 @@ export type S3StreamOptions = {
46
48
  * This call is the durability point a resume journal writes at: once it
47
49
  * returns, a crash can be resumed from that state. */
48
50
  onState?: (state: S3StreamState) => void | Promise<void>;
51
+ /** Cancellation is observed by the incoming iterator between bounded parts. */
52
+ signal?: AbortSignal;
49
53
  };
50
54
  /**
51
55
  * The byte position a resumed stream must start feeding from: one full part per
@@ -79,10 +83,11 @@ export declare class S3UploadClient implements ProviderUploadClient {
79
83
  private readonly endpoint;
80
84
  private readonly credentials;
81
85
  private readonly partBytes;
86
+ private readonly fetchImpl;
82
87
  constructor(kind: ProviderKind, endpoint: S3Endpoint, credentials: Credentials, partBytes?: number,
83
88
  /** S3 requires every part except the last to be at least 5 MiB. Local emulators and
84
89
  * MinIO do not, so this is the deliberate opt-out for those targets only. */
85
- allowSmallParts?: boolean);
90
+ allowSmallParts?: boolean, fetchImpl?: StorageFetch);
86
91
  private sign;
87
92
  private put;
88
93
  /**
@@ -91,22 +96,33 @@ export declare class S3UploadClient implements ProviderUploadClient {
91
96
  * a plain checksummed PUT does everything multipart machinery did — which
92
97
  * is exactly why the chunk layout needs none of it.
93
98
  */
94
- putObject(key: string, body: Buffer): Promise<{
99
+ putObject(key: string, body: Buffer, options?: {
100
+ signal?: AbortSignal;
101
+ }): Promise<{
95
102
  checksum: string;
96
103
  }>;
97
- headObject(key: string): Promise<{
104
+ headObject(key: string, options?: {
105
+ signal?: AbortSignal;
106
+ }): Promise<{
98
107
  exists: boolean;
99
108
  bytes: number;
100
109
  checksum?: string;
101
110
  }>;
102
- getObject(key: string): Promise<Buffer>;
111
+ getObject(key: string, options?: {
112
+ signal?: AbortSignal;
113
+ }): Promise<Buffer>;
114
+ /** Object-key streaming counterpart used by the background index store. */
115
+ getObjectStream(key: string, options?: {
116
+ signal?: AbortSignal;
117
+ }): Promise<ByteStream>;
118
+ private downloadKeyStream;
103
119
  deleteObject(key: string): Promise<void>;
104
- initiateMultipart(key: string): Promise<string>;
120
+ initiateMultipart(key: string, signal?: AbortSignal): Promise<string>;
105
121
  completeMultipart(key: string, uploadId: string, parts: {
106
122
  partNumber: number;
107
123
  etag: string;
108
124
  checksum: string;
109
- }[]): Promise<string>;
125
+ }[], signal?: AbortSignal): Promise<string>;
110
126
  /**
111
127
  * What the provider ACTUALLY holds for an in-flight multipart — the resume
112
128
  * path's source of truth. A journal can lag the provider by exactly the
@@ -119,7 +135,7 @@ export declare class S3UploadClient implements ProviderUploadClient {
119
135
  etag: string;
120
136
  bytes: number;
121
137
  }[]>;
122
- abortMultipart(key: string, uploadId: string): Promise<void>;
138
+ abortMultipart(key: string, uploadId: string, signal?: AbortSignal): Promise<void>;
123
139
  /** Uploads ciphertext and returns the checksum the provider confirmed. */
124
140
  upload(lease: UploadLease, ciphertext: Buffer): Promise<{
125
141
  remoteChecksum: string;
@@ -157,7 +173,13 @@ export declare class S3UploadClient implements ProviderUploadClient {
157
173
  * GET with the short-lived credential it was vended, exactly as it does for
158
174
  * the upload.
159
175
  */
160
- download(lease: UploadLease): Promise<Buffer>;
176
+ download(lease: UploadLease, options?: {
177
+ signal?: AbortSignal;
178
+ }): Promise<Buffer>;
179
+ /** The production restore/transfer door: response chunks flow straight to disk. */
180
+ downloadStream(lease: UploadLease, options?: {
181
+ signal?: AbortSignal;
182
+ }): Promise<ByteStream>;
161
183
  /**
162
184
  * Confirms the object exists remotely and reports what the provider stored.
163
185
  *