From 717c2e304c158e6bb0b4cab34e0c27deeacb59b6 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 3 Sep 2026 09:41:24 -0700 Subject: [PATCH 01/11] feat(node): add node implementation Signed-off-by: Edward Liang --- .github/dependabot.yml | 9 + .github/workflows/node-release.yml | 181 +++ .github/workflows/node.yml | 60 + .gitignore | 2 +- node/.gitignore | 14 + node/.prettierignore | 4 + node/.prettierrc.json | 6 + node/LICENSE | 202 +++ node/NOTICE | 2 + node/README.md | 165 +++ node/eslint.config.mjs | 23 + node/examples/connect-valkey.mjs | 68 + node/examples/generate-token.mjs | 15 + node/package-lock.json | 1947 ++++++++++++++++++++++++++++ node/package.json | 54 + node/scripts/clean.mjs | 8 + node/src/cli.ts | 163 +++ node/src/errors.ts | 33 + node/src/index.ts | 16 + node/src/token-generator.ts | 349 +++++ node/test/cli.test.ts | 142 ++ node/test/package.test.ts | 204 +++ node/test/token-generator.test.ts | 434 +++++++ node/tsconfig.json | 19 + node/tsconfig.test.json | 10 + 25 files changed, 4129 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/node-release.yml create mode 100644 .github/workflows/node.yml create mode 100644 node/.gitignore create mode 100644 node/.prettierignore create mode 100644 node/.prettierrc.json create mode 100644 node/LICENSE create mode 100644 node/NOTICE create mode 100644 node/README.md create mode 100644 node/eslint.config.mjs create mode 100644 node/examples/connect-valkey.mjs create mode 100644 node/examples/generate-token.mjs create mode 100644 node/package-lock.json create mode 100644 node/package.json create mode 100644 node/scripts/clean.mjs create mode 100644 node/src/cli.ts create mode 100644 node/src/errors.ts create mode 100644 node/src/index.ts create mode 100644 node/src/token-generator.ts create mode 100644 node/test/cli.test.ts create mode 100644 node/test/package.test.ts create mode 100644 node/test/token-generator.test.ts create mode 100644 node/tsconfig.json create mode 100644 node/tsconfig.test.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c181f1d..863a9e4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,3 +30,12 @@ updates: groups: tooling: patterns: ["*"] + + # Node runtime/dev dependencies in node/package.json. + - package-ecosystem: npm + directory: /node + schedule: + interval: weekly + groups: + node: + patterns: ["*"] diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml new file mode 100644 index 0000000..eecf8ed --- /dev/null +++ b/.github/workflows/node-release.yml @@ -0,0 +1,181 @@ +name: Publish Node package to npm + +# The npm trusted publisher must reference this exact workflow filename and the +# `npm` environment, or OIDC publishing is rejected. +on: + release: + types: [published] + +permissions: + contents: read + +# Cancelling during a publish can leave a release partially processed. +concurrency: + group: node-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + name: test (Node ${{ matrix.node-version }}) + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node-version: ["22.x", "24.x"] + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: node/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Validate, test, and build + run: | + npm run lint + npm run format:check + npm run typecheck + npm test + npm run build + npm pack --dry-run + + build: + name: build and verify + needs: test + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js 24 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: "24.x" + cache: npm + cache-dependency-path: node/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Verify tag, version, and publication guard + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + node <<'NODE' + const fs = require("node:fs"); + const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); + const tag = process.env.RELEASE_TAG; + const expected = `node-v${packageJson.version}`; + if (tag !== expected) { + console.error(`Release tag must be ${expected}, received ${tag}.`); + process.exit(1); + } + if (packageJson.private === true) { + console.error("The npm package is still marked private; publication is disabled."); + process.exit(1); + } + console.log(`Publishing version ${packageJson.version}.`); + NODE + + - name: Build package + run: npm run build + + - name: Validate and archive package + run: | + rm -rf release-artifacts + mkdir -p release-artifacts + npm pack --dry-run + npm pack --pack-destination release-artifacts + + - name: Save package artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-package + path: node/release-artifacts/*.tgz + if-no-files-found: error + + publish: + name: publish + needs: build + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: npm + permissions: + contents: read + id-token: write + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js 24 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: "24.x" + registry-url: https://registry.npmjs.org + + - name: Ensure npm supports trusted publishing + run: | + npm install --global npm@11.5.1 + node --version + npm --version + + - name: Retrieve package artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-package + path: node/release-artifacts + + - name: Publish to npm with provenance + working-directory: node + run: npm publish release-artifacts/*.tgz --access public --provenance + + - name: Smoke-test the published package + env: + RELEASE_VERSION: ${{ github.ref_name }} + run: | + version="${RELEASE_VERSION#node-v}" + tmp="$(mktemp -d)" + for attempt in 1 2 3 4 5; do + if npm install --prefix "$tmp" --no-save "@aws/developer-toolkit-elasticache@${version}"; then + break + fi + if [ "$attempt" -eq 5 ]; then + echo "::error::Could not install the published package after 5 attempts." + exit 1 + fi + echo "Install attempt ${attempt} failed; waiting for registry propagation." + sleep 15 + done + cd "$tmp" + node --input-type=module <<'NODE' + const packageName = "@aws/developer-toolkit-elasticache"; + const packageModule = await import(packageName); + for (const name of [ + "ConfigurationError", + "ElastiCacheIAMAuthTokenProvider", + "InvalidParameterError", + "ToolkitInputError", + "generateIamAuthToken", + ]) { + if (!(name in packageModule)) { + throw new Error(`Published package is missing export ${name}.`); + } + } + NODE diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000..2604ceb --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,60 @@ +name: Node CI + +# Path-filtered so the Node package is validated independently from Python. +on: + push: + branches: [main] + paths: + - "node/**" + - ".github/workflows/node.yml" + pull_request: + paths: + - "node/**" + - ".github/workflows/node.yml" + +permissions: + contents: read + +jobs: + test: + name: Node ${{ matrix.node-version }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node-version: ["22.x", "24.x"] + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: node/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Validate package contents + run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index 84a6b4b..106bde7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ *.iml .vscode/ -# Language-specific ignores live in python/.gitignore and java/.gitignore so +# Language-specific ignores live in python/.gitignore and node/.gitignore so # each package stays self-contained. diff --git a/node/.gitignore b/node/.gitignore new file mode 100644 index 0000000..4abc033 --- /dev/null +++ b/node/.gitignore @@ -0,0 +1,14 @@ +# Build artifacts +dist/ +dist-test/ +node_modules/ + +# npm +*.tgz +npm-debug.log* + +# Test / coverage output +coverage/ + +# OS / editor +.DS_Store diff --git a/node/.prettierignore b/node/.prettierignore new file mode 100644 index 0000000..a32a6f6 --- /dev/null +++ b/node/.prettierignore @@ -0,0 +1,4 @@ +dist/ +dist-test/ +node_modules/ +package-lock.json diff --git a/node/.prettierrc.json b/node/.prettierrc.json new file mode 100644 index 0000000..e020f03 --- /dev/null +++ b/node/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 90, + "proseWrap": "always", + "singleQuote": false, + "trailingComma": "all" +} diff --git a/node/LICENSE b/node/LICENSE new file mode 100644 index 0000000..54b76e4 --- /dev/null +++ b/node/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Amazon + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/node/NOTICE b/node/NOTICE new file mode 100644 index 0000000..e8a6095 --- /dev/null +++ b/node/NOTICE @@ -0,0 +1,2 @@ +Developer Toolkit for Amazon ElastiCache +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/node/README.md b/node/README.md new file mode 100644 index 0000000..348d9f3 --- /dev/null +++ b/node/README.md @@ -0,0 +1,165 @@ +# Developer Toolkit for Amazon ElastiCache (Node.js) + +This package generates IAM authentication tokens for Amazon ElastiCache serverless caches +and node-based replication groups. It provides a typed TypeScript API, an async token +provider, and the `generate_iam_auth_token` command. + +## Installation + +The package requires Node.js 22 or newer. To install the current source package: + +```bash +git clone https://github.com/aws/developer-toolkit-elasticache.git +cd developer-toolkit-elasticache/node +npm ci +npm run build +npm install . +``` + +For a published package, install it with npm: + +```bash +npm install @aws/developer-toolkit-elasticache +``` + +## Usage + +The cache must have TLS enabled and an IAM-enabled user with access to the cache. The AWS +identity used by the credential chain must have the `elasticache:Connect` permission. + +### Generate a token + +```typescript +import { generateIamAuthToken } from "@aws/developer-toolkit-elasticache"; + +const token = await generateIamAuthToken({ + serverlessCacheName: "my-cache", + // Use replicationGroupId: "my-group" for a node-based replication group. + userId: "iam-user", + region: "us-east-1", +}); +``` + +Provide exactly one of `serverlessCacheName` and `replicationGroupId`. The value is the +ElastiCache cache name or replication group id used as the SigV4 signing host; it is not +the connection endpoint DNS name. Cache names and user ids are normalized to lowercase +before validation and signing. + +The `region` option is optional. Resolution uses the following order: + +1. The explicit `region` option. +2. `AWS_REGION`. +3. `AWS_DEFAULT_REGION`. +4. The region in the standard AWS shared configuration. + +Credentials use the standard Node.js AWS provider chain, including environment variables, +shared config and credentials files, SSO, assume-role, container credentials, and EC2 +instance metadata. One-shot calls resolve the region and credentials for each invocation, +so refreshed configuration and credentials are used. + +### Reconnecting clients + +`ElastiCacheIAMAuthTokenProvider` is client-agnostic. It resolves and retains the region +once during construction, exposes the normalized `userId`, and asynchronously vends a +fresh token each time `getToken()` is called. Credential providers are still invoked for +every token, so refreshed credentials are used without changing the signing region. Since +Node's region configuration is asynchronous, missing region configuration is reported by +`getToken()` rather than the synchronous constructor: + +```typescript +import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache"; + +const auth = new ElastiCacheIAMAuthTokenProvider({ + serverlessCacheName: "my-cache", + userId: "iam-user", + region: "us-east-1", +}); + +const username = auth.userId; +const password = await auth.getToken(); +``` + +Pass `username` and `password` to the client connection configuration. Fetch another token +whenever the client creates a new connection. See +[`examples/connect-valkey.mjs`](examples/connect-valkey.mjs) for a Valkey GLIDE bridge. + +### Examples + +The package includes runnable ESM examples for Node.js 22 and newer. After installing the +package and configuring AWS credentials, run: + +```bash +node examples/generate-token.mjs +``` + +The Valkey GLIDE example uses an optional client dependency and does not add it to this +package's runtime dependencies. Install it in the application that runs the example, then +run: + +```bash +npm install @valkey/valkey-glide +node examples/connect-valkey.mjs +``` + +### Command line + +The npm package installs the `generate_iam_auth_token` executable: + +```bash +generate_iam_auth_token \ + --serverless-cache-name my-cache \ + --user-id iam-user \ + --region us-east-1 +``` + +The Python-compatible form with a leading command is also accepted: + +```bash +generate_iam_auth_token generate_iam_auth_token \ + --replication-group-id my-group \ + --user-id iam-user +``` + +The token is the only successful stdout output, followed by a newline. Input and AWS +configuration errors produce one `Error: ...` line on stderr and exit with status 1. +Malformed command-line arguments exit with status 2. + +## Errors + +The public error hierarchy is: + +- `ToolkitInputError`: base class for user-correctable failures. +- `InvalidParameterError`: invalid values or target combinations. +- `ConfigurationError`: missing AWS region or credentials. + +## Security + +An IAM auth token is a short-lived bearer credential. Do not log, persist, or place it in +shell history. Connect to ElastiCache over TLS. When using a command-line client, pass the +token through `REDISCLI_AUTH` or `VALKEYCLI_AUTH` instead of a password argument that may +appear in the process list: + +```bash +export VALKEYCLI_AUTH=$(generate_iam_auth_token \ + --serverless-cache-name my-cache \ + --user-id iam-user \ + --region us-east-1) +valkey-cli --tls -h --user iam-user +unset VALKEYCLI_AUTH +``` + +## Development + +```bash +npm ci +npm run lint +npm run format:check +npm run typecheck +npm test +npm run build +npm run package:check +``` + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/node/eslint.config.mjs b/node/eslint.config.mjs new file mode 100644 index 0000000..6f0112e --- /dev/null +++ b/node/eslint.config.mjs @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/", "dist-test/", "node_modules/"], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts", "**/*.mjs"], + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, +); diff --git a/node/examples/connect-valkey.mjs b/node/examples/connect-valkey.mjs new file mode 100644 index 0000000..d267226 --- /dev/null +++ b/node/examples/connect-valkey.mjs @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* global console, setTimeout, clearTimeout */ + +/** + * Run this example after installing @valkey/valkey-glide: + * + * npm install @valkey/valkey-glide + * + * The cache name used for signing is different from the connection endpoint. + * For a node-based deployment, use replicationGroupId instead. + * + * The token is a 15-minute bearer credential. GLIDE reuses the credentials + * passed at connection time for its own automatic reconnects, so a + * long-running process must refresh the client's password on an interval + * shorter than the token TTL. `updateConnectionPassword` rotates it without + * dropping the connection. + */ +import { GlideClient } from "@valkey/valkey-glide"; +import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache"; + +const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; +// A failed refresh gets retried well before the 15-minute token TTL expires, +// rather than waiting for the next full 10-minute interval. +const TOKEN_REFRESH_RETRY_MS = 30 * 1000; + +const auth = new ElastiCacheIAMAuthTokenProvider({ + serverlessCacheName: "my-cache", + userId: "my-iam-user", + region: "us-east-1", +}); + +const client = await GlideClient.createClient({ + addresses: [ + { + host: "my-cache-abc123.serverless.use1.cache.amazonaws.com", + port: 6379, + }, + ], + credentials: { + username: auth.userId, + password: await auth.getToken(), + }, + useTLS: true, +}); + +let refreshTimer; +function scheduleTokenRefresh(delayMs) { + refreshTimer = setTimeout(async () => { + try { + const token = await auth.getToken(); + await client.updateConnectionPassword(token, true); + scheduleTokenRefresh(TOKEN_REFRESH_INTERVAL_MS); + } catch (error) { + console.error("Failed to refresh IAM auth token:", error); + scheduleTokenRefresh(TOKEN_REFRESH_RETRY_MS); + } + }, delayMs); + refreshTimer.unref?.(); +} +scheduleTokenRefresh(TOKEN_REFRESH_INTERVAL_MS); + +await client.set("hello", "world"); +console.log(await client.get("hello")); + +clearTimeout(refreshTimer); +client.close(); diff --git a/node/examples/generate-token.mjs b/node/examples/generate-token.mjs new file mode 100644 index 0000000..d9ad0e8 --- /dev/null +++ b/node/examples/generate-token.mjs @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* global console */ + +import { generateIamAuthToken } from "@aws/developer-toolkit-elasticache"; + +const token = await generateIamAuthToken({ + serverlessCacheName: "my-cache", + userId: "my-iam-user", + region: "us-east-1", +}); + +// Do not print the token. It is a short-lived bearer credential. +console.log(`Generated an IAM auth token (${token.length} characters).`); diff --git a/node/package-lock.json b/node/package-lock.json new file mode 100644 index 0000000..bbb0f21 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,1947 @@ +{ + "name": "@aws/developer-toolkit-elasticache", + "version": "0.1.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aws/developer-toolkit-elasticache", + "version": "0.1.0-dev.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-providers": "^3.943.0", + "@smithy/core": "^3.18.0", + "@smithy/hash-node": "^4.2.7", + "@smithy/signature-v4": "^5.2.7", + "@smithy/types": "^4.9.0" + }, + "bin": { + "generate_iam_auth_token": "dist/cli.js" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "eslint": "^9.39.1", + "prettier": "^3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.69.tgz", + "integrity": "sha512-vpsh9VWmQVC/nsdzf72F2yUMBOFT1aq+hoLseYV03zjVXVHkjqSNJskFRKPFxc3MRFrHNbSSmOwsbYE15u9ecw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1123.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1123.0.tgz", + "integrity": "sha512-hfpS7dywjHDyzDRLl/IF03Uy4FY9MpnPT7eSArkvTwP4F29G1r3PYwNyhCTa+wX/pyeyuVKkXX44XeQlWDiMJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.69", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.5.2.tgz", + "integrity": "sha512-OcD8fGClTkP0BWHVEAgUp1RZyCw8cKfqTPQ+DgrSF5jvR8zKkw2Aud79L4G/1Fu3QKLcsHExxRIPQCcKx7+xkg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.0.tgz", + "integrity": "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "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/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..3703998 --- /dev/null +++ b/node/package.json @@ -0,0 +1,54 @@ +{ + "name": "@aws/developer-toolkit-elasticache", + "version": "0.1.0-dev.0", + "description": "Developer toolkit for Amazon ElastiCache", + "type": "module", + "private": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "files": [ + "dist", + "LICENSE", + "NOTICE", + "README.md", + "examples" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "generate_iam_auth_token": "./dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"", + "build:test": "tsc -p tsconfig.test.json", + "clean": "node scripts/clean.mjs", + "format:check": "prettier --check .", + "lint": "eslint .", + "package:check": "npm run clean && npm run build && npm pack --dry-run", + "test": "npm run build && npm run build:test && node --test dist-test/test/*.test.js", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@aws-sdk/credential-providers": "^3.943.0", + "@smithy/core": "^3.18.0", + "@smithy/hash-node": "^4.2.7", + "@smithy/signature-v4": "^5.2.7", + "@smithy/types": "^4.9.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "eslint": "^9.39.1", + "prettier": "^3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.3" + } +} diff --git a/node/scripts/clean.mjs b/node/scripts/clean.mjs new file mode 100644 index 0000000..0a3ef8e --- /dev/null +++ b/node/scripts/clean.mjs @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { rmSync } from "node:fs"; + +for (const directory of ["dist", "dist-test"]) { + rmSync(directory, { force: true, recursive: true }); +} diff --git a/node/src/cli.ts b/node/src/cli.ts new file mode 100644 index 0000000..01dbfc2 --- /dev/null +++ b/node/src/cli.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { parseArgs } from "node:util"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; + +import { ToolkitInputError } from "./errors.js"; +import { generateIamAuthToken } from "./token-generator.js"; + +const COMMAND = "generate_iam_auth_token"; +const USAGE = `Usage: ${COMMAND} [options] + +Generate an IAM auth token for an ElastiCache cache. + +Options: + --serverless-cache-name Serverless cache name + --replication-group-id Node-based replication group id + --user-id ElastiCache user id + --region AWS region + --help Show this help +`; + +type ParsedArguments = + | { help: true } + | { + help: false; + userId: string; + region?: string; + serverlessCacheName?: string; + replicationGroupId?: string; + }; + +function parserMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + // Keep stderr errors to one line so hostile arguments cannot inject extra + // log lines through embedded LF or CRLF characters. + return message.replace(/[\r\n]+/g, " "); +} + +function readString( + values: Record, + option: string, +): string | undefined { + const value = values[option]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new TypeError(`Option '--${option}' requires a value.`); + } + return value; +} + +function parseCliArguments(argv: string[]): ParsedArguments { + let args = argv; + if (args[0] === COMMAND) { + // Also accept the Python-compatible form documented in the README, where the + // executable name is repeated as a leading command argument. + args = args.slice(1); + } + if (args.length === 0) { + throw new TypeError("Missing required options."); + } + + const { values } = parseArgs({ + args, + allowPositionals: false, + options: { + "serverless-cache-name": { type: "string" }, + "replication-group-id": { type: "string" }, + "user-id": { type: "string" }, + region: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + strict: true, + }); + + if (values.help) { + return { help: true }; + } + + const serverlessCacheName = readString(values, "serverless-cache-name"); + const replicationGroupId = readString(values, "replication-group-id"); + const userId = readString(values, "user-id"); + const region = readString(values, "region"); + const hasServerlessTarget = serverlessCacheName !== undefined; + const hasReplicationGroupTarget = replicationGroupId !== undefined; + + if (hasServerlessTarget === hasReplicationGroupTarget) { + throw new TypeError( + "Exactly one of '--serverless-cache-name' and '--replication-group-id' is required.", + ); + } + if (userId === undefined) { + throw new TypeError("Option '--user-id' is required."); + } + + return { + help: false, + userId, + region, + serverlessCacheName, + replicationGroupId, + }; +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + let arguments_: ParsedArguments; + try { + arguments_ = parseCliArguments(argv); + } catch (error) { + process.stderr.write(`Error: ${parserMessage(error)}\n`); + return 2; + } + + if (arguments_.help) { + process.stdout.write(USAGE); + return 0; + } + + try { + const token = await generateIamAuthToken({ + userId: arguments_.userId, + region: arguments_.region, + serverlessCacheName: arguments_.serverlessCacheName, + replicationGroupId: arguments_.replicationGroupId, + }); + process.stdout.write(`${token}\n`); + return 0; + } catch (error) { + if (error instanceof ToolkitInputError) { + process.stderr.write(`Error: ${parserMessage(error)}\n`); + return 1; + } + throw error; + } +} + +function isMainModule(): boolean { + if (!process.argv[1]) { + return false; + } + + try { + // npm's installed or globally-linked bin entry is commonly a symlink, so + // compare canonical filesystem paths rather than the raw argument strings. + return ( + realpathSync(fileURLToPath(import.meta.url)) === + realpathSync(resolve(process.argv[1])) + ); + } catch { + return false; + } +} + +if (isMainModule()) { + const exitCode = await main(); + process.exitCode = exitCode; +} diff --git a/node/src/errors.ts b/node/src/errors.ts new file mode 100644 index 0000000..160be57 --- /dev/null +++ b/node/src/errors.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Base class for failures that can be corrected by changing the invocation or + * the AWS configuration. The CLI reports these without a stack trace. + */ +export class ToolkitInputError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolkitInputError"; + } +} + +/** + * A parameter value or combination of values is invalid. + */ +export class InvalidParameterError extends ToolkitInputError { + constructor(message: string) { + super(message); + this.name = "InvalidParameterError"; + } +} + +/** + * Required AWS configuration, such as a region or credentials, is missing. + */ +export class ConfigurationError extends ToolkitInputError { + constructor(message: string) { + super(message); + this.name = "ConfigurationError"; + } +} diff --git a/node/src/index.ts b/node/src/index.ts new file mode 100644 index 0000000..e3d7192 --- /dev/null +++ b/node/src/index.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + ConfigurationError, + InvalidParameterError, + ToolkitInputError, +} from "./errors.js"; +export type { + TokenGeneratorDependencies, + TokenGeneratorOptions, +} from "./token-generator.js"; +export { + ElastiCacheIAMAuthTokenProvider, + generateIamAuthToken, +} from "./token-generator.js"; diff --git a/node/src/token-generator.ts b/node/src/token-generator.ts new file mode 100644 index 0000000..45edc12 --- /dev/null +++ b/node/src/token-generator.ts @@ -0,0 +1,349 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; +import { loadConfig, NODE_REGION_CONFIG_OPTIONS } from "@smithy/core/config"; +import { HttpRequest } from "@smithy/core/protocols"; +import { Hash } from "@smithy/hash-node"; +import { SignatureV4 } from "@smithy/signature-v4"; +import type { + AwsCredentialIdentity, + AwsCredentialIdentityProvider, + QueryParameterBag, +} from "@smithy/types"; + +import { ConfigurationError, InvalidParameterError } from "./errors.js"; + +const TOKEN_TTL_SECONDS = 900; +const SIGNING_SERVICE = "elasticache"; + +// The cache name becomes the SigV4 signing host. Keep this validation tight so a +// malformed or hostile value cannot be signed as a different host. +const CACHE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$/; + +// ElastiCache service-managed user ids may use the special "default." prefix, +// for example "default.iam-user". +const USER_ID_PATTERN = /^(?:default\.)?[a-zA-Z][a-zA-Z0-9-]*$/; + +const NO_REGION_MESSAGE = + "No AWS region found. Pass region explicitly, or configure one via " + + "AWS_REGION, AWS_DEFAULT_REGION, or the region setting in your AWS config profile."; +const NO_CREDENTIALS_MESSAGE = + "No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role."; + +export interface TokenGeneratorOptions { + /** ElastiCache user id used in the signed `User` query parameter. */ + userId: string; + /** AWS region. Resolution falls back to the supported AWS configuration sources. */ + region?: string; + /** Name of a serverless cache. Exactly one target option is required. */ + serverlessCacheName?: string; + /** Id of a node-based replication group. Exactly one target option is required. */ + replicationGroupId?: string; +} + +/** + * Advanced dependencies for deterministic tests and applications with an + * explicitly managed AWS configuration. These are intentionally optional; the + * normal API uses the standard Node.js AWS credential and region providers. + */ +export interface TokenGeneratorDependencies { + credentialProvider?: AwsCredentialIdentityProvider; + regionProvider?: () => Promise; + signingDate?: Date; +} + +type Target = { + cacheName: string; + serverless: boolean; +}; + +type RegionResolution = { region: string } | { error: ConfigurationError }; + +function validateAndNormalize( + parameter: string, + value: unknown, + pattern: RegExp, +): string { + if (typeof value !== "string") { + throw new InvalidParameterError( + `Invalid value for parameter '${parameter}': must match ${pattern.source}`, + ); + } + + // ElastiCache stores cache names and user ids lowercase server-side. Normalize + // before validation and signing so the signed value matches the value sent by + // the client; otherwise authentication can fail with WRONGPASS. + const normalized = value.toLowerCase(); + if (!pattern.test(normalized)) { + // Do not include the supplied value: it may contain newlines or URL syntax. + throw new InvalidParameterError( + `Invalid value for parameter '${parameter}': must match ${pattern.source}`, + ); + } + return normalized; +} + +function resolveTarget(options: TokenGeneratorOptions): Target { + const { serverlessCacheName, replicationGroupId } = options; + + // Validate inside each branch so an invalid value is reported under the public + // parameter name the caller actually supplied. + if (serverlessCacheName && !replicationGroupId) { + return { + cacheName: validateAndNormalize( + "serverlessCacheName", + serverlessCacheName, + CACHE_NAME_PATTERN, + ), + serverless: true, + }; + } + + if (replicationGroupId && !serverlessCacheName) { + return { + cacheName: validateAndNormalize( + "replicationGroupId", + replicationGroupId, + CACHE_NAME_PATTERN, + ), + serverless: false, + }; + } + + throw new InvalidParameterError( + "Invalid parameter combination for 'serverlessCacheName' and " + + "'replicationGroupId': exactly one must be provided.", + ); +} + +async function resolveRegion( + region: string | undefined, + regionProvider: (() => Promise) | undefined, +): Promise { + const fromArgument = region || undefined; + const fromEnvironment = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION; + if (fromArgument || fromEnvironment) { + return fromArgument || fromEnvironment!; + } + + // Environment variables were checked above; now fall back to the shared + // AWS configuration while preserving the documented precedence. + try { + const resolved = await (regionProvider ?? loadSharedRegion)(); + if (resolved) { + return resolved; + } + } catch { + // Present one stable, actionable configuration error instead of leaking + // provider internals or a file path from the local AWS configuration. + } + + throw new ConfigurationError(NO_REGION_MESSAGE); +} + +function retainRegion( + region: string | undefined, + regionProvider: (() => Promise) | undefined, +): Promise { + // Keep this promise fulfilled even when configuration is invalid. A provider + // may be constructed without an immediate async error observer; getToken() + // reports the retained configuration error when the token is requested. + return resolveRegion(region, regionProvider).then( + (resolvedRegion) => ({ region: resolvedRegion }), + (error: unknown) => ({ + error: + error instanceof ConfigurationError + ? error + : new ConfigurationError(NO_REGION_MESSAGE), + }), + ); +} + +async function loadSharedRegion(): Promise { + // Override the SDK selector/default so all "no region" cases return undefined + // here and are converted by resolveRegion into one stable ConfigurationError, + // rather than exposing the SDK's provider-specific error shape. + const regionConfig = { + ...NODE_REGION_CONFIG_OPTIONS, + environmentVariableSelector: (env: Record) => + env.AWS_REGION || env.AWS_DEFAULT_REGION, + default: async () => undefined, + }; + return loadConfig(regionConfig)(); +} + +async function resolveCredentials( + credentialProvider: AwsCredentialIdentityProvider, +): Promise { + try { + // Resolve on every request instead of caching: SSO, assume-role, container, + // and instance-metadata providers may rotate or refresh their credentials. + const credentials = await credentialProvider(); + if (!credentials?.accessKeyId || !credentials.secretAccessKey) { + throw new Error("The credential provider returned no usable credentials."); + } + return credentials; + } catch { + throw new ConfigurationError(NO_CREDENTIALS_MESSAGE); + } +} + +function encodeQueryComponent(value: string): string { + // encodeURIComponent leaves !'()* unescaped, but SigV4 requires them to be + // percent-encoded. Returning a different encoding from the one that was signed + // makes the service canonicalize a different query and silently reject the token. + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function formatQuery(query: QueryParameterBag): string { + // Preserve repeated query values while applying the SigV4-compatible encoding + // above to every key and value in the final token. + const entries: string[] = []; + for (const [key, value] of Object.entries(query)) { + if (value === null) { + entries.push(`${encodeQueryComponent(key)}=`); + continue; + } + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + entries.push(`${encodeQueryComponent(key)}=${encodeQueryComponent(item)}`); + } + } + return entries.join("&"); +} + +/** + * Sign the ElastiCache IAM authentication request. + * + * `target.cacheName` is deliberately used as the hostname and `Host` header: it + * is the SigV4 signing host expected by ElastiCache, not the connection endpoint + * DNS name. For serverless caches, `ResourceType` is part of the canonical query, + * so it must be added before presigning rather than appended afterward. + */ +async function signToken( + target: Target, + userId: string, + region: string, + credentials: AwsCredentialIdentity, + signingDate: Date | undefined, +): Promise { + const query: QueryParameterBag = { + Action: "connect", + User: userId, + }; + if (target.serverless) { + // This parameter changes the canonical request and therefore the signature; + // node-based replication groups must omit it. + query.ResourceType = "ServerlessCache"; + } + + const request = new HttpRequest({ + protocol: "https:", + hostname: target.cacheName, + method: "GET", + path: "/", + headers: { host: target.cacheName }, + query, + }); + const signer = new SignatureV4({ + credentials, + region, + service: SIGNING_SERVICE, + sha256: Hash.bind(null, "sha256"), + }); + const signed = await signer.presign(request, { + expiresIn: TOKEN_TTL_SECONDS, + signingDate, + }); + + return `${signed.hostname}/?${formatQuery(signed.query ?? {})}`; +} + +function defaultCredentialProvider(): AwsCredentialIdentityProvider { + return fromNodeProviderChain(); +} + +/** + * Generate an ElastiCache IAM authentication token. + * + * Exactly one of `serverlessCacheName` or `replicationGroupId` must be provided. + * The selected value is used as the SigV4 signing host; it is not the connection + * endpoint DNS name. The `userId` is normalized to the lowercase value used in the + * signed `User` query parameter. + * + * When `region` is omitted, resolution checks `AWS_REGION`, `AWS_DEFAULT_REGION`, + * and the standard AWS shared configuration. Credentials use the standard Node.js + * provider chain and are resolved for each invocation so refreshed credentials are + * picked up. + * + * @param options Target, user, and optional region used to create the token. + * @param dependencies Optional providers and signing time for controlled callers + * and deterministic tests. + * @returns The presigned ElastiCache IAM authentication token. + */ +export async function generateIamAuthToken( + options: TokenGeneratorOptions, + dependencies: TokenGeneratorDependencies = {}, +): Promise { + const target = resolveTarget(options); + const userId = validateAndNormalize("userId", options.userId, USER_ID_PATTERN); + const region = await resolveRegion(options.region, dependencies.regionProvider); + const credentials = await resolveCredentials( + dependencies.credentialProvider ?? defaultCredentialProvider(), + ); + + return signToken(target, userId, region, credentials, dependencies.signingDate); +} + +/** + * Client-agnostic ElastiCache IAM token provider. + * + * The target and normalized user id are retained, and the region is resolved once + * during construction. `getToken()` signs a fresh token and re-fetches credentials + * on every call so rotated SSO, assume-role, container, or instance credentials are + * used without changing the signing region. + */ +export class ElastiCacheIAMAuthTokenProvider { + private readonly target: Target; + private readonly userIdValue: string; + private readonly credentialProvider: AwsCredentialIdentityProvider; + private readonly regionResolution: Promise; + private readonly signingDate?: Date; + + constructor( + options: TokenGeneratorOptions, + dependencies: TokenGeneratorDependencies = {}, + ) { + this.target = resolveTarget(options); + this.userIdValue = validateAndNormalize("userId", options.userId, USER_ID_PATTERN); + this.credentialProvider = + dependencies.credentialProvider ?? defaultCredentialProvider(); + this.regionResolution = retainRegion(options.region, dependencies.regionProvider); + this.signingDate = dependencies.signingDate; + } + + get userId(): string { + return this.userIdValue; + } + + /** Sign and return a fresh token using the provider's retained target and region. */ + async getToken(): Promise { + const regionResolution = await this.regionResolution; + if ("error" in regionResolution) { + throw regionResolution.error; + } + const credentials = await resolveCredentials(this.credentialProvider); + return signToken( + this.target, + this.userIdValue, + regionResolution.region, + credentials, + this.signingDate, + ); + } +} diff --git a/node/test/cli.test.ts b/node/test/cli.test.ts new file mode 100644 index 0000000..3648359 --- /dev/null +++ b/node/test/cli.test.ts @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const CLI_PATH = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"; +const SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + +function runCli(args: string[], overrides: NodeJS.ProcessEnv = {}) { + const environment: NodeJS.ProcessEnv = { + ...process.env, + AWS_ACCESS_KEY_ID: ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY: SECRET_ACCESS_KEY, + AWS_REGION: "us-east-1", + AWS_EC2_METADATA_DISABLED: "true", + }; + delete environment.AWS_PROFILE; + delete environment.AWS_DEFAULT_REGION; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete environment[key]; + } else { + environment[key] = value; + } + } + return spawnSync(process.execPath, [CLI_PATH, ...args], { + encoding: "utf8", + env: environment, + }); +} + +test("writes only the token to stdout", () => { + const result = runCli([ + "--serverless-cache-name", + "my-cache", + "--user-id", + "testuser", + "--region", + "us-east-1", + ]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^my-cache\/\?Action=connect&User=testuser/); + assert.match(result.stdout, /X-Amz-Signature=[0-9a-f]{64}\n$/); +}); + +test("accepts the Python-compatible leading command", () => { + const result = runCli([ + "generate_iam_auth_token", + "--replication-group-id", + "my-group", + "--user-id", + "testuser", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /^my-group\/\?Action=connect&User=testuser/); + assert.equal(result.stdout.includes("ResourceType"), false); +}); + +test("reports toolkit input errors on one stderr line with exit 1", () => { + const result = runCli([ + "--serverless-cache-name", + "evil.com/inject", + "--user-id", + "testuser", + ]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^Error: .*\n$/); + assert.equal(result.stderr.includes("evil.com/inject"), false); +}); + +test("returns exit 2 for malformed arguments", () => { + const missingTarget = runCli(["--user-id", "testuser"]); + assert.equal(missingTarget.status, 2); + assert.match(missingTarget.stderr, /^Error: .*\n$/); + + const bothTargets = runCli([ + "--serverless-cache-name", + "my-cache", + "--replication-group-id", + "my-group", + "--user-id", + "testuser", + ]); + assert.equal(bothTargets.status, 2); + + const unknownOption = runCli(["--unknown", "value"]); + assert.equal(unknownOption.status, 2); +}); + +test("returns exit 2 for missing values, positionals, and no arguments", () => { + const malformedInvocations = [[], ["--serverless-cache-name"], ["unexpected"]]; + + for (const args of malformedInvocations) { + const result = runCli(args); + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^Error: [^\r\n]*\n$/); + } +}); + +test("reports missing credentials as a one-line configuration error", () => { + const result = runCli( + ["--serverless-cache-name", "my-cache", "--user-id", "testuser"], + { + AWS_ACCESS_KEY_ID: undefined, + AWS_SECRET_ACCESS_KEY: undefined, + AWS_SESSION_TOKEN: undefined, + AWS_PROFILE: undefined, + AWS_DEFAULT_PROFILE: undefined, + AWS_WEB_IDENTITY_TOKEN_FILE: undefined, + AWS_ROLE_ARN: undefined, + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined, + AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined, + AWS_CONFIG_FILE: "/dev/null", + AWS_SHARED_CREDENTIALS_FILE: "/dev/null", + }, + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal( + result.stderr, + "Error: No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role.\n", + ); +}); + +test("prints help without importing or invoking the signing chain", () => { + const result = runCli(["--help"]); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^Usage: generate_iam_auth_token/m); +}); diff --git a/node/test/package.test.ts b/node/test/package.test.ts new file mode 100644 index 0000000..f7166a3 --- /dev/null +++ b/node/test/package.test.ts @@ -0,0 +1,204 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const PACKAGE_NAME = "@aws/developer-toolkit-elasticache"; +const NPM_INSTALL_TIMEOUT_MS = 60_000; + +type PackedFile = { + path: string; + mode: number; +}; + +type PackResult = { + filename: string; + files: PackedFile[]; +}; + +test("packed CLI is executable and launches through its bin entry", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-package-test-")); + const packageDirectory = join(temporaryRoot, "package"); + const installationDirectory = join(temporaryRoot, "installed"); + const npmCacheDirectory = join(temporaryRoot, "npm-cache"); + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + + try { + mkdirSync(packageDirectory); + mkdirSync(installationDirectory); + const pack = spawnSync( + npmCommand, + ["pack", "--json", "--pack-destination", packageDirectory], + { + cwd: PACKAGE_ROOT, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: npmCacheDirectory, + npm_config_update_notifier: "false", + }, + }, + ); + assert.equal(pack.status, 0, pack.stderr); + + const packResult = JSON.parse(pack.stdout) as PackResult[]; + const packed = packResult[0]; + assert.ok(packed); + const packedCli = packed.files.find((file) => file.path === "dist/cli.js"); + assert.ok(packedCli); + assert.equal(packedCli.mode, 0o755); + assert.equal( + packed.files.some((file) => file.path === "examples/generate-token.mjs"), + true, + ); + assert.equal( + packed.files.some((file) => file.path === "examples/connect-valkey.mjs"), + true, + ); + + const tarballPath = join(packageDirectory, packed.filename); + const install = spawnSync( + npmCommand, + [ + "install", + tarballPath, + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefix", + installationDirectory, + ], + { + cwd: PACKAGE_ROOT, + encoding: "utf8", + timeout: NPM_INSTALL_TIMEOUT_MS, + env: { + ...process.env, + npm_config_cache: npmCacheDirectory, + npm_config_update_notifier: "false", + }, + }, + ); + assert.equal( + install.status, + 0, + install.signal + ? `npm install was killed with ${install.signal} (likely no registry access; ` + + `this test installs the packed tarball's dependencies from the npm registry)` + : install.stderr, + ); + + const installedNodeModules = join(installationDirectory, "node_modules"); + const installedPackageDirectory = join( + installedNodeModules, + "@aws", + "developer-toolkit-elasticache", + ); + const packedPackageJson = JSON.parse( + readFileSync(join(installedPackageDirectory, "package.json"), "utf8"), + ) as { bin: Record }; + assert.deepEqual(packedPackageJson.bin, { + generate_iam_auth_token: "./dist/cli.js", + }); + + const executablePath = join(installedPackageDirectory, "dist", "cli.js"); + assert.equal( + readFileSync(executablePath, "utf8").startsWith("#!/usr/bin/env node\n"), + true, + ); + const installedBinPath = join( + installedNodeModules, + ".bin", + process.platform === "win32" + ? "generate_iam_auth_token.cmd" + : "generate_iam_auth_token", + ); + assert.equal(existsSync(installedBinPath), true); + + const exportsProbePath = join(installedNodeModules, "exports-probe.mjs"); + writeFileSync( + exportsProbePath, + [ + 'import assert from "node:assert/strict";', + 'import { ConfigurationError, ElastiCacheIAMAuthTokenProvider, InvalidParameterError, ToolkitInputError, generateIamAuthToken } from "@aws/developer-toolkit-elasticache";', + 'assert.equal(typeof generateIamAuthToken, "function");', + 'assert.equal(typeof ElastiCacheIAMAuthTokenProvider, "function");', + "assert.equal(ConfigurationError.prototype instanceof ToolkitInputError, true);", + "assert.equal(ConfigurationError.prototype instanceof InvalidParameterError, false);", + ].join("\n"), + ); + const exportsProbe = spawnSync(process.execPath, [exportsProbePath], { + cwd: installationDirectory, + encoding: "utf8", + }); + assert.equal(exportsProbe.status, 0, exportsProbe.stderr); + + const result = spawnSync( + installedBinPath, + ["--serverless-cache-name", "my-cache", "--user-id", "testuser"], + { + cwd: installationDirectory, + encoding: "utf8", + shell: process.platform === "win32", + env: { + ...process.env, + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + AWS_REGION: "us-east-1", + AWS_EC2_METADATA_DISABLED: "true", + npm_config_cache: npmCacheDirectory, + npm_config_update_notifier: "false", + }, + }, + ); + assert.equal(result.status, 0, result.stderr || result.error?.message); + assert.equal(result.stderr, ""); + const processDetails = JSON.stringify({ + error: result.error?.message, + signal: result.signal, + stderr: result.stderr, + stdout: result.stdout, + }); + assert.match( + result.stdout, + /^my-cache\/\?Action=connect&User=testuser/, + processDetails, + ); + assert.match(result.stdout, /X-Amz-Signature=[0-9a-f]{64}\n$/, processDetails); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("package root exports the complete public API", async () => { + const packageModule = await import(PACKAGE_NAME); + + assert.equal(typeof packageModule.generateIamAuthToken, "function"); + assert.equal(typeof packageModule.ElastiCacheIAMAuthTokenProvider, "function"); + assert.equal(typeof packageModule.ToolkitInputError, "function"); + assert.equal(typeof packageModule.InvalidParameterError, "function"); + assert.equal(typeof packageModule.ConfigurationError, "function"); + assert.equal( + packageModule.ConfigurationError.prototype instanceof packageModule.ToolkitInputError, + true, + ); + assert.equal( + packageModule.ConfigurationError.prototype instanceof + packageModule.InvalidParameterError, + false, + ); +}); diff --git a/node/test/token-generator.test.ts b/node/test/token-generator.test.ts new file mode 100644 index 0000000..c48d799 --- /dev/null +++ b/node/test/token-generator.test.ts @@ -0,0 +1,434 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + ConfigurationError, + ElastiCacheIAMAuthTokenProvider, + InvalidParameterError, + ToolkitInputError, + generateIamAuthToken, +} from "../src/index.js"; + +const CACHE = "my-cache"; +const USER = "testuser"; +const REGION = "us-east-1"; +const SESSION_REGION = "eu-west-1"; +const CREDENTIALS = { + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", +}; +const ROTATED_CREDENTIALS = { + accessKeyId: "AKIAI44QH8DHBEXAMPLE", + secretAccessKey: "other-secret", +}; +const SIGNING_DATE = new Date("2025-01-01T00:00:00Z"); + +function fixedDependencies( + credentials: typeof CREDENTIALS = CREDENTIALS, + region = REGION, +) { + return { + credentialProvider: async () => credentials, + regionProvider: async () => region, + signingDate: SIGNING_DATE, + }; +} + +async function withoutRegion(callback: () => Promise): Promise { + // This helper is intentionally opt-in: only tests that exercise region fallback + // should be isolated from the developer's ambient AWS_REGION configuration. + const awsRegion = process.env.AWS_REGION; + const awsDefaultRegion = process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + try { + return await callback(); + } finally { + if (awsRegion === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = awsRegion; + } + if (awsDefaultRegion === undefined) { + delete process.env.AWS_DEFAULT_REGION; + } else { + process.env.AWS_DEFAULT_REGION = awsDefaultRegion; + } + } +} + +// These known-answer tests pin the complete presigned token, including its +// signature. A structural test could still pass after a signing-host, canonical +// query, encoding, or credential-scope change that makes real authentication fail. +test("generates the exact serverless known-answer token", async () => { + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER, region: REGION }, + fixedDependencies(), + ); + + assert.equal( + token, + "my-cache/?Action=connect&User=testuser&ResourceType=ServerlessCache" + + "&X-Amz-Algorithm=AWS4-HMAC-SHA256" + + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" + + "%2Felasticache%2Faws4_request" + + "&X-Amz-Date=20250101T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" + + "&X-Amz-Signature=" + + "28f349fd92f74e0192de149a74743ae7d087bb136523c342bdd67632a8360023", + ); +}); + +test("generates the exact replication-group known-answer token", async () => { + const token = await generateIamAuthToken( + { replicationGroupId: CACHE, userId: USER, region: REGION }, + fixedDependencies(), + ); + + assert.equal( + token, + "my-cache/?Action=connect&User=testuser" + + "&X-Amz-Algorithm=AWS4-HMAC-SHA256" + + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" + + "%2Felasticache%2Faws4_request" + + "&X-Amz-Date=20250101T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" + + "&X-Amz-Signature=" + + "cd78a7de74c1bb1c8c685cac428f783b5433ce5fbf569af867570311d6a85797", + ); + assert.equal(token.includes("ResourceType"), false); +}); + +test("normalizes the cache name and user id before signing", async () => { + const auth = new ElastiCacheIAMAuthTokenProvider( + { + serverlessCacheName: "My-Cache", + userId: "MixedCaseUser", + region: REGION, + }, + fixedDependencies(), + ); + + assert.equal(auth.userId, "mixedcaseuser"); + const token = await auth.getToken(); + assert.equal(token.startsWith("my-cache/"), true); + assert.equal(token.includes("User=mixedcaseuser"), true); + assert.equal(token.includes("MixedCaseUser"), false); +}); + +test("accepts service-managed default users", async () => { + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: "DEFAULT.IAM-USER", region: REGION }, + fixedDependencies(), + ); + assert.equal(token.includes("User=default.iam-user"), true); +}); + +test("requires exactly one target", async () => { + await assert.rejects( + () => generateIamAuthToken({ userId: USER, region: REGION }, fixedDependencies()), + InvalidParameterError, + ); + await assert.rejects( + () => + generateIamAuthToken( + { + userId: USER, + region: REGION, + serverlessCacheName: CACHE, + replicationGroupId: CACHE, + }, + fixedDependencies(), + ), + InvalidParameterError, + ); +}); + +test("rejects malformed cache names without echoing input", async () => { + const hostile = "evil.com/inject\nlog-entry"; + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: hostile, userId: USER, region: REGION }, + fixedDependencies(), + ), + (error: unknown) => { + assert.equal(error instanceof InvalidParameterError, true); + assert.equal((error as Error).message.includes("serverlessCacheName"), true); + assert.equal((error as Error).message.includes(hostile), false); + return true; + }, + ); +}); + +test("rejects malformed user ids", async () => { + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: "user.name", region: REGION }, + fixedDependencies(), + ), + InvalidParameterError, + ); +}); + +test("configuration errors are toolkit errors but not parameter errors", async () => { + assert.equal(ConfigurationError.prototype instanceof ToolkitInputError, true); + assert.equal(ConfigurationError.prototype instanceof InvalidParameterError, false); + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER, region: REGION }, + { + credentialProvider: async () => { + throw new Error("credential chain failed"); + }, + signingDate: SIGNING_DATE, + }, + ), + ConfigurationError, + ); +}); + +test("normalizes rejected async credential providers", async () => { + const providerFailure = new Error("secret credential detail\nmust not escape"); + + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER, region: REGION }, + { + credentialProvider: async () => { + throw providerFailure; + }, + signingDate: SIGNING_DATE, + }, + ), + (error: unknown) => { + assert.equal(error instanceof ConfigurationError, true); + assert.equal( + (error as Error).message, + "No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role.", + ); + assert.equal((error as Error).message.includes("secret credential detail"), false); + return true; + }, + ); +}); + +test("normalizes unusable async credential results", async () => { + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER, region: REGION }, + { + credentialProvider: async () => ({ + accessKeyId: "", + secretAccessKey: "not-a-usable-key", + }), + signingDate: SIGNING_DATE, + }, + ), + ConfigurationError, + ); +}); + +test("resolves explicit region before environment and provider", async () => { + const oldRegion = process.env.AWS_REGION; + process.env.AWS_REGION = "us-west-2"; + try { + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER, region: REGION }, + { + ...fixedDependencies(), + regionProvider: async () => SESSION_REGION, + }, + ); + assert.equal(token.includes("%2Fus-east-1%2F"), true); + assert.equal(token.includes("%2Feu-west-1%2F"), false); + } finally { + if (oldRegion === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = oldRegion; + } + } +}); + +test("resolves AWS_REGION before AWS_DEFAULT_REGION and shared config", async () => { + const oldRegion = process.env.AWS_REGION; + const oldDefaultRegion = process.env.AWS_DEFAULT_REGION; + process.env.AWS_REGION = "us-west-2"; + process.env.AWS_DEFAULT_REGION = "ap-southeast-1"; + try { + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER }, + { + credentialProvider: async () => CREDENTIALS, + regionProvider: async () => SESSION_REGION, + signingDate: SIGNING_DATE, + }, + ); + assert.equal(token.includes("%2Fus-west-2%2F"), true); + assert.equal(token.includes("%2Fap-southeast-1%2F"), false); + assert.equal(token.includes("%2Feu-west-1%2F"), false); + } finally { + if (oldRegion === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = oldRegion; + } + if (oldDefaultRegion === undefined) { + delete process.env.AWS_DEFAULT_REGION; + } else { + process.env.AWS_DEFAULT_REGION = oldDefaultRegion; + } + } +}); + +test("resolves AWS_DEFAULT_REGION when AWS_REGION is absent", async () => { + await withoutRegion(async () => { + process.env.AWS_DEFAULT_REGION = "ap-southeast-1"; + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER }, + { + ...fixedDependencies(CREDENTIALS, SESSION_REGION), + regionProvider: async () => "ap-northeast-1", + }, + ); + assert.equal(token.includes("%2Fap-southeast-1%2F"), true); + assert.equal(token.includes("%2Fap-northeast-1%2F"), false); + assert.equal(token.includes("%2Feu-west-1%2F"), false); + }); +}); + +test("falls back to the injected shared region provider", async () => { + await withoutRegion(async () => { + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER }, + fixedDependencies(CREDENTIALS, SESSION_REGION), + ); + assert.equal(token.includes("%2Feu-west-1%2F"), true); + }); +}); + +test("normalizes rejected region providers", async () => { + await withoutRegion(async () => { + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER }, + { + credentialProvider: async () => CREDENTIALS, + regionProvider: async () => { + throw new Error("private config path\nmust not escape"); + }, + }, + ), + (error: unknown) => { + assert.equal(error instanceof ConfigurationError, true); + assert.equal( + (error as Error).message, + "No AWS region found. Pass region explicitly, or configure one via " + + "AWS_REGION, AWS_DEFAULT_REGION, or the region setting in your AWS config profile.", + ); + assert.equal((error as Error).message.includes("private config path"), false); + return true; + }, + ); + }); +}); + +test("raises a configuration error when no region is available", async () => { + await withoutRegion(async () => { + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: USER }, + { + credentialProvider: async () => CREDENTIALS, + regionProvider: async () => undefined, + }, + ), + (error: unknown) => { + assert.equal(error instanceof ConfigurationError, true); + assert.equal((error as Error).message.includes("AWS_REGION"), true); + assert.equal((error as Error).message.includes("AWS_DEFAULT_REGION"), true); + return true; + }, + ); + }); +}); + +test("provider resolves credentials for every token request", async () => { + // Credentials are deliberately not cached: refreshed SSO, assume-role, + // container, and instance-metadata credentials must be used on the next request. + let calls = 0; + const auth = new ElastiCacheIAMAuthTokenProvider( + { serverlessCacheName: CACHE, userId: USER, region: REGION }, + { + credentialProvider: async () => { + calls++; + return calls === 1 ? CREDENTIALS : ROTATED_CREDENTIALS; + }, + signingDate: SIGNING_DATE, + }, + ); + + const first = await auth.getToken(); + const second = await auth.getToken(); + assert.equal(calls, 2); + assert.notEqual(first, second); +}); + +test("provider retains its construction-time region while credentials rotate", async () => { + // The provider refreshes credentials but keeps the region used to construct it; + // changing the region source later must not change the signing scope. + await withoutRegion(async () => { + let regionSource = REGION; + let regionCalls = 0; + let credentialCalls = 0; + const auth = new ElastiCacheIAMAuthTokenProvider( + { serverlessCacheName: CACHE, userId: USER }, + { + credentialProvider: async () => { + credentialCalls++; + return credentialCalls === 1 ? CREDENTIALS : ROTATED_CREDENTIALS; + }, + regionProvider: async () => { + regionCalls++; + return regionSource; + }, + signingDate: SIGNING_DATE, + }, + ); + + regionSource = SESSION_REGION; + const first = await auth.getToken(); + regionSource = "ap-southeast-1"; + const second = await auth.getToken(); + + assert.equal(regionCalls, 1); + assert.equal(credentialCalls, 2); + assert.equal(first.includes("%2Fus-east-1%2F"), true); + assert.equal(first.includes("%2Feu-west-1%2F"), false); + assert.equal(second.includes("%2Fus-east-1%2F"), true); + assert.equal(second.includes("%2Fap-southeast-1%2F"), false); + }); +}); + +test("provider reports async region configuration errors through getToken", async () => { + await withoutRegion(async () => { + const auth = new ElastiCacheIAMAuthTokenProvider( + { serverlessCacheName: CACHE, userId: USER }, + { + credentialProvider: async () => CREDENTIALS, + regionProvider: async () => undefined, + }, + ); + + await assert.rejects(() => auth.getToken(), ConfigurationError); + }); +}); diff --git a/node/tsconfig.json b/node/tsconfig.json new file mode 100644 index 0000000..dfeb093 --- /dev/null +++ b/node/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/node/tsconfig.test.json b/node/tsconfig.test.json new file mode 100644 index 0000000..4cf4d89 --- /dev/null +++ b/node/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "outDir": "dist-test", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 2d856738d470ec43be3b482786dc6220c1ee4aac Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Wed, 9 Sep 2026 14:50:08 -0700 Subject: [PATCH 02/11] fix(node): change to factory creation from node's async nature Signed-off-by: Edward Liang --- node/README.md | 10 +- node/examples/connect-valkey.mjs | 4 +- node/src/token-generator.ts | 26 +++++ node/test/token-generator.test.ts | 166 +++++++++++++++++++++++++++++- 4 files changed, 199 insertions(+), 7 deletions(-) diff --git a/node/README.md b/node/README.md index 348d9f3..e2d683f 100644 --- a/node/README.md +++ b/node/README.md @@ -62,14 +62,16 @@ so refreshed configuration and credentials are used. `ElastiCacheIAMAuthTokenProvider` is client-agnostic. It resolves and retains the region once during construction, exposes the normalized `userId`, and asynchronously vends a fresh token each time `getToken()` is called. Credential providers are still invoked for -every token, so refreshed credentials are used without changing the signing region. Since -Node's region configuration is asynchronous, missing region configuration is reported by -`getToken()` rather than the synchronous constructor: +every token, so refreshed credentials are used without changing the signing region. + +Node's region configuration is asynchronous, so use the `create()` factory to surface a +missing region up front. The synchronous constructor is also supported, but it retains the +configuration error and reports it from the first `getToken()` call instead. ```typescript import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache"; -const auth = new ElastiCacheIAMAuthTokenProvider({ +const auth = await ElastiCacheIAMAuthTokenProvider.create({ serverlessCacheName: "my-cache", userId: "iam-user", region: "us-east-1", diff --git a/node/examples/connect-valkey.mjs b/node/examples/connect-valkey.mjs index d267226..2dc0c27 100644 --- a/node/examples/connect-valkey.mjs +++ b/node/examples/connect-valkey.mjs @@ -25,7 +25,9 @@ const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // rather than waiting for the next full 10-minute interval. const TOKEN_REFRESH_RETRY_MS = 30 * 1000; -const auth = new ElastiCacheIAMAuthTokenProvider({ +// create() resolves the region up front, so a missing region fails here rather +// than at the first token request. +const auth = await ElastiCacheIAMAuthTokenProvider.create({ serverlessCacheName: "my-cache", userId: "my-iam-user", region: "us-east-1", diff --git a/node/src/token-generator.ts b/node/src/token-generator.ts index 45edc12..b3d6599 100644 --- a/node/src/token-generator.ts +++ b/node/src/token-generator.ts @@ -307,6 +307,11 @@ export async function generateIamAuthToken( * during construction. `getToken()` signs a fresh token and re-fetches credentials * on every call so rotated SSO, assume-role, container, or instance credentials are * used without changing the signing region. + * + * Region resolution is asynchronous, so a constructor cannot report a missing region. + * Prefer `ElastiCacheIAMAuthTokenProvider.create()`, which awaits that resolution and + * fails immediately; the constructor retains the error and reports it from the first + * `getToken()` call instead. */ export class ElastiCacheIAMAuthTokenProvider { private readonly target: Target; @@ -327,6 +332,27 @@ export class ElastiCacheIAMAuthTokenProvider { this.signingDate = dependencies.signingDate; } + /** + * Create a provider, resolving the region before returning. + * + * Use this instead of `new` to surface a missing or unreadable region + * configuration up front rather than at the first connection attempt. + * + * @throws {InvalidParameterError} for an invalid target or user id. + * @throws {ConfigurationError} when no region can be resolved. + */ + static async create( + options: TokenGeneratorOptions, + dependencies: TokenGeneratorDependencies = {}, + ): Promise { + const provider = new ElastiCacheIAMAuthTokenProvider(options, dependencies); + const regionResolution = await provider.regionResolution; + if ("error" in regionResolution) { + throw regionResolution.error; + } + return provider; + } + get userId(): string { return this.userIdValue; } diff --git a/node/test/token-generator.test.ts b/node/test/token-generator.test.ts index c48d799..e5a0737 100644 --- a/node/test/token-generator.test.ts +++ b/node/test/token-generator.test.ts @@ -162,17 +162,132 @@ test("rejects malformed cache names without echoing input", async () => { ); }); -test("rejects malformed user ids", async () => { +const INVALID_CACHE_NAMES = [ + "evil.com/path", // slash / path injection into the signing host + "my-cache@evil.com", // userinfo host confusion + "my-cache/?X=1", // query injection into the host + "my cache", // whitespace + "my_cache", // underscore not allowed + "1-cache", // must start with a letter, not a digit + "-cache", // must start with a letter, not a hyphen + "cache-", // cannot end with a hyphen + "my--cache", // cannot contain two consecutive hyphens + "-", // a bare hyphen is neither +]; + +const VALID_CACHE_NAMES = [ + "c", // a single letter is the shortest legal name + "my-cache", + "cache1", + "a-1-b-2", // hyphens between alphanumerics, repeatedly + "MyCache", // signed lowercase, but accepted as given +]; + +const INVALID_USER_IDS = [ + "", // empty + "1user", // must start with a letter + "-user", // must start with a letter, not a hyphen + "user name", // whitespace + "user@host", // userinfo-style value + "user.name", // a dot is only allowed via the "default." prefix + "default.", // nothing after the prefix + "default.1x", // label after the prefix must start with a letter + "notdefault.foo", // the prefix must be literally "default." +]; + +const VALID_USER_IDS = [ + "iam-user", + "myuser", + "default", // service-managed default user + "default.iam-user", // service-managed IAM user + "default.other", // any default.* is accepted + "DEFAULT.IAM-USER", // case-normalized before matching + "a-1-b-2", +]; + +for (const badName of INVALID_CACHE_NAMES) { + test(`rejects the malformed cache name ${JSON.stringify(badName)}`, async () => { + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: badName, userId: USER, region: REGION }, + fixedDependencies(), + ), + (error: unknown) => { + assert.equal(error instanceof InvalidParameterError, true); + assert.equal((error as Error).message.includes("serverlessCacheName"), true); + return true; + }, + ); + }); +} + +for (const goodName of VALID_CACHE_NAMES) { + test(`accepts the cache name ${JSON.stringify(goodName)}`, async () => { + // Tightening the pattern must not reject names the service accepts. + const token = await generateIamAuthToken( + { serverlessCacheName: goodName, userId: USER, region: REGION }, + fixedDependencies(), + ); + assert.equal(token.startsWith(`${goodName.toLowerCase()}/`), true); + }); +} + +test("reports a bad replication group id under its own parameter name", async () => { + await assert.rejects( + () => + generateIamAuthToken( + { replicationGroupId: "bad_name!", userId: USER, region: REGION }, + fixedDependencies(), + ), + (error: unknown) => { + assert.equal(error instanceof InvalidParameterError, true); + assert.equal((error as Error).message.includes("replicationGroupId"), true); + return true; + }, + ); +}); + +test("treats an empty cache name as a missing target", async () => { + // An empty name is indistinguishable from no name, so it fails earlier. await assert.rejects( () => generateIamAuthToken( - { serverlessCacheName: CACHE, userId: "user.name", region: REGION }, + { serverlessCacheName: "", userId: USER, region: REGION }, fixedDependencies(), ), InvalidParameterError, ); }); +for (const badUserId of INVALID_USER_IDS) { + test(`rejects the malformed user id ${JSON.stringify(badUserId)}`, async () => { + await assert.rejects( + () => + generateIamAuthToken( + { serverlessCacheName: CACHE, userId: badUserId, region: REGION }, + fixedDependencies(), + ), + (error: unknown) => { + assert.equal(error instanceof InvalidParameterError, true); + assert.equal((error as Error).message.includes("userId"), true); + return true; + }, + ); + }); +} + +for (const goodUserId of VALID_USER_IDS) { + test(`accepts the user id ${JSON.stringify(goodUserId)}`, async () => { + // Case does not matter: the id is lowercased before both matching and signing. + const token = await generateIamAuthToken( + { serverlessCacheName: CACHE, userId: goodUserId, region: REGION }, + fixedDependencies(), + ); + assert.equal(token.includes(`User=${goodUserId.toLowerCase()}`), true); + }); +} + test("configuration errors are toolkit errors but not parameter errors", async () => { assert.equal(ConfigurationError.prototype instanceof ToolkitInputError, true); assert.equal(ConfigurationError.prototype instanceof InvalidParameterError, false); @@ -419,6 +534,53 @@ test("provider retains its construction-time region while credentials rotate", a }); }); +test("create() fails immediately when no region is available", async () => { + // Parity with the Python provider, which fails during construction rather than + // at the first connection attempt. + await withoutRegion(async () => { + await assert.rejects( + () => + ElastiCacheIAMAuthTokenProvider.create( + { serverlessCacheName: CACHE, userId: USER }, + { + credentialProvider: async () => CREDENTIALS, + regionProvider: async () => undefined, + }, + ), + (error: unknown) => { + assert.equal(error instanceof ConfigurationError, true); + assert.equal((error as Error).message.includes("AWS_REGION"), true); + return true; + }, + ); + }); +}); + +test("create() validates its parameters before resolving the region", async () => { + await assert.rejects( + () => + ElastiCacheIAMAuthTokenProvider.create( + { serverlessCacheName: "my_cache", userId: USER, region: REGION }, + fixedDependencies(), + ), + InvalidParameterError, + ); +}); + +test("create() returns a usable provider", async () => { + await withoutRegion(async () => { + const auth = await ElastiCacheIAMAuthTokenProvider.create( + { serverlessCacheName: CACHE, userId: USER }, + fixedDependencies(CREDENTIALS, SESSION_REGION), + ); + + assert.equal(auth instanceof ElastiCacheIAMAuthTokenProvider, true); + assert.equal(auth.userId, USER); + const token = await auth.getToken(); + assert.equal(token.includes("%2Feu-west-1%2F"), true); + }); +}); + test("provider reports async region configuration errors through getToken", async () => { await withoutRegion(async () => { const auth = new ElastiCacheIAMAuthTokenProvider( From f1777cf63f4a6b202cc05965146ae1c32e76332e Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Wed, 9 Sep 2026 14:54:12 -0700 Subject: [PATCH 03/11] fix(node): remove release file Signed-off-by: Edward Liang --- .github/workflows/node-release.yml | 181 ----------------------------- 1 file changed, 181 deletions(-) delete mode 100644 .github/workflows/node-release.yml diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml deleted file mode 100644 index eecf8ed..0000000 --- a/.github/workflows/node-release.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Publish Node package to npm - -# The npm trusted publisher must reference this exact workflow filename and the -# `npm` environment, or OIDC publishing is rejected. -on: - release: - types: [published] - -permissions: - contents: read - -# Cancelling during a publish can leave a release partially processed. -concurrency: - group: node-release-${{ github.ref }} - cancel-in-progress: false - -jobs: - test: - name: test (Node ${{ matrix.node-version }}) - if: startsWith(github.ref_name, 'node-v') - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - node-version: ["22.x", "24.x"] - defaults: - run: - working-directory: node - steps: - - name: Check out the repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: ${{ matrix.node-version }} - cache: npm - cache-dependency-path: node/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Validate, test, and build - run: | - npm run lint - npm run format:check - npm run typecheck - npm test - npm run build - npm pack --dry-run - - build: - name: build and verify - needs: test - if: startsWith(github.ref_name, 'node-v') - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: node - steps: - - name: Check out the repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Node.js 24 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: "24.x" - cache: npm - cache-dependency-path: node/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Verify tag, version, and publication guard - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - node <<'NODE' - const fs = require("node:fs"); - const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); - const tag = process.env.RELEASE_TAG; - const expected = `node-v${packageJson.version}`; - if (tag !== expected) { - console.error(`Release tag must be ${expected}, received ${tag}.`); - process.exit(1); - } - if (packageJson.private === true) { - console.error("The npm package is still marked private; publication is disabled."); - process.exit(1); - } - console.log(`Publishing version ${packageJson.version}.`); - NODE - - - name: Build package - run: npm run build - - - name: Validate and archive package - run: | - rm -rf release-artifacts - mkdir -p release-artifacts - npm pack --dry-run - npm pack --pack-destination release-artifacts - - - name: Save package artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: node-package - path: node/release-artifacts/*.tgz - if-no-files-found: error - - publish: - name: publish - needs: build - if: startsWith(github.ref_name, 'node-v') - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: npm - permissions: - contents: read - id-token: write - steps: - - name: Check out the repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Node.js 24 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: "24.x" - registry-url: https://registry.npmjs.org - - - name: Ensure npm supports trusted publishing - run: | - npm install --global npm@11.5.1 - node --version - npm --version - - - name: Retrieve package artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: node-package - path: node/release-artifacts - - - name: Publish to npm with provenance - working-directory: node - run: npm publish release-artifacts/*.tgz --access public --provenance - - - name: Smoke-test the published package - env: - RELEASE_VERSION: ${{ github.ref_name }} - run: | - version="${RELEASE_VERSION#node-v}" - tmp="$(mktemp -d)" - for attempt in 1 2 3 4 5; do - if npm install --prefix "$tmp" --no-save "@aws/developer-toolkit-elasticache@${version}"; then - break - fi - if [ "$attempt" -eq 5 ]; then - echo "::error::Could not install the published package after 5 attempts." - exit 1 - fi - echo "Install attempt ${attempt} failed; waiting for registry propagation." - sleep 15 - done - cd "$tmp" - node --input-type=module <<'NODE' - const packageName = "@aws/developer-toolkit-elasticache"; - const packageModule = await import(packageName); - for (const name of [ - "ConfigurationError", - "ElastiCacheIAMAuthTokenProvider", - "InvalidParameterError", - "ToolkitInputError", - "generateIamAuthToken", - ]) { - if (!(name in packageModule)) { - throw new Error(`Published package is missing export ${name}.`); - } - } - NODE From 76a8dd8bd7beadffac01e956f58905175565ea48 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 08:48:27 -0700 Subject: [PATCH 04/11] chore(node): remove unused CLI and stale documentation Signed-off-by: Edward Liang --- .github/workflows/node.yml | 2 +- node/README.md | 43 ++------ node/package-lock.json | 3 - node/package.json | 5 +- node/src/cli.ts | 163 ------------------------------ node/src/errors.ts | 2 +- node/test/cli.test.ts | 142 -------------------------- node/test/package.test.ts | 74 +------------- node/test/token-generator.test.ts | 3 +- 9 files changed, 13 insertions(+), 424 deletions(-) delete mode 100644 node/src/cli.ts delete mode 100644 node/test/cli.test.ts diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 2604ceb..346bc1e 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -1,6 +1,6 @@ name: Node CI -# Path-filtered so the Node package is validated independently from Python. +# Path-filtered so the Node package is validated independently. on: push: branches: [main] diff --git a/node/README.md b/node/README.md index e2d683f..cdbf746 100644 --- a/node/README.md +++ b/node/README.md @@ -1,8 +1,8 @@ # Developer Toolkit for Amazon ElastiCache (Node.js) This package generates IAM authentication tokens for Amazon ElastiCache serverless caches -and node-based replication groups. It provides a typed TypeScript API, an async token -provider, and the `generate_iam_auth_token` command. +and node-based replication groups. It provides a typed TypeScript API and an async token +provider. ## Installation @@ -45,7 +45,9 @@ ElastiCache cache name or replication group id used as the SigV4 signing host; i the connection endpoint DNS name. Cache names and user ids are normalized to lowercase before validation and signing. -The `region` option is optional. Resolution uses the following order: +The `region` option is optional when a region is available through the standard AWS +configuration chain. If no region can be resolved, the operation throws +`ConfigurationError`. 1. The explicit `region` option. 2. `AWS_REGION`. @@ -103,29 +105,6 @@ npm install @valkey/valkey-glide node examples/connect-valkey.mjs ``` -### Command line - -The npm package installs the `generate_iam_auth_token` executable: - -```bash -generate_iam_auth_token \ - --serverless-cache-name my-cache \ - --user-id iam-user \ - --region us-east-1 -``` - -The Python-compatible form with a leading command is also accepted: - -```bash -generate_iam_auth_token generate_iam_auth_token \ - --replication-group-id my-group \ - --user-id iam-user -``` - -The token is the only successful stdout output, followed by a newline. Input and AWS -configuration errors produce one `Error: ...` line on stderr and exit with status 1. -Malformed command-line arguments exit with status 2. - ## Errors The public error hierarchy is: @@ -139,16 +118,8 @@ The public error hierarchy is: An IAM auth token is a short-lived bearer credential. Do not log, persist, or place it in shell history. Connect to ElastiCache over TLS. When using a command-line client, pass the token through `REDISCLI_AUTH` or `VALKEYCLI_AUTH` instead of a password argument that may -appear in the process list: - -```bash -export VALKEYCLI_AUTH=$(generate_iam_auth_token \ - --serverless-cache-name my-cache \ - --user-id iam-user \ - --region us-east-1) -valkey-cli --tls -h --user iam-user -unset VALKEYCLI_AUTH -``` +appear in the process list. Generate the token programmatically with +`generateIamAuthToken()` and avoid logging or persisting it. ## Development diff --git a/node/package-lock.json b/node/package-lock.json index bbb0f21..252d1a4 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -15,9 +15,6 @@ "@smithy/signature-v4": "^5.2.7", "@smithy/types": "^4.9.0" }, - "bin": { - "generate_iam_auth_token": "dist/cli.js" - }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", diff --git a/node/package.json b/node/package.json index 3703998..c39cd26 100644 --- a/node/package.json +++ b/node/package.json @@ -23,11 +23,8 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "bin": { - "generate_iam_auth_token": "./dist/cli.js" - }, "scripts": { - "build": "tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"", + "build": "tsc -p tsconfig.json", "build:test": "tsc -p tsconfig.test.json", "clean": "node scripts/clean.mjs", "format:check": "prettier --check .", diff --git a/node/src/cli.ts b/node/src/cli.ts deleted file mode 100644 index 01dbfc2..0000000 --- a/node/src/cli.ts +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env node - -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { parseArgs } from "node:util"; -import { realpathSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; - -import { ToolkitInputError } from "./errors.js"; -import { generateIamAuthToken } from "./token-generator.js"; - -const COMMAND = "generate_iam_auth_token"; -const USAGE = `Usage: ${COMMAND} [options] - -Generate an IAM auth token for an ElastiCache cache. - -Options: - --serverless-cache-name Serverless cache name - --replication-group-id Node-based replication group id - --user-id ElastiCache user id - --region AWS region - --help Show this help -`; - -type ParsedArguments = - | { help: true } - | { - help: false; - userId: string; - region?: string; - serverlessCacheName?: string; - replicationGroupId?: string; - }; - -function parserMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - // Keep stderr errors to one line so hostile arguments cannot inject extra - // log lines through embedded LF or CRLF characters. - return message.replace(/[\r\n]+/g, " "); -} - -function readString( - values: Record, - option: string, -): string | undefined { - const value = values[option]; - if (value === undefined) { - return undefined; - } - if (typeof value !== "string") { - throw new TypeError(`Option '--${option}' requires a value.`); - } - return value; -} - -function parseCliArguments(argv: string[]): ParsedArguments { - let args = argv; - if (args[0] === COMMAND) { - // Also accept the Python-compatible form documented in the README, where the - // executable name is repeated as a leading command argument. - args = args.slice(1); - } - if (args.length === 0) { - throw new TypeError("Missing required options."); - } - - const { values } = parseArgs({ - args, - allowPositionals: false, - options: { - "serverless-cache-name": { type: "string" }, - "replication-group-id": { type: "string" }, - "user-id": { type: "string" }, - region: { type: "string" }, - help: { type: "boolean", short: "h" }, - }, - strict: true, - }); - - if (values.help) { - return { help: true }; - } - - const serverlessCacheName = readString(values, "serverless-cache-name"); - const replicationGroupId = readString(values, "replication-group-id"); - const userId = readString(values, "user-id"); - const region = readString(values, "region"); - const hasServerlessTarget = serverlessCacheName !== undefined; - const hasReplicationGroupTarget = replicationGroupId !== undefined; - - if (hasServerlessTarget === hasReplicationGroupTarget) { - throw new TypeError( - "Exactly one of '--serverless-cache-name' and '--replication-group-id' is required.", - ); - } - if (userId === undefined) { - throw new TypeError("Option '--user-id' is required."); - } - - return { - help: false, - userId, - region, - serverlessCacheName, - replicationGroupId, - }; -} - -export async function main(argv: string[] = process.argv.slice(2)): Promise { - let arguments_: ParsedArguments; - try { - arguments_ = parseCliArguments(argv); - } catch (error) { - process.stderr.write(`Error: ${parserMessage(error)}\n`); - return 2; - } - - if (arguments_.help) { - process.stdout.write(USAGE); - return 0; - } - - try { - const token = await generateIamAuthToken({ - userId: arguments_.userId, - region: arguments_.region, - serverlessCacheName: arguments_.serverlessCacheName, - replicationGroupId: arguments_.replicationGroupId, - }); - process.stdout.write(`${token}\n`); - return 0; - } catch (error) { - if (error instanceof ToolkitInputError) { - process.stderr.write(`Error: ${parserMessage(error)}\n`); - return 1; - } - throw error; - } -} - -function isMainModule(): boolean { - if (!process.argv[1]) { - return false; - } - - try { - // npm's installed or globally-linked bin entry is commonly a symlink, so - // compare canonical filesystem paths rather than the raw argument strings. - return ( - realpathSync(fileURLToPath(import.meta.url)) === - realpathSync(resolve(process.argv[1])) - ); - } catch { - return false; - } -} - -if (isMainModule()) { - const exitCode = await main(); - process.exitCode = exitCode; -} diff --git a/node/src/errors.ts b/node/src/errors.ts index 160be57..7a72b74 100644 --- a/node/src/errors.ts +++ b/node/src/errors.ts @@ -3,7 +3,7 @@ /** * Base class for failures that can be corrected by changing the invocation or - * the AWS configuration. The CLI reports these without a stack trace. + * the AWS configuration. */ export class ToolkitInputError extends Error { constructor(message: string) { diff --git a/node/test/cli.test.ts b/node/test/cli.test.ts deleted file mode 100644 index 3648359..0000000 --- a/node/test/cli.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { test } from "node:test"; - -const CLI_PATH = fileURLToPath(new URL("../src/cli.js", import.meta.url)); -const ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"; -const SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; - -function runCli(args: string[], overrides: NodeJS.ProcessEnv = {}) { - const environment: NodeJS.ProcessEnv = { - ...process.env, - AWS_ACCESS_KEY_ID: ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY: SECRET_ACCESS_KEY, - AWS_REGION: "us-east-1", - AWS_EC2_METADATA_DISABLED: "true", - }; - delete environment.AWS_PROFILE; - delete environment.AWS_DEFAULT_REGION; - for (const [key, value] of Object.entries(overrides)) { - if (value === undefined) { - delete environment[key]; - } else { - environment[key] = value; - } - } - return spawnSync(process.execPath, [CLI_PATH, ...args], { - encoding: "utf8", - env: environment, - }); -} - -test("writes only the token to stdout", () => { - const result = runCli([ - "--serverless-cache-name", - "my-cache", - "--user-id", - "testuser", - "--region", - "us-east-1", - ]); - - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /^my-cache\/\?Action=connect&User=testuser/); - assert.match(result.stdout, /X-Amz-Signature=[0-9a-f]{64}\n$/); -}); - -test("accepts the Python-compatible leading command", () => { - const result = runCli([ - "generate_iam_auth_token", - "--replication-group-id", - "my-group", - "--user-id", - "testuser", - ]); - - assert.equal(result.status, 0); - assert.match(result.stdout, /^my-group\/\?Action=connect&User=testuser/); - assert.equal(result.stdout.includes("ResourceType"), false); -}); - -test("reports toolkit input errors on one stderr line with exit 1", () => { - const result = runCli([ - "--serverless-cache-name", - "evil.com/inject", - "--user-id", - "testuser", - ]); - - assert.equal(result.status, 1); - assert.equal(result.stdout, ""); - assert.match(result.stderr, /^Error: .*\n$/); - assert.equal(result.stderr.includes("evil.com/inject"), false); -}); - -test("returns exit 2 for malformed arguments", () => { - const missingTarget = runCli(["--user-id", "testuser"]); - assert.equal(missingTarget.status, 2); - assert.match(missingTarget.stderr, /^Error: .*\n$/); - - const bothTargets = runCli([ - "--serverless-cache-name", - "my-cache", - "--replication-group-id", - "my-group", - "--user-id", - "testuser", - ]); - assert.equal(bothTargets.status, 2); - - const unknownOption = runCli(["--unknown", "value"]); - assert.equal(unknownOption.status, 2); -}); - -test("returns exit 2 for missing values, positionals, and no arguments", () => { - const malformedInvocations = [[], ["--serverless-cache-name"], ["unexpected"]]; - - for (const args of malformedInvocations) { - const result = runCli(args); - assert.equal(result.status, 2); - assert.equal(result.stdout, ""); - assert.match(result.stderr, /^Error: [^\r\n]*\n$/); - } -}); - -test("reports missing credentials as a one-line configuration error", () => { - const result = runCli( - ["--serverless-cache-name", "my-cache", "--user-id", "testuser"], - { - AWS_ACCESS_KEY_ID: undefined, - AWS_SECRET_ACCESS_KEY: undefined, - AWS_SESSION_TOKEN: undefined, - AWS_PROFILE: undefined, - AWS_DEFAULT_PROFILE: undefined, - AWS_WEB_IDENTITY_TOKEN_FILE: undefined, - AWS_ROLE_ARN: undefined, - AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined, - AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined, - AWS_CONFIG_FILE: "/dev/null", - AWS_SHARED_CREDENTIALS_FILE: "/dev/null", - }, - ); - - assert.equal(result.status, 1); - assert.equal(result.stdout, ""); - assert.equal( - result.stderr, - "Error: No AWS credentials found. Configure credentials via the environment, " + - "shared config/credentials files, or an instance/container role.\n", - ); -}); - -test("prints help without importing or invoking the signing chain", () => { - const result = runCli(["--help"]); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /^Usage: generate_iam_auth_token/m); -}); diff --git a/node/test/package.test.ts b/node/test/package.test.ts index f7166a3..39efe25 100644 --- a/node/test/package.test.ts +++ b/node/test/package.test.ts @@ -3,14 +3,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -22,7 +15,6 @@ const NPM_INSTALL_TIMEOUT_MS = 60_000; type PackedFile = { path: string; - mode: number; }; type PackResult = { @@ -30,7 +22,7 @@ type PackResult = { files: PackedFile[]; }; -test("packed CLI is executable and launches through its bin entry", () => { +test("packed package installs and exposes its public API", () => { const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-package-test-")); const packageDirectory = join(temporaryRoot, "package"); const installationDirectory = join(temporaryRoot, "installed"); @@ -58,9 +50,6 @@ test("packed CLI is executable and launches through its bin entry", () => { const packResult = JSON.parse(pack.stdout) as PackResult[]; const packed = packResult[0]; assert.ok(packed); - const packedCli = packed.files.find((file) => file.path === "dist/cli.js"); - assert.ok(packedCli); - assert.equal(packedCli.mode, 0o755); assert.equal( packed.files.some((file) => file.path === "examples/generate-token.mjs"), true, @@ -103,32 +92,6 @@ test("packed CLI is executable and launches through its bin entry", () => { ); const installedNodeModules = join(installationDirectory, "node_modules"); - const installedPackageDirectory = join( - installedNodeModules, - "@aws", - "developer-toolkit-elasticache", - ); - const packedPackageJson = JSON.parse( - readFileSync(join(installedPackageDirectory, "package.json"), "utf8"), - ) as { bin: Record }; - assert.deepEqual(packedPackageJson.bin, { - generate_iam_auth_token: "./dist/cli.js", - }); - - const executablePath = join(installedPackageDirectory, "dist", "cli.js"); - assert.equal( - readFileSync(executablePath, "utf8").startsWith("#!/usr/bin/env node\n"), - true, - ); - const installedBinPath = join( - installedNodeModules, - ".bin", - process.platform === "win32" - ? "generate_iam_auth_token.cmd" - : "generate_iam_auth_token", - ); - assert.equal(existsSync(installedBinPath), true); - const exportsProbePath = join(installedNodeModules, "exports-probe.mjs"); writeFileSync( exportsProbePath, @@ -146,39 +109,6 @@ test("packed CLI is executable and launches through its bin entry", () => { encoding: "utf8", }); assert.equal(exportsProbe.status, 0, exportsProbe.stderr); - - const result = spawnSync( - installedBinPath, - ["--serverless-cache-name", "my-cache", "--user-id", "testuser"], - { - cwd: installationDirectory, - encoding: "utf8", - shell: process.platform === "win32", - env: { - ...process.env, - AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", - AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - AWS_REGION: "us-east-1", - AWS_EC2_METADATA_DISABLED: "true", - npm_config_cache: npmCacheDirectory, - npm_config_update_notifier: "false", - }, - }, - ); - assert.equal(result.status, 0, result.stderr || result.error?.message); - assert.equal(result.stderr, ""); - const processDetails = JSON.stringify({ - error: result.error?.message, - signal: result.signal, - stderr: result.stderr, - stdout: result.stdout, - }); - assert.match( - result.stdout, - /^my-cache\/\?Action=connect&User=testuser/, - processDetails, - ); - assert.match(result.stdout, /X-Amz-Signature=[0-9a-f]{64}\n$/, processDetails); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } diff --git a/node/test/token-generator.test.ts b/node/test/token-generator.test.ts index e5a0737..46bf3a8 100644 --- a/node/test/token-generator.test.ts +++ b/node/test/token-generator.test.ts @@ -535,8 +535,7 @@ test("provider retains its construction-time region while credentials rotate", a }); test("create() fails immediately when no region is available", async () => { - // Parity with the Python provider, which fails during construction rather than - // at the first connection attempt. + // Fails during construction rather than at the first connection attempt. await withoutRegion(async () => { await assert.rejects( () => From febd27ac768368c632651ed27d253385fab4f36f Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 08:52:37 -0700 Subject: [PATCH 05/11] docs(node): add node to root readme Signed-off-by: Edward Liang --- README.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 143d99c..0ef57bf 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # Developer Toolkit for ElastiCache -This repository contains tools to work with [Amazon ElastiCache](https://aws.amazon.com/elasticache/). +This repository contains libraries and tools for working with +[Amazon ElastiCache](https://aws.amazon.com/elasticache/). -The Python library offers [IAM authentication](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) -for ElastiCache: generates a signed token that ElastiCache requires for connection -with an IAM-enabled user, so applications can authenticate to a Valkey or -Redis OSS cache with AWS credentials. +The Python and Node.js libraries support +[IAM authentication](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) +for ElastiCache. They generate the signed token required to connect with an +IAM-enabled user, allowing applications to authenticate to a Valkey or Redis OSS +cache with AWS credentials. ## Packages | Language | Package | Documentation | |---|---|---| | Python | [`developer-toolkit-elasticache`](https://pypi.org/project/developer-toolkit-elasticache/) on PyPI | [python/README.md](python/README.md) | +| Node.js | `@aws/developer-toolkit-elasticache` on npm | [node/README.md](node/README.md) | +## Quick start -## Quick start (Python) +### Python ```bash python3 -m pip install developer-toolkit-elasticache @@ -34,6 +38,26 @@ Use the returned token as the password when connecting to the cache. See the [Python README](python/README.md) for requirements, the credential chain, the Python command-line tool, and client integration examples. +### Node.js + +```bash +npm install @aws/developer-toolkit-elasticache +``` + +```typescript +import { generateIamAuthToken } from "@aws/developer-toolkit-elasticache"; + +const token = await generateIamAuthToken({ + serverlessCacheName: "my-cache", // or replicationGroupId: "my-group" + userId: "iam-user", + region: "us-east-1", +}); +``` + +Use the returned token as the password when connecting to the cache. See the +[Node.js README](node/README.md) for requirements, the credential and region +chains, the async token provider, and client integration examples. + ## Getting help Please use these community resources for getting help. We use the GitHub issues for From 7ddd0a791f98a5d67202bd29ee64fd3ec611f189 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 16:22:48 +0000 Subject: [PATCH 06/11] style(node): fix prettier formatting Signed-off-by: Edward Liang --- node/package.json | 1 + node/src/errors.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/node/package.json b/node/package.json index c39cd26..2444474 100644 --- a/node/package.json +++ b/node/package.json @@ -27,6 +27,7 @@ "build": "tsc -p tsconfig.json", "build:test": "tsc -p tsconfig.test.json", "clean": "node scripts/clean.mjs", + "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", "package:check": "npm run clean && npm run build && npm pack --dry-run", diff --git a/node/src/errors.ts b/node/src/errors.ts index 7a72b74..dbbb6b6 100644 --- a/node/src/errors.ts +++ b/node/src/errors.ts @@ -3,7 +3,7 @@ /** * Base class for failures that can be corrected by changing the invocation or - * the AWS configuration. + * the AWS configuration. */ export class ToolkitInputError extends Error { constructor(message: string) { From c9f26dc4ec4f35b90a5316e861c5830f32e01df2 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 16:35:50 +0000 Subject: [PATCH 07/11] style(node): add gitattributes for windows line endings Signed-off-by: Edward Liang --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file From 94c4be615da5f7d8570cdc1510479fc86390091b Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 18:30:09 +0000 Subject: [PATCH 08/11] fix(ci): debug and fix windows npm pack call Signed-off-by: Edward Liang --- node/test/package.test.ts | 46 +++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/node/test/package.test.ts b/node/test/package.test.ts index 39efe25..650b08f 100644 --- a/node/test/package.test.ts +++ b/node/test/package.test.ts @@ -27,25 +27,34 @@ test("packed package installs and exposes its public API", () => { const packageDirectory = join(temporaryRoot, "package"); const installationDirectory = join(temporaryRoot, "installed"); const npmCacheDirectory = join(temporaryRoot, "npm-cache"); - const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const npmCommand = process.env.npm_execpath + ? process.execPath + : process.platform === "win32" + ? "npm.cmd" + : "npm"; + const npmCommandArgs = process.env.npm_execpath ? [process.env.npm_execpath] : []; + const npmSpawnOptions = { + cwd: PACKAGE_ROOT, + encoding: "utf8" as const, + env: { + ...process.env, + npm_config_cache: npmCacheDirectory, + npm_config_update_notifier: "false", + }, + shell: process.platform === "win32" && !process.env.npm_execpath, + }; try { mkdirSync(packageDirectory); mkdirSync(installationDirectory); const pack = spawnSync( npmCommand, - ["pack", "--json", "--pack-destination", packageDirectory], + [...npmCommandArgs, "pack", "--json", "--pack-destination", packageDirectory], { - cwd: PACKAGE_ROOT, - encoding: "utf8", - env: { - ...process.env, - npm_config_cache: npmCacheDirectory, - npm_config_update_notifier: "false", - }, + ...npmSpawnOptions, }, ); - assert.equal(pack.status, 0, pack.stderr); + assert.equal(pack.status, 0, pack.error?.message ?? pack.stderr); const packResult = JSON.parse(pack.stdout) as PackResult[]; const packed = packResult[0]; @@ -63,6 +72,7 @@ test("packed package installs and exposes its public API", () => { const install = spawnSync( npmCommand, [ + ...npmCommandArgs, "install", tarballPath, "--ignore-scripts", @@ -72,14 +82,8 @@ test("packed package installs and exposes its public API", () => { installationDirectory, ], { - cwd: PACKAGE_ROOT, - encoding: "utf8", + ...npmSpawnOptions, timeout: NPM_INSTALL_TIMEOUT_MS, - env: { - ...process.env, - npm_config_cache: npmCacheDirectory, - npm_config_update_notifier: "false", - }, }, ); assert.equal( @@ -88,7 +92,7 @@ test("packed package installs and exposes its public API", () => { install.signal ? `npm install was killed with ${install.signal} (likely no registry access; ` + `this test installs the packed tarball's dependencies from the npm registry)` - : install.stderr, + : (install.error?.message ?? install.stderr), ); const installedNodeModules = join(installationDirectory, "node_modules"); @@ -108,7 +112,11 @@ test("packed package installs and exposes its public API", () => { cwd: installationDirectory, encoding: "utf8", }); - assert.equal(exportsProbe.status, 0, exportsProbe.stderr); + assert.equal( + exportsProbe.status, + 0, + exportsProbe.error?.message ?? exportsProbe.stderr, + ); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } From 577558058fa010265014f6c3198b42485026c657 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 20:26:38 +0000 Subject: [PATCH 09/11] fix(ci): run node workflows on release branch too Signed-off-by: Edward Liang --- .github/workflows/node.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 346bc1e..a11335c 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -3,7 +3,9 @@ name: Node CI # Path-filtered so the Node package is validated independently. on: push: - branches: [main] + branches: + - main + - "release-*" paths: - "node/**" - ".github/workflows/node.yml" From a04da1e8931af70596994273581266ad742bacc8 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Thu, 10 Sep 2026 22:51:27 +0000 Subject: [PATCH 10/11] fix(node): revert Node license change Signed-off-by: Edward Liang --- node/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/LICENSE b/node/LICENSE index 54b76e4..d645695 100644 --- a/node/LICENSE +++ b/node/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Amazon + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 60044f776673c6c8ad31df454b031801bca61e57 Mon Sep 17 00:00:00 2001 From: Edward Liang Date: Fri, 11 Sep 2026 18:46:40 +0000 Subject: [PATCH 11/11] fix(node): address comments Signed-off-by: Edward Liang --- .github/workflows/node.yml | 20 +++- node/CHANGELOG.md | 16 ++++ node/README.md | 14 +-- node/examples/connect-iovalkey.mjs | 42 +++++++++ node/examples/connect-valkey.mjs | 70 -------------- node/package.json | 9 +- node/scripts/list-supported-versions.mjs | 50 ++++++++++ node/test/list-supported-versions.test.ts | 109 ++++++++++++++++++++++ node/test/package.test.ts | 6 +- 9 files changed, 253 insertions(+), 83 deletions(-) create mode 100644 node/CHANGELOG.md create mode 100644 node/examples/connect-iovalkey.mjs delete mode 100644 node/examples/connect-valkey.mjs create mode 100644 node/scripts/list-supported-versions.mjs create mode 100644 node/test/list-supported-versions.test.ts diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index a11335c..175c123 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -18,14 +18,30 @@ permissions: contents: read jobs: + # Load the test matrix from package.json's supportedNodeVersions. + versions: + name: node versions + runs-on: ubuntu-latest + outputs: + data: ${{ steps.set.outputs.data }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Generate supported versions from package.json + id: set + working-directory: node + run: echo "data=$(npm run --silent list-supported-versions)" >> "$GITHUB_OUTPUT" + test: name: Node ${{ matrix.node-version }} (${{ matrix.os }}) + needs: versions runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - node-version: ["22.x", "24.x"] + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: ${{ fromJson(needs.versions.outputs.data).all }} defaults: run: working-directory: node diff --git a/node/CHANGELOG.md b/node/CHANGELOG.md new file mode 100644 index 0000000..b50d05b --- /dev/null +++ b/node/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to the Node.js package are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- `generateIamAuthToken()` for signing IAM authentication tokens for Amazon ElastiCache + serverless caches and node-based replication groups. +- `ElastiCacheIAMAuthTokenProvider` for generating fresh tokens when clients connect or + reconnect. +- `ToolkitInputError`, `InvalidParameterError`, and `ConfigurationError`. +- Runnable token-generation and iovalkey integration examples under `examples/`. diff --git a/node/README.md b/node/README.md index cdbf746..ad60e40 100644 --- a/node/README.md +++ b/node/README.md @@ -85,24 +85,26 @@ const password = await auth.getToken(); Pass `username` and `password` to the client connection configuration. Fetch another token whenever the client creates a new connection. See -[`examples/connect-valkey.mjs`](examples/connect-valkey.mjs) for a Valkey GLIDE bridge. +[`examples/connect-iovalkey.mjs`](examples/connect-iovalkey.mjs) for an iovalkey +integration. ### Examples -The package includes runnable ESM examples for Node.js 22 and newer. After installing the -package and configuring AWS credentials, run: +The source repository includes runnable ESM examples for Node.js 22 and newer. After +installing the package and configuring AWS credentials, run: ```bash node examples/generate-token.mjs ``` -The Valkey GLIDE example uses an optional client dependency and does not add it to this +The iovalkey example demonstrates integration with a client that does not provide built-in +ElastiCache IAM authentication. It is an optional dependency and is not added to this package's runtime dependencies. Install it in the application that runs the example, then run: ```bash -npm install @valkey/valkey-glide -node examples/connect-valkey.mjs +npm install iovalkey +node examples/connect-iovalkey.mjs ``` ## Errors diff --git a/node/examples/connect-iovalkey.mjs b/node/examples/connect-iovalkey.mjs new file mode 100644 index 0000000..d68d540 --- /dev/null +++ b/node/examples/connect-iovalkey.mjs @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* global console */ + +/** + * Run this example after installing iovalkey: + * + * npm install iovalkey + * + * The cache name used for signing is different from the connection endpoint. + * For a node-based deployment, use replicationGroupId instead. + * + * iovalkey does not generate ElastiCache IAM authentication tokens. This + * short-lived example disables automatic reconnects because they would reuse + * the initial token. Long-running applications should create a replacement + * client with a fresh token whenever the connection is lost. + */ +import { Valkey } from "iovalkey"; +import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache"; + +const auth = await ElastiCacheIAMAuthTokenProvider.create({ + serverlessCacheName: "my-cache", + userId: "my-iam-user", + region: "us-east-1", +}); + +const client = new Valkey({ + host: "my-cache-abc123.serverless.use1.cache.amazonaws.com", + port: 6379, + username: auth.userId, + password: await auth.getToken(), + tls: {}, + retryStrategy: () => null, +}); + +try { + await client.set("hello", "world"); + console.log(await client.get("hello")); +} finally { + client.disconnect(); +} diff --git a/node/examples/connect-valkey.mjs b/node/examples/connect-valkey.mjs deleted file mode 100644 index 2dc0c27..0000000 --- a/node/examples/connect-valkey.mjs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/* global console, setTimeout, clearTimeout */ - -/** - * Run this example after installing @valkey/valkey-glide: - * - * npm install @valkey/valkey-glide - * - * The cache name used for signing is different from the connection endpoint. - * For a node-based deployment, use replicationGroupId instead. - * - * The token is a 15-minute bearer credential. GLIDE reuses the credentials - * passed at connection time for its own automatic reconnects, so a - * long-running process must refresh the client's password on an interval - * shorter than the token TTL. `updateConnectionPassword` rotates it without - * dropping the connection. - */ -import { GlideClient } from "@valkey/valkey-glide"; -import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache"; - -const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; -// A failed refresh gets retried well before the 15-minute token TTL expires, -// rather than waiting for the next full 10-minute interval. -const TOKEN_REFRESH_RETRY_MS = 30 * 1000; - -// create() resolves the region up front, so a missing region fails here rather -// than at the first token request. -const auth = await ElastiCacheIAMAuthTokenProvider.create({ - serverlessCacheName: "my-cache", - userId: "my-iam-user", - region: "us-east-1", -}); - -const client = await GlideClient.createClient({ - addresses: [ - { - host: "my-cache-abc123.serverless.use1.cache.amazonaws.com", - port: 6379, - }, - ], - credentials: { - username: auth.userId, - password: await auth.getToken(), - }, - useTLS: true, -}); - -let refreshTimer; -function scheduleTokenRefresh(delayMs) { - refreshTimer = setTimeout(async () => { - try { - const token = await auth.getToken(); - await client.updateConnectionPassword(token, true); - scheduleTokenRefresh(TOKEN_REFRESH_INTERVAL_MS); - } catch (error) { - console.error("Failed to refresh IAM auth token:", error); - scheduleTokenRefresh(TOKEN_REFRESH_RETRY_MS); - } - }, delayMs); - refreshTimer.unref?.(); -} -scheduleTokenRefresh(TOKEN_REFRESH_INTERVAL_MS); - -await client.set("hello", "world"); -console.log(await client.get("hello")); - -clearTimeout(refreshTimer); -client.close(); diff --git a/node/package.json b/node/package.json index 2444474..0fa62ba 100644 --- a/node/package.json +++ b/node/package.json @@ -8,12 +8,16 @@ "engines": { "node": ">=22" }, + "supportedNodeVersions": [ + "22.x", + "24.x" + ], "files": [ "dist", + "CHANGELOG.md", "LICENSE", "NOTICE", - "README.md", - "examples" + "README.md" ], "exports": { ".": { @@ -30,6 +34,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", + "list-supported-versions": "node scripts/list-supported-versions.mjs", "package:check": "npm run clean && npm run build && npm pack --dry-run", "test": "npm run build && npm run build:test && node --test dist-test/test/*.test.js", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/node/scripts/list-supported-versions.mjs b/node/scripts/list-supported-versions.mjs new file mode 100644 index 0000000..9e524f1 --- /dev/null +++ b/node/scripts/list-supported-versions.mjs @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from "node:fs"; +import { stdout } from "node:process"; +import { URL } from "node:url"; + +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); +const versions = packageJson.supportedNodeVersions; + +if (!Array.isArray(versions) || versions.length === 0) { + throw new Error("package.json supportedNodeVersions must be a non-empty array."); +} + +const majors = versions.map((version) => { + const match = /^(\d+)\.x$/.exec(version); + if (!match) { + throw new Error( + `Unsupported Node.js version ${JSON.stringify(version)}: expected ".x".`, + ); + } + return Number(match[1]); +}); + +if (new Set(majors).size !== majors.length) { + throw new Error("package.json supportedNodeVersions must not contain duplicates."); +} + +if (majors.some((major, index) => index > 0 && major <= majors[index - 1])) { + throw new Error( + "package.json supportedNodeVersions must be ordered from oldest to newest.", + ); +} + +const engineMatch = /^>=(\d+)$/.exec(packageJson.engines?.node ?? ""); +if (!engineMatch || Number(engineMatch[1]) !== majors[0]) { + throw new Error( + "package.json engines.node must match the oldest supported Node.js version.", + ); +} + +stdout.write( + JSON.stringify({ + all: versions, + min: versions[0], + max: versions.at(-1), + }), +); diff --git a/node/test/list-supported-versions.test.ts b/node/test/list-supported-versions.test.ts new file mode 100644 index 0000000..5fd8f11 --- /dev/null +++ b/node/test/list-supported-versions.test.ts @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const SCRIPT_PATH = join(PACKAGE_ROOT, "scripts", "list-supported-versions.mjs"); + +function runListSupportedVersions(packageJson: Record) { + const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-node-versions-")); + const scriptsDirectory = join(temporaryRoot, "scripts"); + const temporaryScript = join(scriptsDirectory, "list-supported-versions.mjs"); + + try { + mkdirSync(scriptsDirectory); + copyFileSync(SCRIPT_PATH, temporaryScript); + writeFileSync(join(temporaryRoot, "package.json"), JSON.stringify(packageJson)); + return spawnSync(process.execPath, [temporaryScript], { + encoding: "utf8", + }); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +test("prints the supported Node.js version matrix", () => { + const result = runListSupportedVersions({ + engines: { node: ">=22" }, + supportedNodeVersions: ["22.x", "24.x"], + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + all: ["22.x", "24.x"], + min: "22.x", + max: "24.x", + }); +}); + +const invalidMetadataCases = [ + { + name: "rejects a missing supported version list", + packageJson: { engines: { node: ">=22" } }, + expectedError: /must be a non-empty array/, + }, + { + name: "rejects an empty supported version list", + packageJson: { + engines: { node: ">=22" }, + supportedNodeVersions: [], + }, + expectedError: /must be a non-empty array/, + }, + { + name: "rejects a non-array supported version list", + packageJson: { + engines: { node: ">=22" }, + supportedNodeVersions: "22.x", + }, + expectedError: /must be a non-empty array/, + }, + { + name: "rejects malformed supported versions", + packageJson: { + engines: { node: ">=22" }, + supportedNodeVersions: ["22"], + }, + expectedError: /expected "\.x"/, + }, + { + name: "rejects duplicate supported versions", + packageJson: { + engines: { node: ">=22" }, + supportedNodeVersions: ["22.x", "22.x"], + }, + expectedError: /must not contain duplicates/, + }, + { + name: "rejects unordered supported versions", + packageJson: { + engines: { node: ">=22" }, + supportedNodeVersions: ["24.x", "22.x"], + }, + expectedError: /must be ordered from oldest to newest/, + }, + { + name: "rejects an engine that differs from the oldest supported version", + packageJson: { + engines: { node: ">=20" }, + supportedNodeVersions: ["22.x", "24.x"], + }, + expectedError: /must match the oldest supported Node\.js version/, + }, +] as const; + +for (const { name, packageJson, expectedError } of invalidMetadataCases) { + test(name, () => { + const result = runListSupportedVersions(packageJson); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, expectedError); + }); +} diff --git a/node/test/package.test.ts b/node/test/package.test.ts index 650b08f..0bf233e 100644 --- a/node/test/package.test.ts +++ b/node/test/package.test.ts @@ -60,12 +60,12 @@ test("packed package installs and exposes its public API", () => { const packed = packResult[0]; assert.ok(packed); assert.equal( - packed.files.some((file) => file.path === "examples/generate-token.mjs"), + packed.files.some((file) => file.path === "CHANGELOG.md"), true, ); assert.equal( - packed.files.some((file) => file.path === "examples/connect-valkey.mjs"), - true, + packed.files.some((file) => file.path.startsWith("examples/")), + false, ); const tarballPath = join(packageDirectory, packed.filename);