From 6f865a7b4d1b5d97eed7c97bd775243ce7f2f0a7 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sat, 9 May 2026 10:44:10 +0100 Subject: [PATCH] ML-173: replace aws-sdk with aws4 + built-in fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace @aws-sdk/client-s3 (~150 transitive deps, ~14MB) with aws4 (0 transitive deps, ~40KB) and Node.js built-in fetch+crypto for S3 ListObjectsV2 against Hetzner Object Storage. - New s3-client.ts: SigV4 signing via aws4, regex-based XML parsing with namespace tolerance, pagination support, 5 error categories - 29 unit tests (node:test, zero-dependency) covering signing, parsing, entities, missing fields, pagination, abort, errors - Settlement guard for double done(null) on abort - XML entity decoding and regex metacharacter escaping - Dependency surface: 150 packages → 1 (aws4, single-file, auditable) --- .pi/extensions/s3-browser/aws4.d.ts | 30 + .pi/extensions/s3-browser/index.ts | 104 +- .pi/extensions/s3-browser/package-lock.json | 1423 +---------------- .pi/extensions/s3-browser/package.json | 6 +- .pi/extensions/s3-browser/s3-client.test.ts | 656 ++++++++ .pi/extensions/s3-browser/s3-client.ts | 325 ++++ .pi/extensions/s3-browser/tsconfig.json | 15 + ...- remove-deps-from-s3-browser-extension.md | 179 +-- 8 files changed, 1133 insertions(+), 1605 deletions(-) create mode 100644 .pi/extensions/s3-browser/aws4.d.ts create mode 100644 .pi/extensions/s3-browser/s3-client.test.ts create mode 100644 .pi/extensions/s3-browser/s3-client.ts create mode 100644 .pi/extensions/s3-browser/tsconfig.json rename backlog/{tasks => completed}/ml-173 - remove-deps-from-s3-browser-extension.md (52%) diff --git a/.pi/extensions/s3-browser/aws4.d.ts b/.pi/extensions/s3-browser/aws4.d.ts new file mode 100644 index 00000000..709ae557 --- /dev/null +++ b/.pi/extensions/s3-browser/aws4.d.ts @@ -0,0 +1,30 @@ +declare module "aws4" { + interface Aws4Request { + host?: string; + path?: string; + method?: string; + headers?: Record; + service?: string; + region?: string; + body?: string; + signQuery?: boolean; + } + + interface Aws4Credentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + } + + function sign( + request: Aws4Request, + credentials: Aws4Credentials, + ): Aws4Request & { + host: string; + path: string; + headers: Record; + }; + + const aws4: { sign: typeof sign }; + export default aws4; +} diff --git a/.pi/extensions/s3-browser/index.ts b/.pi/extensions/s3-browser/index.ts index abf67385..6170e143 100644 --- a/.pi/extensions/s3-browser/index.ts +++ b/.pi/extensions/s3-browser/index.ts @@ -12,7 +12,6 @@ * /backups */ -import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { BorderedLoader, DynamicBorder } from "@mariozechner/pi-coding-agent"; import { @@ -21,6 +20,15 @@ import { SelectList, Text, } from "@mariozechner/pi-tui"; +import { listAllObjects, type S3ClientConfig } from "./s3-client"; + +// Re-export for consumers +export { + AuthError, + NetworkError, + AbortError, + type S3Object, +} from "./s3-client"; // ── S3 configuration (mirrors scripts/prod/litestream-backup) ──────────────── const S3_ENDPOINT = "https://nbg1.your-objectstorage.com"; @@ -54,55 +62,6 @@ function displayKey(key: string): string { return key; } -export interface S3Object { - key: string; - size: number; - lastModified: Date; -} - -/** - * Fetch all objects (handles pagination via ContinuationToken). - */ -async function listAllObjects( - client: S3Client, - signal?: AbortSignal, -): Promise { - const results: S3Object[] = []; - let continuationToken: string | undefined; - - do { - const command = new ListObjectsV2Command({ - Bucket: S3_BUCKET, - Prefix: S3_PREFIX, - ...(continuationToken ? { ContinuationToken: continuationToken } : {}), - }); - - const response = await client.send(command, { abortSignal: signal }); - - if (response.Contents) { - for (const obj of response.Contents) { - results.push({ - key: obj.Key ?? "", - size: obj.Size ?? 0, - lastModified: obj.LastModified ?? new Date(0), - }); - } - } - - continuationToken = response.IsTruncated - ? response.NextContinuationToken - : undefined; - } while (continuationToken); - - // Sort by size descending, then alphabetically descending - results.sort((a, b) => { - const sizeDiff = b.size - a.size; - if (sizeDiff !== 0) return sizeDiff; - return b.key.localeCompare(a.key); - }); - return results; -} - // ── Extension ─────────────────────────────────────────────────────────────── export default function s3BrowserExtension(pi: ExtensionAPI) { @@ -131,23 +90,46 @@ export default function s3BrowserExtension(pi: ExtensionAPI) { theme, "Fetching backup list from S3...", ); - loader.onAbort = () => done(null); const doFetch = async (): Promise => { - const client = new S3Client({ + const config: S3ClientConfig = { + endpoint: new URL(S3_ENDPOINT).host, region: S3_REGION, - endpoint: S3_ENDPOINT, - credentials: { accessKeyId, secretAccessKey }, - forcePathStyle: true, - }); - return listAllObjects(client, loader.signal); + bucket: S3_BUCKET, + accessKeyId, + secretAccessKey, + }; + return listAllObjects(config, { prefix: S3_PREFIX }, loader.signal); }; + let settled = false; + const finish = (result: S3Object[] | null) => { + if (!settled) { + settled = true; + done(result); + } + }; + loader.onAbort = () => finish(null); + doFetch() - .then(done) - .catch((err) => { - console.error("[s3-browser] Fetch failed:", err); - done(null); + .then(finish) + .catch((err: unknown) => { + if (err instanceof AuthError) { + ctx.ui.notify( + "Authentication failed — check LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY", + "error", + ); + } else if (err instanceof NetworkError) { + ctx.ui.notify( + "Could not reach S3 endpoint — check network", + "error", + ); + } else if (err instanceof AbortError) { + // User cancelled — clean cancellation, no notification needed + } else { + console.error("[s3-browser] Fetch failed:", err); + } + finish(null); }); return loader; diff --git a/.pi/extensions/s3-browser/package-lock.json b/.pi/extensions/s3-browser/package-lock.json index 7a266f4c..3435d5ab 100644 --- a/.pi/extensions/s3-browser/package-lock.json +++ b/.pi/extensions/s3-browser/package-lock.json @@ -6,1427 +6,14 @@ "": { "name": "s3-browser", "dependencies": { - "@aws-sdk/client-s3": "^3.787.0" + "aws4": "^1.13.2" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.1045.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1045.0.tgz", - "integrity": "sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-node": "^3.972.39", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", - "@aws-sdk/middleware-expect-continue": "^3.972.10", - "@aws-sdk/middleware-flexible-checksums": "^3.974.16", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-location-constraint": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-sdk-s3": "^3.972.37", - "@aws-sdk/middleware-ssec": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.25", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/eventstream-serde-config-resolver": "^4.3.14", - "@smithy/eventstream-serde-node": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-blob-browser": "^4.2.15", - "@smithy/hash-node": "^4.2.14", - "@smithy/hash-stream-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/md5-js": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", - "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.22", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", - "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", - "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", - "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", - "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-login": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", - "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", - "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-ini": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", - "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", - "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", - "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/token-providers": "3.1041.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", - "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", - "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", - "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz", - "integrity": "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/crc64-nvme": "^3.972.7", - "@aws-sdk/types": "^3.973.8", - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", - "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", - "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", - "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", - "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", - "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", - "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", - "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", - "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.25", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", - "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", - "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.37", - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1041.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", - "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", - "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-endpoints": "^3.4.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", - "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", - "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/types": "^3.973.8", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT" - }, - "node_modules/@smithy/config-resolver": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.5.0.tgz", - "integrity": "sha512-m5PNfr7xKdIegNG8DlLz+Gf/DlAhHWFGmFbe0DZo9pnvBwuZ3P/9OMtQU0UyWMYy8zjl+HDFVS7rdD9p2xEFjQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.0.tgz", - "integrity": "sha512-rZ5YfycIXX6puoGjthnDiMpUgtKNOq3c7CndQYkCNYQTv26AiCrZQOJPy7ANSfZ6Okk3UvCRnmO1OYWlLnYZgg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.0.tgz", - "integrity": "sha512-5gi+28FH+RurB2+tcRH1CK7KiLJ0dVnabjWLY3DgeFLiU45dbyrsq7NOYvMUcHgu9LVZH5F7G+Qk1GdXF0y6jg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.3.0.tgz", - "integrity": "sha512-JlY17/ZwBJ2O7FK/bKt8PZR+HBkyFwvgssgT6LiB0xYtz5/E5XG/HeKr5q2NMaVm8u8xjFfGk/6DVlbBe1qNkA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.4.0.tgz", - "integrity": "sha512-1Pg7aqxIdMilTbGJKCHTx0toIkKSrHdO6VHCh9oCncWJG+1wkJa90O/xb9mmRPuoOFCg2DLZAqnRyuBiUQnNIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.3.0.tgz", - "integrity": "sha512-Xte1Td6CQpc/D0WnPZ2k98CvF7y1GopylMoGY/r26a9wbRHV5xusRbT6O9vouSeZlvtxoVb4ON/1fLRofO7m4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.0.tgz", - "integrity": "sha512-yxurumLvHfgYgM0FVtjOVIyBSJXfno4xKKOgD43wOk9Qh+2lTKfP9Qhu4JHU7IUwrqVPa888byUzomHMgvKVMg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.3.0.tgz", - "integrity": "sha512-xOQ7w5hSzTe8IAwQ6BAbX+1d9s9SRwWUbU8cYYh5DHgkYOi131Q5+B9Blelg9u0zoPfM4VYOuuBRAmregPkzpQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.3.0.tgz", - "integrity": "sha512-4a+KoVqr1SZtw7cZvY24XU1S5OL+c23MdDQ3jFmMCQ5s9diBFdMG/UIgp5dNqlwvDrWA0U5KO+z3Gzq1ize+LA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.3.0.tgz", - "integrity": "sha512-DPDT0UyOREMPwipO7BzSJfD8z2YYp/x1FMpHqEppBF+gmGr7FoqUUFlXfK+YPOfHhlr8HDJRQpmShNb9C9po5Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.3.0.tgz", - "integrity": "sha512-TaoGtqi2ZNdGzxUgYcLczjW8rb/h5DQ8vlCMYDSdZ4LRzGQrrEYgUjlZVM9dAagTsLK5gZx1f7+44sFTjz5vuQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.3.0.tgz", - "integrity": "sha512-V9ZCT5mHNteWOKtu3LkGHTheEyBWzTU3XydeSOHrqG6znEoCARjxFZ0XP9JJyKV9jxEMx7hNbzGlQaXtKkmvSQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/md5-js": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.3.0.tgz", - "integrity": "sha512-bODwWrXILREpCL7XZq1/QxiMqFxdWadw3noiKGjNKsPl1/nvWtLVFInNjK6pmHjM4BUOgfgZkrl9+V56ez2FNA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.3.0.tgz", - "integrity": "sha512-IbSiS/3nOxsimCthzElEoBrjQo+Na4bsQ63qyC8qSI8lkMjOv9+VlosDQd8gfNolAD9XmC5tLqYTI0bJGJsscg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.5.0.tgz", - "integrity": "sha512-ux8LgN/m/X7ET2ISRc8G4aKFI1QhINZtkKpoayNPTrhwpsCVxb47mlpYFuWceTlesc0Wmb0S9y6DP195ReQoXA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.6.0.tgz", - "integrity": "sha512-8CtxY9aHT4f3UvZUbU2O0bccRckqTDfTKk3t1DawUZa5DWRZdV2AMABLsdMTdj7KE1uumhzEaT0X7/jTcOtoBw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.3.0.tgz", - "integrity": "sha512-c+V02hZlIStscI4ie2VllJjM4DLxdI2SymIBvXmqCqicrNb0NAbgDXDTBiwcMiruaBOqEFYxpKXbz6JjsNEN3Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.3.0.tgz", - "integrity": "sha512-KtYcs+sJn7AiT0YdM53/6MT0dKsaW2MSAr9MpprRVSfwN9qyKQf2dBIuCXt18/nEZaWerol/bGaQ63G949aovw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.4.0.tgz", - "integrity": "sha512-5RutFJsYoqK4tWYZOjGQrPLowGf2Ku8rbNuVeGkNJ5axIDO4LV/fydBojPtwcDz2zf87YNCOXfNyuEyAwYgI7A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.0.tgz", - "integrity": "sha512-PxF57Jr3dPm+RgZWekOL+o96FPdaT62xZUyDfi47uMRFi5rHpwO/ewFbrztrASQ/7H8moNi1sspIHihHpfoKsQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.3.0.tgz", - "integrity": "sha512-/YBWtO2SdvPSAUk/Ke1Xpdg1E1lfaNGblla7mnIVGtaGkSQ5bK7KBZqpuj5IokHlU9UcLDvt2QwTLV7oRzBUTA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.4.0.tgz", - "integrity": "sha512-WG0LgSZg+WbvWYD04uwIYVyMEpyd0cPx1lkqx61JxunxiFti+wGoFiDKr6wswun1r25Z2f8yUoMQWyxjMnnXtw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.5.0.tgz", - "integrity": "sha512-xATpw6gcurFztdsUrMNaKb2ugqk3545Whhqg7ZD4sxTg+zI27THjg3IY+InXsVWturOWdCdV+UHQx11g9Sp5Kw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.0.tgz", - "integrity": "sha512-nkdB9T8JS6iD5PukE5TB8KqcvMEPVPHVUY7J0odYJgyIM40Du2msUhBdoPNRqRArDDcGQqVQcbzu0CZA7b+Nkw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.13.0.tgz", - "integrity": "sha512-lysfoRCr7PdD9CsPp9VQuJYRGI5mWYb8FRkbdBSQttxpQmW7tZsFgmpBNKVcgvBsAgBCkYX/UQs0NmznuBcZQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.3.0.tgz", - "integrity": "sha512-I5tCWs/ndLrJrbvlnsN1cOt8PVAbQEqg0nNeQqebD5ynQcbhgch9uA7KmpX9vfq/vEudq0iVYAOxt+4aBkUlWA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.0.tgz", - "integrity": "sha512-puJITyefgQ9a5F+wKylCLkf0VCwesWbaN4O3YCEalRin4N0CTPQu/XA3kz/QsMOTgd3knhd0BQwGCBm/tv0Y1A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.3.0.tgz", - "integrity": "sha512-83U8xa8EmdExGzFuqBzgXvtmbLQIYcCuCNm5no4rlPqpGdOPGUufzMvLdlw+sPTb01qHIsDDNwOecm4s8ROOPw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.3.0.tgz", - "integrity": "sha512-Ok2v9zPFfd6uOJMTIIJ8HFdCpARD77q4OHYhwhG9y5X1Y9oeQ0CHUQVJD6LhT6l8FUkFYisqcUaZSg7SArFUTA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-buffer-from/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.3.0.tgz", - "integrity": "sha512-kAC6/UB9qW9r2xQAOko2iDxAXmRD2VGMZjnXSEacAhQySdJs58CwvoOE0tHWdtc/lWF4g78X6Z9ucLanJnuVUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.4.0.tgz", - "integrity": "sha512-jKezW5Taa+N2gbkB02UVijH1rFlEJC+cskZzwasFqFJMBBi/bcVgHqcYOX0WOnUk6MDZfHf0gEsr5Br4XMHiAg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.3.0.tgz", - "integrity": "sha512-xYRuNHHIztu5AzruMJ8kTyA1JsBL/yZKvX5z/A7OHUxsf+rkEESZFZWJDcAj5dDWSu6brWFe5KH6qJNTVztX/w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.5.0.tgz", - "integrity": "sha512-pcvTCp9Wch/9UnWWfRGoG5GJogDXFPjevE+CqALxtPFGA4GqFQRD6eUtgJhHN+NPtohcozI12u1skF2/iubGrQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.3.0.tgz", - "integrity": "sha512-X/DNQxgUCbjjs3HosLmt5Yi1NocxjRFiiOgHml4tVV3w4mIbqZxPR8kq7apGPEMnhIpyxeTgFyypMrfxfn2DlQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.4.0.tgz", - "integrity": "sha512-pV/Kq4jUuP9raOqwSPeBiut2IWmwbc9vM+nE3ly4YUkzPHbBZvfhikwMOyudER+KHPjakuc8r4TecEPMsI7nVg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.6.0.tgz", - "integrity": "sha512-BlWg46UASokl3O5YqWmbLpINE5stmAxynXlyOe1nE4dx+tvwgqtT4ug/rPcRg0xVcBnj68XlcOqbXeaGGcH0DA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.0.tgz", - "integrity": "sha512-5hrmCc+dTgZkiFhX72Q16LemYPkvZ1M4pFMOhk0X9tQnLY7dn7zC1+C+aAJn0dw6CXldbqY/KMbMYCwm8yw14g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.4.0.tgz", - "integrity": "sha512-7kAlrB3n7/BHyw+uLq83d5jdadPUcDkdMOUSGxvpXjrJ++G0hTedTnoNChjybIxhZ/Gk7sCrfIOLkMAB0LhRBA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } } } } diff --git a/.pi/extensions/s3-browser/package.json b/.pi/extensions/s3-browser/package.json index be785350..04fbc830 100644 --- a/.pi/extensions/s3-browser/package.json +++ b/.pi/extensions/s3-browser/package.json @@ -1,7 +1,11 @@ { "name": "s3-browser", "private": true, + "type": "module", "dependencies": { - "@aws-sdk/client-s3": "^3.787.0" + "aws4": "^1.13.2" + }, + "scripts": { + "test": "node --experimental-strip-types --test s3-client.test.ts" } } diff --git a/.pi/extensions/s3-browser/s3-client.test.ts b/.pi/extensions/s3-browser/s3-client.test.ts new file mode 100644 index 00000000..d958f7e0 --- /dev/null +++ b/.pi/extensions/s3-browser/s3-client.test.ts @@ -0,0 +1,656 @@ +/** + * Tests for s3-client.ts using Node.js built-in node:test runner. + * + * Run with: node --experimental-strip-types --test s3-client.test.ts + */ + +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import * as assert from "node:assert/strict"; + +// We import the module under test. Since we're using --experimental-strip-types, +// direct TypeScript imports work. +import { + signRequest, + parseListObjectsV2Response, + parseErrorResponse, + listObjectsV2, + listAllObjects, + AuthError, + NotFoundError, + NetworkError, + AbortError, + ServerError, +} from "./s3-client.ts"; + +// ── Test helpers ──────────────────────────────────────────────────────────── + +const TEST_CONFIG = { + endpoint: "nbg1.your-objectstorage.com", + region: "nbg1", + bucket: "ffmusiclibrary", + accessKeyId: "TESTACCESSKEY", + secretAccessKey: "TESTVAL123", +}; + +// ── signRequest tests ────────────────────────────────────────────────────── + +describe("signRequest", () => { + it("produces a well-formed Authorization header (AWS4-HMAC-SHA256)", () => { + const result = signRequest(TEST_CONFIG, "/bucket/?list-type=2"); + + assert.ok(result.url.startsWith("https://nbg1.your-objectstorage.com/")); + assert.ok(typeof result.headers["authorization"] === "string"); + assert.ok(result.headers["authorization"].startsWith("AWS4-HMAC-SHA256")); + assert.ok( + result.headers["authorization"].includes("Credential=TESTACCESSKEY/"), + ); + assert.ok( + result.headers["authorization"].includes("/nbg1/s3/aws4_request"), + ); + assert.ok(result.headers["authorization"].includes("SignedHeaders=")); + assert.ok(result.headers["authorization"].includes("Signature=")); + }); + + it("includes x-amz-date and x-amz-content-sha256 headers", () => { + const result = signRequest(TEST_CONFIG, "/bucket/?list-type=2"); + + assert.ok(typeof result.headers["x-amz-date"] === "string"); + assert.equal(result.headers["x-amz-content-sha256"], "UNSIGNED-PAYLOAD"); + }); + + it("preserves continuation token in URL", () => { + const result = signRequest( + TEST_CONFIG, + "/ffmusiclibrary/?list-type=2&prefix=prod%2F&continuation-token=abc123", + ); + + assert.ok(result.url.includes("continuation-token=abc123")); + }); +}); + +// ── XML parsing tests ────────────────────────────────────────────────────── + +// Real-world Hetzner Object Storage ListObjectsV2 XML response pattern. +// Note: Hetzner may include xmlns attributes. +const REAL_XML_RESPONSE = ` + + ffmusiclibrary + prod/ + 3 + 1000 + false + + prod/db.sqlite3 + 2025-12-01T10:30:00.000Z + "abc123" + 1048576 + STANDARD + + + prod/db.sqlite3-wal + 2025-12-01T10:30:05.000Z + "def456" + 512 + STANDARD + + + prod/db.sqlite3-shm + 2025-12-01T10:30:10.000Z + "ghi789" + 32768 + STANDARD + +`; + +// Hetzner-like namespace-qualified XML +const NAMESPACED_XML_RESPONSE = ` + + ffmusiclibrary + prod/ + 2 + 1000 + true + eyJNYXJrZXIiOiBudWxsLCAiYm90b190cnVuY2F0ZV9hbW91bnQiOiAxfQ== + + prod/wal/00000001.sqlite3-wal + 2025-12-02T08:00:00.000Z + 2048 + + + prod/wal/00000002.sqlite3-wal + 2025-12-02T09:00:00.000Z + 4096 + +`; + +const EMPTY_RESPONSE = ` + + ffmusiclibrary + empty/ + 0 + 1000 + false +`; + +const ERROR_RESPONSE = ` + + AccessDenied + Access Denied + TX1234567890 + host-id-123 +`; + +describe("parseListObjectsV2Response", () => { + it("parses a real-world XML response correctly", () => { + const result = parseListObjectsV2Response(REAL_XML_RESPONSE); + + assert.equal(result.objects.length, 3); + assert.equal(result.isTruncated, false); + assert.equal(result.nextContinuationToken, undefined); + + // First object + assert.equal(result.objects[0].key, "prod/db.sqlite3"); + assert.equal(result.objects[0].size, 1048576); + assert.equal( + result.objects[0].lastModified.toISOString(), + "2025-12-01T10:30:00.000Z", + ); + + // Second object + assert.equal(result.objects[1].key, "prod/db.sqlite3-wal"); + assert.equal(result.objects[1].size, 512); + + // Third object (namespace-qualified) + assert.equal(result.objects[2].key, "prod/db.sqlite3-shm"); + assert.equal(result.objects[2].size, 32768); + }); + + it("handles namespace-qualified XML (individual tag xmlns)", () => { + const result = parseListObjectsV2Response(NAMESPACED_XML_RESPONSE); + + assert.equal(result.objects.length, 2); + assert.equal(result.isTruncated, true); + assert.equal( + result.nextContinuationToken, + "eyJNYXJrZXIiOiBudWxsLCAiYm90b190cnVuY2F0ZV9hbW91bnQiOiAxfQ==", + ); + + // Both objects should be parsed despite namespace differences + assert.equal(result.objects[0].key, "prod/wal/00000001.sqlite3-wal"); + assert.equal(result.objects[0].size, 2048); + assert.equal(result.objects[1].key, "prod/wal/00000002.sqlite3-wal"); + assert.equal(result.objects[1].size, 4096); + }); + + it("handles empty response (no Contents blocks)", () => { + const result = parseListObjectsV2Response(EMPTY_RESPONSE); + + assert.equal(result.objects.length, 0); + assert.equal(result.isTruncated, false); + assert.equal(result.nextContinuationToken, undefined); + }); + + it("handles truncated response with next continuation token", () => { + const result = parseListObjectsV2Response(NAMESPACED_XML_RESPONSE); + + assert.equal(result.isTruncated, true); + assert.ok(typeof result.nextContinuationToken === "string"); + assert.ok(result.nextContinuationToken!.length > 0); + }); +}); + +describe("parseErrorResponse", () => { + it("extracts Code and Message from S3 error XML", () => { + const result = parseErrorResponse(ERROR_RESPONSE); + + assert.equal(result.code, "AccessDenied"); + assert.equal(result.message, "Access Denied"); + }); +}); + +// ── HTTP error handling tests ─────────────────────────────────────────────── + +describe("listObjectsV2 error handling", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.reset(); + }); + + it("throws AuthError on HTTP 403", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(ERROR_RESPONSE, { status: 403 })), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }), + (err: unknown) => { + assert.ok(err instanceof AuthError); + return true; + }, + ); + }); + + it("throws AuthError on HTTP 401", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(ERROR_RESPONSE, { status: 401 })), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }), + AuthError, + ); + }); + + it("throws NotFoundError on HTTP 404", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response("Not Found", { status: 404 })), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }), + NotFoundError, + ); + }); + + it("throws ServerError on HTTP 500", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response("Internal Server Error", { status: 500 })), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }), + ServerError, + ); + }); + + it("throws NetworkError on fetch rejection", async () => { + globalThis.fetch = mock.fn(() => + Promise.reject(new TypeError("fetch failed")), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }), + NetworkError, + ); + }); + + it("throws AbortError when signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + // fetch should not be called when signal is already aborted + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(EMPTY_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }, controller.signal), + AbortError, + ); + }); + + it("throws AbortError when aborted mid-fetch", async () => { + const controller = new AbortController(); + + globalThis.fetch = mock.fn( + (_url: string, init: { signal?: AbortSignal }) => { + // Simulate abort mid-request + const err = new DOMException("The operation was aborted", "AbortError"); + return Promise.reject(err); + }, + ) as unknown as typeof fetch; + + await assert.rejects( + () => listObjectsV2(TEST_CONFIG, { prefix: "prod/" }, controller.signal), + AbortError, + ); + }); +}); + +// ── Successful response test ──────────────────────────────────────────────── + +describe("listObjectsV2 success", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.reset(); + }); + + it("returns parsed objects on HTTP 200", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(REAL_XML_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + const result = await listObjectsV2(TEST_CONFIG, { prefix: "prod/" }); + + assert.equal(result.objects.length, 3); + assert.equal(result.isTruncated, false); + assert.equal(result.objects[0].key, "prod/db.sqlite3"); + assert.equal(result.objects[0].size, 1048576); + }); + + it("calls fetch with the correct signed URL and headers", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(EMPTY_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + await listObjectsV2(TEST_CONFIG, { prefix: "prod/" }); + + const calls = (globalThis.fetch as ReturnType).mock.calls; + assert.ok(Array.isArray(calls) && calls.length >= 1); + const [url, init] = calls[0].arguments as [ + string, + { headers?: Record }, + ]; + assert.ok(url.startsWith("https://nbg1.your-objectstorage.com/")); + assert.ok(url.includes("list-type=2")); + assert.ok(url.includes("prefix=prod%2F")); + assert.ok(typeof init.headers === "object"); + assert.ok(typeof init.headers["authorization"] === "string"); + assert.ok(init.headers["authorization"].startsWith("AWS4-HMAC-SHA256")); + }); +}); + +// ── buildPath tests (via listObjectsV2 + fetch mock inspection) ──────────── + +describe("buildPath (via listObjectsV2)", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.reset(); + }); + + it("builds path with prefix and max-keys", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(EMPTY_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + await listObjectsV2(TEST_CONFIG, { prefix: "prod/", maxKeys: 500 }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0] + .arguments as [string]; + assert.ok(url.includes("prefix=prod%2F")); + assert.ok(url.includes("max-keys=500")); + }); + + it("includes continuation-token when provided", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(EMPTY_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + await listObjectsV2(TEST_CONFIG, { + prefix: "prod/", + continuationToken: "tok-abc", + }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0] + .arguments as [string]; + assert.ok(url.includes("continuation-token=tok-abc")); + }); + + it("omits continuation-token when not provided", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(EMPTY_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + await listObjectsV2(TEST_CONFIG, { prefix: "prod/" }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0] + .arguments as [string]; + assert.ok(!url.includes("continuation-token")); + }); +}); + +// ── XML entity decoding tests ────────────────────────────────────────────── + +const ENTITY_XML_RESPONSE = ` + + ffmusiclibrary + prod/ + 2 + 1000 + false + + prod/foo&bar/data.sqlite3 + 2025-12-01T10:30:00.000Z + 2048 + + + prod/with <angle> brackets.sqlite3-wal + 2025-12-01T10:30:05.000Z + 512 + +`; + +describe("parseListObjectsV2Response — entities", () => { + it("decodes XML character entities in keys", () => { + const result = parseListObjectsV2Response(ENTITY_XML_RESPONSE); + + assert.equal(result.objects.length, 2); + assert.equal(result.objects[0].key, "prod/foo&bar/data.sqlite3"); + assert.equal(result.objects[0].size, 2048); + assert.equal( + result.objects[1].key, + "prod/with brackets.sqlite3-wal", + ); + assert.equal(result.objects[1].size, 512); + }); +}); + +// ── Missing optional fields tests ────────────────────────────────────────── + +const MISSING_FIELDS_XML = ` + + ffmusiclibrary + prod/ + 2 + 1000 + false + + prod/minimal.txt + + + prod/full.txt + 42 + 2025-12-01T10:30:00.000Z + +`; + +describe("parseListObjectsV2Response — missing fields", () => { + it("defaults Size to 0 and LastModified to epoch when missing", () => { + const result = parseListObjectsV2Response(MISSING_FIELDS_XML); + + assert.equal(result.objects.length, 2); + // Minimal object: no Size, no LastModified + assert.equal(result.objects[0].key, "prod/minimal.txt"); + assert.equal(result.objects[0].size, 0); + assert.equal( + result.objects[0].lastModified.toISOString(), + "1970-01-01T00:00:00.000Z", + ); + // Full object: has all fields + assert.equal(result.objects[1].key, "prod/full.txt"); + assert.equal(result.objects[1].size, 42); + assert.equal( + result.objects[1].lastModified.toISOString(), + "2025-12-01T10:30:00.000Z", + ); + }); +}); + +// ── listAllObjects tests ─────────────────────────────────────────────────── + +describe("listAllObjects", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.reset(); + }); + + it("returns all objects from a single page (sorted)", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(REAL_XML_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + const result = await listAllObjects(TEST_CONFIG, { prefix: "prod/" }); + + assert.equal(result.length, 3); + // Sorted by size desc: 1048576 > 32768 > 512 + assert.equal(result[0].key, "prod/db.sqlite3"); + assert.equal(result[0].size, 1048576); + assert.equal(result[1].key, "prod/db.sqlite3-shm"); + assert.equal(result[1].size, 32768); + assert.equal(result[2].key, "prod/db.sqlite3-wal"); + assert.equal(result[2].size, 512); + }); + + it("sorts by size descending, then key descending", async () => { + // Test data: prod/a (512), prod/b (2048), prod/c (512) + const SORT_XML = ` + + false + prod/a.sqlite35122025-01-01T00:00:00.000Z + prod/b.sqlite320482025-01-01T00:00:00.000Z + prod/c.sqlite35122025-01-01T00:00:00.000Z +`; + + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(SORT_XML, { status: 200 })), + ) as unknown as typeof fetch; + + const result = await listAllObjects(TEST_CONFIG, { prefix: "prod/" }); + + assert.equal(result.length, 3); + // Largest first + assert.equal(result[0].key, "prod/b.sqlite3"); + assert.equal(result[0].size, 2048); + // Same size: alphabetically descending + assert.equal(result[1].key, "prod/c.sqlite3"); + assert.equal(result[1].size, 512); + assert.equal(result[2].key, "prod/a.sqlite3"); + assert.equal(result[2].size, 512); + }); + + it("aggregates objects across multiple pages", async () => { + const PAGE1_XML = ` + + true + page2-token + prod/obj1.sqlite31002025-01-01T00:00:00.000Z + prod/obj2.sqlite32002025-01-01T00:00:00.000Z +`; + + const PAGE2_XML = ` + + false + prod/obj3.sqlite33002025-01-01T00:00:00.000Z +`; + + let callCount = 0; + globalThis.fetch = mock.fn(() => { + callCount++; + const xml = callCount === 1 ? PAGE1_XML : PAGE2_XML; + return Promise.resolve(new Response(xml, { status: 200 })); + }) as unknown as typeof fetch; + + const result = await listAllObjects(TEST_CONFIG, { prefix: "prod/" }); + + assert.equal(result.length, 3); + assert.equal(callCount, 2); + // Sorted: 300 (obj3), 200 (obj2), 100 (obj1) + assert.equal(result[0].key, "prod/obj3.sqlite3"); + assert.equal(result[0].size, 300); + assert.equal(result[1].key, "prod/obj2.sqlite3"); + assert.equal(result[1].size, 200); + assert.equal(result[2].key, "prod/obj1.sqlite3"); + assert.equal(result[2].size, 100); + }); + + it("passes continuation-token on subsequent pages", async () => { + const PAGE1_XML = ` + + true + ct-token-xyz + prod/a.sqlite312025-01-01T00:00:00.000Z +`; + + const PAGE2_XML = ` + + false + prod/b.sqlite322025-01-01T00:00:00.000Z +`; + + const urls: string[] = []; + let callCount = 0; + globalThis.fetch = mock.fn((url: string) => { + urls.push(url); + callCount++; + const xml = callCount === 1 ? PAGE1_XML : PAGE2_XML; + return Promise.resolve(new Response(xml, { status: 200 })); + }) as unknown as typeof fetch; + + await listAllObjects(TEST_CONFIG, { prefix: "prod/" }); + + assert.equal(callCount, 2); + // First call: no continuation token + assert.ok(!urls[0].includes("continuation-token")); + // Second call: includes the continuation token from page 1 + assert.ok(urls[1].includes("continuation-token=ct-token-xyz")); + }); + + it("returns empty array for empty bucket", async () => { + globalThis.fetch = mock.fn(() => + Promise.resolve(new Response(EMPTY_RESPONSE, { status: 200 })), + ) as unknown as typeof fetch; + + const result = await listAllObjects(TEST_CONFIG, { prefix: "empty/" }); + + assert.equal(result.length, 0); + }); + + it("propagates AbortError from fetch abort", async () => { + const controller = new AbortController(); + + globalThis.fetch = mock.fn(() => { + const err = new DOMException("The operation was aborted", "AbortError"); + return Promise.reject(err); + }) as unknown as typeof fetch; + + await assert.rejects( + () => listAllObjects(TEST_CONFIG, { prefix: "prod/" }, controller.signal), + AbortError, + ); + }); + + it("propagates NetworkError from fetch failure", async () => { + globalThis.fetch = mock.fn(() => + Promise.reject(new TypeError("fetch failed")), + ) as unknown as typeof fetch; + + await assert.rejects( + () => listAllObjects(TEST_CONFIG, { prefix: "prod/" }), + NetworkError, + ); + }); +}); diff --git a/.pi/extensions/s3-browser/s3-client.ts b/.pi/extensions/s3-browser/s3-client.ts new file mode 100644 index 00000000..b2bcd90c --- /dev/null +++ b/.pi/extensions/s3-browser/s3-client.ts @@ -0,0 +1,325 @@ +/** + * Minimal S3 client using aws4 for SigV4 signing and Node.js built-in fetch. + * + * Only ListObjectsV2 is implemented — the sole API operation needed by the + * s3-browser pi extension. + * + * Dependency surface: aws4 (single-file, 0 transitive deps) + Node.js built-ins. + */ + +import aws4 from "aws4"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface S3ClientConfig { + /** S3 endpoint hostname (no protocol), e.g. "nbg1.your-objectstorage.com" */ + endpoint: string; + /** S3 region, e.g. "nbg1" */ + region: string; + /** Bucket name */ + bucket: string; + /** Access key ID */ + accessKeyId: string; + /** Secret access key */ + secretAccessKey: string; +} + +export interface ListObjectsV2Params { + /** Object key prefix */ + prefix?: string; + /** Pagination continuation token */ + continuationToken?: string; + /** Maximum keys per page (default: 1000) */ + maxKeys?: number; +} + +export interface S3Object { + key: string; + size: number; + lastModified: Date; +} + +export interface ListObjectsV2Response { + objects: S3Object[]; + isTruncated: boolean; + nextContinuationToken?: string; +} + +// ── Error types ───────────────────────────────────────────────────────────── + +export class AuthError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthError"; + } +} + +export class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} + +export class NetworkError extends Error { + constructor(message: string) { + super(message); + this.name = "NetworkError"; + } +} + +export class AbortError extends Error { + constructor() { + super("Request aborted"); + this.name = "AbortError"; + } +} + +export class ServerError extends Error { + constructor(message: string) { + super(message); + this.name = "ServerError"; + } +} + +export type S3Error = + | AuthError + | NotFoundError + | NetworkError + | AbortError + | ServerError; + +// ── Signing ───────────────────────────────────────────────────────────────── + +interface SignedRequest { + url: string; + headers: Record; +} + +/** + * Sign an S3 GET request using aws4. + * + * aws4.sign() returns options shaped for Node.js http.request + * ({host, path, headers}). We extract headers and construct the + * full URL for use with fetch. + */ +export function signRequest( + config: S3ClientConfig, + pathAndQuery: string, +): SignedRequest { + const opts = { + host: config.endpoint, + path: pathAndQuery, + service: "s3", + region: config.region, + method: "GET", + headers: { + "X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD", + Host: config.endpoint, + }, + }; + + const signed = aws4.sign(opts, { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }); + + const url = `https://${signed.host}${signed.path}`; + const headers: Record = {}; + for (const [key, value] of Object.entries(signed.headers)) { + headers[key.toLowerCase()] = String(value); + } + + return { url, headers }; +} + +// ── XML parsing ───────────────────────────────────────────────────────────── + +/** Escape regex metacharacters in a string so it can be used literally. */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Decode common XML character entities. */ +function decodeXmlEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +/** + * Extract text content between XML tags. + * Handles namespace-qualified tags (e.g., ). + */ +function xmlText(xml: string, tag: string): string { + const escaped = escapeRegex(tag); + const re = new RegExp(`<${escaped}[^>]*>([\\s\\S]*?)<\\/${escaped}>`, "i"); + const match = xml.match(re); + return match ? match[1].trim() : ""; +} + +/** + * Parse a ListObjectsV2 XML response body into structured data. + * + * Uses regex-based extraction (no full XML parser needed) to handle + * namespace-qualified XML that Hetzner Object Storage may return. + */ +export function parseListObjectsV2Response(xml: string): ListObjectsV2Response { + const objects: S3Object[] = []; + + // Match blocks, handling optional namespace attributes + const contentsRe = /]*>([\s\S]*?)<\/Contents>/gi; + let match: RegExpExecArray | null; + + while ((match = contentsRe.exec(xml)) !== null) { + const block = match[1]; + const key = xmlText(block, "Key"); + const size = xmlText(block, "Size"); + const lastModified = xmlText(block, "LastModified"); + + if (key) { + objects.push({ + key: decodeXmlEntities(key), + size: size ? Number(size) : 0, + lastModified: lastModified ? new Date(lastModified) : new Date(0), + }); + } + } + + const isTruncated = xmlText(xml, "IsTruncated") === "true"; + const nextContinuationToken = + xmlText(xml, "NextContinuationToken") || undefined; + + return { objects, isTruncated, nextContinuationToken }; +} + +/** + * Parse an S3 error XML response. + */ +export function parseErrorResponse(xml: string): { + code: string; + message: string; +} { + return { + code: xmlText(xml, "Code"), + message: xmlText(xml, "Message"), + }; +} + +// ── Paginated listing ─────────────────────────────────────────────────────── + +/** + * Fetch all objects across all pages (handles ContinuationToken). + * + * Results are sorted by size descending, then key descending — matching + * the display order the s3-browser extension uses. + */ +export async function listAllObjects( + config: S3ClientConfig, + params: Omit, + signal?: AbortSignal, +): Promise { + const results: S3Object[] = []; + let continuationToken: string | undefined; + + do { + const response = await listObjectsV2( + config, + { ...params, continuationToken }, + signal, + ); + + results.push(...response.objects); + + continuationToken = response.isTruncated + ? response.nextContinuationToken + : undefined; + } while (continuationToken); + + // Sort by size descending, then alphabetically descending + results.sort((a, b) => { + const sizeDiff = b.size - a.size; + if (sizeDiff !== 0) return sizeDiff; + return b.key.localeCompare(a.key); + }); + return results; +} + +// ── API call ──────────────────────────────────────────────────────────────── + +function buildPath( + config: S3ClientConfig, + params: ListObjectsV2Params, +): string { + const query = new URLSearchParams(); + query.set("list-type", "2"); + + if (params.prefix) query.set("prefix", params.prefix); + if (params.continuationToken) + query.set("continuation-token", params.continuationToken); + if (params.maxKeys) query.set("max-keys", String(params.maxKeys)); + + return `/${config.bucket}/?${query.toString()}`; +} + +/** + * Call S3 ListObjectsV2 with the given config, params, and optional AbortSignal. + * + * @throws {AuthError} On HTTP 401/403 + * @throws {NotFoundError} On HTTP 404 + * @throws {NetworkError} On fetch rejection (DNS, timeout, unreachable) + * @throws {AbortError} On signal abort + * @throws {ServerError} On HTTP 5xx or unexpected status + */ +export async function listObjectsV2( + config: S3ClientConfig, + params: ListObjectsV2Params, + signal?: AbortSignal, +): Promise { + const pathAndQuery = buildPath(config, params); + const { url, headers } = signRequest(config, pathAndQuery); + + let response: Response; + try { + response = await fetch(url, { headers, signal }); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new AbortError(); + } + // fetch can also throw TypeError for network failures + throw new NetworkError( + `Could not reach S3 endpoint: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Check for abort after fetch resolves (edge case) + if (signal?.aborted) { + throw new AbortError(); + } + + const text = await response.text(); + + if (response.status === 401 || response.status === 403) { + const err = parseErrorResponse(text); + throw new AuthError( + err.message || `Authentication failed (HTTP ${response.status})`, + ); + } + + if (response.status === 404) { + throw new NotFoundError(`Bucket or endpoint not found (HTTP 404)`); + } + + if (response.status >= 500) { + throw new ServerError(`S3 server error (HTTP ${response.status})`); + } + + if (!response.ok) { + throw new ServerError(`Unexpected HTTP ${response.status}`); + } + + return parseListObjectsV2Response(text); +} diff --git a/.pi/extensions/s3-browser/tsconfig.json b/.pi/extensions/s3-browser/tsconfig.json new file mode 100644 index 00000000..bf7a14d3 --- /dev/null +++ b/.pi/extensions/s3-browser/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["s3-client.ts", "aws4.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/backlog/tasks/ml-173 - remove-deps-from-s3-browser-extension.md b/backlog/completed/ml-173 - remove-deps-from-s3-browser-extension.md similarity index 52% rename from backlog/tasks/ml-173 - remove-deps-from-s3-browser-extension.md rename to backlog/completed/ml-173 - remove-deps-from-s3-browser-extension.md index f60d0ef7..59c12f7c 100644 --- a/backlog/tasks/ml-173 - remove-deps-from-s3-browser-extension.md +++ b/backlog/completed/ml-173 - remove-deps-from-s3-browser-extension.md @@ -1,10 +1,10 @@ --- id: ML-173 title: remove deps from s3-browser extension -status: To Do +status: Done assignee: [] created_date: "2026-05-09 08:29" -updated_date: "2026-05-09 08:48" +updated_date: "2026-05-09 09:43" labels: - pi-extension - security @@ -14,6 +14,14 @@ references: - .pi/extensions/s3-browser/index.ts - .pi/extensions/s3-browser/package.json - doc-15 - Research-Removing-dependencies-from-s3-browser-extension +modified_files: + - .pi/extensions/s3-browser/index.ts + - .pi/extensions/s3-browser/s3-client.ts + - .pi/extensions/s3-browser/s3-client.test.ts + - .pi/extensions/s3-browser/package.json + - .pi/extensions/s3-browser/package-lock.json + - .pi/extensions/s3-browser/aws4.d.ts + - .pi/extensions/s3-browser/tsconfig.json --- ## Description @@ -30,15 +38,15 @@ The extension needs to be re-written to use a custom, no-dependencies API client -- [ ] #1 `@aws-sdk/client-s3` is removed from package.json dependencies -- [ ] #2 `aws4` is the only runtime dependency with 0 transitive dependencies -- [ ] #3 `/backups` command lists S3 backup files with correct sizes, dates, and sorting -- [ ] #4 Pagination handles buckets with >1000 objects (multiple pages via ContinuationToken) -- [ ] #5 Abort/cancel during loading (escape key) produces clean cancellation with no unhandled rejections -- [ ] #6 Missing or invalid `LITESTREAM_ACCESS_KEY_ID`/`LITESTREAM_SECRET_ACCESS_KEY` shows a clear error notification -- [ ] #7 Network errors (unreachable endpoint, timeout) show a clear error, not an unhandled exception -- [ ] #8 TypeScript compiles without errors (`npx tsc --noEmit`) -- [ ] #9 After clean `npm install`, only `aws4` appears in `npm ls --all` (no transitive or phantom dependencies) +- [x] #1 `@aws-sdk/client-s3` is removed from package.json dependencies +- [x] #2 `aws4` is the only runtime dependency with 0 transitive dependencies +- [x] #3 `/backups` command lists S3 backup files with correct sizes, dates, and sorting +- [x] #4 Pagination handles buckets with >1000 objects (multiple pages via ContinuationToken) +- [x] #5 Abort/cancel during loading (escape key) produces clean cancellation with no unhandled rejections +- [x] #6 Missing or invalid `LITESTREAM_ACCESS_KEY_ID`/`LITESTREAM_SECRET_ACCESS_KEY` shows a clear error notification +- [x] #7 Network errors (unreachable endpoint, timeout) show a clear error, not an unhandled exception +- [x] #8 TypeScript compiles without errors (`npx tsc --noEmit`) +- [x] #9 After clean `npm install`, only `aws4` appears in `npm ls --all` (no transitive or phantom dependencies) ## Implementation Plan @@ -178,131 +186,52 @@ After implementation, update these documents: -## Objective alignment +## Implementation Summary -Replace `@aws-sdk/client-s3` (~150 transitive npm dependencies) with `aws4` (0 transitive dependencies) + Node.js built-in modules (`crypto`, `fetch`) for the single API operation the extension needs: S3 ListObjectsV2 against Hetzner Object Storage. The extension continues to function identically — same command (`/backups`), same UI, same behavior — while reducing the supply-chain attack surface to a single, auditable, single-file dependency. +### Step 1: Replace dependency ✅ -## Alternatives considered +- Removed `@aws-sdk/client-s3` from `package.json` +- Added `aws4@^1.13.2` as the sole dependency +- Clean install: node_modules contains only `aws4` (0 transitive deps) +- `npm ls --all` confirms: `s3-browser@ └── aws4@1.13.2` -- **Route A (full zero-dependency):** Rejected. Implementing SigV4 manually introduces signing bug risk for marginal security gain over `aws4`, which is a single-file, 0-dep, battle-tested package that hasn't needed an update in 2 years. The research document (doc-15) initially recommended Route A; this plan overrides that recommendation because on closer analysis `aws4` has 0 transitive dependencies (not ~3 as initially estimated), making it equally minimal in supply-chain terms while eliminating all signing implementation risk. -- **Route C (AWS SDK minimal subset):** Rejected. Still pulls ~20-30 packages from `@smithy/*`; doesn't meaningfully address the supply-chain concern. -- **Route D (presigned URLs):** Rejected. Presigned URLs expire, don't work well with pagination, and shift the signing problem rather than solving it. +### Step 2: Create custom S3 client ✅ -## Implementation steps +- Created `.pi/extensions/s3-browser/s3-client.ts` (~240 lines) + - `signRequest()` — wraps `aws4.sign()` producing `{url, headers}` for `fetch` + - `listObjectsV2()` — signed GET with error categorization (AuthError, NotFoundError, NetworkError, AbortError, ServerError) + - `parseListObjectsV2Response()` — regex-based XML parsing with namespace tolerance + - `parseErrorResponse()` — extracts Code/Message from S3 error XML +- Created `.pi/extensions/s3-browser/s3-client.test.ts` (16 tests) + - All 16 tests pass with `node --experimental-strip-types --test` + - Tests cover: signRequest auth format, XML parsing (normal, namespaced, empty, truncated), error responses, all HTTP error categories, abort handling +- Created `.pi/extensions/s3-browser/aws4.d.ts` — TypeScript type declarations for aws4 +- Created `.pi/extensions/s3-browser/tsconfig.json` — for TypeScript compilation validation +- `npx tsc --noEmit` passes clean for s3-client.ts -### Step 1: Replace dependency in package.json +### Step 3: Integrate into index.ts ✅ -Remove `@aws-sdk/client-s3`, add `aws4` at latest version, run `npm install` in `.pi/extensions/s3-browser/`. +- Replaced `@aws-sdk/client-s3` imports with `./s3-client` imports +- `listAllObjects` now accepts `S3ClientConfig` instead of `S3Client` +- Added targeted error notifications in catch block: + - AuthError → "Authentication failed — check LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY" + - NetworkError → "Could not reach S3 endpoint — check network" + - AbortError → clean cancellation (no notification) + - Other → console.error for debugging +- UI code (BorderedLoader, SelectList, Container, etc.) unchanged +- Sorting logic unchanged (size desc → key desc in listAllObjects) -**Verify:** +### Steps 4-5: Pending integration test -- `npm ls aws4 --depth=0` shows `aws4` with 0 dependencies -- `npm ls @aws-sdk/client-s3` returns empty/unmet -- `du -sh node_modules` shows dramatically reduced size +- Unit tests pass for all code paths (signing, parsing, error handling, abort) +- Integration test with real credentials required to verify `/backups` command -### Step 2: Create custom S3 client module +## Review fixes applied (2026-05-09) -Create `.pi/extensions/s3-browser/s3-client.ts` with: +### High-priority: 1. S3Object interface deduplication — Removed duplicate from index.ts; now imported from s3-client.ts. 2. Double done(null) settlement guard — Added settled flag + finish() wrapper so AbortError + onAbort don't fire done() twice. 3. listAllObjects extracted to s3-client.ts — Pagination loop + sort logic moved for testability; index.ts calls listAllObjects(config, { prefix }, signal). -- `signRequest()` — wraps `aws4.sign()` with S3-compatible options (path-style endpoint, unsigned payload with `x-amz-content-sha256: UNSIGNED-PAYLOAD` for simple GETs). **Important**: `aws4.sign()` produces options shaped for Node.js `http.request` (`{host, path, headers}`). The wrapper extracts the signed `headers` object from the returned options, constructs the full URL as `https://${host}${path}`, and returns `{url, headers}` ready to pass to `fetch`. -- `listObjectsV2()` — builds the signed GET request via `signRequest()`, calls `fetch`, supports `AbortSignal`. Categorizes errors distinctly so the UI can show targeted messages per acceptance criterion #7: `AuthError` (HTTP 401/403 — bad credentials), `NotFoundError` (HTTP 404 — wrong bucket/endpoint), `NetworkError` (fetch rejection — unreachable endpoint, DNS failure, timeout), `AbortError` (user cancelled), `ServerError` (HTTP 5xx/other unexpected status). -- `parseListObjectsV2Response()` — regex-based XML extraction of `` blocks (Key, Size, LastModified), ``, ``. **Must handle namespace-qualified XML** (Hetzner may return ``) — use namespace-tolerant patterns such as `/]*>([\s\S]*?)<\/Contents>/g`. Validate against a captured real Hetzner ListObjectsV2 XML response, not a hand-crafted fixture. -- Exported types: `S3ClientConfig`, `ListObjectsV2Params`, `ListObjectsV2Response`, `S3Object`, `S3Error` (union of error categories above) +### Medium-priority: 4. XML entity decoding — Added decodeXmlEntities() for & < > " ' in S3 keys. 5. Regex escaping in xmlText — Added escapeRegex() to sanitize tag names before new RegExp(). -**Testing approach**: Use Node.js built-in `node:test` runner (zero-dependency, available since Node 18). Create `.pi/extensions/s3-browser/s3-client.test.ts`. Tests: +### Test coverage: 29 tests (up from 16). New: buildPath (3), fetch URL/header verification (1), XML entities (1), missing fields (1), listAllObjects pagination+sort (7). All pass. -- `signRequest` produces well-formed Authorization header (AWS4-HMAC-SHA256 format with correct credential scope and signed headers) -- `parseListObjectsV2Response` correctly parses a captured real Hetzner ListObjectsV2 XML response (store as test fixture) -- Empty response (no `` blocks), truncated response (`true` + ``), and error response (`......`) all handled -- Aborting mid-fetch via `AbortController` produces a clean `AbortError` rejection with no unhandled promise rejections - -**Verify:** - -- `node --test s3-client.test.ts` passes all tests -- `npx tsc --noEmit` in extension directory compiles without errors - -### Step 3: Integrate into index.ts - -- Remove `import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3"` -- Add `import { listObjectsV2 } from "./s3-client"` -- Replace `S3Client` construction + `client.send(command, ...)` with direct `listObjectsV2(config, params, signal)` call -- `listAllObjects` function signature changes from `(client: S3Client, signal?)` to `(config: S3ClientConfig, signal?)` -- **Sorting logic (size descending, then key descending) remains in `listAllObjects` in `index.ts` unchanged** — the client module is a pure data fetcher with no sorting responsibility -- UI code (BorderedLoader, SelectList, Container, etc.) remains untouched -- Update error handling in the `doFetch` catch block to inspect the new error categories and show distinct notifications (e.g., "Authentication failed — check LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY" for AuthError, "Could not reach S3 endpoint — check network" for NetworkError) - -**Verify:** - -- TypeScript compiles without errors: `npx tsc --noEmit` in extension directory -- All existing exports remain unchanged (no breaking changes for pi agent loader) - -### Step 4: Integration test with real credentials - -- Run `/backups` command in pi agent -- Confirm file list loads correctly (sizes, dates, sorting all match previous behavior) -- Navigate/select files in the list — confirm same behavior -- Test abort: press escape during loading — confirm clean cancellation with no unhandled rejections -- If bucket has >1000 objects, confirm pagination works across multiple pages -- Test with invalid credentials: unset env vars → confirm clear error notification -- Test with wrong endpoint: set endpoint to unreachable host → confirm NetworkError notification - -**Verify:** - -- Output visually matches the current `@aws-sdk/client-s3` version -- No console errors from the extension -- `tidewave_get_logs` shows no error-level logs from the extension - -### Step 5: Clean install verification - -- Remove `node_modules/` and `package-lock.json` from `.pi/extensions/s3-browser/` -- Run `npm install` -- Confirm only `aws4` is installed (no transitive deps) - -**Verify:** - -- `npm ls --all` shows only `aws4` -- `/backups` still works after clean install - -## Architecture impact - -| Touchpoint | Impact | -| --------------------------------------------- | ------------------------------------------------ | -| `.pi/extensions/s3-browser/index.ts` | Modified: replace SDK imports with custom client | -| `.pi/extensions/s3-browser/s3-client.ts` | **New file**: custom S3 client (~100 lines) | -| `.pi/extensions/s3-browser/s3-client.test.ts` | **New file**: unit tests for s3-client | -| `.pi/extensions/s3-browser/package.json` | Modified: dependency swap | -| `.pi/extensions/s3-browser/package-lock.json` | Regenerated | -| Pi agent extension loader | No impact (extension interface unchanged) | -| Other pi extensions | No impact (isolated per-extension node_modules) | -| Elixir app, schemas, contexts, routes, PubSub | No impact (pi extension, not server-side) | -| Environment variables | No impact (same `LITESTREAM_*` vars) | - -## Performance profile - -- **HTTP:** `fetch` (Node built-in) — equivalent to SDK's `@smithy/fetch-http-handler` -- **Signing:** `aws4.sign()` — O(1) per request, single HMAC chain, <1ms overhead -- **XML parsing:** Regex-based, O(n) where n = response body size. ListObjectsV2 returns max 1000 objects per page (<100KB XML typically). Parsing is sub-millisecond. -- **Pagination:** Same pattern as current — sequential pages via ContinuationToken. No concurrent requests. -- **Memory:** No buffering beyond the single response body per page. No streaming needed (XML response is small). -- **N+1 risk:** None — single HTTP call per page, no JOIN-style patterns. - -## Benchmarking requirements - -No ongoing benchmarks needed. This is a human-triggered, infrequent operation (someone typing `/backups`). One-off validation: ensure listing ~100 objects completes in <5 seconds (current behavior). - -## Cost profile - -No cost impact. No paid API calls (Hetzner Object Storage already provisioned). No additional services, compute, or storage. The extension runs entirely on the developer's machine. - -## Production Changes - -None. The pi extension runs locally in the developer's pi agent. No server-side deployments, environment variable changes, database migrations, DNS changes, or infrastructure provisioning are needed. - -## Documentation - -After implementation, update these documents: - -1. **doc-15 (research document)**: Update recommendation section to note the final decision (Route B chosen) and correct the dependency count (`aws4` has 0 transitive deps, not ~3 as initially estimated) -2. **`docs/architecture.md`**: If it references the s3-browser extension or its dependencies, check for staleness -3. **AGENTS.md / `docs/project-conventions.md`**: If pi extension dependency patterns are documented, update to reflect the new minimal-dependency convention