sealkeep 0.9.0 → 0.11.1
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.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +252 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +332 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5940 -297
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /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,
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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>.
|
|
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
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
/**
|
|
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
|
|
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: [
|
|
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: {
|
|
278
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
26
|
-
* is handed to a network client, and
|
|
27
|
-
*
|
|
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
|
|
16
|
-
* is handed to a network client, and
|
|
17
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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}.
|
|
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}.
|
|
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
|
|
99
|
+
putObject(key: string, body: Buffer, options?: {
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
}): Promise<{
|
|
95
102
|
checksum: string;
|
|
96
103
|
}>;
|
|
97
|
-
headObject(key: string
|
|
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
|
|
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
|
|
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
|
*
|