sealkeep 0.5.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 +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { type Credentials } from "./sigv4.js";
|
|
2
|
+
import type { ProviderKind, ProviderUploadClient, UploadLease } from "../control-plane.js";
|
|
3
|
+
export type S3Endpoint = {
|
|
4
|
+
region: string;
|
|
5
|
+
bucket: string;
|
|
6
|
+
/** Which S3-compatible service this is. Decides the default host and addressing style. */
|
|
7
|
+
provider?: "s3" | "r2" | "b2";
|
|
8
|
+
/** Set for R2, MinIO, or any non-AWS S3-compatible endpoint. */
|
|
9
|
+
host?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
protocol?: "https" | "http";
|
|
12
|
+
pathStyle?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare const DEFAULT_PART_BYTES: number;
|
|
15
|
+
export declare const MIN_PART_BYTES: number;
|
|
16
|
+
/**
|
|
17
|
+
* The checksum S3 stores for a multipart object, computed the way S3 computes it:
|
|
18
|
+
* SHA-256 over the concatenated raw part digests, base64, then `-<part count>`.
|
|
19
|
+
*
|
|
20
|
+
* Worth deriving rather than skipping. A multipart object's stored checksum is
|
|
21
|
+
* never the whole-object SHA-256, so comparing the two always fails — and the
|
|
22
|
+
* cheap way out is to skip the check whenever a `-N` suffix appears, which
|
|
23
|
+
* silently disables verification for exactly the largest archives. Computing it
|
|
24
|
+
* keeps the comparison real at every size.
|
|
25
|
+
*/
|
|
26
|
+
export declare function compositeChecksum(partChecksumsB64: readonly string[]): string;
|
|
27
|
+
/** One uploaded part as the resume journal records it. `etag` and `checksum` are
|
|
28
|
+
* what CompleteMultipartUpload needs back; `bytes` is what makes a short part
|
|
29
|
+
* detectable at resume time, when the part bodies themselves are long gone. */
|
|
30
|
+
export type S3StreamPart = {
|
|
31
|
+
partNumber: number;
|
|
32
|
+
etag: string;
|
|
33
|
+
checksum: string;
|
|
34
|
+
bytes: number;
|
|
35
|
+
};
|
|
36
|
+
/** Everything a crashed stream needs to continue. Journal exactly this shape. */
|
|
37
|
+
export type S3StreamState = {
|
|
38
|
+
uploadId: string;
|
|
39
|
+
parts: S3StreamPart[];
|
|
40
|
+
};
|
|
41
|
+
export type S3StreamOptions = {
|
|
42
|
+
/** State from an earlier run's onState: skips initiation, reuses the parts
|
|
43
|
+
* already uploaded, and continues numbering after them. */
|
|
44
|
+
resume?: S3StreamState;
|
|
45
|
+
/** Awaited after every completed part upload with the full current state.
|
|
46
|
+
* This call is the durability point a resume journal writes at: once it
|
|
47
|
+
* returns, a crash can be resumed from that state. */
|
|
48
|
+
onState?: (state: S3StreamState) => void | Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The byte position a resumed stream must start feeding from: one full part per
|
|
52
|
+
* seeded entry, with exactly one exception.
|
|
53
|
+
*
|
|
54
|
+
* A short part anywhere but LAST means the journal describes a
|
|
55
|
+
* differently-shaped upload (or a different part size), and continuing would
|
|
56
|
+
* splice new bytes at the wrong offset — refused. A short LAST seed is the
|
|
57
|
+
* one legitimate shape: the object ended there, and the only thing left to do
|
|
58
|
+
* is complete. `uploadStream` therefore refuses any further data after a short
|
|
59
|
+
* last seed — bytes cannot follow a tail.
|
|
60
|
+
*/
|
|
61
|
+
export declare function resumeOffsetBytes(parts: readonly S3StreamPart[], partBytes: number): number;
|
|
62
|
+
/** True when the seeds end in a short (object-ending) part: completion-only resume. */
|
|
63
|
+
export declare function seededTailIsFinal(parts: readonly S3StreamPart[], partBytes: number): boolean;
|
|
64
|
+
export declare function xmlValue(body: string, tag: string): string | null;
|
|
65
|
+
export declare function target(endpoint: S3Endpoint, key: string): {
|
|
66
|
+
host: string;
|
|
67
|
+
path: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* An S3-compatible upload client for S3, R2, and MinIO.
|
|
71
|
+
*
|
|
72
|
+
* Requests are presigned locally with credentials this process was handed; no
|
|
73
|
+
* long-lived credential is ever written to a Sealkeep config file. Only the
|
|
74
|
+
* checksum header is signed, because `content-length` is a forbidden header for
|
|
75
|
+
* `fetch` and could not be sent verbatim — the SHA-256 already pins the bytes.
|
|
76
|
+
*/
|
|
77
|
+
export declare class S3UploadClient implements ProviderUploadClient {
|
|
78
|
+
readonly kind: ProviderKind;
|
|
79
|
+
private readonly endpoint;
|
|
80
|
+
private readonly credentials;
|
|
81
|
+
private readonly partBytes;
|
|
82
|
+
constructor(kind: ProviderKind, endpoint: S3Endpoint, credentials: Credentials, partBytes?: number,
|
|
83
|
+
/** S3 requires every part except the last to be at least 5 MiB. Local emulators and
|
|
84
|
+
* MinIO do not, so this is the deliberate opt-out for those targets only. */
|
|
85
|
+
allowSmallParts?: boolean);
|
|
86
|
+
private sign;
|
|
87
|
+
private put;
|
|
88
|
+
/**
|
|
89
|
+
* The chunk-folder layout's whole provider API: put one object, ask about
|
|
90
|
+
* one object, remove one object. A 4 MB sealed chunk has a known length, so
|
|
91
|
+
* a plain checksummed PUT does everything multipart machinery did — which
|
|
92
|
+
* is exactly why the chunk layout needs none of it.
|
|
93
|
+
*/
|
|
94
|
+
putObject(key: string, body: Buffer): Promise<{
|
|
95
|
+
checksum: string;
|
|
96
|
+
}>;
|
|
97
|
+
headObject(key: string): Promise<{
|
|
98
|
+
exists: boolean;
|
|
99
|
+
bytes: number;
|
|
100
|
+
checksum?: string;
|
|
101
|
+
}>;
|
|
102
|
+
getObject(key: string): Promise<Buffer>;
|
|
103
|
+
deleteObject(key: string): Promise<void>;
|
|
104
|
+
initiateMultipart(key: string): Promise<string>;
|
|
105
|
+
completeMultipart(key: string, uploadId: string, parts: {
|
|
106
|
+
partNumber: number;
|
|
107
|
+
etag: string;
|
|
108
|
+
checksum: string;
|
|
109
|
+
}[]): Promise<string>;
|
|
110
|
+
/**
|
|
111
|
+
* What the provider ACTUALLY holds for an in-flight multipart — the resume
|
|
112
|
+
* path's source of truth. A journal can lag the provider by exactly the
|
|
113
|
+
* parts that landed between a PUT and the journal write that would have
|
|
114
|
+
* recorded them; ListParts is how a resume sees those, so nothing the
|
|
115
|
+
* provider holds can be silently overwritten with re-sealed bytes.
|
|
116
|
+
*/
|
|
117
|
+
listParts(key: string, uploadId: string): Promise<{
|
|
118
|
+
partNumber: number;
|
|
119
|
+
etag: string;
|
|
120
|
+
bytes: number;
|
|
121
|
+
}[]>;
|
|
122
|
+
abortMultipart(key: string, uploadId: string): Promise<void>;
|
|
123
|
+
/** Uploads ciphertext and returns the checksum the provider confirmed. */
|
|
124
|
+
upload(lease: UploadLease, ciphertext: Buffer): Promise<{
|
|
125
|
+
remoteChecksum: string;
|
|
126
|
+
bytes: number;
|
|
127
|
+
}>;
|
|
128
|
+
/**
|
|
129
|
+
* Streams an object up as it is produced: stored chunks accumulate into
|
|
130
|
+
* multipart parts of `partBytes`, each part uploads as soon as it fills, and
|
|
131
|
+
* the remainder goes up as the final part. A source that ends before one
|
|
132
|
+
* part fills becomes a plain single PUT — S3 refuses multiparts under 5 MB
|
|
133
|
+
* except as the last part, and a one-part multipart is just ceremony.
|
|
134
|
+
*
|
|
135
|
+
* Peak memory is one part buffer. On any failure the multipart is aborted so
|
|
136
|
+
* no half-object lingers as billable invisible parts.
|
|
137
|
+
*
|
|
138
|
+
* Resume: `options.resume` seeds the parts of an earlier run and skips
|
|
139
|
+
* initiation. The caller guarantees the incoming chunks begin at the byte
|
|
140
|
+
* position right after the seeded parts — it re-seals from a chunk boundary,
|
|
141
|
+
* so that guarantee only holds when `partBytes` is a multiple of the seal
|
|
142
|
+
* chunk size, an alignment invisible from here and therefore the caller's
|
|
143
|
+
* problem. What IS checkable is that the seeded parts imply a part-aligned
|
|
144
|
+
* offset, so a journal carrying a short non-final part is refused before any
|
|
145
|
+
* request goes out. `options.onState` is awaited after every completed part
|
|
146
|
+
* with the full state — the journal's durability point.
|
|
147
|
+
*/
|
|
148
|
+
uploadStream(key: string, chunks: AsyncIterable<Buffer>, options?: S3StreamOptions): Promise<{
|
|
149
|
+
remoteChecksum: string;
|
|
150
|
+
bytes: number;
|
|
151
|
+
}>;
|
|
152
|
+
/**
|
|
153
|
+
* Fetches one object back.
|
|
154
|
+
*
|
|
155
|
+
* Managed downloads from an S3-compatible provider cannot be presigned by the
|
|
156
|
+
* control plane — Spala has no SigV4 signer — so the machine signs its own
|
|
157
|
+
* GET with the short-lived credential it was vended, exactly as it does for
|
|
158
|
+
* the upload.
|
|
159
|
+
*/
|
|
160
|
+
download(lease: UploadLease): Promise<Buffer>;
|
|
161
|
+
/**
|
|
162
|
+
* Confirms the object exists remotely and reports what the provider stored.
|
|
163
|
+
*
|
|
164
|
+
* `x-amz-checksum-mode: ENABLED` is required before S3 will return a stored
|
|
165
|
+
* checksum on HEAD at all. Without it the header is simply absent, and a
|
|
166
|
+
* caller that skips the comparison when the header is missing ends up
|
|
167
|
+
* verifying nothing but the byte count.
|
|
168
|
+
*/
|
|
169
|
+
head(lease: UploadLease): Promise<{
|
|
170
|
+
exists: boolean;
|
|
171
|
+
bytes: number;
|
|
172
|
+
checksum?: string;
|
|
173
|
+
}>;
|
|
174
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fail } from "../errors.js";
|
|
3
|
+
import { presign } from "./sigv4.js";
|
|
4
|
+
export const DEFAULT_PART_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
export const MIN_PART_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
const sha256b64 = (body) => createHash("sha256").update(body).digest("base64");
|
|
7
|
+
/**
|
|
8
|
+
* The checksum S3 stores for a multipart object, computed the way S3 computes it:
|
|
9
|
+
* SHA-256 over the concatenated raw part digests, base64, then `-<part count>`.
|
|
10
|
+
*
|
|
11
|
+
* Worth deriving rather than skipping. A multipart object's stored checksum is
|
|
12
|
+
* never the whole-object SHA-256, so comparing the two always fails — and the
|
|
13
|
+
* cheap way out is to skip the check whenever a `-N` suffix appears, which
|
|
14
|
+
* silently disables verification for exactly the largest archives. Computing it
|
|
15
|
+
* keeps the comparison real at every size.
|
|
16
|
+
*/
|
|
17
|
+
export function compositeChecksum(partChecksumsB64) {
|
|
18
|
+
const digests = Buffer.concat(partChecksumsB64.map((part) => Buffer.from(part, "base64")));
|
|
19
|
+
return `${createHash("sha256").update(digests).digest("base64")}-${partChecksumsB64.length}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The byte position a resumed stream must start feeding from: one full part per
|
|
23
|
+
* seeded entry, with exactly one exception.
|
|
24
|
+
*
|
|
25
|
+
* A short part anywhere but LAST means the journal describes a
|
|
26
|
+
* differently-shaped upload (or a different part size), and continuing would
|
|
27
|
+
* splice new bytes at the wrong offset — refused. A short LAST seed is the
|
|
28
|
+
* one legitimate shape: the object ended there, and the only thing left to do
|
|
29
|
+
* is complete. `uploadStream` therefore refuses any further data after a short
|
|
30
|
+
* last seed — bytes cannot follow a tail.
|
|
31
|
+
*/
|
|
32
|
+
export function resumeOffsetBytes(parts, partBytes) {
|
|
33
|
+
let offset = 0;
|
|
34
|
+
parts.forEach((part, index) => {
|
|
35
|
+
if (part.bytes !== partBytes && index !== parts.length - 1) {
|
|
36
|
+
fail("invalid_argument", `Cannot resume this upload: seeded part ${part.partNumber} holds ${part.bytes} bytes, but every part before the final one must hold exactly ${partBytes} bytes for the resume offset to land on a part boundary.`, { partNumber: part.partNumber, partBytes, bytes: part.bytes });
|
|
37
|
+
}
|
|
38
|
+
offset += part.bytes;
|
|
39
|
+
});
|
|
40
|
+
return offset;
|
|
41
|
+
}
|
|
42
|
+
/** True when the seeds end in a short (object-ending) part: completion-only resume. */
|
|
43
|
+
export function seededTailIsFinal(parts, partBytes) {
|
|
44
|
+
return parts.length > 0 && parts[parts.length - 1].bytes !== partBytes;
|
|
45
|
+
}
|
|
46
|
+
/** Minimal extraction for the three fields S3 returns in XML. No general XML parsing is required. */
|
|
47
|
+
/**
|
|
48
|
+
* Where each S3-compatible provider lives when no host is given explicitly.
|
|
49
|
+
*
|
|
50
|
+
* B2 and R2 speak S3, so they reuse this client wholesale; only the endpoint
|
|
51
|
+
* differs. B2 is path-style (`.../bucket/key`) rather than virtual-hosted,
|
|
52
|
+
* which is why the caller must not assume a bucket-prefixed host.
|
|
53
|
+
*/
|
|
54
|
+
function defaultHost(endpoint) {
|
|
55
|
+
if (endpoint.provider === "b2")
|
|
56
|
+
return `s3.${endpoint.region}.backblazeb2.com`;
|
|
57
|
+
// R2 endpoints are per Cloudflare account, so there is nothing to derive from
|
|
58
|
+
// bucket and region. Falling through to the AWS host would send a presigned
|
|
59
|
+
// request — carrying the customer's R2 access key id in X-Amz-Credential — to
|
|
60
|
+
// Amazon, for a bucket name they do not own. Refuse instead.
|
|
61
|
+
if (endpoint.provider === "r2")
|
|
62
|
+
fail("storage_not_configured", "R2 needs its endpoint host: pass --endpoint https://<account-id>.r2.cloudflarestorage.com", { provider: "r2", bucket: endpoint.bucket });
|
|
63
|
+
return `${endpoint.bucket}.s3.${endpoint.region}.amazonaws.com`;
|
|
64
|
+
}
|
|
65
|
+
export function xmlValue(body, tag) {
|
|
66
|
+
const match = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`).exec(body);
|
|
67
|
+
return match ? match[1] : null;
|
|
68
|
+
}
|
|
69
|
+
export function target(endpoint, key) {
|
|
70
|
+
const host = endpoint.host ?? defaultHost(endpoint);
|
|
71
|
+
const pathStyle = endpoint.pathStyle ?? (endpoint.provider === "b2" || Boolean(endpoint.host));
|
|
72
|
+
return { host, path: pathStyle ? `/${endpoint.bucket}/${key}` : `/${key}` };
|
|
73
|
+
}
|
|
74
|
+
async function readError(response) {
|
|
75
|
+
const body = await response.text().catch(() => "");
|
|
76
|
+
return `${response.status} ${response.statusText}${body ? `: ${xmlValue(body, "Message") ?? body.slice(0, 200)}` : ""}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* An S3-compatible upload client for S3, R2, and MinIO.
|
|
80
|
+
*
|
|
81
|
+
* Requests are presigned locally with credentials this process was handed; no
|
|
82
|
+
* long-lived credential is ever written to a Sealkeep config file. Only the
|
|
83
|
+
* checksum header is signed, because `content-length` is a forbidden header for
|
|
84
|
+
* `fetch` and could not be sent verbatim — the SHA-256 already pins the bytes.
|
|
85
|
+
*/
|
|
86
|
+
export class S3UploadClient {
|
|
87
|
+
kind;
|
|
88
|
+
endpoint;
|
|
89
|
+
credentials;
|
|
90
|
+
partBytes;
|
|
91
|
+
constructor(kind, endpoint, credentials, partBytes = DEFAULT_PART_BYTES,
|
|
92
|
+
/** S3 requires every part except the last to be at least 5 MiB. Local emulators and
|
|
93
|
+
* MinIO do not, so this is the deliberate opt-out for those targets only. */
|
|
94
|
+
allowSmallParts = false) {
|
|
95
|
+
this.kind = kind;
|
|
96
|
+
this.endpoint = endpoint;
|
|
97
|
+
this.credentials = credentials;
|
|
98
|
+
this.partBytes = partBytes;
|
|
99
|
+
if (partBytes < MIN_PART_BYTES && !allowSmallParts)
|
|
100
|
+
fail("invalid_argument", `S3 multipart parts must be at least ${MIN_PART_BYTES} bytes`, { partBytes });
|
|
101
|
+
if (partBytes <= 0)
|
|
102
|
+
fail("invalid_argument", "partBytes must be positive");
|
|
103
|
+
}
|
|
104
|
+
sign(method, key, query, headers = {}, expiresInSeconds = 900) {
|
|
105
|
+
const { host, path } = target(this.endpoint, key);
|
|
106
|
+
return presign({ method, host, port: this.endpoint.port, protocol: this.endpoint.protocol, path, region: this.endpoint.region, credentials: this.credentials, query, headers, expiresInSeconds });
|
|
107
|
+
}
|
|
108
|
+
async put(key, body, query = {}) {
|
|
109
|
+
const checksum = sha256b64(body);
|
|
110
|
+
const { url } = this.sign("PUT", key, query, { "x-amz-checksum-sha256": checksum });
|
|
111
|
+
const response = await fetch(url, { method: "PUT", body: new Uint8Array(body), headers: { "x-amz-checksum-sha256": checksum } });
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
fail("internal", `Upload failed for ${key}: ${await readError(response)}`, { key, status: response.status });
|
|
114
|
+
return { etag: (response.headers.get("etag") ?? "").replace(/"/g, ""), checksum };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The chunk-folder layout's whole provider API: put one object, ask about
|
|
118
|
+
* one object, remove one object. A 4 MB sealed chunk has a known length, so
|
|
119
|
+
* a plain checksummed PUT does everything multipart machinery did — which
|
|
120
|
+
* is exactly why the chunk layout needs none of it.
|
|
121
|
+
*/
|
|
122
|
+
async putObject(key, body) {
|
|
123
|
+
const { checksum } = await this.put(key, body);
|
|
124
|
+
return { checksum };
|
|
125
|
+
}
|
|
126
|
+
async headObject(key) {
|
|
127
|
+
const { url } = this.sign("HEAD", key, {}, { "x-amz-checksum-mode": "ENABLED" });
|
|
128
|
+
const response = await fetch(url, { method: "HEAD", headers: { "x-amz-checksum-mode": "ENABLED" } });
|
|
129
|
+
if (response.status === 404)
|
|
130
|
+
return { exists: false, bytes: 0 };
|
|
131
|
+
if (!response.ok)
|
|
132
|
+
fail("internal", `Could not read ${key}: ${response.status} ${response.statusText}`, { key, status: response.status });
|
|
133
|
+
return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: response.headers.get("x-amz-checksum-sha256") ?? undefined };
|
|
134
|
+
}
|
|
135
|
+
async getObject(key) {
|
|
136
|
+
const { url } = this.sign("GET", key, {});
|
|
137
|
+
const response = await fetch(url);
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
fail("internal", `Could not download ${key}: ${await readError(response)}`, { key, status: response.status });
|
|
140
|
+
return Buffer.from(await response.arrayBuffer());
|
|
141
|
+
}
|
|
142
|
+
async deleteObject(key) {
|
|
143
|
+
const { url } = this.sign("DELETE", key, {});
|
|
144
|
+
const response = await fetch(url, { method: "DELETE" });
|
|
145
|
+
if (!response.ok && response.status !== 404)
|
|
146
|
+
fail("internal", `Could not delete ${key}: ${await readError(response)}`, { key, status: response.status });
|
|
147
|
+
}
|
|
148
|
+
async initiateMultipart(key) {
|
|
149
|
+
const { url } = this.sign("POST", key, { uploads: "" });
|
|
150
|
+
const response = await fetch(url, { method: "POST" });
|
|
151
|
+
if (!response.ok)
|
|
152
|
+
fail("internal", `Could not start a multipart upload for ${key}: ${await readError(response)}`, { key });
|
|
153
|
+
const uploadId = xmlValue(await response.text(), "UploadId");
|
|
154
|
+
return uploadId ?? fail("internal", `Multipart initiation for ${key} returned no UploadId`, { key });
|
|
155
|
+
}
|
|
156
|
+
async completeMultipart(key, uploadId, parts) {
|
|
157
|
+
const body = Buffer.from(`<CompleteMultipartUpload>${parts.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>"${part.etag}"</ETag><ChecksumSHA256>${part.checksum}</ChecksumSHA256></Part>`).join("")}</CompleteMultipartUpload>`);
|
|
158
|
+
const { url } = this.sign("POST", key, { uploadId });
|
|
159
|
+
const response = await fetch(url, { method: "POST", body: new Uint8Array(body) });
|
|
160
|
+
if (!response.ok)
|
|
161
|
+
fail("internal", `Could not complete the multipart upload for ${key}: ${await readError(response)}`, { key });
|
|
162
|
+
const text = await response.text();
|
|
163
|
+
if (xmlValue(text, "Code"))
|
|
164
|
+
fail("internal", `Multipart completion for ${key} reported ${xmlValue(text, "Code")}`, { key });
|
|
165
|
+
return (xmlValue(text, "ETag") ?? "").replace(/"|"/g, "");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* What the provider ACTUALLY holds for an in-flight multipart — the resume
|
|
169
|
+
* path's source of truth. A journal can lag the provider by exactly the
|
|
170
|
+
* parts that landed between a PUT and the journal write that would have
|
|
171
|
+
* recorded them; ListParts is how a resume sees those, so nothing the
|
|
172
|
+
* provider holds can be silently overwritten with re-sealed bytes.
|
|
173
|
+
*/
|
|
174
|
+
async listParts(key, uploadId) {
|
|
175
|
+
const { url } = this.sign("GET", key, { uploadId });
|
|
176
|
+
const response = await fetch(url);
|
|
177
|
+
if (!response.ok)
|
|
178
|
+
fail("internal", `Cannot resume this upload: the provider would not list its parts (${await readError(response)})`, { key, uploadId });
|
|
179
|
+
const text = await response.text();
|
|
180
|
+
const parts = [];
|
|
181
|
+
for (const match of text.matchAll(/<Part>([\s\S]*?)<\/Part>/g)) {
|
|
182
|
+
const block = match[1];
|
|
183
|
+
const partNumber = Number(xmlValue(block, "PartNumber"));
|
|
184
|
+
const etag = (xmlValue(block, "ETag") ?? "").replace(/"|"/g, "");
|
|
185
|
+
const bytes = Number(xmlValue(block, "Size"));
|
|
186
|
+
if (!Number.isInteger(partNumber) || !Number.isFinite(bytes))
|
|
187
|
+
continue;
|
|
188
|
+
parts.push({ partNumber, etag, bytes });
|
|
189
|
+
}
|
|
190
|
+
parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
191
|
+
return parts;
|
|
192
|
+
}
|
|
193
|
+
async abortMultipart(key, uploadId) {
|
|
194
|
+
const { url } = this.sign("DELETE", key, { uploadId });
|
|
195
|
+
await fetch(url, { method: "DELETE" }).catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
/** Uploads ciphertext and returns the checksum the provider confirmed. */
|
|
198
|
+
async upload(lease, ciphertext) {
|
|
199
|
+
const key = lease.objectKey;
|
|
200
|
+
if (ciphertext.length <= this.partBytes) {
|
|
201
|
+
const { checksum } = await this.put(key, ciphertext);
|
|
202
|
+
return { remoteChecksum: checksum, bytes: ciphertext.length };
|
|
203
|
+
}
|
|
204
|
+
const uploadId = await this.initiateMultipart(key);
|
|
205
|
+
try {
|
|
206
|
+
const parts = [];
|
|
207
|
+
for (let offset = 0, partNumber = 1; offset < ciphertext.length; offset += this.partBytes, partNumber += 1) {
|
|
208
|
+
const slice = ciphertext.subarray(offset, Math.min(offset + this.partBytes, ciphertext.length));
|
|
209
|
+
const { etag, checksum } = await this.put(key, slice, { partNumber: String(partNumber), uploadId });
|
|
210
|
+
parts.push({ partNumber, etag, checksum });
|
|
211
|
+
}
|
|
212
|
+
await this.completeMultipart(key, uploadId, parts);
|
|
213
|
+
// The composite, not sha256b64(ciphertext): a multipart object's stored
|
|
214
|
+
// checksum is derived from the part digests, so the whole-object hash
|
|
215
|
+
// would never match what `head` reads back.
|
|
216
|
+
return { remoteChecksum: compositeChecksum(parts.map((part) => part.checksum)), bytes: ciphertext.length };
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
await this.abortMultipart(key, uploadId);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Streams an object up as it is produced: stored chunks accumulate into
|
|
225
|
+
* multipart parts of `partBytes`, each part uploads as soon as it fills, and
|
|
226
|
+
* the remainder goes up as the final part. A source that ends before one
|
|
227
|
+
* part fills becomes a plain single PUT — S3 refuses multiparts under 5 MB
|
|
228
|
+
* except as the last part, and a one-part multipart is just ceremony.
|
|
229
|
+
*
|
|
230
|
+
* Peak memory is one part buffer. On any failure the multipart is aborted so
|
|
231
|
+
* no half-object lingers as billable invisible parts.
|
|
232
|
+
*
|
|
233
|
+
* Resume: `options.resume` seeds the parts of an earlier run and skips
|
|
234
|
+
* initiation. The caller guarantees the incoming chunks begin at the byte
|
|
235
|
+
* position right after the seeded parts — it re-seals from a chunk boundary,
|
|
236
|
+
* so that guarantee only holds when `partBytes` is a multiple of the seal
|
|
237
|
+
* chunk size, an alignment invisible from here and therefore the caller's
|
|
238
|
+
* problem. What IS checkable is that the seeded parts imply a part-aligned
|
|
239
|
+
* offset, so a journal carrying a short non-final part is refused before any
|
|
240
|
+
* request goes out. `options.onState` is awaited after every completed part
|
|
241
|
+
* with the full state — the journal's durability point.
|
|
242
|
+
*/
|
|
243
|
+
async uploadStream(key, chunks, options = {}) {
|
|
244
|
+
const resume = options.resume;
|
|
245
|
+
if (resume) {
|
|
246
|
+
// Refusals happen before the try block on purpose: the catch below
|
|
247
|
+
// aborts the multipart, and a rejected resume must leave the seeded
|
|
248
|
+
// parts alive so the caller can correct its journal and try again.
|
|
249
|
+
resume.parts.forEach((part, index) => {
|
|
250
|
+
if (part.partNumber !== index + 1) {
|
|
251
|
+
fail("invalid_argument", `Cannot resume this upload: the seeded parts must be numbered 1 through ${resume.parts.length} without gaps, but entry ${index + 1} calls itself part ${part.partNumber}.`, { key, uploadId: resume.uploadId });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const seededBytes = resume ? resumeOffsetBytes(resume.parts, this.partBytes) : 0;
|
|
256
|
+
const completionOnly = resume ? seededTailIsFinal(resume.parts, this.partBytes) : false;
|
|
257
|
+
let uploadId = resume?.uploadId ?? null;
|
|
258
|
+
let held = [];
|
|
259
|
+
let heldBytes = 0;
|
|
260
|
+
// Whole-object accounting: head() reports the object's full length, so a
|
|
261
|
+
// resumed run counts the seeded bytes as well as what it sends itself.
|
|
262
|
+
let total = seededBytes;
|
|
263
|
+
const parts = resume ? resume.parts.map((part) => ({ ...part })) : [];
|
|
264
|
+
// Snapshots, not the live array: the journal may hold a state across an
|
|
265
|
+
// await while more parts land, and a shared reference would let a later
|
|
266
|
+
// push rewrite history the journal already recorded.
|
|
267
|
+
const journal = async (id) => {
|
|
268
|
+
if (options.onState)
|
|
269
|
+
await options.onState({ uploadId: id, parts: parts.map((part) => ({ ...part })) });
|
|
270
|
+
};
|
|
271
|
+
try {
|
|
272
|
+
for await (const chunk of chunks) {
|
|
273
|
+
if (completionOnly && chunk.length > 0) {
|
|
274
|
+
fail("invalid_argument", "Cannot resume this upload with more data: the seeded parts already end in the object's final short part, so the only thing left is completion.", { key });
|
|
275
|
+
}
|
|
276
|
+
held.push(chunk);
|
|
277
|
+
heldBytes += chunk.length;
|
|
278
|
+
total += chunk.length;
|
|
279
|
+
while (heldBytes >= this.partBytes) {
|
|
280
|
+
const buffered = Buffer.concat(held);
|
|
281
|
+
const part = buffered.subarray(0, this.partBytes);
|
|
282
|
+
held = part.length < buffered.length ? [buffered.subarray(this.partBytes)] : [];
|
|
283
|
+
heldBytes = buffered.length - part.length;
|
|
284
|
+
uploadId = uploadId ?? await this.initiateMultipart(key);
|
|
285
|
+
const { etag, checksum } = await this.put(key, part, { partNumber: String(parts.length + 1), uploadId });
|
|
286
|
+
parts.push({ partNumber: parts.length + 1, etag, checksum, bytes: part.length });
|
|
287
|
+
await journal(uploadId);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const tail = Buffer.concat(held);
|
|
291
|
+
if (!uploadId) {
|
|
292
|
+
// The whole object fit inside one part: a single PUT stores it with a
|
|
293
|
+
// whole-object checksum, exactly like the buffered small-object path.
|
|
294
|
+
// (Never reached on resume — a seeded uploadId always exists there.)
|
|
295
|
+
const { checksum } = await this.put(key, tail);
|
|
296
|
+
return { remoteChecksum: checksum, bytes: total };
|
|
297
|
+
}
|
|
298
|
+
if (tail.length > 0 || parts.length === 0) {
|
|
299
|
+
const { etag, checksum } = await this.put(key, tail, { partNumber: String(parts.length + 1), uploadId });
|
|
300
|
+
parts.push({ partNumber: parts.length + 1, etag, checksum, bytes: tail.length });
|
|
301
|
+
await journal(uploadId);
|
|
302
|
+
}
|
|
303
|
+
await this.completeMultipart(key, uploadId, parts);
|
|
304
|
+
return { remoteChecksum: compositeChecksum(parts.map((part) => part.checksum)), bytes: total };
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (uploadId)
|
|
308
|
+
await this.abortMultipart(key, uploadId);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Fetches one object back.
|
|
314
|
+
*
|
|
315
|
+
* Managed downloads from an S3-compatible provider cannot be presigned by the
|
|
316
|
+
* control plane — Spala has no SigV4 signer — so the machine signs its own
|
|
317
|
+
* GET with the short-lived credential it was vended, exactly as it does for
|
|
318
|
+
* the upload.
|
|
319
|
+
*/
|
|
320
|
+
async download(lease) {
|
|
321
|
+
const { url } = this.sign("GET", lease.objectKey, {});
|
|
322
|
+
const response = await fetch(url, { method: "GET" });
|
|
323
|
+
if (!response.ok)
|
|
324
|
+
fail("internal", `Could not read ${lease.objectKey}: ${await readError(response)}`, { key: lease.objectKey, status: response.status });
|
|
325
|
+
return Buffer.from(await response.arrayBuffer());
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Confirms the object exists remotely and reports what the provider stored.
|
|
329
|
+
*
|
|
330
|
+
* `x-amz-checksum-mode: ENABLED` is required before S3 will return a stored
|
|
331
|
+
* checksum on HEAD at all. Without it the header is simply absent, and a
|
|
332
|
+
* caller that skips the comparison when the header is missing ends up
|
|
333
|
+
* verifying nothing but the byte count.
|
|
334
|
+
*/
|
|
335
|
+
async head(lease) {
|
|
336
|
+
const mode = { "x-amz-checksum-mode": "ENABLED" };
|
|
337
|
+
const { url } = this.sign("HEAD", lease.objectKey, {}, mode);
|
|
338
|
+
const response = await fetch(url, { method: "HEAD", headers: mode });
|
|
339
|
+
if (response.status === 404)
|
|
340
|
+
return { exists: false, bytes: 0 };
|
|
341
|
+
if (!response.ok)
|
|
342
|
+
fail("internal", `Could not read ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
|
|
343
|
+
return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: response.headers.get("x-amz-checksum-sha256") ?? undefined };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4 for S3-compatible endpoints (S3, R2, MinIO).
|
|
3
|
+
*
|
|
4
|
+
* Implemented from the published specification and checked against AWS's own
|
|
5
|
+
* documented example in `test/sigv4.test.ts`, including the intermediate canonical
|
|
6
|
+
* request and string-to-sign, so a regression is caught at the step that broke.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
9
|
+
export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
10
|
+
export type Credentials = {
|
|
11
|
+
accessKeyId: string;
|
|
12
|
+
secretAccessKey: string;
|
|
13
|
+
sessionToken?: string;
|
|
14
|
+
};
|
|
15
|
+
/** RFC 3986 unreserved characters stay literal; everything else is percent-encoded. */
|
|
16
|
+
export declare function uriEncode(value: string, encodeSlash?: boolean): string;
|
|
17
|
+
export declare function canonicalUri(path: string): string;
|
|
18
|
+
export declare function canonicalQuery(query: Iterable<[string, string]>): string;
|
|
19
|
+
export declare function canonicalHeaders(headers: Record<string, string>): {
|
|
20
|
+
canonical: string;
|
|
21
|
+
signed: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function canonicalRequest(input: {
|
|
24
|
+
method: string;
|
|
25
|
+
path: string;
|
|
26
|
+
query: Iterable<[string, string]>;
|
|
27
|
+
headers: Record<string, string>;
|
|
28
|
+
payloadHash: string;
|
|
29
|
+
}): {
|
|
30
|
+
request: string;
|
|
31
|
+
signedHeaders: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function credentialScope(date: string, region: string, service: string): string;
|
|
34
|
+
export declare function stringToSign(amzDate: string, scope: string, request: string): string;
|
|
35
|
+
export declare function signingKey(secretAccessKey: string, date: string, region: string, service: string): Buffer;
|
|
36
|
+
export declare function sign(secretAccessKey: string, date: string, region: string, service: string, toSign: string): string;
|
|
37
|
+
/** `20130524T000000Z` and its `20130524` date portion. */
|
|
38
|
+
export declare function amzDates(when: Date): {
|
|
39
|
+
amzDate: string;
|
|
40
|
+
date: string;
|
|
41
|
+
};
|
|
42
|
+
export type PresignInput = {
|
|
43
|
+
method: string;
|
|
44
|
+
host: string;
|
|
45
|
+
path: string;
|
|
46
|
+
region: string;
|
|
47
|
+
service?: string;
|
|
48
|
+
credentials: Credentials;
|
|
49
|
+
expiresInSeconds?: number;
|
|
50
|
+
query?: Record<string, string>;
|
|
51
|
+
headers?: Record<string, string>;
|
|
52
|
+
when?: Date;
|
|
53
|
+
payloadHash?: string;
|
|
54
|
+
protocol?: "https" | "http";
|
|
55
|
+
port?: number;
|
|
56
|
+
};
|
|
57
|
+
export type PresignResult = {
|
|
58
|
+
url: string;
|
|
59
|
+
signature: string;
|
|
60
|
+
canonicalRequest: string;
|
|
61
|
+
stringToSign: string;
|
|
62
|
+
signedHeaders: string;
|
|
63
|
+
expiresAt: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Produces a presigned URL. Host is always signed; any extra header supplied here
|
|
67
|
+
* is signed too and must be sent verbatim by the uploader or the request is rejected.
|
|
68
|
+
*/
|
|
69
|
+
export declare function presign(input: PresignInput): PresignResult;
|
|
70
|
+
/** Recomputes a presigned signature so a server can verify it. Used by the test emulator. */
|
|
71
|
+
export declare function verifyPresigned(input: {
|
|
72
|
+
method: string;
|
|
73
|
+
url: URL;
|
|
74
|
+
credentials: Credentials;
|
|
75
|
+
region: string;
|
|
76
|
+
service?: string;
|
|
77
|
+
extraHeaders?: Record<string, string>;
|
|
78
|
+
}): boolean;
|