@daisy-workflow/plugin-aws-s3 0.1.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/lib/actions.js ADDED
@@ -0,0 +1,403 @@
1
+ // Operation handlers for the aws-s3 plugin. Same shape as the generic
2
+ // s3 plugin, plus AWS-specific extras: storage class, server-side
3
+ // encryption (KMS), object tags, requester pays, presigned URLs, and
4
+ // bucket location.
5
+
6
+ import { buildUrl, s3Fetch, xmlText, xmlAll, encodeTags, endpointForRegion } from "./client.js";
7
+ import { presignUrl } from "./sigv4.js";
8
+
9
+ // ── shared header helpers ─────────────────────────────────────────────
10
+ // Apply optional AWS-specific upload headers to a header bag.
11
+ function applyUploadExtras(headers, input) {
12
+ const { acl, storageClass, serverSideEncryption, ssekmsKeyId, tags, requesterPays, metadata } = input || {};
13
+ if (acl) headers["x-amz-acl"] = acl;
14
+ if (storageClass) headers["x-amz-storage-class"] = storageClass;
15
+ if (serverSideEncryption) headers["x-amz-server-side-encryption"] = serverSideEncryption;
16
+ if (ssekmsKeyId) headers["x-amz-server-side-encryption-aws-kms-key-id"] = ssekmsKeyId;
17
+ if (requesterPays === true) headers["x-amz-request-payer"] = "requester";
18
+ const tagging = encodeTags(tags);
19
+ if (tagging) headers["x-amz-tagging"] = tagging;
20
+ if (metadata && typeof metadata === "object") {
21
+ for (const [k, v] of Object.entries(metadata)) {
22
+ headers[`x-amz-meta-${k.toLowerCase()}`] = String(v);
23
+ }
24
+ }
25
+ }
26
+
27
+ // ── bucket.getAll ─────────────────────────────────────────────────────
28
+ export async function bucketGetAll(auth, input, signal) {
29
+ const { timeoutMs = 30000 } = input || {};
30
+ const url = buildUrl(auth, {});
31
+ const { status, body } = await s3Fetch(auth, { method: "GET", url }, timeoutMs, signal);
32
+ const xml = body.toString("utf8");
33
+ const buckets = xmlAll(xml, "Bucket").map(b => ({
34
+ name: xmlText(b, "Name"),
35
+ creationDate: xmlText(b, "CreationDate"),
36
+ }));
37
+ return {
38
+ status,
39
+ result: { owner: { id: xmlText(xml, "ID"), displayName: xmlText(xml, "DisplayName") }, buckets, count: buckets.length },
40
+ url,
41
+ };
42
+ }
43
+
44
+ // ── bucket.create ─────────────────────────────────────────────────────
45
+ export async function bucketCreate(auth, input, signal) {
46
+ const { bucket, acl, timeoutMs = 30000 } = input || {};
47
+ if (!bucket) throw new Error("operation=bucket.create requires bucket");
48
+
49
+ let body = "";
50
+ if (auth.region && auth.region !== "us-east-1") {
51
+ body =
52
+ `<?xml version="1.0" encoding="UTF-8"?>` +
53
+ `<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">` +
54
+ `<LocationConstraint>${auth.region}</LocationConstraint>` +
55
+ `</CreateBucketConfiguration>`;
56
+ }
57
+ const headers = { "Content-Type": "application/xml" };
58
+ if (acl) headers["x-amz-acl"] = acl;
59
+
60
+ const url = buildUrl(auth, { bucket });
61
+ const { status } = await s3Fetch(auth, { method: "PUT", url, headers, body }, timeoutMs, signal);
62
+ return { status, result: { bucket, created: true, region: auth.region }, url };
63
+ }
64
+
65
+ // ── bucket.delete ─────────────────────────────────────────────────────
66
+ export async function bucketDelete(auth, input, signal) {
67
+ const { bucket, timeoutMs = 30000 } = input || {};
68
+ if (!bucket) throw new Error("operation=bucket.delete requires bucket");
69
+ const url = buildUrl(auth, { bucket });
70
+ const { status } = await s3Fetch(auth, { method: "DELETE", url }, timeoutMs, signal);
71
+ return { status, result: { bucket, deleted: true }, url };
72
+ }
73
+
74
+ // ── bucket.search ─────────────────────────────────────────────────────
75
+ export async function bucketSearch(auth, input, signal) {
76
+ return fileGetAll(auth, input, signal);
77
+ }
78
+
79
+ // ── bucket.location ───────────────────────────────────────────────────
80
+ // GET /<bucket>?location → LocationConstraint
81
+ // AWS quirk: us-east-1 returns an empty <LocationConstraint/>.
82
+ export async function bucketLocation(auth, input, signal) {
83
+ const { bucket, timeoutMs = 30000 } = input || {};
84
+ if (!bucket) throw new Error("operation=bucket.location requires bucket");
85
+ const url = buildUrl(auth, { bucket, query: { location: "" } });
86
+ const { status, body } = await s3Fetch(auth, { method: "GET", url }, timeoutMs, signal);
87
+ const xml = body.toString("utf8");
88
+ const loc = xmlText(xml, "LocationConstraint", "") || "us-east-1";
89
+ return { status, result: { bucket, location: loc }, url };
90
+ }
91
+
92
+ // ── file.getAll ───────────────────────────────────────────────────────
93
+ export async function fileGetAll(auth, input, signal) {
94
+ const { bucket, prefix, maxKeys = 1000, continuationToken, requesterPays, timeoutMs = 30000 } = input || {};
95
+ if (!bucket) throw new Error("operation=file.getAll requires bucket");
96
+
97
+ const query = { "list-type": "2", "max-keys": String(Math.min(1000, Math.max(1, Number(maxKeys) || 1000))) };
98
+ if (prefix) query.prefix = prefix;
99
+ if (continuationToken) query["continuation-token"] = continuationToken;
100
+
101
+ const headers = {};
102
+ if (requesterPays === true) headers["x-amz-request-payer"] = "requester";
103
+
104
+ const url = buildUrl(auth, { bucket, query });
105
+ const { status, body } = await s3Fetch(auth, { method: "GET", url, headers }, timeoutMs, signal);
106
+ const xml = body.toString("utf8");
107
+
108
+ const objects = xmlAll(xml, "Contents").map(c => ({
109
+ key: xmlText(c, "Key"),
110
+ lastModified: xmlText(c, "LastModified"),
111
+ etag: (xmlText(c, "ETag") || "").replace(/^"|"$/g, ""),
112
+ size: Number(xmlText(c, "Size") || 0),
113
+ storageClass: xmlText(c, "StorageClass"),
114
+ }));
115
+
116
+ return {
117
+ status,
118
+ result: {
119
+ bucket,
120
+ prefix: prefix || null,
121
+ isTruncated: xmlText(xml, "IsTruncated") === "true",
122
+ nextContinuationToken: xmlText(xml, "NextContinuationToken"),
123
+ keyCount: Number(xmlText(xml, "KeyCount") || objects.length),
124
+ objects,
125
+ },
126
+ url,
127
+ };
128
+ }
129
+
130
+ // ── file.head ─────────────────────────────────────────────────────────
131
+ // Fetch only metadata (HEAD, no body). Useful to test existence /
132
+ // inspect storage class / SSE without paying transfer.
133
+ export async function fileHead(auth, input, signal) {
134
+ const { bucket, key, requesterPays, timeoutMs = 30000 } = input || {};
135
+ if (!bucket) throw new Error("operation=file.head requires bucket");
136
+ if (!key) throw new Error("operation=file.head requires key");
137
+
138
+ const headers = {};
139
+ if (requesterPays === true) headers["x-amz-request-payer"] = "requester";
140
+
141
+ const url = buildUrl(auth, { bucket, key });
142
+ const { status, headers: h } = await s3Fetch(auth, { method: "HEAD", url, headers }, timeoutMs, signal);
143
+ return {
144
+ status,
145
+ result: {
146
+ bucket, key,
147
+ contentType: h["content-type"] || null,
148
+ contentLength: Number(h["content-length"] || 0),
149
+ etag: (h.etag || "").replace(/^"|"$/g, ""),
150
+ lastModified: h["last-modified"] || null,
151
+ versionId: h["x-amz-version-id"] || null,
152
+ storageClass: h["x-amz-storage-class"] || "STANDARD",
153
+ serverSideEncryption: h["x-amz-server-side-encryption"] || null,
154
+ ssekmsKeyId: h["x-amz-server-side-encryption-aws-kms-key-id"] || null,
155
+ metadata: Object.fromEntries(
156
+ Object.entries(h).filter(([k]) => k.startsWith("x-amz-meta-")).map(([k, v]) => [k.replace("x-amz-meta-", ""), v]),
157
+ ),
158
+ },
159
+ url,
160
+ };
161
+ }
162
+
163
+ // ── file.upload ───────────────────────────────────────────────────────
164
+ export async function fileUpload(auth, input, signal) {
165
+ const { bucket, key, body = "", bodyEncoding = "utf8", contentType, timeoutMs = 60000 } = input || {};
166
+ if (!bucket) throw new Error("operation=file.upload requires bucket");
167
+ if (!key) throw new Error("operation=file.upload requires key");
168
+
169
+ const buf = bodyEncoding === "base64"
170
+ ? Buffer.from(String(body), "base64")
171
+ : Buffer.from(String(body), "utf8");
172
+
173
+ const headers = {
174
+ "Content-Type": contentType || (bodyEncoding === "base64" ? "application/octet-stream" : "text/plain; charset=utf-8"),
175
+ "Content-Length": String(buf.length),
176
+ };
177
+ applyUploadExtras(headers, input);
178
+
179
+ const url = buildUrl(auth, { bucket, key });
180
+ const { status, headers: rHdrs } = await s3Fetch(
181
+ auth, { method: "PUT", url, headers, body: buf }, timeoutMs, signal,
182
+ );
183
+ return {
184
+ status,
185
+ result: {
186
+ bucket, key,
187
+ size: buf.length,
188
+ etag: (rHdrs.etag || "").replace(/^"|"$/g, ""),
189
+ versionId: rHdrs["x-amz-version-id"] || null,
190
+ serverSideEncryption: rHdrs["x-amz-server-side-encryption"] || null,
191
+ ssekmsKeyId: rHdrs["x-amz-server-side-encryption-aws-kms-key-id"] || null,
192
+ },
193
+ url,
194
+ };
195
+ }
196
+
197
+ // ── file.download ─────────────────────────────────────────────────────
198
+ export async function fileDownload(auth, input, signal) {
199
+ const { bucket, key, responseEncoding = "base64", requesterPays, timeoutMs = 60000 } = input || {};
200
+ if (!bucket) throw new Error("operation=file.download requires bucket");
201
+ if (!key) throw new Error("operation=file.download requires key");
202
+
203
+ const headers = {};
204
+ if (requesterPays === true) headers["x-amz-request-payer"] = "requester";
205
+
206
+ const url = buildUrl(auth, { bucket, key });
207
+ const { status, headers: h, body } = await s3Fetch(auth, { method: "GET", url, headers }, timeoutMs, signal);
208
+
209
+ const data = responseEncoding === "utf8" ? body.toString("utf8") : body.toString("base64");
210
+ return {
211
+ status,
212
+ result: {
213
+ bucket, key,
214
+ contentType: h["content-type"] || null,
215
+ contentLength: Number(h["content-length"] || body.length),
216
+ etag: (h.etag || "").replace(/^"|"$/g, ""),
217
+ lastModified: h["last-modified"] || null,
218
+ versionId: h["x-amz-version-id"] || null,
219
+ encoding: responseEncoding,
220
+ data,
221
+ },
222
+ url,
223
+ };
224
+ }
225
+
226
+ // ── file.copy ─────────────────────────────────────────────────────────
227
+ export async function fileCopy(auth, input, signal) {
228
+ const { bucket, key, copySource, timeoutMs = 30000 } = input || {};
229
+ if (!bucket) throw new Error("operation=file.copy requires bucket");
230
+ if (!key) throw new Error("operation=file.copy requires key (destination)");
231
+ if (!copySource) throw new Error("operation=file.copy requires copySource ('srcBucket/srcKey')");
232
+
233
+ const url = buildUrl(auth, { bucket, key });
234
+ const headers = {
235
+ "x-amz-copy-source": "/" + String(copySource).split("/").map((s, i) => i === 0 ? encodeURIComponent(s) : s).join("/"),
236
+ };
237
+ // Copy also accepts the AWS-specific extras for the destination object.
238
+ applyUploadExtras(headers, input);
239
+
240
+ const { status, body } = await s3Fetch(auth, { method: "PUT", url, headers }, timeoutMs, signal);
241
+ const xml = body.toString("utf8");
242
+ return {
243
+ status,
244
+ result: {
245
+ bucket, key, copySource,
246
+ etag: (xmlText(xml, "ETag") || "").replace(/^"|"$/g, ""),
247
+ lastModified: xmlText(xml, "LastModified"),
248
+ },
249
+ url,
250
+ };
251
+ }
252
+
253
+ // ── file.delete ───────────────────────────────────────────────────────
254
+ export async function fileDelete(auth, input, signal) {
255
+ const { bucket, key, timeoutMs = 30000 } = input || {};
256
+ if (!bucket) throw new Error("operation=file.delete requires bucket");
257
+ if (!key) throw new Error("operation=file.delete requires key");
258
+ const url = buildUrl(auth, { bucket, key });
259
+ const { status, headers } = await s3Fetch(auth, { method: "DELETE", url }, timeoutMs, signal);
260
+ return { status, result: { bucket, key, deleted: true, versionId: headers["x-amz-version-id"] || null }, url };
261
+ }
262
+
263
+ // ── file.presignedUrl ─────────────────────────────────────────────────
264
+ // Generate a presigned URL that a browser / curl / IoT device can use
265
+ // without holding AWS credentials. No HTTP call is made — this is pure
266
+ // crypto.
267
+ export async function filePresignedUrl(auth, input, _signal) {
268
+ const { bucket, key, presignedMethod = "GET", presignedExpiresIn = 900 } = input || {};
269
+ if (!bucket) throw new Error("operation=file.presignedUrl requires bucket");
270
+ if (!key) throw new Error("operation=file.presignedUrl requires key");
271
+
272
+ const url = buildUrl(auth, { bucket, key });
273
+ const signed = presignUrl({
274
+ method: presignedMethod,
275
+ url,
276
+ region: auth.region,
277
+ accessKeyId: auth.accessKeyId,
278
+ secretAccessKey: auth.secretAccessKey,
279
+ sessionToken: auth.sessionToken,
280
+ expiresIn: presignedExpiresIn,
281
+ });
282
+ const expiresAt = new Date(Date.now() + presignedExpiresIn * 1000).toISOString();
283
+
284
+ return {
285
+ status: 200,
286
+ result: {
287
+ bucket, key,
288
+ method: presignedMethod,
289
+ expiresIn: presignedExpiresIn,
290
+ expiresAt,
291
+ url: signed,
292
+ },
293
+ url: signed,
294
+ };
295
+ }
296
+
297
+ // ── folder.create ─────────────────────────────────────────────────────
298
+ export async function folderCreate(auth, input, signal) {
299
+ const { bucket, folderName, timeoutMs = 30000 } = input || {};
300
+ if (!bucket) throw new Error("operation=folder.create requires bucket");
301
+ if (!folderName) throw new Error("operation=folder.create requires folderName");
302
+
303
+ const key = String(folderName).replace(/\/+$/, "") + "/";
304
+ const headers = { "Content-Type": "application/x-directory", "Content-Length": "0" };
305
+ applyUploadExtras(headers, input);
306
+
307
+ const url = buildUrl(auth, { bucket, key });
308
+ const { status } = await s3Fetch(auth, { method: "PUT", url, headers, body: "" }, timeoutMs, signal);
309
+ return { status, result: { bucket, folder: key, created: true }, url };
310
+ }
311
+
312
+ // ── folder.getAll ─────────────────────────────────────────────────────
313
+ export async function folderGetAll(auth, input, signal) {
314
+ const { bucket, prefix, delimiter = "/", timeoutMs = 30000 } = input || {};
315
+ if (!bucket) throw new Error("operation=folder.getAll requires bucket");
316
+
317
+ const query = { "list-type": "2", delimiter };
318
+ if (prefix) query.prefix = prefix;
319
+
320
+ const url = buildUrl(auth, { bucket, query });
321
+ const { status, body } = await s3Fetch(auth, { method: "GET", url }, timeoutMs, signal);
322
+ const xml = body.toString("utf8");
323
+ const folders = xmlAll(xml, "CommonPrefixes").map(p => xmlText(p, "Prefix"));
324
+ return {
325
+ status,
326
+ result: { bucket, prefix: prefix || null, delimiter, folders, count: folders.length },
327
+ url,
328
+ };
329
+ }
330
+
331
+ // ── folder.delete ─────────────────────────────────────────────────────
332
+ export async function folderDelete(auth, input, signal) {
333
+ const { bucket, prefix, timeoutMs = 60000 } = input || {};
334
+ if (!bucket) throw new Error("operation=folder.delete requires bucket");
335
+ if (!prefix) throw new Error("operation=folder.delete requires prefix (folder path)");
336
+
337
+ const normPrefix = String(prefix).endsWith("/") ? prefix : prefix + "/";
338
+ let token = null;
339
+ let totalDeleted = 0;
340
+ let totalErrors = 0;
341
+
342
+ do {
343
+ const listUrl = buildUrl(auth, {
344
+ bucket,
345
+ query: { "list-type": "2", prefix: normPrefix, "max-keys": "1000", ...(token ? { "continuation-token": token } : {}) },
346
+ });
347
+ const { body: listBody } = await s3Fetch(auth, { method: "GET", url: listUrl }, timeoutMs, signal);
348
+ const listXml = listBody.toString("utf8");
349
+ const keys = xmlAll(listXml, "Contents").map(c => xmlText(c, "Key")).filter(Boolean);
350
+ token = xmlText(listXml, "IsTruncated") === "true" ? xmlText(listXml, "NextContinuationToken") : null;
351
+ if (keys.length === 0) break;
352
+
353
+ const deleteXml =
354
+ `<?xml version="1.0" encoding="UTF-8"?>` +
355
+ `<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/">` +
356
+ keys.map(k => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("") +
357
+ `<Quiet>false</Quiet>` +
358
+ `</Delete>`;
359
+ const md5 = await md5Base64(deleteXml);
360
+
361
+ const delUrl = buildUrl(auth, { bucket, query: { delete: "" } });
362
+ const { body: delBody } = await s3Fetch(
363
+ auth,
364
+ { method: "POST", url: delUrl, headers: { "Content-Type": "application/xml", "Content-MD5": md5 }, body: deleteXml },
365
+ timeoutMs, signal,
366
+ );
367
+ const delResXml = delBody.toString("utf8");
368
+ totalDeleted += xmlAll(delResXml, "Deleted").length;
369
+ totalErrors += xmlAll(delResXml, "Error").length;
370
+ } while (token);
371
+
372
+ return { status: 200, result: { bucket, prefix: normPrefix, deleted: totalDeleted, errors: totalErrors }, url: buildUrl(auth, { bucket }) };
373
+ }
374
+
375
+ async function md5Base64(text) {
376
+ const { createHash } = await import("node:crypto");
377
+ return createHash("md5").update(text).digest("base64");
378
+ }
379
+ function escapeXml(s) {
380
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
381
+ }
382
+
383
+ // Operation → handler map. Single source of truth used by index.js.
384
+ export const OPERATIONS = {
385
+ "bucket.getAll": bucketGetAll,
386
+ "bucket.create": bucketCreate,
387
+ "bucket.delete": bucketDelete,
388
+ "bucket.search": bucketSearch,
389
+ "bucket.location": bucketLocation,
390
+ "file.getAll": fileGetAll,
391
+ "file.head": fileHead,
392
+ "file.upload": fileUpload,
393
+ "file.download": fileDownload,
394
+ "file.copy": fileCopy,
395
+ "file.delete": fileDelete,
396
+ "file.presignedUrl": filePresignedUrl,
397
+ "folder.create": folderCreate,
398
+ "folder.getAll": folderGetAll,
399
+ "folder.delete": folderDelete,
400
+ };
401
+
402
+ // re-export for the verify step / smoke tests
403
+ export { endpointForRegion };
package/lib/client.js ADDED
@@ -0,0 +1,169 @@
1
+ // AWS S3 client. Region-derived endpoints + signed-fetch wrapper +
2
+ // minimal XML helpers. Always uses virtual-host style (the AWS
3
+ // recommendation post-2020).
4
+
5
+ import { signRequest } from "./sigv4.js";
6
+
7
+ const SERVICE = "s3";
8
+
9
+ // ── auth ──────────────────────────────────────────────────────────────
10
+ export function loadAwsAuth(ctx, configName = "aws-s3", regionOverride) {
11
+ const cfg = ctx?.config?.[configName];
12
+ if (!cfg) {
13
+ throw new Error(
14
+ `AWS S3 config "${configName}" not found in workspace. ` +
15
+ `Add a generic config on the Configurations page with accessKeyId, secretAccessKey, region.`,
16
+ );
17
+ }
18
+ const region = String(regionOverride || cfg.region || "us-east-1");
19
+ const accessKeyId = String(cfg.accessKeyId || "");
20
+ const secretAccessKey = String(cfg.secretAccessKey || "");
21
+ const sessionToken = cfg.sessionToken ? String(cfg.sessionToken) : null;
22
+ const customEndpoint = cfg.customEndpoint ? String(cfg.customEndpoint).replace(/\/+$/, "") : null;
23
+
24
+ if (!accessKeyId || !secretAccessKey) {
25
+ throw new Error(
26
+ `AWS S3 config "${configName}" is missing accessKeyId / secretAccessKey.`,
27
+ );
28
+ }
29
+ return { region, accessKeyId, secretAccessKey, sessionToken, customEndpoint };
30
+ }
31
+
32
+ // ── endpoint derivation ───────────────────────────────────────────────
33
+ // AWS S3 endpoint patterns:
34
+ // • us-east-1: https://s3.amazonaws.com (legacy, still works)
35
+ // or https://s3.us-east-1.amazonaws.com (recommended)
36
+ // • other regions: https://s3.<region>.amazonaws.com
37
+ // • China: https://s3.<region>.amazonaws.com.cn
38
+ // • GovCloud: https://s3.<region>.amazonaws.com (us-gov-west-1, us-gov-east-1)
39
+ //
40
+ // When a customEndpoint is set (VPC interface endpoint, S3 Transfer
41
+ // Acceleration host, AWS PrivateLink, etc.) we use it verbatim.
42
+ export function endpointForRegion(auth) {
43
+ if (auth.customEndpoint) return auth.customEndpoint;
44
+ const suffix = auth.region.startsWith("cn-") ? "amazonaws.com.cn" : "amazonaws.com";
45
+ // Use the regional form even for us-east-1 (consistent SigV4 region).
46
+ return `https://s3.${auth.region}.${suffix}`;
47
+ }
48
+
49
+ // ── URL building ──────────────────────────────────────────────────────
50
+ // Always virtual-host style. AWS recommends this for new code and
51
+ // transfer-accelerated endpoints (s3-accelerate.amazonaws.com) require
52
+ // it.
53
+ export function buildUrl(auth, { bucket, key, query }) {
54
+ const base = new URL(endpointForRegion(auth));
55
+ if (bucket) {
56
+ // Virtual-host: bucket goes onto the hostname.
57
+ base.hostname = `${bucket}.${base.hostname}`;
58
+ base.pathname = key ? `/${encodeKey(key)}` : "/";
59
+ } else if (key) {
60
+ base.pathname = `/${encodeKey(key)}`;
61
+ }
62
+ if (query) {
63
+ for (const [k, v] of Object.entries(query)) {
64
+ if (v == null) continue;
65
+ base.searchParams.set(k, String(v));
66
+ }
67
+ }
68
+ return base.toString();
69
+ }
70
+
71
+ function encodeKey(key) {
72
+ return String(key).split("/").map(seg => encodeURIComponent(seg)).join("/");
73
+ }
74
+
75
+ // ── tagging serialization ─────────────────────────────────────────────
76
+ // x-amz-tagging is a single header containing URL-encoded query string
77
+ // pairs: "key1=value1&key2=value2".
78
+ export function encodeTags(tags) {
79
+ if (!tags || typeof tags !== "object") return null;
80
+ const parts = [];
81
+ for (const [k, v] of Object.entries(tags)) {
82
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v ?? ""))}`);
83
+ }
84
+ return parts.length ? parts.join("&") : null;
85
+ }
86
+
87
+ // ── fetch + sign ──────────────────────────────────────────────────────
88
+ export async function s3Fetch(auth, { method, url, headers = {}, body = null }, timeoutMs = 30000, signal) {
89
+ const ac = new AbortController();
90
+ const timer = setTimeout(
91
+ () => ac.abort(new Error(`AWS S3 request timed out after ${timeoutMs}ms`)),
92
+ timeoutMs,
93
+ );
94
+ const onUpstream = () => ac.abort(signal?.reason);
95
+ if (signal) {
96
+ if (signal.aborted) ac.abort(signal.reason);
97
+ else signal.addEventListener("abort", onUpstream, { once: true });
98
+ }
99
+
100
+ let bodyBuf;
101
+ if (body == null) bodyBuf = Buffer.alloc(0);
102
+ else if (Buffer.isBuffer(body)) bodyBuf = body;
103
+ else bodyBuf = Buffer.from(String(body));
104
+
105
+ const signed = signRequest({
106
+ method, url, headers, body: bodyBuf,
107
+ region: auth.region,
108
+ service: SERVICE,
109
+ accessKeyId: auth.accessKeyId,
110
+ secretAccessKey: auth.secretAccessKey,
111
+ sessionToken: auth.sessionToken,
112
+ });
113
+
114
+ try {
115
+ const res = await fetch(url, {
116
+ method,
117
+ headers: signed,
118
+ body: method === "GET" || method === "HEAD" ? undefined : bodyBuf,
119
+ signal: ac.signal,
120
+ });
121
+ const ab = await res.arrayBuffer();
122
+ const buf = Buffer.from(ab);
123
+
124
+ if (!res.ok) {
125
+ const text = buf.toString("utf8");
126
+ const err = parseS3Error(text) || { Code: `HTTP_${res.status}`, Message: text.slice(0, 500) };
127
+ const e = new Error(`AWS S3 ${method} ${url} failed: ${err.Code}: ${err.Message}`);
128
+ e.status = res.status;
129
+ e.code = err.Code;
130
+ e.body = err;
131
+ throw e;
132
+ }
133
+
134
+ const hdrs = {};
135
+ for (const [k, v] of res.headers) hdrs[k.toLowerCase()] = v;
136
+ return { status: res.status, headers: hdrs, body: buf };
137
+ } finally {
138
+ clearTimeout(timer);
139
+ if (signal) signal.removeEventListener?.("abort", onUpstream);
140
+ }
141
+ }
142
+
143
+ // ── tiny XML helpers ──────────────────────────────────────────────────
144
+ const TEXT_RE = (tag) => new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
145
+ const ALL_RE = (tag) => new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "gi");
146
+
147
+ export function xmlText(xml, tag, dflt = null) {
148
+ const m = TEXT_RE(tag).exec(xml || "");
149
+ return m ? decodeXmlEntities(m[1].trim()) : dflt;
150
+ }
151
+ export function xmlAll(xml, tag) {
152
+ const out = [];
153
+ const re = ALL_RE(tag);
154
+ let m;
155
+ while ((m = re.exec(xml || ""))) out.push(m[1]);
156
+ return out;
157
+ }
158
+ function decodeXmlEntities(s) {
159
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'");
160
+ }
161
+ function parseS3Error(xml) {
162
+ if (!xml || !xml.includes("<Error>")) return null;
163
+ return {
164
+ Code: xmlText(xml, "Code", "Unknown"),
165
+ Message: xmlText(xml, "Message", ""),
166
+ Resource: xmlText(xml, "Resource", null),
167
+ RequestId: xmlText(xml, "RequestId", null),
168
+ };
169
+ }