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