diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml new file mode 100644 index 0000000..9f0d603 --- /dev/null +++ b/.github/actions/acceptance-tests/action.yaml @@ -0,0 +1,53 @@ +name: Acceptance tests +description: "Run acceptance tests for this repo" + +inputs: + testType: + description: Type of test to run + required: true + + targetEnvironment: + description: Name of the environment under test + required: true + + targetAccountGroup: + description: Name of the account group under test + default: nhs-notify-client-callbacks-dev + required: true + + targetComponent: + description: Name of the component under test + required: true + +runs: + using: "composite" + + steps: + - name: Fetch terraform output + uses: actions/download-artifact@v4 + with: + name: terraform-output-${{ inputs.targetComponent }} + + - name: Get Node version + id: nodejs_version + shell: bash + run: | + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: "Repo setup" + uses: ./.github/actions/node-install + with: + GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + + - name: "Set PR NUMBER environment variable" + shell: bash + run: | + echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV + + - name: Run test - ${{ inputs.testType }} + shell: bash + env: + PROJECT: nhs + COMPONENT: ${{ inputs.targetComponent }} + run: | + make test-${{ inputs.testType }} diff --git a/.github/actions/node-install/action.yaml b/.github/actions/node-install/action.yaml new file mode 100644 index 0000000..b1ed2d0 --- /dev/null +++ b/.github/actions/node-install/action.yaml @@ -0,0 +1,24 @@ +name: 'npm install and setup' +description: 'Setup node, authenticate github package repository and perform clean npm install' + +inputs: + GITHUB_TOKEN: + description: "Token for access to github package registry" + required: true + +runs: + using: 'composite' + steps: + - name: 'Use Node.js' + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + registry-url: 'https://npm.pkg.github.com' + scope: '@nhsdigital' + + - name: 'Install dependencies' + shell: bash + env: + NODE_AUTH_TOKEN: ${{ inputs.GITHUB_TOKEN }} + run: | + npm ci diff --git a/.github/actions/test-types.json b/.github/actions/test-types.json new file mode 100644 index 0000000..4fe0a8a --- /dev/null +++ b/.github/actions/test-types.json @@ -0,0 +1,3 @@ +[ + "integration" +] diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index bb88afb..cd4d89e 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -174,9 +174,11 @@ jobs: --overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" acceptance-stage: # Recommended maximum execution time is 10 minutes name: "Acceptance stage" - needs: [metadata, build-stage] + needs: [metadata, build-stage, pr-create-dynamic-environment] uses: ./.github/workflows/stage-4-acceptance.yaml - if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) || (github.event_name == 'push' && github.ref == 'refs/heads/main') + if: >- + contains(fromJSON('["success", "skipped"]'), needs.pr-create-dynamic-environment.result) && + (needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) || (github.event_name == 'push' && github.ref == 'refs/heads/main')) with: build_datetime: "${{ needs.metadata.outputs.build_datetime }}" build_timestamp: "${{ needs.metadata.outputs.build_timestamp }}" diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml index c6dc58e..4ae997e 100644 --- a/.github/workflows/stage-4-acceptance.yaml +++ b/.github/workflows/stage-4-acceptance.yaml @@ -36,139 +36,37 @@ on: required: true type: string +permissions: + id-token: write + contents: read + jobs: - environment-set-up: - name: "Environment set up" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Create infractructure" - run: | - echo "Creating infractructure..." - - name: "Update database" - run: | - echo "Updating database..." - - name: "Deploy application" - run: | - echo "Deploying application..." - test-contract: - name: "Contract test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run contract test" - run: | - make test-contract - - name: "Save result" - run: | - echo "Nothing to save" - test-security: - name: "Security test" + run-acceptance-tests: + name: Run Acceptance Tests runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run security test" - run: | - make test-security - - name: "Save result" - run: | - echo "Nothing to save" - test-ui: - name: "UI test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run UI test" - run: | - make test-ui - - name: "Save result" - run: | - echo "Nothing to save" - test-ui-performance: - name: "UI performance test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run UI performance test" - run: | - make test-ui-performance - - name: "Save result" - run: | - echo "Nothing to save" - test-integration: - name: "Integration test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run integration test" - run: | - make test-integration - - name: "Save result" - run: | - echo "Nothing to save" - test-accessibility: - name: "Accessibility test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run accessibility test" - run: | - make test-accessibility - - name: "Save result" - run: | - echo "Nothing to save" - test-load: - name: "Load test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run load tests" - run: | - make test-load - - name: "Save result" - run: | - echo "Nothing to save" - environment-tear-down: - name: "Environment tear down" - runs-on: ubuntu-latest - needs: - [ - test-accessibility, - test-contract, - test-integration, - test-load, - test-security, - test-ui-performance, - test-ui, - ] - if: always() - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Tear down environment" - run: | - echo "Tearing down environment..." + - uses: actions/checkout@v4 + + - name: "Use Node.js" + uses: actions/setup-node@v4 + with: + node-version: "${{ inputs.nodejs_version }}" + registry-url: "https://npm.pkg.github.com" + scope: "@nhsdigital" + + - name: Trigger Acceptance Tests + shell: bash + env: + APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }} + APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_ENVIRONMENT: ${{ inputs.target_environment }} + run: | + .github/scripts/dispatch_internal_repo_workflow.sh \ + --targetWorkflow "dispatch-contextual-tests-dynamic-env.yaml" \ + --infraRepoName "nhs-notify-client-callbacks" \ + --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \ + --overrideProjectName "nhs" \ + --targetEnvironment "$TARGET_ENVIRONMENT" \ + --targetAccountGroup "nhs-notify-client-callbacks-dev" \ + --targetComponent "callbacks" diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d3ce40..22c3292 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -239,6 +239,20 @@ export default defineConfig([ ], }, }, + { + files: ["tools/client-subscriptions-management/**/*.ts"], + rules: { + "no-console": "off", + "import-x/first": "off", + }, + }, + { + files: ["lambdas/client-transform-filter-lambda/**/*.ts"], + rules: { + "no-console": "off", + "import-x/first": "off", + }, + }, // misc rule overrides { diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index baaa7d2..1c3f444 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -47,7 +47,11 @@ | [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-sqs.zip | n/a | ## Outputs -No outputs. +| Name | Description | +|------|-------------| +| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | +| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | +| [mock\_webhook\_url](#output\_mock\_webhook\_url) | URL endpoint for mock webhook (for TEST\_WEBHOOK\_URL environment variable) | diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf index 2fff974..386e237 100644 --- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf @@ -35,8 +35,11 @@ module "client_transform_filter_lambda" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - ENVIRONMENT = var.environment - METRICS_NAMESPACE = "nhs-notify-client-callbacks" + ENVIRONMENT = var.environment + METRICS_NAMESPACE = "nhs-notify-client-callbacks-metrics" + CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.id + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" } } diff --git a/infrastructure/terraform/components/callbacks/outputs.tf b/infrastructure/terraform/components/callbacks/outputs.tf new file mode 100644 index 0000000..b042e36 --- /dev/null +++ b/infrastructure/terraform/components/callbacks/outputs.tf @@ -0,0 +1,29 @@ +## +# Deployment details +## + +output "deployment" { + description = "Deployment details used for post-deployment scripts" + value = { + aws_region = var.region + aws_account_id = var.aws_account_id + project = var.project + environment = var.environment + group = var.group + component = var.component + } +} + +## +# Mock Webhook Lambda Outputs (test/dev environments only) +## + +output "mock_webhook_lambda_log_group_name" { + description = "CloudWatch log group name for mock webhook lambda (for integration test queries)" + value = var.deploy_mock_webhook ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null +} + +output "mock_webhook_url" { + description = "URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable)" + value = var.deploy_mock_webhook ? aws_lambda_function_url.mock_webhook[0].function_url : null +} diff --git a/jest.config.base.ts b/jest.config.base.ts index f057e3e..52c1d02 100644 --- a/jest.config.base.ts +++ b/jest.config.base.ts @@ -5,7 +5,7 @@ export const baseJestConfig: Config = { clearMocks: true, collectCoverage: true, coverageDirectory: "./.reports/unit/coverage", - coverageProvider: "v8", + coverageProvider: "babel", coveragePathIgnorePatterns: ["/__tests__/", "/node_modules/"], transform: { "^.+\\.ts$": "ts-jest" }, testPathIgnorePatterns: [".build"], diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json index 5cc0128..4a4be91 100644 --- a/lambdas/client-transform-filter-lambda/package.json +++ b/lambdas/client-transform-filter-lambda/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", "@nhs-notify-client-callbacks/models": "*", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts new file mode 100644 index 0000000..0c495c8 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts @@ -0,0 +1,11 @@ +import { resolveCacheTtlMs } from "services/config-loader-service"; + +describe("cache ttl configuration", () => { + it("falls back to default TTL when invalid", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "not-a-number", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(60_000); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts new file mode 100644 index 0000000..a60973f --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts @@ -0,0 +1,11 @@ +import { resolveCacheTtlMs } from "services/config-loader-service"; + +describe("cache ttl configuration", () => { + it("uses the configured TTL when valid", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "120", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(120_000); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts new file mode 100644 index 0000000..bb11ccb --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts @@ -0,0 +1,140 @@ +// eslint-disable-next-line unicorn/no-useless-undefined +const mockLoadClientConfig = jest.fn().mockResolvedValue(undefined); +const mockConfigLoader = jest.fn().mockImplementation(() => ({ + loadClientConfig: mockLoadClientConfig, +})); + +jest.mock("services/config-loader", () => ({ + ConfigLoader: mockConfigLoader, +})); + +jest.mock("aws-embedded-metrics", () => ({ + createMetricsLogger: jest.fn(() => ({ + setNamespace: jest.fn(), + setDimensions: jest.fn(), + putMetric: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined as unknown), + })), + Unit: { + Count: "Count", + Milliseconds: "Milliseconds", + }, +})); + +import type { SQSRecord } from "aws-lambda"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { configLoaderService, handler } from ".."; + +const makeSqsRecord = (body: object): SQSRecord => ({ + messageId: "sqs-id", + receiptHandle: "receipt", + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue", + awsRegion: "eu-west-2", +}); + +const validEvent = { + specversion: "1.0", + id: "event-id", + source: "/nhs/england/notify/development/primary/data-plane/client-callbacks", + subject: "customer/test/message/msg-123", + type: EventTypes.MESSAGE_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00.000Z", + datacontenttype: "application/json", + dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json", + traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: "DELIVERED", + channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }, +}; + +describe("config prefix resolution", () => { + beforeEach(() => { + mockLoadClientConfig.mockClear(); + mockConfigLoader.mockClear(); + configLoaderService.reset(); // force lazy re-creation of ConfigLoader on next call + process.env.METRICS_NAMESPACE = "test-namespace"; + process.env.ENVIRONMENT = "test"; + }); + + afterEach(() => { + delete process.env.METRICS_NAMESPACE; + delete process.env.ENVIRONMENT; + }); + + it("uses the default prefix when env is not set", async () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket"; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + await handler([makeSqsRecord(validEvent)]); + + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ + keyPrefix: "client_subscriptions/", + }), + ); + + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + + if (originalPrefix === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; + } + }); + + it("uses the configured prefix when env is set", async () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket"; + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + + await handler([makeSqsRecord(validEvent)]); + + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ + keyPrefix: "custom_prefix/", + }), + ); + + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + + if (originalPrefix === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; + } + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts new file mode 100644 index 0000000..7d123b5 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -0,0 +1,250 @@ +/** + * Integration-style test for the complete handler flow including S3 config loading and + * subscription filtering. Uses the real ConfigLoader + ConfigCache + filter pipeline + * with a mocked S3Client. + */ +// Mock S3Client before importing the handler +const mockSend = jest.fn(); +jest.mock("@aws-sdk/client-s3", () => { + const actual = jest.requireActual("@aws-sdk/client-s3"); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ + send: mockSend, + })), + }; +}); + +jest.mock("aws-embedded-metrics", () => ({ + createMetricsLogger: jest.fn(() => ({ + setNamespace: jest.fn(), + setDimensions: jest.fn(), + putMetric: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined as unknown), + })), + Unit: { + Count: "Count", + Milliseconds: "Milliseconds", + }, +})); + +import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3"; +import type { SQSRecord } from "aws-lambda"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { createS3Client } from "services/config-loader-service"; +import { configLoaderService, handler } from ".."; + +const makeSqsRecord = (body: object): SQSRecord => ({ + messageId: "sqs-id", + receiptHandle: "receipt", + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue", + awsRegion: "eu-west-2", +}); + +const createValidConfig = (clientId: string) => [ + { + Name: `${clientId}-message`, + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify([]), + EventDetail: JSON.stringify({}), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: `${clientId}-target`, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED", "FAILED"], + }, +]; + +const validMessageStatusEvent = (clientId: string, messageStatus: string) => ({ + specversion: "1.0", + id: "event-id", + source: "/nhs/england/notify/development/primary/data-plane/client-callbacks", + subject: `customer/test/message/msg-123`, + type: EventTypes.MESSAGE_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00.000Z", + datacontenttype: "application/json", + dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json", + traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus, + channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId, + }, +}); + +describe("Lambda handler with S3 subscription filtering", () => { + beforeAll(() => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"; + process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60"; + process.env.METRICS_NAMESPACE = "test-namespace"; + process.env.ENVIRONMENT = "test"; + }); + + beforeEach(() => { + mockSend.mockClear(); + // Reset loader and clear cache for clean state between tests + configLoaderService.reset( + createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }), + ); + }); + + afterAll(() => { + configLoaderService.reset(); + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + delete process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS; + delete process.env.METRICS_NAMESPACE; + delete process.env.ENVIRONMENT; + }); + + it("passes event through when client config matches subscription", async () => { + mockSend.mockResolvedValue({ + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))), + }, + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + ]); + + expect(result).toHaveLength(1); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("filters out event when status is not in subscription", async () => { + mockSend.mockResolvedValue({ + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))), + }, + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "CREATED")), + ]); + + expect(result).toHaveLength(0); + }); + + it("filters out event when client has no configuration in S3", async () => { + mockSend.mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-no-config", "DELIVERED")), + ]); + + expect(result).toHaveLength(0); + }); + + it("passes matching events and filters non-matching in the same batch", async () => { + // First call (client-1 DELIVERED) → match + // Second call (client-1 CREATED) → no match + // Both share the same client config (cached after first call) + mockSend.mockResolvedValue({ + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))), + }, + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + makeSqsRecord(validMessageStatusEvent("client-1", "CREATED")), + ]); + + // Only the DELIVERED event passes the filter + expect(result).toHaveLength(1); + expect((result[0].data as { messageStatus: string }).messageStatus).toBe( + "DELIVERED", + ); + }); + + it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", async () => { + configLoaderService.reset(); + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + await expect( + handler([ + makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")), + ]), + ).rejects.toThrow("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = + originalBucket ?? "test-bucket"; + }); + + it("loads configs for multiple distinct clients in parallel and deduplicates S3 fetches", async () => { + mockSend.mockImplementation((cmd: { input: { Key: string } }) => { + const clientId = cmd.input.Key.replace( + "client_subscriptions/", + "", + ).replace(".json", ""); + return Promise.resolve({ + Body: { + transformToString: jest + .fn() + .mockResolvedValue(JSON.stringify(createValidConfig(clientId))), + }, + }); + }); + + const result = await handler([ + makeSqsRecord(validMessageStatusEvent("client-a", "DELIVERED")), + makeSqsRecord(validMessageStatusEvent("client-b", "DELIVERED")), + makeSqsRecord(validMessageStatusEvent("client-a", "DELIVERED")), // duplicate client + ]); + + // All three events match their respective configs + expect(result).toHaveLength(3); + // S3 fetched once per distinct client (client-a and client-b), not once per event + expect(mockSend).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts new file mode 100644 index 0000000..ae483b7 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts @@ -0,0 +1,32 @@ +import { createS3Client } from "services/config-loader-service"; + +describe("createS3Client", () => { + it("sets forcePathStyle=true when endpoint contains localhost", () => { + const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; + const client = createS3Client(env); + + // Access the config through the client's config property + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + expect(config.forcePathStyle).toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint does not contain localhost", () => { + const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" }; + const client = createS3Client(env); + + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint is not set", () => { + const env = {}; + const client = createS3Client(env); + + const { config } = client as any; + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 13de95b..dcb4599 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -9,9 +9,71 @@ import type { } from "@nhs-notify-client-callbacks/models"; import type { Logger } from "services/logger"; import type { CallbackMetrics } from "services/metrics"; +import type { ConfigLoader } from "services/config-loader"; import { ObservabilityService } from "services/observability"; +import { ConfigLoaderService } from "services/config-loader-service"; import { createHandler } from ".."; +const createPassthroughConfigLoader = (): ConfigLoader => + ({ + loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [ + { + SubscriptionType: "MessageStatus", + Name: "unit-test-message", + ClientId: clientId, + Description: "Pass-through for unit tests", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + Statuses: [ + "DELIVERED", + "FAILED", + "PENDING", + "SENDING", + "TECHNICAL_FAILURE", + "PERMANENT_FAILURE", + ], + }, + { + SubscriptionType: "ChannelStatus", + Name: "unit-test-nhsapp", + ClientId: clientId, + Description: "Pass-through for unit tests", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + ChannelType: "NHSAPP", + ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + SupplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, + { + SubscriptionType: "ChannelStatus", + Name: "unit-test-sms", + ClientId: clientId, + Description: "Pass-through for unit tests", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + SupplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, + ]), + }) as unknown as ConfigLoader; + +const makeStubConfigLoaderService = (): ConfigLoaderService => { + const loader = createPassthroughConfigLoader(); + return { getLoader: () => loader } as unknown as ConfigLoaderService; +}; + describe("Lambda handler", () => { const mockLogger = { info: jest.fn(), @@ -29,6 +91,8 @@ describe("Lambda handler", () => { emitTransformationFailure: jest.fn(), emitDeliveryInitiated: jest.fn(), emitValidationError: jest.fn(), + emitFilteringStarted: jest.fn(), + emitFilteringMatched: jest.fn(), } as unknown as CallbackMetrics; const mockMetricsLogger = { @@ -38,6 +102,7 @@ describe("Lambda handler", () => { const handler = createHandler({ createObservabilityService: () => new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), + createConfigLoaderService: makeStubConfigLoaderService, }); beforeEach(() => { @@ -174,7 +239,7 @@ describe("Lambda handler", () => { }; await expect(handler([sqsMessage])).rejects.toThrow( - 'Validation failed: type: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1"', + "Validation failed: type: Invalid option", ); }); @@ -260,6 +325,61 @@ describe("Lambda handler", () => { ); }); + it("should use 'Unknown error' message when a non-Error is thrown during SQS message parsing", async () => { + const faultyMetrics = { + emitEventReceived: jest.fn(), + emitValidationError: jest.fn(), + emitTransformationFailure: jest.fn(), + emitDeliveryInitiated: jest.fn(), + emitTransformationSuccess: jest.fn(), + }; + const faultyLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + addContext: jest.fn(), + clearContext: jest.fn(), + }; + const faultyObservability = { + recordProcessingStarted: jest.fn(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw "non-error-thrown"; + }), + getLogger: jest.fn().mockReturnValue(faultyLogger), + getMetrics: jest.fn().mockReturnValue(faultyMetrics), + // eslint-disable-next-line unicorn/no-useless-undefined + flush: jest.fn().mockResolvedValue(undefined), + } as unknown as ObservabilityService; + + const faultyHandler = createHandler({ + createObservabilityService: () => faultyObservability, + createConfigLoaderService: makeStubConfigLoaderService, + }); + + const sqsMessage: SQSRecord = { + messageId: "sqs-msg-id-non-error", + receiptHandle: "receipt-handle-non-error", + body: JSON.stringify(validMessageStatusEvent), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "mock-md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue", + awsRegion: "eu-west-2", + }; + + await expect(faultyHandler([sqsMessage])).rejects.toThrow( + "Failed to parse SQS message body as JSON: Unknown error", + ); + }); + it("should process empty batch successfully", async () => { const result = await handler([]); @@ -392,7 +512,14 @@ describe("createHandler default wiring", () => { }); expect(state.testHandler).toBeDefined(); + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; const result = await state.testHandler!([]); + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } expect(state.createMetricLogger).toHaveBeenCalledTimes(1); expect(state.CallbackMetrics).toHaveBeenCalledWith(state.mockMetricsLogger); @@ -405,6 +532,7 @@ describe("createHandler default wiring", () => { expect(state.processEvents).toHaveBeenCalledWith( [], state.mockObservabilityInstance, + expect.any(Object), ); expect(result).toEqual(["ok"]); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts index 03206bf..e8b9308 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts @@ -21,7 +21,7 @@ describe("callback-logger", () => { }); describe("logCallbackGenerated", () => { - describe("MESSAGE_STATUS_TRANSITIONED events", () => { + describe("MESSAGE_STATUS_PUBLISHED events", () => { const messageStatusPayload: ClientCallbackPayload = { data: [ { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts new file mode 100644 index 0000000..4341818 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -0,0 +1,86 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { ConfigCache } from "services/config-cache"; + +describe("ConfigCache", () => { + it("stores and retrieves configuration", () => { + const cache = new ConfigCache(60_000); + const config: ClientSubscriptionConfiguration = [ + { + Name: "test", + ClientId: "client-1", + Description: "Test", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + SubscriptionType: "MessageStatus" as const, + Statuses: ["DELIVERED"], + }, + ]; + + cache.set("client-1", config); + const result = cache.get("client-1"); + + expect(result).toEqual(config); + }); + + it("returns undefined for non-existent key", () => { + const cache = new ConfigCache(60_000); + const result = cache.get("non-existent"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for expired entries", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); + + const cache = new ConfigCache(1000); // 1 second TTL + const config: ClientSubscriptionConfiguration = [ + { + Name: "test", + ClientId: "client-1", + Description: "Test", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + SubscriptionType: "MessageStatus" as const, + Statuses: ["DELIVERED"], + }, + ]; + + cache.set("client-1", config); + + // Advance time past expiry + jest.advanceTimersByTime(1500); + + const result = cache.get("client-1"); + + expect(result).toBeUndefined(); + + jest.useRealTimers(); + }); + + it("clears all entries", () => { + const cache = new ConfigCache(60_000); + const config: ClientSubscriptionConfiguration = [ + { + Name: "test", + ClientId: "client-1", + Description: "Test", + EventSource: "[]", + EventDetail: "{}", + Targets: [], + SubscriptionType: "MessageStatus" as const, + Statuses: ["DELIVERED"], + }, + ]; + + cache.set("client-1", config); + cache.set("client-2", config); + + cache.clear(); + + expect(cache.get("client-1")).toBeUndefined(); + expect(cache.get("client-2")).toBeUndefined(); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts new file mode 100644 index 0000000..753a1e8 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts @@ -0,0 +1,76 @@ +import { + ConfigLoaderService, + createS3Client, +} from "services/config-loader-service"; + +describe("ConfigLoaderService", () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + beforeEach(() => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; + }); + + afterEach(() => { + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + }); + + describe("getLoader", () => { + it("returns the same loader instance on subsequent calls (lazy singleton)", () => { + const service = new ConfigLoaderService(); + const first = service.getLoader(); + const second = service.getLoader(); + expect(first).toBe(second); + }); + + it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const service = new ConfigLoaderService(); + expect(() => service.getLoader()).toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + }); + }); + + describe("reset", () => { + it("clears the cached loader so a new one is created on next getLoader call", () => { + const service = new ConfigLoaderService(); + const before = service.getLoader(); + service.reset(); + const after = service.getLoader(); + expect(after).not.toBe(before); + }); + + it("initialises a new loader with a custom S3Client when provided", () => { + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + const service = new ConfigLoaderService(); + service.reset(customClient); + // Should not throw and the loader should be available immediately + expect(() => service.getLoader()).not.toThrow(); + }); + + it("uses the configured key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is set", () => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + const service = new ConfigLoaderService(); + expect(() => service.reset(customClient)).not.toThrow(); + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + }); + + it("throws when S3Client is provided but CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const customClient = createS3Client(); + const service = new ConfigLoaderService(); + expect(() => service.reset(customClient)).toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts new file mode 100644 index 0000000..29dc52a --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -0,0 +1,140 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; +import { ConfigValidationError } from "services/validators/config-validator"; + +const mockBody = (json: string) => ({ + transformToString: jest.fn().mockResolvedValue(json), +}); + +const createValidConfig = (clientId: string) => [ + { + Name: `${clientId}-message`, + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: `${clientId}-target`, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, +]; + +const createLoader = (send: jest.Mock) => + new ConfigLoader({ + bucketName: "bucket", + keyPrefix: "client_subscriptions/", + s3Client: { send } as unknown as S3Client, + cache: new ConfigCache(60_000), + }); + +describe("ConfigLoader", () => { + it("loads and validates client configuration from S3", async () => { + const send = jest.fn().mockResolvedValue({ + Body: mockBody(JSON.stringify(createValidConfig("client-1"))), + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + expect(send.mock.calls[0][0].input).toEqual({ + Bucket: "bucket", + Key: "client_subscriptions/client-1.json", + }); + }); + + it("returns cached configuration on subsequent calls", async () => { + const send = jest.fn().mockResolvedValue({ + Body: mockBody(JSON.stringify(createValidConfig("client-1"))), + }); + const loader = createLoader(send); + + await loader.loadClientConfig("client-1"); + await loader.loadClientConfig("client-1"); + + expect(send).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when the configuration file is missing", async () => { + const send = jest + .fn() + .mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).resolves.toBeUndefined(); + }); + + it("throws when configuration fails validation", async () => { + const send = jest.fn().mockResolvedValue({ + Body: mockBody(JSON.stringify([{ SubscriptionType: "MessageStatus" }])), + }); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + ConfigValidationError, + ); + }); + + it("throws when S3 response body is empty", async () => { + const send = jest.fn().mockResolvedValue({}); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + ConfigValidationError, + ); + }); + + it("wraps S3 errors as ConfigValidationError", async () => { + const send = jest.fn().mockRejectedValue(new Error("S3 access denied")); + const loader = createLoader(send); + + const error = await loader + .loadClientConfig("client-1") + .catch((error_) => error_); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error.issues).toEqual([ + { path: "config", message: "S3 access denied" }, + ]); + }); + + it("wraps non-Error values thrown by S3 as ConfigValidationError", async () => { + const send = jest.fn().mockRejectedValue("unexpected string error"); + const loader = createLoader(send); + + const error = await loader + .loadClientConfig("client-1") + .catch((error_) => error_); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error.issues).toEqual([ + { path: "config", message: "unexpected string error" }, + ]); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts new file mode 100644 index 0000000..bffc5f8 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts @@ -0,0 +1,118 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; + +describe("config update integration", () => { + it("reloads configuration after cache expiry", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2025-01-01T10:00:00Z")); + + const send = jest + .fn() + .mockResolvedValueOnce({ + Body: { + transformToString: jest.fn().mockResolvedValue( + JSON.stringify([ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]), + ), + }, + }) + .mockResolvedValueOnce({ + Body: { + transformToString: jest.fn().mockResolvedValue( + JSON.stringify([ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["FAILED"], + }, + ]), + ), + }, + }); + + const loader = new ConfigLoader({ + bucketName: "bucket", + keyPrefix: "client_subscriptions/", + s3Client: { send } as unknown as S3Client, + cache: new ConfigCache(1000), + }); + + const first = await loader.loadClientConfig("client-1"); + const firstMessage = first?.find( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + expect(firstMessage?.Statuses).toEqual(["DELIVERED"]); + + jest.advanceTimersByTime(1500); + + const second = await loader.loadClientConfig("client-1"); + const secondMessage = second?.find( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + expect(secondMessage?.Statuses).toEqual(["FAILED"]); + + jest.useRealTimers(); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts index 57ef1fe..bd4ad4c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts @@ -1,8 +1,10 @@ import { + ConfigValidationError, ErrorType, LambdaError, TransformationError, ValidationError, + formatValidationIssuePath, getEventError, wrapUnknownError, } from "services/error-handler"; @@ -127,6 +129,60 @@ describe("TransformationError", () => { }); }); +describe("formatValidationIssuePath", () => { + it("returns empty string for empty path", () => { + expect(formatValidationIssuePath([])).toBe(""); + }); + + it("returns string segment directly at root", () => { + expect(formatValidationIssuePath(["traceparent"])).toBe("traceparent"); + }); + + it("uses dot notation for nested string segments", () => { + expect(formatValidationIssuePath(["data", "clientId"])).toBe( + "data.clientId", + ); + }); + + it("uses bracket notation for numeric segments", () => { + expect(formatValidationIssuePath([0])).toBe("[0]"); + }); + + it("combines bracket and dot notation for mixed paths", () => { + expect(formatValidationIssuePath(["channels", 0, "type"])).toBe( + "channels[0].type", + ); + }); +}); + +describe("ConfigValidationError", () => { + it("should create error with issues array", () => { + const issues = [ + { + path: "[0].Name", + message: "Expected Name to be unique", + }, + ]; + const error = new ConfigValidationError(issues); + + expect(error.message).toBe( + "Client subscription configuration validation failed", + ); + expect(error.issues).toBe(issues); + expect(error.errorType).toBe(ErrorType.VALIDATION_ERROR); + expect(error.retryable).toBe(false); + expect(error.correlationId).toBeUndefined(); + expect(error.name).toBe("ConfigValidationError"); + }); + + it("should be instanceof LambdaError and Error", () => { + const error = new ConfigValidationError([]); + expect(error).toBeInstanceOf(ConfigValidationError); + expect(error).toBeInstanceOf(LambdaError); + expect(error).toBeInstanceOf(Error); + }); +}); + describe("wrapUnknownError", () => { it("should return LambdaError as-is", () => { const originalError = new ValidationError("Original", "corr-123"); @@ -298,4 +354,23 @@ describe("getEventError", () => { expect(mockMetrics.emitTransformationFailure).toHaveBeenCalled(); expect(mockMetrics.emitValidationError).not.toHaveBeenCalled(); }); + + it("should return ConfigValidationError and emit validation metric", () => { + const error = new ConfigValidationError([ + { + path: "[0].Name", + message: "Expected Name to be unique", + }, + ]); + + const result = getEventError(error, mockMetrics, mockEventLogger); + + expect(result).toBe(error); + expect(mockEventLogger.error).toHaveBeenCalledWith( + "Client config validation failed", + { error }, + ); + expect(mockMetrics.emitValidationError).toHaveBeenCalled(); + expect(mockMetrics.emitTransformationFailure).not.toHaveBeenCalled(); + }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts new file mode 100644 index 0000000..be8d9cd --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts @@ -0,0 +1,100 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +const createSubscription = ( + eventSource: string[], + eventDetail: Record, +): ClientSubscriptionConfiguration[number] => ({ + Name: "test", + ClientId: "client-1", + Description: "Test subscription", + EventSource: JSON.stringify(eventSource), + EventDetail: JSON.stringify(eventDetail), + Targets: [], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], +}); + +describe("matchesEventPattern", () => { + it("matches when source and detail match", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: "MessageStatus", + }); + + expect(result).toBe(true); + }); + + it("matches when sources list is empty", () => { + const subscription = createSubscription([], { + clientId: ["client-1"], + }); + + const result = matchesEventPattern(subscription, "any-source", { + clientId: "client-1", + }); + + expect(result).toBe(true); + }); + + it("does not match when source is different", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + }); + + const result = matchesEventPattern(subscription, "source-b", { + clientId: "client-1", + }); + + expect(result).toBe(false); + }); + + it("does not match when detail value is different", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: "ChannelStatus", + }); + + expect(result).toBe(false); + }); + + it("does not match when detail key is missing in event", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + channel: ["EMAIL"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: "MessageStatus", + // channel is missing + }); + + expect(result).toBe(false); + }); + + it("does not match when detail value is undefined", () => { + const subscription = createSubscription(["source-a"], { + clientId: ["client-1"], + type: ["MessageStatus"], + }); + + const result = matchesEventPattern(subscription, "source-a", { + clientId: "client-1", + type: undefined, + }); + + expect(result).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts new file mode 100644 index 0000000..682d0ff --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts @@ -0,0 +1,1186 @@ +import type { + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source, + subject: "subject", + type, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: notifyData, +}); + +describe("subscription filters", () => { + it("matches message status subscriptions by client, status, and event pattern", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects message status subscriptions when event source mismatches", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches channel status subscriptions by channel and supplier status", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects channel status subscriptions when channel does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "SMS", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when event source mismatches", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-b", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when clientId does not match", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-2", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when status does not match", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "FAILED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects message status subscriptions when status has not changed", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + previousMessageStatus: "DELIVERED", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches message status subscriptions when status has changed", () => { + const notifyData: MessageStatusData = { + messageId: "message-id", + messageReference: "reference", + messageStatus: "DELIVERED", + previousMessageStatus: "PENDING_ENRICHMENT", + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId: "client-1", + }; + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-message", + ClientId: "client-1", + Description: "Message config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + ]; + + expect( + matchesMessageStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects channel status subscriptions when clientId does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + supplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-2", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when channelStatus does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "FAILED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "read", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when supplierStatus does not match", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "rejected", + previousSupplierStatus: "notified", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("rejects channel status subscriptions when neither status changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", // No change + supplierStatus: "read", + previousSupplierStatus: "read", // No change + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); + + it("matches when only channelStatus changed and is subscribed (OR logic)", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "notified", + previousSupplierStatus: "notified", // No change + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], // Not subscribed to NOTIFIED + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "SENDING", + previousChannelStatus: "SENDING", // No change + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], // Not subscribed to SENDING + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches with empty supplierStatuses array when channelStatus changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: [], // Empty array = not subscribed to any supplier status changes + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("matches with empty channelStatuses array when supplierStatus changed", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: [], // Empty array = not subscribed to any channel status changes + SupplierStatuses: ["read"], + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(true); + }); + + it("rejects with both arrays empty", () => { + const notifyData: ChannelStatusData = { + messageId: "message-id", + messageReference: "reference", + channel: "EMAIL", + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", // Changed + supplierStatus: "read", + previousSupplierStatus: "notified", // Changed + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + + const config: ClientSubscriptionConfiguration = [ + { + Name: "client-1-email", + ClientId: "client-1", + Description: "Channel config", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: [], // Empty + SupplierStatuses: [], // Empty + }, + ]; + + expect( + matchesChannelStatusSubscription(config, { event, notifyData }), + ).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts index 5e6b146..32c8a24 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts @@ -299,6 +299,16 @@ describe("extractCorrelationId", () => { expect(correlationId).toBeUndefined(); }); + + it("should return undefined when id is present but not a string", () => { + const event = { + id: 42, + }; + + const correlationId = extractCorrelationId(event); + + expect(correlationId).toBeUndefined(); + }); }); describe("logLifecycleEvent", () => { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts index b0e4578..bdbcc3a 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts @@ -131,4 +131,28 @@ describe("CallbackMetrics", () => { ); }); }); + + describe("emitFilteringStarted", () => { + it("should emit FilteringStarted metric", () => { + callbackMetrics.emitFilteringStarted(); + + expect(mockPutMetric).toHaveBeenCalledWith( + "FilteringStarted", + 1, + Unit.Count, + ); + }); + }); + + describe("emitFilteringMatched", () => { + it("should emit FilteringMatched metric", () => { + callbackMetrics.emitFilteringMatched(); + + expect(mockPutMetric).toHaveBeenCalledWith( + "FilteringMatched", + 1, + Unit.Count, + ); + }); + }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts new file mode 100644 index 0000000..1ded580 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -0,0 +1,263 @@ +import type { + Channel, + ChannelStatus, + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatus, + MessageStatusData, + StatusPublishEvent, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { TransformationError } from "services/error-handler"; +import { evaluateSubscriptionFilters } from "services/subscription-filter"; + +const createMessageStatusEvent = ( + clientId: string, + status: MessageStatus, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: status, + channels: [], + timestamp: "2025-01-01T10:00:00Z", + routingPlan: { + id: "plan-id", + name: "plan-name", + version: "1", + createdDate: "2025-01-01T10:00:00Z", + }, + clientId, + }, +}); + +const createChannelStatusEvent = ( + clientId: string, + channel: Channel, + channelStatus: ChannelStatus, + supplierStatus: SupplierStatus, + previousChannelStatus?: ChannelStatus, + previousSupplierStatus?: SupplierStatus, +): StatusPublishEvent => ({ + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.CHANNEL_STATUS_PUBLISHED, + time: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + traceparent: "traceparent", + data: { + messageId: "msg-123", + messageReference: "ref-123", + channel, + channelStatus, + previousChannelStatus, + supplierStatus, + previousSupplierStatus, + cascadeType: "primary" as const, + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId, + }, +}); + +const createMessageStatusConfig = ( + clientId: string, + statuses: MessageStatus[], +): ClientSubscriptionConfiguration => [ + { + Name: "client-message", + ClientId: clientId, + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: statuses, + }, +]; + +const createChannelStatusConfig = ( + clientId: string, + channelType: Channel, + channelStatuses: ChannelStatus[], + supplierStatuses: SupplierStatus[], +): ClientSubscriptionConfiguration => [ + { + Name: `client-${channelType}`, + ClientId: clientId, + Description: `${channelType} channel status subscription`, + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["ChannelStatus"], + channel: [channelType], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: channelType, + ChannelStatuses: channelStatuses, + SupplierStatuses: supplierStatuses, + }, +]; + +describe("evaluateSubscriptionFilters", () => { + describe("when config is undefined", () => { + it("returns not matched with Unknown subscription type", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + // eslint-disable-next-line unicorn/no-useless-undefined -- Testing explicit undefined config + const result = evaluateSubscriptionFilters(event, undefined); + + expect(result).toEqual({ + matched: false, + subscriptionType: "Unknown", + }); + }); + }); + + describe("when event is MessageStatus", () => { + it("returns matched true when status matches subscription", () => { + const event = createMessageStatusEvent("client-1", "DELIVERED"); + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "MessageStatus", + }); + }); + + it("returns matched false when status does not match subscription", () => { + const event = createMessageStatusEvent("client-1", "FAILED"); + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "MessageStatus", + }); + }); + }); + + describe("when event is ChannelStatus", () => { + it("returns matched true when channel and statuses match subscription", () => { + const event = createChannelStatusEvent( + "client-1", + "EMAIL", + "DELIVERED", + "delivered", + "SENDING", + "notified", + ); + const config = createChannelStatusConfig( + "client-1", + "EMAIL", + ["DELIVERED"], + ["delivered"], + ); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: true, + subscriptionType: "ChannelStatus", + }); + }); + + it("returns matched false when channel status does not match subscription", () => { + const event = createChannelStatusEvent( + "client-1", + "EMAIL", + "FAILED", + "delivered", + "FAILED", // previousChannelStatus (no change) + "delivered", // previousSupplierStatus (no change) + ); + const config = createChannelStatusConfig( + "client-1", + "EMAIL", + ["DELIVERED"], + ["delivered"], + ); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "ChannelStatus", + }); + }); + }); + + describe("when event type is unknown", () => { + it("throws a TransformationError", () => { + const event = { + ...createMessageStatusEvent("client-1", "DELIVERED"), + type: "unknown-event-type", + } as StatusPublishEvent; + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + expect(() => evaluateSubscriptionFilters(event, config)).toThrow( + new TransformationError("Unsupported event type: unknown-event-type"), + ); + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts new file mode 100644 index 0000000..49e3158 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts @@ -0,0 +1,106 @@ +import { + type ChannelStatusData, + type MessageStatusData, + type StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { TransformationError } from "services/error-handler"; +import { transformEvent } from "services/transformers/event-transformer"; + +const baseEvent = { + specversion: "1.0", + source: "/nhs/england/notify/development/primary/data-plane/messaging", + subject: "customer/client-abc-123/message/msg-789-xyz", + time: "2026-02-05T14:30:00.000Z", + datacontenttype: "application/json", + traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01", +}; + +const messageStatusEvent: StatusPublishEvent = { + ...baseEvent, + id: "msg-event-id-001", + dataschema: "https://notify.nhs.uk/schemas/message-status-published-v1.json", + type: "uk.nhs.notify.message.status.PUBLISHED.v1", + data: { + clientId: "client-abc-123", + messageId: "msg-789-xyz", + messageReference: "client-ref-12345", + messageStatus: "DELIVERED", + channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }], + timestamp: "2026-02-05T14:29:55Z", + routingPlan: { + id: "routing-plan-123", + name: "NHS App", + version: "v1", + createdDate: "2023-11-17T14:27:51.413Z", + }, + }, +}; + +const channelStatusEvent: StatusPublishEvent = { + ...baseEvent, + id: "ch-event-id-001", + dataschema: "https://notify.nhs.uk/schemas/channel-status-published-v1.json", + type: "uk.nhs.notify.channel.status.PUBLISHED.v1", + data: { + clientId: "client-abc-123", + messageId: "msg-789-xyz", + messageReference: "client-ref-12345", + channel: "NHSAPP", + channelStatus: "DELIVERED", + supplierStatus: "delivered", + cascadeType: "primary", + cascadeOrder: 1, + retryCount: 0, + timestamp: "2026-02-05T14:29:55Z", + }, +}; + +describe("event-transformer", () => { + describe("transformEvent", () => { + it("transforms a message status event", () => { + const result = transformEvent(messageStatusEvent, "corr-id-001"); + + expect(result.data[0].type).toBe("MessageStatus"); + }); + + it("transforms a channel status event", () => { + const result = transformEvent(channelStatusEvent, "corr-id-002"); + + expect(result.data[0].type).toBe("ChannelStatus"); + }); + + it("throws TransformationError for unsupported event type", () => { + const unsupportedEvent = { + ...messageStatusEvent, + type: "uk.nhs.notify.unsupported.event.v1", + } as unknown as StatusPublishEvent; + + expect(() => transformEvent(unsupportedEvent, "corr-id-003")).toThrow( + TransformationError, + ); + + expect(() => transformEvent(unsupportedEvent, "corr-id-003")).toThrow( + "Unsupported event type: uk.nhs.notify.unsupported.event.v1", + ); + }); + + it("includes correlationId in TransformationError when provided", () => { + const unsupportedEvent = { + ...messageStatusEvent, + type: "uk.nhs.notify.unknown.v1", + } as unknown as StatusPublishEvent; + + let caughtError: unknown; + try { + transformEvent(unsupportedEvent, "test-correlation-id"); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(TransformationError); + expect((caughtError as TransformationError).message).toBe( + "Unsupported event type: uk.nhs.notify.unknown.v1", + ); + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts new file mode 100644 index 0000000..8fb189d --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -0,0 +1,138 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + ConfigValidationError, + validateClientConfig, +} from "services/validators/config-validator"; + +const createValidConfig = (): ClientSubscriptionConfiguration => [ + { + Name: "client-message", + ClientId: "client-1", + Description: "Message status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + Statuses: ["DELIVERED"], + }, + { + Name: "client-channel", + ClientId: "client-1", + Description: "Channel status subscription", + EventSource: JSON.stringify(["source-a"]), + EventDetail: JSON.stringify({ + clientId: ["client-1"], + type: ["ChannelStatus"], + channel: ["EMAIL"], + }), + Targets: [ + { + Type: "API", + TargetId: "target", + Name: "target", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["read"], + }, +]; + +describe("validateClientConfig", () => { + it("returns the config when valid", () => { + const config = createValidConfig(); + + expect(validateClientConfig(config)).toEqual(config); + }); + + it("throws when config is not an array", () => { + expect(() => validateClientConfig({})).toThrow(ConfigValidationError); + }); + + it("throws when invocation endpoint is not https", () => { + const config = createValidConfig(); + config[0].Targets[0].InvocationEndpoint = "http://example.com"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when subscription names are not unique", () => { + const config = createValidConfig(); + config[1].Name = config[0].Name; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventSource is invalid JSON", () => { + const config = createValidConfig(); + config[0].EventSource = "not-json"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventSource is valid JSON but not an array", () => { + const config = createValidConfig(); + config[0].EventSource = JSON.stringify({ not: "array" }); + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventDetail is invalid JSON", () => { + const config = createValidConfig(); + config[0].EventDetail = "not-json"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when EventDetail is valid JSON but not a record of string arrays", () => { + const config = createValidConfig(); + config[0].EventDetail = JSON.stringify({ key: "not-array" }); + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); + + it("throws when InvocationEndpoint is not a valid URL", () => { + const config = createValidConfig(); + config[0].Targets[0].InvocationEndpoint = "not-a-url"; + + expect(() => validateClientConfig(config)).toThrow(ConfigValidationError); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts index ec9d424..8792bc4 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts @@ -70,7 +70,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - 'Validation failed: type: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1"', + "Validation failed: type: Invalid option", ); }); }); @@ -199,7 +199,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels.0.type: Invalid input: expected string, received undefined", + "Validation failed: channels[0].type: Invalid input: expected string, received undefined", ); }); @@ -213,7 +213,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels.0.channelStatus: Invalid input: expected string, received undefined", + "Validation failed: channels[0].channelStatus: Invalid input: expected string, received undefined", ); }); }); @@ -318,6 +318,34 @@ describe("event-validator", () => { jest.unmock("cloudevents"); }); + + it("should format generic Error exceptions during validation", () => { + jest.resetModules(); + + jest.isolateModules(() => { + const { ValidationError: RealCloudEventsValidationError } = + jest.requireActual("cloudevents"); + + jest.doMock("cloudevents", () => ({ + CloudEvent: jest.fn(() => { + throw new Error("generic processing error"); + }), + ValidationError: RealCloudEventsValidationError, + })); + + const moduleUnderTest = jest.requireActual( + "services/validators/event-validator", + ); + + expect(() => + moduleUnderTest.validateStatusPublishEvent({ + specversion: "1.0", + }), + ).toThrow("generic processing error"); + }); + + jest.unmock("cloudevents"); + }); }); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 3f2dc6a..4e1f20a 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -9,6 +9,8 @@ import { transformEvent } from "services/transformers/event-transformer"; import { extractCorrelationId } from "services/logger"; import { ValidationError, getEventError } from "services/error-handler"; import type { ObservabilityService } from "services/observability"; +import type { ConfigLoader } from "services/config-loader"; +import { evaluateSubscriptionFilters } from "services/subscription-filter"; const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10; @@ -21,6 +23,8 @@ class BatchStats { failed = 0; + filtered = 0; + processed = 0; recordSuccess(): void { @@ -33,10 +37,15 @@ class BatchStats { this.processed += 1; } + recordFiltered(): void { + this.filtered += 1; + } + toObject() { return { successful: this.successful, failed: this.failed, + filtered: this.filtered, processed: this.processed, }; } @@ -117,6 +126,58 @@ function recordDeliveryInitiated( } } +async function filterBatch( + transformedEvents: TransformedEvent[], + configLoader: ConfigLoader, + observability: ObservabilityService, + stats: BatchStats, +): Promise { + observability.recordFilteringStarted({ batchSize: transformedEvents.length }); + + const uniqueClientIds = new Set( + transformedEvents.map((e) => e.data.clientId), + ); + + const configEntries = await pMap( + uniqueClientIds, + async (clientId) => { + const config = await configLoader.loadClientConfig(clientId); + return [clientId, config] as const; + }, + { concurrency: BATCH_CONCURRENCY }, + ); + + const configByClientId = new Map(configEntries); + + const filtered: TransformedEvent[] = []; + + for (const event of transformedEvents) { + const { clientId } = event.data; + const config = configByClientId.get(clientId); + const filterResult = evaluateSubscriptionFilters(event, config); + + if (filterResult.matched) { + filtered.push(event); + observability.recordFilteringMatched({ + clientId, + eventType: event.type, + subscriptionType: filterResult.subscriptionType, + }); + } else { + stats.recordFiltered(); + observability + .getLogger() + .info("Event filtered out - no matching subscription", { + clientId, + eventType: event.type, + subscriptionType: filterResult.subscriptionType, + }); + } + } + + return filtered; +} + async function transformBatch( sqsRecords: SQSRecord[], observability: ObservabilityService, @@ -146,6 +207,7 @@ async function transformBatch( export async function processEvents( event: SQSRecord[], observability: ObservabilityService, + configLoader: ConfigLoader, ): Promise { const startTime = Date.now(); const stats = new BatchStats(); @@ -153,6 +215,13 @@ export async function processEvents( try { const transformedEvents = await transformBatch(event, observability, stats); + const filteredEvents = await filterBatch( + transformedEvents, + configLoader, + observability, + stats, + ); + const processingTime = Date.now() - startTime; observability.logBatchProcessingCompleted({ ...stats.toObject(), @@ -160,10 +229,10 @@ export async function processEvents( processingTimeMs: processingTime, }); - recordDeliveryInitiated(transformedEvents, observability); + recordDeliveryInitiated(filteredEvents, observability); await observability.flush(); - return transformedEvents; + return filteredEvents; } catch (error) { stats.recordFailure(); diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts index 757b83c..5ef8e19 100644 --- a/lambdas/client-transform-filter-lambda/src/index.ts +++ b/lambdas/client-transform-filter-lambda/src/index.ts @@ -2,10 +2,14 @@ import type { SQSRecord } from "aws-lambda"; import { Logger } from "services/logger"; import { CallbackMetrics, createMetricLogger } from "services/metrics"; import { ObservabilityService } from "services/observability"; +import { ConfigLoaderService } from "services/config-loader-service"; import { type TransformedEvent, processEvents } from "handler"; +export const configLoaderService = new ConfigLoaderService(); + export interface HandlerDependencies { - createObservabilityService: () => ObservabilityService; + createObservabilityService?: () => ObservabilityService; + createConfigLoaderService?: () => ConfigLoaderService; } function createDefaultObservabilityService(): ObservabilityService { @@ -16,16 +20,23 @@ function createDefaultObservabilityService(): ObservabilityService { return new ObservabilityService(logger, metrics, metricsLogger); } +function createDefaultConfigLoaderService(): ConfigLoaderService { + return configLoaderService; +} + export function createHandler( dependencies: Partial = {}, ): (event: SQSRecord[]) => Promise { const createObservabilityService = dependencies.createObservabilityService ?? createDefaultObservabilityService; + const configLoader = ( + dependencies.createConfigLoaderService ?? createDefaultConfigLoaderService + )(); return async (event: SQSRecord[]): Promise => { const observability = createObservabilityService(); - return processEvents(event, observability); + return processEvents(event, observability, configLoader.getLoader()); }; } diff --git a/lambdas/client-transform-filter-lambda/src/services/config-cache.ts b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts new file mode 100644 index 0000000..e371fdd --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts @@ -0,0 +1,37 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; + +type CacheEntry = { + value: ClientSubscriptionConfiguration; + expiresAt: number; +}; + +export class ConfigCache { + private readonly cache = new Map(); + + constructor(private readonly ttlMs: number) {} + + get(clientId: string): ClientSubscriptionConfiguration | undefined { + const entry = this.cache.get(clientId); + if (!entry) { + return undefined; + } + + if (entry.expiresAt <= Date.now()) { + this.cache.delete(clientId); + return undefined; + } + + return entry.value; + } + + set(clientId: string, value: ClientSubscriptionConfiguration): void { + this.cache.set(clientId, { + value, + expiresAt: Date.now() + this.ttlMs, + }); + } + + clear(): void { + this.cache.clear(); + } +} diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts new file mode 100644 index 0000000..b0af71b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts @@ -0,0 +1,77 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; + +const DEFAULT_CACHE_TTL_SECONDS = 60; + +export const resolveCacheTtlMs = ( + env: NodeJS.ProcessEnv = process.env, +): number => { + const configuredTtlSeconds = Number.parseInt( + env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`, + 10, + ); + const cacheTtlSeconds = Number.isFinite(configuredTtlSeconds) + ? configuredTtlSeconds + : DEFAULT_CACHE_TTL_SECONDS; + return cacheTtlSeconds * 1000; +}; + +export const createS3Client = ( + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + return new S3Client({ endpoint, forcePathStyle }); +}; + +export class ConfigLoaderService { + private readonly cache: ConfigCache; + + private loader: ConfigLoader | undefined; + + constructor(cacheTtlMs: number = resolveCacheTtlMs()) { + this.cache = new ConfigCache(cacheTtlMs); + } + + getLoader(): ConfigLoader { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + + if (this.loader) { + return this.loader; + } + + this.loader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? + "client_subscriptions/", + s3Client: createS3Client(), + cache: this.cache, + }); + + return this.loader; + } + + reset(s3Client?: S3Client): void { + this.loader = undefined; + this.cache.clear(); + if (s3Client) { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + this.loader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? + "client_subscriptions/", + s3Client, + cache: this.cache, + }); + } + } +} diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts new file mode 100644 index 0000000..d95e141 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -0,0 +1,81 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { ConfigCache } from "services/config-cache"; +import { logger } from "services/logger"; +import { + ConfigValidationError, + validateClientConfig, +} from "services/validators/config-validator"; + +type ConfigLoaderOptions = { + bucketName: string; + keyPrefix: string; + s3Client: S3Client; + cache: ConfigCache; +}; + +function throwAsConfigError(error: unknown, clientId: string): never { + if (error instanceof ConfigValidationError) { + logger.error("Config validation failed with schema violations", { + clientId, + validationErrors: error.issues, + }); + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + logger.error("Failed to load config from S3", { clientId }); + throw new ConfigValidationError([{ path: "config", message }]); +} + +export class ConfigLoader { + constructor(private readonly options: ConfigLoaderOptions) {} + + async loadClientConfig( + clientId: string, + ): Promise { + const cached = this.options.cache.get(clientId); + if (cached) { + logger.debug("Config loaded from cache", { clientId, cacheHit: true }); + return cached; + } + + logger.debug("Config not in cache, fetching from S3", { + clientId, + cacheHit: false, + }); + + try { + const response = await this.options.s3Client.send( + new GetObjectCommand({ + Bucket: this.options.bucketName, + Key: `${this.options.keyPrefix}${clientId}.json`, + }), + ); + + if (!response.Body) { + throw new Error("S3 response body was empty"); + } + + const rawConfig = await response.Body.transformToString(); + const parsedConfig = JSON.parse(rawConfig) as unknown; + const validated = validateClientConfig(parsedConfig); + this.options.cache.set(clientId, validated); + logger.info("Config loaded successfully from S3", { + clientId, + subscriptionCount: validated.length, + }); + return validated; + } catch (error) { + if (error instanceof NoSuchKey) { + logger.info( + "No config found in S3 for client - events will be filtered out", + { clientId }, + ); + return undefined; + } + throwAsConfigError(error, clientId); + return undefined; + } + } +} diff --git a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts index 26e99d2..8eef206 100644 --- a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts +++ b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts @@ -44,6 +44,38 @@ export class TransformationError extends LambdaError { } } +export type ValidationIssue = { + path: string; + message: string; +}; + +export function formatValidationIssuePath(path: (string | number)[]): string { + let formatted = ""; + + for (const segment of path) { + if (typeof segment === "number") { + formatted = `${formatted}[${segment}]`; + } else if (formatted) { + formatted = `${formatted}.${segment}`; + } else { + formatted = segment; + } + } + + return formatted; +} + +export class ConfigValidationError extends LambdaError { + constructor(public readonly issues: ValidationIssue[]) { + super( + ErrorType.VALIDATION_ERROR, + "Client subscription configuration validation failed", + undefined, + false, + ); + } +} + function serializeUnknownError(error: unknown): string { if (typeof error === "string") { return error; @@ -104,6 +136,14 @@ export function getEventError( ? error.correlationId : "unknown"; + if (error instanceof ConfigValidationError) { + eventLogger.error("Client config validation failed", { + error, + }); + metrics.emitValidationError(); + return error; + } + if (error instanceof ValidationError) { eventLogger.error("Event validation failed", { correlationId, diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts new file mode 100644 index 0000000..244f57b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -0,0 +1,108 @@ +import type { + ChannelStatusData, + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusPublishEvent; + notifyData: ChannelStatusData; +}; + +const isChannelStatusSubscription = ( + subscription: ClientSubscriptionConfiguration[number], +): subscription is ChannelStatusSubscriptionConfiguration => + subscription.SubscriptionType === "ChannelStatus"; + +export const matchesChannelStatusSubscription = ( + config: ClientSubscriptionConfiguration, + context: FilterContext, +): boolean => { + const { event, notifyData } = context; + + const matched = config + .filter((sub) => isChannelStatusSubscription(sub)) + .some((subscription) => { + if (subscription.ClientId !== notifyData.clientId) { + return false; + } + + if (subscription.ChannelType !== notifyData.channel) { + logger.debug("Channel status filter rejected: channel type mismatch", { + clientId: notifyData.clientId, + channel: notifyData.channel, + expectedChannel: subscription.ChannelType, + }); + return false; + } + + // Check if supplier status changed AND client is subscribed to it + const supplierStatusChanged = + notifyData.previousSupplierStatus !== notifyData.supplierStatus; + const clientSubscribedSupplierStatus = + subscription.SupplierStatuses.includes(notifyData.supplierStatus); + + // Check if channel status changed AND client is subscribed to it + const channelStatusChanged = + notifyData.previousChannelStatus !== notifyData.channelStatus; + const clientSubscribedChannelStatus = + subscription.ChannelStatuses.includes(notifyData.channelStatus); + + const statusMatch = + (supplierStatusChanged && clientSubscribedSupplierStatus) || + (channelStatusChanged && clientSubscribedChannelStatus); + + if (!statusMatch) { + logger.debug( + "Channel status filter rejected: no matching status change for subscription", + { + clientId: notifyData.clientId, + channelStatus: notifyData.channelStatus, + previousChannelStatus: notifyData.previousChannelStatus, + channelStatusChanged, + clientSubscribedChannelStatus, + supplierStatus: notifyData.supplierStatus, + previousSupplierStatus: notifyData.previousSupplierStatus, + supplierStatusChanged, + clientSubscribedSupplierStatus, + subscribedChannelStatuses: subscription.ChannelStatuses, + subscribedSupplierStatuses: subscription.SupplierStatuses, + }, + ); + return false; + } + + const patternMatch = matchesEventPattern(subscription, event.source, { + channel: notifyData.channel, + clientId: notifyData.clientId, + type: "ChannelStatus", + }); + + if (!patternMatch) { + logger.debug("Channel status filter rejected: event pattern mismatch", { + clientId: notifyData.clientId, + eventSource: event.source, + subscriptionName: subscription.Name, + }); + } + + return patternMatch; + }); + + if (matched) { + logger.debug("Channel status filter matched", { + clientId: notifyData.clientId, + channel: notifyData.channel, + channelStatus: notifyData.channelStatus, + previousChannelStatus: notifyData.previousChannelStatus, + supplierStatus: notifyData.supplierStatus, + previousSupplierStatus: notifyData.previousSupplierStatus, + eventSource: event.source, + }); + } + + return matched; +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts new file mode 100644 index 0000000..bfd906b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts @@ -0,0 +1,49 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; + +// Parsed representation of a subscription's EventSource / EventDetail filter criteria. +// Each key in `detail` maps to the list of allowed values for that event attribute. +type SubscriptionFilter = { + sources: string[]; + detail: Record; +}; + +const parseSubscriptionFilter = ( + subscription: ClientSubscriptionConfiguration[number], +): SubscriptionFilter => { + const sources = JSON.parse(subscription.EventSource) as string[]; + const detail = JSON.parse(subscription.EventDetail) as Record< + string, + string[] + >; + return { sources, detail }; +}; + +const matchesEventSource = (sources: string[], source: string): boolean => + sources.length === 0 || sources.includes(source); + +// Checks that every attribute required by the subscription's detail filter is +// present in the event AND has one of the subscription's allowed values. +const matchesEventDetail = ( + allowedDetailValues: Record, + eventDetail: Record, +): boolean => + Object.entries(allowedDetailValues).every(([key, allowedValues]) => { + // eslint-disable-next-line security/detect-object-injection + const eventValue = eventDetail[key]; + if (!eventValue) { + return false; + } + return allowedValues.includes(eventValue); + }); + +export const matchesEventPattern = ( + subscription: ClientSubscriptionConfiguration[number], + eventSource: string, + eventDetail: Record, +): boolean => { + const subscriptionFilter = parseSubscriptionFilter(subscription); + return ( + matchesEventSource(subscriptionFilter.sources, eventSource) && + matchesEventDetail(subscriptionFilter.detail, eventDetail) + ); +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts new file mode 100644 index 0000000..6e92f95 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -0,0 +1,80 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatusData, + MessageStatusSubscriptionConfiguration, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusPublishEvent; + notifyData: MessageStatusData; +}; + +const isMessageStatusSubscription = ( + subscription: ClientSubscriptionConfiguration[number], +): subscription is MessageStatusSubscriptionConfiguration => + subscription.SubscriptionType === "MessageStatus"; + +export const matchesMessageStatusSubscription = ( + config: ClientSubscriptionConfiguration, + context: FilterContext, +): boolean => { + const { event, notifyData } = context; + + const matched = config + .filter((sub) => isMessageStatusSubscription(sub)) + .some((subscription) => { + if (subscription.ClientId !== notifyData.clientId) { + return false; + } + + // Check if message status changed AND client is subscribed to it + const messageStatusChanged = + notifyData.previousMessageStatus !== notifyData.messageStatus; + const clientSubscribedStatus = subscription.Statuses.includes( + notifyData.messageStatus, + ); + + if (!messageStatusChanged || !clientSubscribedStatus) { + logger.debug( + "Message status filter rejected: no matching status change for subscription", + { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + previousMessageStatus: notifyData.previousMessageStatus, + messageStatusChanged, + clientSubscribedStatus, + expectedStatuses: subscription.Statuses, + }, + ); + return false; + } + + const patternMatch = matchesEventPattern(subscription, event.source, { + clientId: notifyData.clientId, + type: "MessageStatus", + }); + + if (!patternMatch) { + logger.debug("Message status filter rejected: event pattern mismatch", { + clientId: notifyData.clientId, + eventSource: event.source, + subscriptionName: subscription.Name, + }); + } + + return patternMatch; + }); + + if (matched) { + logger.debug("Message status filter matched", { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + eventSource: event.source, + }); + } + + return matched; +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/logger.ts b/lambdas/client-transform-filter-lambda/src/services/logger.ts index 84b7be3..1149d02 100644 --- a/lambdas/client-transform-filter-lambda/src/services/logger.ts +++ b/lambdas/client-transform-filter-lambda/src/services/logger.ts @@ -79,6 +79,8 @@ export function logLifecycleEvent( | "processing-started" | "transformation-started" | "transformation-completed" + | "filtering-started" + | "filtering-matched" | "delivery-initiated" | "batch-processing-completed", context: LogContext, diff --git a/lambdas/client-transform-filter-lambda/src/services/metrics.ts b/lambdas/client-transform-filter-lambda/src/services/metrics.ts index f77b487..398c5ec 100644 --- a/lambdas/client-transform-filter-lambda/src/services/metrics.ts +++ b/lambdas/client-transform-filter-lambda/src/services/metrics.ts @@ -39,4 +39,12 @@ export class CallbackMetrics { emitValidationError(): void { this.metrics.putMetric("ValidationErrors", 1, Unit.Count); } + + emitFilteringStarted(): void { + this.metrics.putMetric("FilteringStarted", 1, Unit.Count); + } + + emitFilteringMatched(): void { + this.metrics.putMetric("FilteringMatched", 1, Unit.Count); + } } diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index bb6126d..d59850a 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -39,9 +39,24 @@ export class ObservabilityService { logLifecycleEvent(this.logger, "transformation-started", context); } + recordFilteringStarted(context: { batchSize: number }): void { + logLifecycleEvent(this.logger, "filtering-started", context); + this.metrics.emitFilteringStarted(); + } + + recordFilteringMatched(context: { + clientId: string; + eventType: string; + subscriptionType: string; + }): void { + logLifecycleEvent(this.logger, "filtering-matched", context); + this.metrics.emitFilteringMatched(); + } + logBatchProcessingCompleted(context: { successful: number; failed: number; + filtered: number; processed: number; batchSize: number; processingTimeMs: number; diff --git a/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts new file mode 100644 index 0000000..33eddf8 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts @@ -0,0 +1,47 @@ +import type { + ChannelStatusData, + ClientSubscriptionConfiguration, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; +import { TransformationError } from "services/error-handler"; +import { logger } from "services/logger"; + +type FilterResult = { + matched: boolean; + subscriptionType: "MessageStatus" | "ChannelStatus" | "Unknown"; +}; + +export const evaluateSubscriptionFilters = ( + event: StatusPublishEvent, + config: ClientSubscriptionConfiguration | undefined, +): FilterResult => { + if (!config) { + logger.debug("No config available for filtering", { + eventType: event.type, + }); + return { matched: false, subscriptionType: "Unknown" }; + } + + if (event.type === EventTypes.MESSAGE_STATUS_PUBLISHED) { + const notifyData = event.data as MessageStatusData; + return { + matched: matchesMessageStatusSubscription(config, { event, notifyData }), + subscriptionType: "MessageStatus", + }; + } + + if (event.type === EventTypes.CHANNEL_STATUS_PUBLISHED) { + const notifyData = event.data as ChannelStatusData; + return { + matched: matchesChannelStatusSubscription(config, { event, notifyData }), + subscriptionType: "ChannelStatus", + }; + } + + logger.warn("Unknown event type for filtering", { eventType: event.type }); + throw new TransformationError(`Unsupported event type: ${event.type}`); +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts new file mode 100644 index 0000000..7b196d6 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import { + ConfigValidationError, + type ValidationIssue, + formatValidationIssuePath, +} from "services/error-handler"; + +export { ConfigValidationError } from "services/error-handler"; + +const jsonStringArraySchema = z.array(z.string()); +const jsonRecordSchema = z.record(z.string(), z.array(z.string())); + +const eventSourceSchema = z.string().superRefine((value, ctx) => { + try { + const parsed = JSON.parse(value) as unknown; + const result = jsonStringArraySchema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: "custom", + message: "Expected JSON array of strings", + }); + } + } catch { + ctx.addIssue({ + code: "custom", + message: "Expected valid JSON array", + }); + } +}); + +const eventDetailSchema = z.string().superRefine((value, ctx) => { + try { + const parsed = JSON.parse(value) as unknown; + const result = jsonRecordSchema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: "custom", + message: "Expected JSON object of string arrays", + }); + } + } catch { + ctx.addIssue({ + code: "custom", + message: "Expected valid JSON object", + }); + } +}); + +const httpsUrlSchema = z.string().refine( + (value) => { + try { + const parsed = new URL(value); + return parsed.protocol === "https:"; + } catch { + return false; + } + }, + { + message: "Expected HTTPS URL", + }, +); + +const targetSchema = z.object({ + Type: z.literal("API"), + TargetId: z.string(), + Name: z.string(), + InputTransformer: z.object({ + InputPaths: z.string(), + InputHeaders: z.object({ + "x-hmac-sha256-signature": z.string(), + }), + }), + InvocationEndpoint: httpsUrlSchema, + InvocationMethod: z.literal("POST"), + InvocationRateLimit: z.number(), + APIKey: z.object({ + HeaderName: z.string(), + HeaderValue: z.string(), + }), +}); + +const baseSubscriptionSchema = z.object({ + Name: z.string(), + ClientId: z.string(), + Description: z.string(), + EventSource: eventSourceSchema, + EventDetail: eventDetailSchema, + Targets: z.array(targetSchema).min(1), +}); + +const messageStatusSchema = baseSubscriptionSchema.extend({ + SubscriptionType: z.literal("MessageStatus"), + Statuses: z.array(z.enum(MESSAGE_STATUSES)), +}); + +const channelStatusSchema = baseSubscriptionSchema.extend({ + SubscriptionType: z.literal("ChannelStatus"), + ChannelType: z.enum(CHANNEL_TYPES), + ChannelStatuses: z.array(z.enum(CHANNEL_STATUSES)), + SupplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)), +}); + +const subscriptionSchema = z.discriminatedUnion("SubscriptionType", [ + messageStatusSchema, + channelStatusSchema, +]); + +const configSchema = z.array(subscriptionSchema).superRefine((config, ctx) => { + const seenNames = new Set(); + + for (const [index, subscription] of config.entries()) { + if (seenNames.has(subscription.Name)) { + ctx.addIssue({ + code: "custom", + message: "Expected Name to be unique", + path: [index, "Name"], + }); + } else { + seenNames.add(subscription.Name); + } + } +}); + +export const validateClientConfig = ( + rawConfig: unknown, +): ClientSubscriptionConfiguration => { + const result = configSchema.safeParse(rawConfig); + + if (!result.success) { + const issues: ValidationIssue[] = result.error.issues.map((issue) => { + const pathSegments = issue.path.filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ); + + return { + path: formatValidationIssuePath(pathSegments), + message: issue.message, + }; + }); + throw new ConfigValidationError(issues); + } + + return result.data; +}; + +export { + type ChannelStatusSubscriptionConfiguration, + type MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts index 82180eb..03e3780 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts @@ -7,7 +7,10 @@ import { EventTypes, type StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; -import { ValidationError } from "services/error-handler"; +import { + ValidationError, + formatValidationIssuePath, +} from "services/error-handler"; import { extractCorrelationId } from "services/logger"; const NHSNotifyExtensionsSchema = z.object({ @@ -66,7 +69,15 @@ function formatValidationError(error: unknown, event: unknown): never { message = `CloudEvents validation failed: ${error.message}`; } else if (error instanceof z.ZodError) { const issues = error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .map((issue) => { + const path = formatValidationIssuePath( + issue.path.filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ), + ); + return path ? `${path}: ${issue.message}` : issue.message; + }) .join(", "); message = `Validation failed: ${issues}`; } else if (error instanceof Error) { diff --git a/package-lock.json b/package-lock.json index 6ba1c04..8a324f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "lambdas/client-transform-filter-lambda", "src/models", "lambdas/mock-webhook-lambda", - "tests/integration" + "tests/integration", + "tools/client-subscriptions-management" ], "devDependencies": { + "@aws-sdk/client-s3": "^3.821.0", "@stylistic/eslint-plugin": "^3.1.0", "@tsconfig/node22": "^22.0.2", "@types/jest": "^29.5.14", @@ -47,6 +49,7 @@ "name": "nhs-notify-client-transform-filter-lambda", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", "@nhs-notify-client-callbacks/models": "*", "aws-embedded-metrics": "^4.2.1", "cloudevents": "^8.0.2", @@ -64,6 +67,19 @@ "typescript": "^5.8.2" } }, + "lambdas/client-transform-filter-lambda/node_modules/p-map": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "lambdas/mock-webhook-lambda": { "name": "nhs-notify-mock-webhook-lambda", "version": "0.0.1", @@ -84,9 +100,7 @@ } }, "lambdas/mock-webhook-lambda/node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "version": "22.19.11", "dev": true, "license": "MIT", "dependencies": { @@ -95,15 +109,23 @@ }, "lambdas/mock-webhook-lambda/node_modules/undici-types": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -114,10 +136,61 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -131,8 +204,6 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -143,8 +214,6 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -156,8 +225,6 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -169,8 +236,6 @@ }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -183,8 +248,6 @@ }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -192,8 +255,6 @@ }, "node_modules/@aws-crypto/util": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -203,8 +264,6 @@ }, "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -215,8 +274,6 @@ }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -228,8 +285,6 @@ }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -240,48 +295,111 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1000.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1000.0.tgz", - "integrity": "sha512-8/YP++CiBIh5jADEmPfBCHYWErHNYlG5Ome5h82F/yB+x6i9ARF/Y/u95Z9IHwO25CDvxTPKH0U66h7HFL8tcg==", + "version": "3.991.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.991.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.991.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-eventbridge": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.1001.0.tgz", + "integrity": "sha512-asySfaKnDTxhMtxCX1dvjDPfJwrQ5xy/tzdmFHmRyURNhIhXG3dwishJ6ROXzOrY7hFCiz+OTWWjZ+IJbrfzkA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-node": "^3.972.14", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/credential-provider-node": "^3.972.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.15", + "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", + "@aws-sdk/util-user-agent-node": "^3.973.1", "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/eventstream-serde-browser": "^4.2.10", - "@smithy/eventstream-serde-config-resolver": "^4.3.10", - "@smithy/eventstream-serde-node": "^4.2.10", - "@smithy/fetch-http-handler": "^5.3.11", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", @@ -292,49 +410,206 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-eventbridge/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.996.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/credential-provider-node": "^3.972.11", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", + "@aws-sdk/middleware-expect-continue": "^3.972.3", + "@aws-sdk/middleware-flexible-checksums": "^3.972.10", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-location-constraint": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-s3": "^3.972.12", + "@aws-sdk/middleware-ssec": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.12", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/signature-v4-multi-region": "3.996.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.996.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.11", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.2", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/middleware-retry": "^4.4.33", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.32", + "@smithy/util-defaults-mode-node": "^4.2.35", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.12", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sqs": { - "version": "3.1000.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1000.0.tgz", - "integrity": "sha512-fGp197WE/wy05DNAKLokN21RwhH17go631U6GT/t3BwHv7DBd5oI4OLT5TLy0dc4freAd3ib3XET1OEc1TG/3Q==", + "version": "3.990.0", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-node": "^3.972.14", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-sqs": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1001.0.tgz", + "integrity": "sha512-1HVxJcad+BTMVQ4lN2jw4SzyVqnIRZ7mb8YjwqMQ6p1MjuklSriVUXKtYFyxLVJnqaw61nFv9F8oHMOK69p6BQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/credential-provider-node": "^3.972.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-sdk-sqs": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.15", + "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", + "@aws-sdk/util-user-agent-node": "^3.973.1", "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/fetch-http-handler": "^5.3.11", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", - "@smithy/md5-js": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", @@ -345,20 +620,36 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.15.tgz", - "integrity": "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==", + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.16.tgz", + "integrity": "sha512-Nasoyb5K4jfvncTKQyA13q55xHoz9as01NVYP05B0Kzux/X5UhMn3qXsZDyWOSXkfSCAIrMBKmVVWbI0vUapdQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", - "@aws-sdk/xml-builder": "^3.972.8", - "@smithy/core": "^3.23.6", + "@aws-sdk/xml-builder": "^3.972.9", + "@smithy/core": "^3.23.7", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", @@ -369,13 +660,24 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.13.tgz", - "integrity": "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.14.tgz", + "integrity": "sha512-PvnBY9rwBuLh9MEsAng28DG+WKl+txerKgf4BU9IPAqYI7FBIo1x6q/utLf4KLyQYgSy1TLQnbQuXx5xfBGASg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", @@ -386,20 +688,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.15.tgz", - "integrity": "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.16.tgz", + "integrity": "sha512-m/QAcvw5OahqGPjeAnKtgfWgjLxeWOYj7JSmxKK6PLyKp2S/t2TAHI6EELEzXnIz28RMgbQLukJkVAqPASVAGQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" }, "engines": { @@ -407,19 +709,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.13.tgz", - "integrity": "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-env": "^3.972.13", - "@aws-sdk/credential-provider-http": "^3.972.15", - "@aws-sdk/credential-provider-login": "^3.972.13", - "@aws-sdk/credential-provider-process": "^3.972.13", - "@aws-sdk/credential-provider-sso": "^3.972.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.13", - "@aws-sdk/nested-clients": "^3.996.3", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.14.tgz", + "integrity": "sha512-EGA7ufqNpZKZcD0RwM6gRDEQgwAf19wQ99R1ptdWYDJAnpcMcWiFyT0RIrgiZFLD28CwJmYjnra75hChnEveWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/credential-provider-env": "^3.972.14", + "@aws-sdk/credential-provider-http": "^3.972.16", + "@aws-sdk/credential-provider-login": "^3.972.14", + "@aws-sdk/credential-provider-process": "^3.972.14", + "@aws-sdk/credential-provider-sso": "^3.972.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.14", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", @@ -432,13 +734,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.13.tgz", - "integrity": "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.14.tgz", + "integrity": "sha512-P2kujQHAoV7irCTv6EGyReKFofkHCjIK+F0ZYf5UxeLeecrCwtrDkHoO2Vjsv/eRUumaKblD8czuk3CLlzwGDw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", @@ -451,17 +753,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.14.tgz", - "integrity": "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.15.tgz", + "integrity": "sha512-59NBJgTcQ2FC94T+SWkN5UQgViFtrLnkswSKhG5xbjPAotOXnkEF2Bf0bfUV1F3VaXzqAPZJoZ3bpg4rr8XD5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.13", - "@aws-sdk/credential-provider-http": "^3.972.15", - "@aws-sdk/credential-provider-ini": "^3.972.13", - "@aws-sdk/credential-provider-process": "^3.972.13", - "@aws-sdk/credential-provider-sso": "^3.972.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.13", + "@aws-sdk/credential-provider-env": "^3.972.14", + "@aws-sdk/credential-provider-http": "^3.972.16", + "@aws-sdk/credential-provider-ini": "^3.972.14", + "@aws-sdk/credential-provider-process": "^3.972.14", + "@aws-sdk/credential-provider-sso": "^3.972.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.14", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", @@ -474,12 +776,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.13.tgz", - "integrity": "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.14.tgz", + "integrity": "sha512-KAF5LBkJInUPaR9dJDw8LqmbPDRTLyXyRoWVGcJQ+DcN9rxVKBRzAK+O4dTIvQtQ7xaIDZ2kY7zUmDlz6CCXdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -491,14 +793,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.13.tgz", - "integrity": "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.14.tgz", + "integrity": "sha512-LQzIYrNABnZzkyuIguFa3VVOox9UxPpRW6PL+QYtRHaGl1Ux/+Zi54tAVK31VdeBKPKU3cxqeu8dbOgNqy+naw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/token-providers": "3.999.0", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", + "@aws-sdk/token-providers": "3.1001.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -510,13 +812,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.13.tgz", - "integrity": "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.14.tgz", + "integrity": "sha512-rOwB3vXHHHnGvAOjTgQETxVAsWjgF61XlbGd/ulvYo7EpdXs8cbIHE3PGih9tTj/65ZOegSqZGFqLaKntaI9Kw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -527,10 +829,60 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.10", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.12", + "@aws-sdk/crc64-nvme": "3.972.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", - "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -542,10 +894,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", - "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -558,8 +920,6 @@ }, "node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", - "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -572,17 +932,24 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-sqs": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.11.tgz", - "integrity": "sha512-Y4dryR0y7wN3hBayLOVSRuP3FeTs8KbNEL4orW/hKpf4jsrneDpI2RifUQVhiyb3QkC83bpeKaOSa0waHiPvcg==", - "dev": true, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.16.tgz", + "integrity": "sha512-U4K1rqyJYvT/zgTI3+rN+MToa51dFnnq1VSsVJuJWPNEKcEnuZVqf7yTpkJJMkYixVW5TTi1dgupd+nmJ0JyWw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", - "@smithy/smithy-client": "^4.12.0", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/core": "^3.23.7", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", + "@smithy/util-config-provider": "^4.2.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -590,16 +957,44 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.972.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.3", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.15.tgz", - "integrity": "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.16.tgz", + "integrity": "sha512-AmVxtxn8ZkNJbuPu3KKfW9IkJgTgcEtgSwbo0NVcAb31iGvLgHXj2nbbyrUDfh2fx8otXmqL+qw1lRaTi+V3vA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", - "@smithy/core": "^3.23.6", + "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" @@ -608,45 +1003,59 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/nested-clients": { + "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", - "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.4.tgz", + "integrity": "sha512-NowB1HfOnWC4kwZOnTg8E8rSL0U+RSjSa++UtEV4ipoH6JOjMLnHyGilqwl+Pe1f0Al6v9yMkSJ/8Ot0f578CQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", + "@aws-sdk/core": "^3.973.16", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.15", + "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", + "@aws-sdk/util-user-agent-node": "^3.973.1", "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/fetch-http-handler": "^5.3.11", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", @@ -657,10 +1066,24 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/region-config-resolver": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", - "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -673,14 +1096,31 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.4.tgz", + "integrity": "sha512-MGa8ro0onekYIiesHX60LwKdkxK3Kd61p7TTbLwZemBqlnD9OLrk9sXZdFOIxXanJ+3AaJnV/jiX866eD/4PDg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.16", + "@aws-sdk/types": "^3.973.4", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/token-providers": { - "version": "3.999.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.999.0.tgz", - "integrity": "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==", + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1001.0.tgz", + "integrity": "sha512-09XAq/uIYgeZhohuGRrR/R+ek3+ljFNdzWCXdqb9rlIERDjSfNiLjTtpHgSK1xTPmC5G4yWoEAyMfTXiggS6wA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/core": "^3.973.16", + "@aws-sdk/nested-clients": "^3.996.4", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -693,8 +1133,6 @@ }, "node_modules/@aws-sdk/types": { "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", - "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -704,16 +1142,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.2", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.990.0", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" }, "engines": { @@ -722,8 +1169,6 @@ }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", - "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -734,8 +1179,6 @@ }, "node_modules/@aws-sdk/util-user-agent-browser": { "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", - "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.4", @@ -745,12 +1188,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.0.tgz", - "integrity": "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==", + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.1.tgz", + "integrity": "sha512-kmgbDqT7aCBEVrqESM2JUjbf0zhDUQ7wnt3q1RuVS+3mglrcfVb2bwkbmf38npOyyPGtQPV5dWN3m+sSFAVAgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.15", + "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", @@ -769,13 +1212,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.8.tgz", - "integrity": "sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", + "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.3.6", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { @@ -784,19 +1227,17 @@ }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -805,7 +1246,7 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", + "version": "7.27.5", "dev": true, "license": "MIT", "engines": { @@ -813,20 +1254,20 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", + "version": "7.27.4", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -850,14 +1291,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", + "version": "7.27.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" }, "engines": { @@ -865,11 +1306,11 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", + "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -887,34 +1328,26 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", + "version": "7.27.3", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -924,7 +1357,7 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -940,7 +1373,7 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -956,23 +1389,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", + "version": "7.27.6", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", + "version": "7.27.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -1029,11 +1462,11 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1065,11 +1498,11 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1173,11 +1606,11 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1187,42 +1620,50 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", + "version": "7.27.4", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "debug": "^4.3.1", + "globals": "^11.1.0" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { - "version": "7.29.0", + "version": "7.27.6", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1255,19 +1696,17 @@ }, "node_modules/@datastructures-js/heap": { "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@datastructures-js/heap/-/heap-4.3.7.tgz", - "integrity": "sha512-Dx4un7Uj0dVxkfoq4RkpzsY2OrvNJgQYZ3n3UlGdl88RxxdHd7oTi21/l3zoxUUe0sXFuNUrfmWqlHzqnoN6Ug==", "license": "MIT" }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=18" @@ -1290,17 +1729,6 @@ "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", - "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", "dev": true, @@ -1322,6 +1750,26 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "dev": true, @@ -1345,18 +1793,18 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.4", + "version": "3.3.1", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", + "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1366,6 +1814,34 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "9.39.3", "dev": true, @@ -1406,17 +1882,29 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.7", + "version": "0.16.6", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "@humanwhocodes/retry": "^0.3.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "dev": true, @@ -1477,7 +1965,7 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", + "version": "3.14.1", "dev": true, "license": "MIT", "dependencies": { @@ -1819,25 +2307,28 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", + "version": "0.3.8", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", "dev": true, "license": "MIT", "engines": { @@ -1845,12 +2336,12 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", + "version": "1.5.0", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", + "version": "0.3.25", "dev": true, "license": "MIT", "dependencies": { @@ -1858,6 +2349,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.3.4", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@nhs-notify-client-callbacks/models": { "resolved": "src/models", "link": true @@ -1896,8 +2427,6 @@ }, "node_modules/@pinojs/redact": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, "node_modules/@pkgr/core": { @@ -1911,8 +2440,15 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", + "version": "0.27.8", "dev": true, "license": "MIT" }, @@ -1934,8 +2470,6 @@ }, "node_modules/@smithy/abort-controller": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", - "integrity": "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -1945,10 +2479,29 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.2", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.9.tgz", - "integrity": "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -1963,9 +2516,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.6", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.6.tgz", - "integrity": "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==", + "version": "3.23.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.7.tgz", + "integrity": "sha512-/+ldRdtiO5Cb26afAZOG1FZM0x7D4AYdjpyOv2OScJw+4C7X+OLdRnNKF5UyUE0VpPgSKr3rnF/kvprRA4h2kg==", "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.2.11", @@ -1974,7 +2527,7 @@ "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-middleware": "^4.2.10", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" @@ -1985,8 +2538,6 @@ }, "node_modules/@smithy/credential-provider-imds": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.10.tgz", - "integrity": "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -2000,14 +2551,12 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.10.tgz", - "integrity": "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -2015,13 +2564,11 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.10.tgz", - "integrity": "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2029,12 +2576,10 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.10.tgz", - "integrity": "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==", + "version": "4.3.8", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2042,13 +2587,11 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.10.tgz", - "integrity": "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2056,13 +2599,11 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.10.tgz", - "integrity": "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==", + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -2070,29 +2611,52 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz", - "integrity": "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.12.tgz", + "integrity": "sha512-muS5tFw+A/uo+U+yig06vk1776UFM+aAp9hFM8efI4ZcHhTcgv6NTeK4x7ltHeMPBwnhEjcf0MULTyxNkSNxDw==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", + "@smithy/util-base64": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.10", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.1", + "@smithy/chunked-blob-reader-native": "^4.2.2", + "@smithy/types": "^4.12.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.10", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", - "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.9", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/types": "^4.12.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -2102,8 +2666,6 @@ }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz", - "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2115,8 +2677,6 @@ }, "node_modules/@smithy/is-array-buffer": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz", - "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2126,14 +2686,11 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.10.tgz", - "integrity": "sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==", - "dev": true, + "version": "4.2.8", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-utf8": "^4.2.1", + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -2142,8 +2699,6 @@ }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz", - "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", @@ -2155,12 +2710,12 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.20.tgz", - "integrity": "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==", + "version": "4.4.21", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.21.tgz", + "integrity": "sha512-CoVGZaqIC0tEjz0ga3ciwCMA5fd/4lIOwO2wx0fH+cTi1zxSFZnMJbIiIF9G1d4vRSDyTupDrpS3FKBBJGkRZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", + "@smithy/core": "^3.23.7", "@smithy/middleware-serde": "^4.2.11", "@smithy/node-config-provider": "^4.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", @@ -2174,15 +2729,15 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.37", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.37.tgz", - "integrity": "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==", + "version": "4.4.38", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.38.tgz", + "integrity": "sha512-WdHvdhjE6Fj78vxFwDKFDwlqGOGRUWrwGeuENUbTVE46Su9mnQM+dXHtbnCaQvwuSYrRsjpe8zUsFpwUp/azlA==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/service-error-classification": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", @@ -2195,8 +2750,6 @@ }, "node_modules/@smithy/middleware-serde": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.11.tgz", - "integrity": "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.10", @@ -2209,8 +2762,6 @@ }, "node_modules/@smithy/middleware-stack": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.10.tgz", - "integrity": "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2222,8 +2773,6 @@ }, "node_modules/@smithy/node-config-provider": { "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.10.tgz", - "integrity": "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.10", @@ -2236,9 +2785,9 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.12.tgz", - "integrity": "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==", + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.13.tgz", + "integrity": "sha512-o8CP8w6tlUA0lk+Qfwm6Ed0jCWk3bEY6iBOJjdBaowbXKCSClk8zIHQvUL6RUZMvuNafF27cbRCMYqw6O1v4aA==", "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.10", @@ -2253,8 +2802,6 @@ }, "node_modules/@smithy/property-provider": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.10.tgz", - "integrity": "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2266,8 +2813,6 @@ }, "node_modules/@smithy/protocol-http": { "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz", - "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2279,8 +2824,6 @@ }, "node_modules/@smithy/querystring-builder": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.10.tgz", - "integrity": "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2293,8 +2836,6 @@ }, "node_modules/@smithy/querystring-parser": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.10.tgz", - "integrity": "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2306,8 +2847,6 @@ }, "node_modules/@smithy/service-error-classification": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.10.tgz", - "integrity": "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0" @@ -2318,8 +2857,6 @@ }, "node_modules/@smithy/shared-ini-file-loader": { "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.5.tgz", - "integrity": "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2331,8 +2868,6 @@ }, "node_modules/@smithy/signature-v4": { "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz", - "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.1", @@ -2349,17 +2884,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.0.tgz", - "integrity": "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.1.tgz", + "integrity": "sha512-Xf9UFHlAihewfkmLNZ6I/Ek6kcYBKoU3cbRS9Z4q++9GWoW0YFbAHs7wMbuXm+nGuKHZ5OKheZMuDdaWPv8DJw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-endpoint": "^4.4.20", + "@smithy/core": "^3.23.7", + "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-stack": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" }, "engines": { @@ -2368,8 +2903,6 @@ }, "node_modules/@smithy/types": { "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2380,8 +2913,6 @@ }, "node_modules/@smithy/url-parser": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.10.tgz", - "integrity": "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==", "license": "Apache-2.0", "dependencies": { "@smithy/querystring-parser": "^4.2.10", @@ -2394,8 +2925,6 @@ }, "node_modules/@smithy/util-base64": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz", - "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.1", @@ -2408,8 +2937,6 @@ }, "node_modules/@smithy/util-body-length-browser": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz", - "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2420,8 +2947,6 @@ }, "node_modules/@smithy/util-body-length-node": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz", - "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2432,8 +2957,6 @@ }, "node_modules/@smithy/util-buffer-from": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz", - "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.1", @@ -2445,8 +2968,6 @@ }, "node_modules/@smithy/util-config-provider": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz", - "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2456,13 +2977,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.36", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.36.tgz", - "integrity": "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==", + "version": "4.3.37", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.37.tgz", + "integrity": "sha512-JlPZhV1kQCGNJgofRTU6E8kHrjCKsb6cps8gco8QDVaFl7biFYzHg0p1x89ytIWyVyCkY3nOpO8tJPM47Vqlww==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2471,16 +2992,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.39", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.39.tgz", - "integrity": "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==", + "version": "4.2.40", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.40.tgz", + "integrity": "sha512-BM5cPEsyxHdYYO4Da77E94lenhaVPNUzBTyCGDkcw/n/mE8Q1cfHwr+n/w2bNPuUsPC30WaW5/hGKWOTKqw8kw==", "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.9", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2490,8 +3011,6 @@ }, "node_modules/@smithy/util-endpoints": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.1.tgz", - "integrity": "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.10", @@ -2504,8 +3023,6 @@ }, "node_modules/@smithy/util-hex-encoding": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", - "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2516,8 +3033,6 @@ }, "node_modules/@smithy/util-middleware": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz", - "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2529,8 +3044,6 @@ }, "node_modules/@smithy/util-retry": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.10.tgz", - "integrity": "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==", "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.10", @@ -2542,13 +3055,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.15", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.15.tgz", - "integrity": "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==", + "version": "4.5.16", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.16.tgz", + "integrity": "sha512-c7awZV6cxY0czgDDSr+Bz0XfRtg8AwW2BWhrHhLJISrpmwv8QzA2qzTllWyMVNdy1+UJr9vCm29hzuh3l8TTFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/node-http-handler": "^4.4.13", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-buffer-from": "^4.2.1", @@ -2562,8 +3075,6 @@ }, "node_modules/@smithy/util-uri-escape": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz", - "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2574,8 +3085,6 @@ }, "node_modules/@smithy/util-utf8": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", - "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.1", @@ -2585,10 +3094,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.9", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.9", + "@smithy/types": "^4.12.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/uuid": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz", - "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2615,6 +3134,28 @@ "eslint": ">=8.40.0" } }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "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/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "dev": true, @@ -2624,7 +3165,7 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", + "version": "1.0.11", "dev": true, "license": "MIT" }, @@ -2644,12 +3185,12 @@ "license": "MIT" }, "node_modules/@tsconfig/node22": { - "version": "22.0.5", + "version": "22.0.2", "dev": true, "license": "MIT" }, "node_modules/@types/aws-lambda": { - "version": "8.10.160", + "version": "8.10.150", "dev": true, "license": "MIT" }, @@ -2683,11 +3224,11 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.28.0", + "version": "7.20.7", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.20.7" } }, "node_modules/@types/estree": { @@ -2748,12 +3289,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json5": { + "version": "0.0.29", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/node": { - "version": "25.3.0", + "version": "24.0.3", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/stack-utils": { @@ -2767,7 +3315,7 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.35", + "version": "17.0.33", "dev": true, "license": "MIT", "dependencies": { @@ -2806,14 +3354,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/@typescript-eslint/parser": { "version": "8.56.1", "dev": true, @@ -2998,28 +3538,16 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.9.2", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ] }, "node_modules/abab": { @@ -3028,7 +3556,7 @@ "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.16.0", + "version": "8.15.0", "dev": true, "license": "MIT", "bin": { @@ -3056,7 +3584,7 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.5", + "version": "8.3.4", "dev": true, "license": "MIT", "dependencies": { @@ -3079,8 +3607,6 @@ }, "node_modules/aggregate-error": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -3092,15 +3618,13 @@ }, "node_modules/aggregate-error/node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ajv": { - "version": "6.14.0", + "version": "6.12.6", "dev": true, "license": "MIT", "dependencies": { @@ -3116,8 +3640,6 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -3133,8 +3655,6 @@ }, "node_modules/ajv-formats/node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3149,8 +3669,6 @@ }, "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/ansi-escapes": { @@ -3167,9 +3685,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3177,7 +3705,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3201,17 +3728,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/arg": { "version": "4.1.3", "dev": true, @@ -3266,6 +3782,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.3", "dev": true, @@ -3300,6 +3859,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", "dev": true, @@ -3335,8 +3911,6 @@ }, "node_modules/async-wait-until": { "version": "2.0.31", - "resolved": "https://registry.npmjs.org/async-wait-until/-/async-wait-until-2.0.31.tgz", - "integrity": "sha512-9VCfHvc4f36oT6sG5p16aKc9zojf3wF4FrjNDxU3Db51SJ1bQ5lWAWtQDDZPysTwSLKBDzNZ083qPkTIj6XnrA==", "dev": true, "license": "MIT", "engines": { @@ -3355,8 +3929,6 @@ }, "node_modules/atomic-sleep": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", "engines": { "node": ">=8.0.0" @@ -3377,8 +3949,6 @@ }, "node_modules/aws-embedded-metrics": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/aws-embedded-metrics/-/aws-embedded-metrics-4.2.1.tgz", - "integrity": "sha512-uzydBXlGQVTB2sZ9ACCQZM3y0u4wdvxxRKFL9LP6RdfI2GcOrCcAsz65UKQvX9iagxFhah322VvvatgP8E7MIg==", "license": "Apache-2.0", "dependencies": { "@datastructures-js/heap": "^4.0.2" @@ -3388,7 +3958,7 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", + "version": "4.10.3", "dev": true, "license": "MPL-2.0", "engines": { @@ -3476,7 +4046,7 @@ } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", + "version": "1.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -3497,7 +4067,7 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" + "@babel/core": "^7.0.0" } }, "node_modules/babel-preset-jest": { @@ -3516,28 +4086,12 @@ } }, "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", + "version": "1.0.2", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } + "license": "MIT" }, "node_modules/bignumber.js": { "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -3545,12 +4099,10 @@ }, "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": "5.0.3", + "version": "5.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -3560,6 +4112,14 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "dev": true, @@ -3572,7 +4132,7 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.25.0", "dev": true, "funding": [ { @@ -3590,11 +4150,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -3704,7 +4263,7 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", + "version": "1.0.30001724", "dev": true, "funding": [ { @@ -3746,7 +4305,7 @@ } }, "node_modules/ci-info": { - "version": "4.4.0", + "version": "4.2.0", "dev": true, "funding": [ { @@ -3785,16 +4344,17 @@ }, "node_modules/clean-stack": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/client-subscriptions-management": { + "resolved": "tools/client-subscriptions-management", + "link": true + }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3807,8 +4367,6 @@ }, "node_modules/cloudevents": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-8.0.3.tgz", - "integrity": "sha512-wTixKNjfLeyj9HQpESvLVVO4xgdqdvX4dTeg1IZ2SCunu/fxVzCamcIZneEyj31V82YolFCKwVeSkr8zResB0Q==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.11.0", @@ -3824,8 +4382,6 @@ }, "node_modules/cloudevents/node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3840,8 +4396,6 @@ }, "node_modules/cloudevents/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/co": { @@ -3854,13 +4408,12 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.3", + "version": "1.0.2", "dev": true, "license": "MIT" }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3871,7 +4424,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3886,13 +4438,18 @@ } }, "node_modules/comment-parser": { - "version": "1.4.5", + "version": "1.4.1", "dev": true, "license": "MIT", "engines": { "node": ">= 12.0.0" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "dev": true, @@ -3904,11 +4461,11 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.48.0", + "version": "3.43.0", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.25.0" }, "funding": { "type": "opencollective", @@ -4065,12 +4622,12 @@ } }, "node_modules/decimal.js": { - "version": "10.6.0", + "version": "10.5.0", "dev": true, "license": "MIT" }, "node_modules/dedent": { - "version": "1.7.1", + "version": "1.6.0", "dev": true, "license": "MIT", "peerDependencies": { @@ -4143,7 +4700,7 @@ } }, "node_modules/diff": { - "version": "4.0.4", + "version": "4.0.2", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4158,6 +4715,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "dev": true, @@ -4244,7 +4814,7 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.302", + "version": "1.5.173", "dev": true, "license": "ISC" }, @@ -4260,12 +4830,11 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, + "version": "8.0.0", "license": "MIT" }, "node_modules/entities": { - "version": "7.0.1", + "version": "6.0.1", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4276,7 +4845,7 @@ } }, "node_modules/error-ex": { - "version": "1.3.4", + "version": "1.3.2", "dev": true, "license": "MIT", "dependencies": { @@ -4284,7 +4853,7 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", + "version": "1.24.0", "dev": true, "license": "MIT", "dependencies": { @@ -4364,6 +4933,34 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -4416,7 +5013,7 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", + "version": "0.25.0", "hasInstallScript": true, "license": "MIT", "bin": { @@ -4426,37 +5023,35 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4606,7 +5201,7 @@ } }, "node_modules/eslint-config-airbnb-extended/node_modules/globals": { - "version": "16.5.0", + "version": "16.2.0", "dev": true, "license": "MIT", "engines": { @@ -4653,6 +5248,28 @@ } } }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/eslint-import-resolver-typescript": { "version": "4.4.4", "dev": true, @@ -4686,6 +5303,34 @@ } } }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/eslint-plugin-html": { "version": "8.1.4", "dev": true, @@ -4697,6 +5342,40 @@ "node": ">=16.0.0" } }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, "node_modules/eslint-plugin-import-x": { "version": "4.16.1", "dev": true, @@ -4732,6 +5411,50 @@ } } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jest": { "version": "28.14.0", "dev": true, @@ -4796,6 +5519,31 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-no-relative-import-paths": { "version": "v1.6.1", "dev": true, @@ -4830,6 +5578,104 @@ } } }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-security": { "version": "3.0.1", "dev": true, @@ -4948,7 +5794,7 @@ } }, "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "16.5.0", + "version": "16.2.0", "dev": true, "license": "MIT", "engines": { @@ -4974,6 +5820,26 @@ } }, "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "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/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "dev": true, "license": "Apache-2.0", @@ -4984,6 +5850,25 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { "version": "10.4.0", "dev": true, @@ -5000,6 +5885,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "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/esprima": { "version": "4.0.1", "dev": true, @@ -5013,7 +5909,7 @@ } }, "node_modules/esquery": { - "version": "1.7.0", + "version": "1.6.0", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5141,8 +6037,6 @@ }, "node_modules/fast-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -5155,10 +6049,22 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "funding": [ { "type": "github", @@ -5167,6 +6073,7 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { @@ -5174,7 +6081,7 @@ } }, "node_modules/fastq": { - "version": "1.20.1", + "version": "1.19.1", "dev": true, "license": "ISC", "dependencies": { @@ -5189,22 +6096,6 @@ "bser": "2.1.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "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", "dev": true, @@ -5284,7 +6175,7 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -5303,6 +6194,18 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -5342,13 +6245,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -5359,7 +6255,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5434,7 +6329,7 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", + "version": "4.10.1", "dev": true, "license": "MIT", "dependencies": { @@ -5474,6 +6369,26 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "14.0.0", "dev": true, @@ -5628,7 +6543,7 @@ "license": "MIT" }, "node_modules/htmlparser2": { - "version": "10.1.0", + "version": "10.0.0", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -5641,8 +6556,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" + "domutils": "^3.2.1", + "entities": "^6.0.0" } }, "node_modules/http-proxy-agent": { @@ -5690,7 +6605,7 @@ } }, "node_modules/ignore": { - "version": "5.3.2", + "version": "7.0.5", "dev": true, "license": "MIT", "engines": { @@ -5777,8 +6692,6 @@ }, "node_modules/is-arguments": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -5971,7 +6884,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5986,12 +6898,11 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.2", + "version": "1.1.0", "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -6259,7 +7170,7 @@ } }, "node_modules/istanbul-reports": { - "version": "3.2.0", + "version": "3.1.7", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6270,6 +7181,24 @@ "node": ">=8" } }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/jest": { "version": "29.7.0", "dev": true, @@ -6810,17 +7739,6 @@ "node": ">=8" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "dev": true, @@ -6900,7 +7818,7 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -6967,8 +7885,6 @@ }, "node_modules/json-bigint": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -7154,7 +8070,7 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.17.21", "dev": true, "license": "MIT" }, @@ -7168,6 +8084,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -7235,17 +8164,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "dev": true, @@ -7273,8 +8191,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { - "version": "10.2.3", + "version": "10.2.4", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -7312,7 +8238,7 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.3.4", + "version": "0.2.4", "dev": true, "license": "MIT", "bin": { @@ -7358,7 +8284,7 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.19", "dev": true, "license": "MIT" }, @@ -7382,10 +8308,20 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", + "version": "2.2.20", "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "dev": true, @@ -7424,6 +8360,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.fromentries": { "version": "2.0.8", "dev": true, @@ -7441,6 +8393,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.groupby": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.values": { "version": "1.2.1", "dev": true, @@ -7460,8 +8427,6 @@ }, "node_modules/on-exit-leak-free": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -7549,21 +8514,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "dev": true, @@ -7611,17 +8561,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -7657,11 +8596,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "version": "2.3.1", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7669,8 +8608,6 @@ }, "node_modules/pino": { "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", @@ -7691,8 +8628,6 @@ }, "node_modules/pino-abstract-transport": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", "license": "MIT", "dependencies": { "split2": "^4.0.0" @@ -7700,8 +8635,6 @@ }, "node_modules/pino-std-serializers": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, "node_modules/pirates": { @@ -7795,7 +8728,7 @@ } }, "node_modules/prettier": { - "version": "3.8.1", + "version": "3.6.0", "dev": true, "license": "MIT", "peer": true, @@ -7846,8 +8779,6 @@ }, "node_modules/process": { "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -7855,8 +8786,6 @@ }, "node_modules/process-warning": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", "funding": [ { "type": "github", @@ -7881,6 +8810,25 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/psl": { "version": "1.15.0", "dev": true, @@ -7941,8 +8889,6 @@ }, "node_modules/quick-format-unescaped": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, "node_modules/react-is": { @@ -7952,8 +8898,6 @@ }, "node_modules/real-require": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", "engines": { "node": ">= 12.13.0" @@ -8054,7 +8998,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8062,8 +9005,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8075,11 +9016,11 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", + "version": "1.22.10", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -8225,8 +9166,6 @@ }, "node_modules/safe-stable-stringify": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", "engines": { "node": ">=10" @@ -8421,8 +9360,6 @@ }, "node_modules/sonic-boom": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" @@ -8447,8 +9384,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" @@ -8512,7 +9447,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8523,11 +9457,6 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "dev": true, @@ -8541,6 +9470,45 @@ "node": ">= 0.4" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "dev": true, @@ -8596,7 +9564,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8622,9 +9589,12 @@ } }, "node_modules/strip-indent": { - "version": "4.1.1", + "version": "4.0.0", "dev": true, "license": "MIT", + "dependencies": { + "min-indent": "^1.0.1" + }, "engines": { "node": ">=12" }, @@ -8709,10 +9679,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/thread-stream": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" @@ -8733,6 +9721,33 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "dev": true, @@ -8902,18 +9917,52 @@ } } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=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/tsx": { - "version": "4.21.0", + "version": "4.20.3", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -8926,23 +9975,23 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=18" } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.3", + "version": "0.25.5", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8953,32 +10002,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/type-check": { @@ -9000,17 +10048,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "dev": true, @@ -9082,7 +10119,7 @@ } }, "node_modules/typescript": { - "version": "5.9.3", + "version": "5.8.3", "dev": true, "license": "Apache-2.0", "bin": { @@ -9145,7 +10182,7 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", + "version": "7.8.0", "dev": true, "license": "MIT" }, @@ -9158,40 +10195,40 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", + "version": "1.9.2", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.2.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.9.2", + "@unrs/resolver-binding-android-arm64": "1.9.2", + "@unrs/resolver-binding-darwin-arm64": "1.9.2", + "@unrs/resolver-binding-darwin-x64": "1.9.2", + "@unrs/resolver-binding-freebsd-x64": "1.9.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", + "@unrs/resolver-binding-linux-x64-musl": "1.9.2", + "@unrs/resolver-binding-wasm32-wasi": "1.9.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", + "version": "1.1.3", "dev": true, "funding": [ { @@ -9238,8 +10275,6 @@ }, "node_modules/util": { "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9251,8 +10286,6 @@ }, "node_modules/uuid": { "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -9442,7 +10475,7 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", + "version": "1.1.19", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -9475,7 +10508,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9507,7 +10539,7 @@ } }, "node_modules/ws": { - "version": "8.19.0", + "version": "8.18.2", "dev": true, "license": "MIT", "engines": { @@ -9549,7 +10581,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -9562,7 +10593,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -9579,7 +10609,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9625,7 +10654,12 @@ "name": "nhs-notify-client-callbacks-integration-tests", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.991.0" + "@aws-sdk/client-cloudwatch-logs": "^3.991.0", + "@aws-sdk/client-eventbridge": "^3.990.0", + "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sqs": "^3.990.0", + "@aws-sdk/client-sts": "^3.821.0", + "async-wait-until": "^2.0.12" }, "devDependencies": { "@aws-sdk/client-sqs": "^3.990.0", @@ -9636,6 +10670,35 @@ "jest": "^29.7.0", "typescript": "^5.8.2" } + }, + "tools/client-subscriptions-management": { + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", + "@nhs-notify-client-callbacks/models": "*", + "yargs": "^17.7.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.10.10", + "@types/yargs": "^17.0.24", + "eslint": "^9.27.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "tools/client-subscriptions-management/node_modules/@types/node": { + "version": "22.19.11", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "tools/client-subscriptions-management/node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index c031567..5fc917e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "devDependencies": { "@stylistic/eslint-plugin": "^3.1.0", + "@aws-sdk/client-s3": "^3.821.0", "@tsconfig/node22": "^22.0.2", "@types/jest": "^29.5.14", "@typescript-eslint/parser": "^8.56.1", @@ -35,7 +36,8 @@ "pretty-format": { "react-is": "19.0.0" }, - "minimatch": "^10.2.2" + "minimatch@^3.0.0": "3.1.5", + "minimatch": "10.2.4" }, "scripts": { "generate-dependencies": "npm run generate-dependencies --workspaces --if-present", @@ -45,12 +47,16 @@ "test:integration": "npm run test:integration --workspace tests/integration", "test:unit": "npm run test:unit --workspaces", "typecheck": "npm run typecheck --workspaces", - "verify": "npm run lint && npm run typecheck && npm run test:unit" + "verify": "npm run lint && npm run typecheck && npm run test:unit", + "subscriptions:get": "npm run get-by-client-id --workspace tools/client-subscriptions-management --", + "subscriptions:put-channel-status": "npm run put-channel-status --workspace tools/client-subscriptions-management --", + "subscriptions:put-message-status": "npm run put-message-status --workspace tools/client-subscriptions-management --" }, "workspaces": [ "lambdas/client-transform-filter-lambda", "src/models", "lambdas/mock-webhook-lambda", - "tests/integration" + "tests/integration", + "tools/client-subscriptions-management" ] } diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index be946be..6a2f270 100644 --- a/scripts/config/sonar-scanner.properties +++ b/scripts/config/sonar-scanner.properties @@ -5,5 +5,5 @@ sonar.qualitygate.wait=true sonar.sourceEncoding=UTF-8 sonar.terraform.provider.aws.version=5.54.1 sonar.cpd.exclusions=**.test.*, src/models/** -sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, src/models/**, **/jest.config.ts +sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, src/models/**, scripts/**/src/__tests__/**, tools/**/src/__tests__/**, **/jest.config.* sonar.javascript.lcov.reportPaths=lcov.info diff --git a/scripts/deploy_client_subscriptions.sh b/scripts/deploy_client_subscriptions.sh new file mode 100644 index 0000000..be8683d --- /dev/null +++ b/scripts/deploy_client_subscriptions.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat < \ + [--terraform-apply] \ + [--environment --group --project --tf-region ] \ + -- + +Examples: + # Message status subscription + ./scripts/deploy_client_subscriptions.sh \ + --subscription-type message \ + --terraform-apply \ + --environment dev \ + --group dev \ + -- \ + --bucket-name my-bucket \ + --client-name "Test Client" \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.com \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 + + # Channel status subscription + ./scripts/deploy_client_subscriptions.sh \ + --subscription-type channel \ + --terraform-apply \ + --environment dev \ + --group dev \ + -- \ + --bucket-name my-bucket \ + --client-name "Test Client" \ + --client-id client-123 \ + --channel-type NHSAPP \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses delivered failed \ + --api-endpoint https://webhook.example.com \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 +EOF +} + +subscription_type="" +terraform_apply="false" +environment="" +group="" +project="nhs" +tf_region="" +forward_args=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --subscription-type) + subscription_type="$2" + shift 2 + ;; + --terraform-apply) + terraform_apply="true" + shift + ;; + --environment) + environment="$2" + shift 2 + ;; + --group) + group="$2" + shift 2 + ;; + --project) + project="$2" + shift 2 + ;; + --tf-region) + tf_region="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + --) + shift + forward_args+=("$@") + break + ;; + *) + forward_args+=("$1") + shift + ;; + esac +done + +if [ -z "$subscription_type" ]; then + echo "Error: --subscription-type is required" + usage + exit 1 +fi + +if [ "$subscription_type" != "message" ] && [ "$subscription_type" != "channel" ]; then + echo "Error: --subscription-type must be 'message' or 'channel'" + usage + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +echo "[deploy-client-subscriptions] Uploading subscription config ($subscription_type)..." + +if [ "$subscription_type" = "message" ]; then + npm --workspace tools/client-subscriptions-management run put-message-status -- "${forward_args[@]}" +else + npm --workspace tools/client-subscriptions-management run put-channel-status -- "${forward_args[@]}" +fi + +if [ "$terraform_apply" = "true" ]; then + if [ -z "$environment" ] || [ -z "$group" ]; then + echo "Error: --environment and --group are required for terraform apply" + exit 1 + fi + + echo "[deploy-client-subscriptions] Running terraform apply for callbacks component..." + if [ -n "$tf_region" ]; then + make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" region="$tf_region" + else + make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" + fi +fi diff --git a/src/models/src/channel-types.ts b/src/models/src/channel-types.ts index d4526fb..d50c7c7 100644 --- a/src/models/src/channel-types.ts +++ b/src/models/src/channel-types.ts @@ -1 +1,3 @@ -export type Channel = "NHSAPP" | "EMAIL" | "SMS" | "LETTER"; +export const CHANNEL_TYPES = ["NHSAPP", "EMAIL", "SMS", "LETTER"] as const; + +export type Channel = (typeof CHANNEL_TYPES)[number]; diff --git a/src/models/src/client-config.ts b/src/models/src/client-config.ts index e3f5c56..6b10f90 100644 --- a/src/models/src/client-config.ts +++ b/src/models/src/client-config.ts @@ -1,3 +1,10 @@ +import type { Channel } from "./channel-types"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "./status-types"; + export type ClientSubscriptionConfiguration = ( | MessageStatusSubscriptionConfiguration | ChannelStatusSubscriptionConfiguration @@ -29,14 +36,16 @@ interface SubscriptionConfigurationBase { }[]; } -export interface MessageStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { +export interface MessageStatusSubscriptionConfiguration + extends SubscriptionConfigurationBase { SubscriptionType: "MessageStatus"; - Statuses: string[]; + Statuses: MessageStatus[]; } -export interface ChannelStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { +export interface ChannelStatusSubscriptionConfiguration + extends SubscriptionConfigurationBase { SubscriptionType: "ChannelStatus"; - ChannelType: string; - ChannelStatuses: string[]; - SupplierStatuses: string[]; + ChannelType: Channel; + ChannelStatuses: ChannelStatus[]; + SupplierStatuses: SupplierStatus[]; } diff --git a/src/models/src/index.ts b/src/models/src/index.ts index d26c42d..c01a868 100644 --- a/src/models/src/index.ts +++ b/src/models/src/index.ts @@ -1,4 +1,5 @@ export type { ChannelStatusData } from "./channel-status-data"; +export { CHANNEL_TYPES } from "./channel-types"; export type { Channel } from "./channel-types"; export type { CallbackItem, @@ -19,6 +20,11 @@ export type { MessageStatusData } from "./message-status-data"; export type { RoutingPlan } from "./routing-plan"; export { EventTypes } from "./status-publish-event"; export type { StatusPublishEvent } from "./status-publish-event"; +export { + CHANNEL_STATUSES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "./status-types"; export type { ChannelStatus, MessageStatus, diff --git a/src/models/src/status-types.ts b/src/models/src/status-types.ts index 2d2bfef..b2e9e5c 100644 --- a/src/models/src/status-types.ts +++ b/src/models/src/status-types.ts @@ -1,41 +1,50 @@ -export type MessageStatus = - | "FAILED" - | "PENDING_ENRICHMENT" - | "DELIVERED" - | "ENRICHED" - | "SENDING"; +export const MESSAGE_STATUSES = [ + "FAILED", + "PENDING_ENRICHMENT", + "DELIVERED", + "ENRICHED", + "SENDING", +] as const; -export type ChannelStatus = - | "ASSIGNING_BATCH" - | "CREATED" - | "SENDING" - | "DELIVERED" - | "FAILED" - | "RETRY" - | "SKIPPED" - | "STALE_PDS"; +export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; -export type SupplierStatus = - | "accepted" - | "cancelled" - | "created" - | "delivered" - | "dispatched" - | "enclosed" - | "failed" - | "forwarded" - | "pending" - | "printed" - | "read" - | "notification_attempted" - | "notified" - | "rejected" - | "returned" - | "sending" - | "sent" - | "received" - | "permanent_failure" - | "temporary_failure" - | "technical_failure" - | "unnotified" - | "unknown"; +export const CHANNEL_STATUSES = [ + "ASSIGNING_BATCH", + "CREATED", + "SENDING", + "DELIVERED", + "FAILED", + "RETRY", + "SKIPPED", + "STALE_PDS", +] as const; + +export type ChannelStatus = (typeof CHANNEL_STATUSES)[number]; + +export const SUPPLIER_STATUSES = [ + "accepted", + "cancelled", + "created", + "delivered", + "dispatched", + "enclosed", + "failed", + "forwarded", + "pending", + "printed", + "read", + "notification_attempted", + "notified", + "rejected", + "returned", + "sending", + "sent", + "received", + "permanent_failure", + "temporary_failure", + "technical_failure", + "unnotified", + "unknown", +] as const; + +export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number]; diff --git a/tests/integration/helpers/aws-helpers.ts b/tests/integration/helpers/aws-helpers.ts new file mode 100644 index 0000000..75d8f9c --- /dev/null +++ b/tests/integration/helpers/aws-helpers.ts @@ -0,0 +1,58 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +export type DeploymentDetails = { + region: string; + environment: string; + project: string; + component: string; + accountId: string; +}; + +/** + * Reads deployment context from environment variables + * + * Requires: AWS_REGION, PR_NUMBER, PROJECT, COMPONENT, AWS_ACCOUNT_ID + */ +export function getDeploymentDetails(): DeploymentDetails { + const region = process.env.AWS_REGION ?? "eu-west-2"; + const environment = process.env.PR_NUMBER; + const project = process.env.PROJECT; + const component = process.env.COMPONENT; + const accountId = process.env.AWS_ACCOUNT_ID; + + if (!environment) { + throw new Error("PR_NUMBER environment variable must be set"); + } + if (!project) { + throw new Error("PROJECT environment variable must be set"); + } + if (!component) { + throw new Error("COMPONENT environment variable must be set"); + } + if (!accountId) { + throw new Error("AWS_ACCOUNT_ID environment variable must be set"); + } + + return { region, environment, project, component, accountId }; +} + +/** + * Builds the subscription config S3 bucket name from deployment details. + */ +export function buildSubscriptionConfigBucketName({ + accountId, + component, + environment, + project, + region, +}: DeploymentDetails): string { + return `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; +} + +/** + * Creates an S3 client configured for the given region. + */ +export function createS3Client(): S3Client { + const region = process.env.AWS_REGION ?? "eu-west-2"; + return new S3Client({ region }); +} diff --git a/tests/integration/helpers/index.ts b/tests/integration/helpers/index.ts index b0718c3..e2b912e 100644 --- a/tests/integration/helpers/index.ts +++ b/tests/integration/helpers/index.ts @@ -1 +1,2 @@ +export * from "./aws-helpers"; export * from "./cloudwatch-helpers"; diff --git a/tests/integration/infrastructure-exists.test.ts b/tests/integration/infrastructure-exists.test.ts new file mode 100644 index 0000000..62a8186 --- /dev/null +++ b/tests/integration/infrastructure-exists.test.ts @@ -0,0 +1,30 @@ +import { HeadBucketCommand } from "@aws-sdk/client-s3"; +import type { S3Client } from "@aws-sdk/client-s3"; +import { + buildSubscriptionConfigBucketName, + createS3Client, + getDeploymentDetails, +} from "helpers"; + +describe("Infrastructure exists", () => { + let s3Client: S3Client; + let bucketName: string; + + beforeAll(async () => { + const deploymentDetails = getDeploymentDetails(); + bucketName = buildSubscriptionConfigBucketName(deploymentDetails); + s3Client = createS3Client(); + }); + + afterAll(() => { + s3Client?.destroy(); + }); + + it("should confirm the subscription config S3 bucket exists", async () => { + const response = await s3Client.send( + new HeadBucketCommand({ Bucket: bucketName }), + ); + + expect(response.$metadata.httpStatusCode).toBe(200); + }); +}); diff --git a/tests/integration/jest.config.ts b/tests/integration/jest.config.ts index 065243c..e52fedf 100644 --- a/tests/integration/jest.config.ts +++ b/tests/integration/jest.config.ts @@ -7,4 +7,7 @@ export default { ...(nodeJestConfig.coveragePathIgnorePatterns ?? []), "/helpers/", ], + moduleNameMapper: { + "^helpers$": "/helpers/index", + }, }; diff --git a/tests/integration/package.json b/tests/integration/package.json index c9e2212..def62d6 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -10,7 +10,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.991.0" + "@aws-sdk/client-cloudwatch-logs": "^3.991.0", + "@aws-sdk/client-eventbridge": "^3.990.0", + "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sts": "^3.821.0", + "@aws-sdk/client-sqs": "^3.990.0", + "async-wait-until": "^2.0.12" }, "devDependencies": { "@aws-sdk/client-sqs": "^3.990.0", diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json index c0ab68d..a5cc2b8 100644 --- a/tests/integration/tsconfig.json +++ b/tests/integration/tsconfig.json @@ -1,7 +1,12 @@ { "compilerOptions": { "baseUrl": ".", - "isolatedModules": true + "isolatedModules": true, + "paths": { + "helpers": [ + "./helpers/index" + ] + } }, "extends": "../../tsconfig.base.json", "include": [ diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md new file mode 100644 index 0000000..ef13a69 --- /dev/null +++ b/tools/client-subscriptions-management/README.md @@ -0,0 +1,65 @@ +# client-subscriptions-management + +TypeScript CLI utility for managing NHS Notify client subscription configuration in S3. + +## Usage + +From the repository root run: + +```bash +npm --workspace tools/client-subscriptions-management [options] +``` + +Set the bucket name via `--bucket-name` or the `CLIENT_SUBSCRIPTION_BUCKET_NAME` environment variable. + +Set the event source via `--event-source` or the `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable. This is **required** for `put-message-status` and `put-channel-status` commands. + +## Commands + +### Get Client Subscriptions By Client ID + +```bash +npm --workspace tools/client-subscriptions-management get-by-client-id \ + --bucket-name my-bucket \ + --client-id client-123 +``` + +### Put Message Status Subscription + +```bash +npm --workspace tools/client-subscriptions-management put-message-status \ + --bucket-name my-bucket \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.com \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 +``` + +Optional: `--client-name "Test Client"` (defaults to client-id if not provided) + +Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable + +### Put Channel Status Subscription + +```bash +npm --workspace tools/client-subscriptions-management put-channel-status \ + --bucket-name my-bucket \ + --client-id client-123 \ + --channel-type EMAIL \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses READ REJECTED \ + --api-endpoint https://webhook.example.com \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 +``` + +Optional: `--client-name "Test Client"` (defaults to client-id if not provided) + +Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable + +**Note**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided. diff --git a/tools/client-subscriptions-management/jest.config.ts b/tools/client-subscriptions-management/jest.config.ts new file mode 100644 index 0000000..679cd1c --- /dev/null +++ b/tools/client-subscriptions-management/jest.config.ts @@ -0,0 +1,20 @@ +import type { Config } from "jest"; + +const jestConfig: Config = { + preset: "ts-jest", + clearMocks: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "babel", + coveragePathIgnorePatterns: ["/__tests__/"], + transform: { "^.+\\.ts$": "ts-jest" }, + testPathIgnorePatterns: [String.raw`\.build`], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + testEnvironment: "node", + modulePaths: ["/src"], + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, +}; + +export default jestConfig; diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json new file mode 100644 index 0000000..33540bd --- /dev/null +++ b/tools/client-subscriptions-management/package.json @@ -0,0 +1,28 @@ +{ + "name": "client-subscriptions-management", + "version": "0.0.1", + "private": true, + "main": "src/index.ts", + "scripts": { + "get-by-client-id": "tsx ./src/entrypoint/cli/get-client-subscriptions.ts", + "put-channel-status": "tsx ./src/entrypoint/cli/put-channel-status.ts", + "put-message-status": "tsx ./src/entrypoint/cli/put-message-status.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", + "@nhs-notify-client-callbacks/models": "*", + "yargs": "^17.7.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.10.10", + "@types/yargs": "^17.0.24", + "eslint": "^9.27.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts new file mode 100644 index 0000000..8862c58 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts @@ -0,0 +1,142 @@ +const originalEventSource = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; +process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = "env-source"; + +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; + +afterAll(() => { + if (originalEventSource === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; + } else { + process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = originalEventSource; + } +}); + +describe("ClientSubscriptionConfigurationBuilder", () => { + it("builds message status subscription with default event source", () => { + const builder = new ClientSubscriptionConfigurationBuilder(); + + const result = builder.messageStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + apiKeyHeaderName: "x-api-key", + clientId: "client-1", + clientName: "Client One", + rateLimit: 10, + statuses: ["DELIVERED"], + dryRun: false, + }); + + expect(result).toMatchObject({ + Name: "client-one", + SubscriptionType: "MessageStatus", + ClientId: "client-1", + Statuses: ["DELIVERED"], + EventSource: JSON.stringify(["env-source"]), + }); + }); + + it("builds message status subscription with explicit event source", () => { + const builder = new ClientSubscriptionConfigurationBuilder( + "default-source", + ); + + const result = builder.messageStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + rateLimit: 10, + statuses: ["FAILED"], + dryRun: false, + eventSource: "explicit-source", + }); + + expect(result.EventSource).toBe(JSON.stringify(["explicit-source"])); + }); + + it("builds channel status subscription with explicit event source", () => { + const builder = new ClientSubscriptionConfigurationBuilder( + "default-source", + ); + + const result = builder.channelStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 20, + dryRun: false, + eventSource: "explicit-source", + }); + + expect(result).toMatchObject({ + Name: "client-one-SMS", + SubscriptionType: "ChannelStatus", + ClientId: "client-1", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + EventSource: JSON.stringify(["explicit-source"]), + }); + }); + + it("defaults channelStatuses and supplierStatuses to [] when not provided", () => { + const builder = new ClientSubscriptionConfigurationBuilder( + "default-source", + ); + + const result = builder.channelStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }); + + expect(result.ChannelStatuses).toEqual([]); + expect(result.SupplierStatuses).toEqual([]); + }); + + it("throws if no event source is available for messageStatus", () => { + const builder = new ClientSubscriptionConfigurationBuilder(""); + + expect(() => + builder.messageStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + rateLimit: 10, + statuses: ["DELIVERED"], + dryRun: false, + }), + ).toThrow( + "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", + ); + }); + + it("throws if no event source is available for channelStatus", () => { + const builder = new ClientSubscriptionConfigurationBuilder(""); + + expect(() => + builder.channelStatus({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 20, + dryRun: false, + }), + ).toThrow( + "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", + ); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts new file mode 100644 index 0000000..d13ee08 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts @@ -0,0 +1,388 @@ +import { z } from "zod"; +import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; +import type { S3Repository } from "src/repository/s3"; +import type { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; + +const createRepository = ( + overrides?: Partial<{ + getObject: jest.Mock; + putRawData: jest.Mock; + messageStatus: jest.Mock; + channelStatus: jest.Mock; + }>, +) => { + const s3Repository = { + getObject: overrides?.getObject ?? jest.fn(), + putRawData: overrides?.putRawData ?? jest.fn(), + } as unknown as S3Repository; + + const configurationBuilder = { + messageStatus: overrides?.messageStatus ?? jest.fn(), + channelStatus: overrides?.channelStatus ?? jest.fn(), + } as unknown as ClientSubscriptionConfigurationBuilder; + + const repository = new ClientSubscriptionRepository( + s3Repository, + configurationBuilder, + ); + + return { repository, s3Repository, configurationBuilder }; +}; + +describe("ClientSubscriptionRepository", () => { + const baseTarget: MessageStatusSubscriptionConfiguration["Targets"][number] = + { + Type: "API", + TargetId: "SendToWebhook", + Name: "client-1", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }; + + const messageSubscription: MessageStatusSubscriptionConfiguration = { + Name: "client-1", + SubscriptionType: "MessageStatus", + ClientId: "client-1", + Statuses: ["DELIVERED"], + Description: "Message subscription", + EventSource: "[]", + EventDetail: "{}", + Targets: [baseTarget], + }; + + const channelSubscription: ChannelStatusSubscriptionConfiguration = { + Name: "client-1-SMS", + SubscriptionType: "ChannelStatus", + ClientId: "client-1", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + Description: "Channel subscription", + EventSource: "[]", + EventDetail: "{}", + Targets: [baseTarget], + }; + + it("returns parsed subscriptions when file exists", async () => { + const storedConfig: ClientSubscriptionConfiguration = [messageSubscription]; + const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); + const { repository } = createRepository({ getObject }); + + const result = await repository.getClientSubscriptions("client-1"); + + expect(result).toEqual(storedConfig); + }); + + it("returns undefined when config file is missing", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const getObject = jest.fn().mockResolvedValue(undefined); + const { repository } = createRepository({ getObject }); + + await expect( + repository.getClientSubscriptions("client-1"), + ).resolves.toBeUndefined(); + }); + + it("replaces existing message subscription", async () => { + const storedConfig: ClientSubscriptionConfiguration = [ + channelSubscription, + messageSubscription, + ]; + const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); + const putRawData = jest.fn(); + const newMessage: MessageStatusSubscriptionConfiguration = { + ...messageSubscription, + Statuses: ["FAILED"], + }; + const messageStatus = jest.fn().mockReturnValue(newMessage); + + const { repository } = createRepository({ + getObject, + putRawData, + messageStatus, + }); + + const result = await repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["FAILED"], + rateLimit: 10, + dryRun: false, + }); + + expect(result).toEqual([channelSubscription, newMessage]); + expect(putRawData).toHaveBeenCalledWith( + JSON.stringify([channelSubscription, newMessage]), + "client_subscriptions/client-1.json", + ); + }); + + it("skips S3 write when dry run is enabled", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const messageStatus = jest.fn().mockReturnValue(messageSubscription); + + const { repository } = createRepository({ + getObject, + putRawData, + messageStatus, + }); + + await repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: true, + }); + + expect(putRawData).not.toHaveBeenCalled(); + }); + + it("replaces existing channel subscription for the channel type", async () => { + const storedConfig: ClientSubscriptionConfiguration = [ + channelSubscription, + messageSubscription, + ]; + const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig)); + const putRawData = jest.fn(); + const newChannel: ChannelStatusSubscriptionConfiguration = { + ...channelSubscription, + ChannelStatuses: ["FAILED"], + }; + const channelStatus = jest.fn().mockReturnValue(newChannel); + + const { repository } = createRepository({ + getObject, + putRawData, + channelStatus, + }); + + const result = await repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["FAILED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }); + + expect(result).toEqual([messageSubscription, newChannel]); + expect(putRawData).toHaveBeenCalledWith( + JSON.stringify([messageSubscription, newChannel]), + "client_subscriptions/client-1.json", + ); + }); + + it("skips S3 write for channel status dry run", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const getObject = jest.fn().mockResolvedValue(undefined); + const putRawData = jest.fn(); + const channelStatus = jest.fn().mockReturnValue(channelSubscription); + + const { repository } = createRepository({ + getObject, + putRawData, + channelStatus, + }); + + await repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: true, + }); + + expect(putRawData).not.toHaveBeenCalled(); + }); + + describe("validation", () => { + it("throws validation error for invalid message status", async () => { + const { repository } = createRepository(); + + await expect( + repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["INVALID_STATUS" as never], + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(z.ZodError); + }); + + it("throws validation error for missing required fields in message subscription", async () => { + const { repository } = createRepository(); + + await expect( + repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + // @ts-expect-error Testing missing field + statuses: undefined, + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(z.ZodError); + }); + + it("throws validation error for invalid channel type", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "INVALID_CHANNEL" as never, + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(z.ZodError); + }); + + it("throws validation error for invalid channel status", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["INVALID_STATUS" as never], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(z.ZodError); + }); + + it("throws validation error for invalid supplier status", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["INVALID_STATUS" as never], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow(z.ZodError); + }); + + it("throws validation error when neither channelStatuses nor supplierStatuses are provided", async () => { + const { repository } = createRepository(); + + await expect( + repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }), + ).rejects.toThrow( + /at least one of channelStatuses or supplierStatuses must be provided/, + ); + }); + + it("applies default value for apiKeyHeaderName on message subscription", async () => { + const getObject = jest.fn().mockResolvedValue(undefined as never); + const messageStatus = jest.fn().mockReturnValue(messageSubscription); + + const { configurationBuilder, repository } = createRepository({ + getObject, + messageStatus, + }); + + await repository.putMessageStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: false, + }); + + expect(configurationBuilder.messageStatus).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyHeaderName: "x-api-key", + }), + ); + }); + + it("applies default value for apiKeyHeaderName on channel subscription", async () => { + const getObject = jest.fn().mockResolvedValue(undefined as never); + const channelStatus = jest.fn().mockReturnValue(channelSubscription); + + const { configurationBuilder, repository } = createRepository({ + getObject, + channelStatus, + }); + + await repository.putChannelStatusSubscription({ + clientName: "Client 1", + clientId: "client-1", + apiKey: "secret", + apiEndpoint: "https://example.com/webhook", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + }); + + expect(configurationBuilder.channelStatus).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyHeaderName: "x-api-key", + }), + ); + }); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/constants.test.ts b/tools/client-subscriptions-management/src/__tests__/constants.test.ts new file mode 100644 index 0000000..9d53f53 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/constants.test.ts @@ -0,0 +1,28 @@ +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; + +describe("constants", () => { + it("exposes message statuses", () => { + expect(MESSAGE_STATUSES).toContain("DELIVERED"); + expect(MESSAGE_STATUSES).toContain("FAILED"); + }); + + it("exposes channel statuses", () => { + expect(CHANNEL_STATUSES).toContain("SENDING"); + expect(CHANNEL_STATUSES).toContain("SKIPPED"); + }); + + it("exposes supplier statuses", () => { + expect(SUPPLIER_STATUSES).toContain("delivered"); + expect(SUPPLIER_STATUSES).toContain("unknown"); + }); + + it("exposes channel types", () => { + expect(CHANNEL_TYPES).toContain("SMS"); + expect(CHANNEL_TYPES).toContain("EMAIL"); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts new file mode 100644 index 0000000..ec19865 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts @@ -0,0 +1,32 @@ +import { createS3Client } from "src/container"; + +describe("createS3Client", () => { + it("sets forcePathStyle=true when endpoint contains localhost", () => { + const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; + const client = createS3Client("eu-west-2", env); + + // Access the config through the client's config property + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + expect(config.forcePathStyle).toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint does not contain localhost", () => { + const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" }; + const client = createS3Client("eu-west-2", env); + + const { config } = client as any; + expect(config.endpoint).toBeDefined(); + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); + + it("does not set forcePathStyle=true when endpoint is not set", () => { + const env = {}; + const client = createS3Client("eu-west-2", env); + + const { config } = client as any; + // S3Client converts undefined to false, so we just check it's not true + expect(config.forcePathStyle).not.toBe(true); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts new file mode 100644 index 0000000..108697e --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -0,0 +1,40 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +const mockS3Repository = jest.fn(); +const mockBuilder = jest.fn(); +const mockRepository = jest.fn(); + +jest.mock("src/repository/s3", () => ({ + S3Repository: mockS3Repository, +})); + +jest.mock("src/domain/client-subscription-builder", () => ({ + ClientSubscriptionConfigurationBuilder: mockBuilder, +})); + +jest.mock("src/repository/client-subscriptions", () => ({ + ClientSubscriptionRepository: mockRepository, +})); + +import { createClientSubscriptionRepository } from "src/container"; + +describe("createClientSubscriptionRepository", () => { + it("creates repository with provided options", () => { + const repoInstance = { repo: true }; + mockRepository.mockReturnValue(repoInstance); + + const result = createClientSubscriptionRepository({ + bucketName: "bucket-1", + region: "eu-west-2", + eventSource: "event-source", + }); + + expect(mockS3Repository).toHaveBeenCalledWith( + "bucket-1", + expect.any(S3Client), + ); + expect(mockBuilder).toHaveBeenCalledWith("event-source"); + expect(mockRepository).toHaveBeenCalledTimes(1); + expect(result).toBe(repoInstance); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts new file mode 100644 index 0000000..39d838c --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts @@ -0,0 +1,175 @@ +const mockGetClientSubscriptions = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + getClientSubscriptions: mockGetClientSubscriptions, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/get-client-subscriptions"; + +describe("get-client-subscriptions CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockGetClientSubscriptions.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("prints formatted config when subscription exists", async () => { + mockGetClientSubscriptions.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(mockCreateRepository).toHaveBeenCalled(); + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1"); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); + + it("prints message when no configuration exists", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.log).toHaveBeenCalledWith( + "No configuration exists for client: client-1", + ); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockImplementation(() => { + throw new Error("Boom"); + }); + + await cli.runCli([ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ], + true, + ); + + expect(runCliSpy).toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("does not execute when not main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("uses process.argv when no args are provided", async () => { + process.argv = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + ]; + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.runCli(); + + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1"); + }); + + it("uses default args in main when none are provided", async () => { + process.argv = [ + "node", + "script", + "--client-id", + "client-2", + "--bucket-name", + "bucket-2", + ]; + // eslint-disable-next-line unicorn/no-useless-undefined + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.main(); + + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-2"); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/helper.test.ts new file mode 100644 index 0000000..b7f9145 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/helper.test.ts @@ -0,0 +1,174 @@ +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; +import { + formatSubscriptionFileResponse, + normalizeClientName, + resolveBucketName, + resolveEventSource, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +describe("cli helper", () => { + const messageSubscription: MessageStatusSubscriptionConfiguration = { + Name: "client-a", + SubscriptionType: "MessageStatus", + ClientId: "client-a", + Statuses: ["DELIVERED"], + Description: "Message subscription", + EventSource: '["source-a"]', + EventDetail: "{}", + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: "client-a", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + }; + + const channelSubscription: ChannelStatusSubscriptionConfiguration = { + Name: "client-a-sms", + SubscriptionType: "ChannelStatus", + ClientId: "client-a", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + Description: "Channel subscription", + EventSource: '["source-a"]', + EventDetail: "{}", + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: "client-a-sms", + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 20, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + }; + + it("formats subscription output", () => { + const config: ClientSubscriptionConfiguration = [ + messageSubscription, + channelSubscription, + ]; + + const result = formatSubscriptionFileResponse(config); + + expect(result).toEqual([ + { + clientId: "client-a", + subscriptionType: "MessageStatus", + statuses: ["DELIVERED"], + clientApiEndpoint: "https://example.com/webhook", + clientApiKey: "secret", + rateLimit: 10, + }, + { + clientId: "client-a", + subscriptionType: "ChannelStatus", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + clientApiEndpoint: "https://example.com/webhook", + clientApiKey: "secret", + rateLimit: 20, + }, + ]); + }); + + it("normalizes client name", () => { + expect(normalizeClientName("My Client Name")).toBe("my-client-name"); + }); + + it("resolves bucket name from argument", () => { + expect(resolveBucketName("bucket-1")).toBe("bucket-1"); + }); + + it("resolves bucket name from env", () => { + expect( + resolveBucketName(undefined, { + CLIENT_SUBSCRIPTION_BUCKET_NAME: "bucket-env", + } as NodeJS.ProcessEnv), + ).toBe("bucket-env"); + }); + + it("throws when bucket name is missing", () => { + expect(() => resolveBucketName(undefined, {} as NodeJS.ProcessEnv)).toThrow( + "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)", + ); + }); + + it("resolves event source from argument", () => { + expect(resolveEventSource("my-source")).toBe("my-source"); + }); + + it("resolves event source from env", () => { + expect( + resolveEventSource(undefined, { + CLIENT_SUBSCRIPTION_EVENT_SOURCE: "env-source", + } as NodeJS.ProcessEnv), + ).toBe("env-source"); + }); + + it("throws when event source is missing", () => { + expect(() => + resolveEventSource(undefined, {} as NodeJS.ProcessEnv), + ).toThrow( + "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)", + ); + }); + + it("resolves region from argument", () => { + expect(resolveRegion("eu-west-2")).toBe("eu-west-2"); + }); + + it("resolves region from AWS_REGION", () => { + expect( + resolveRegion(undefined, { + AWS_REGION: "eu-west-1", + } as NodeJS.ProcessEnv), + ).toBe("eu-west-1"); + }); + + it("resolves region from AWS_DEFAULT_REGION", () => { + expect( + resolveRegion(undefined, { + AWS_DEFAULT_REGION: "eu-west-3", + } as NodeJS.ProcessEnv), + ).toBe("eu-west-3"); + }); + + it("returns undefined when region is not set", () => { + expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts new file mode 100644 index 0000000..b4423ba --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts @@ -0,0 +1,385 @@ +const mockPutChannelStatusSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + putChannelStatusSubscription: mockPutChannelStatusSubscription, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveRegion = jest.fn().mockReturnValue("region"); +const mockResolveEventSource = jest.fn().mockReturnValue("source-a"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveEventSource: mockResolveEventSource, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/put-channel-status"; + +describe("put-channel-status CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockPutChannelStatusSubscription.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + mockResolveEventSource.mockReset(); + mockResolveEventSource.mockReturnValue("source-a"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("rejects non-https endpoints", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: api-endpoint must start with https://", + ); + expect(process.exitCode).toBe(1); + expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled(); + }); + + it("rejects when neither channel-statuses nor supplier-statuses are provided", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + expect(process.exitCode).toBe(1); + expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled(); + }); + + it("writes channel subscription and logs response", async () => { + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + "--event-source", + "source-a", + "--api-key-header-name", + "x-api-key", + ]); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalledWith({ + clientName: "Client One", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + channelType: "SMS", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + rateLimit: 10, + dryRun: false, + eventSource: "source-a", + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockImplementation(() => { + throw new Error("Boom"); + }); + + await cli.runCli([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + true, + ); + + expect(runCliSpy).toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("does not execute when not main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("uses process.argv when no args are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.runCli(); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalled(); + }); + + it("uses default args in main when none are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client Two", + "--client-id", + "client-2", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main(); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalled(); + }); + + it("defaults client-name to client-id when not provided", async () => { + mockPutChannelStatusSubscription.mockResolvedValue([ + { SubscriptionType: "ChannelStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "delivered", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(mockPutChannelStatusSubscription).toHaveBeenCalledWith({ + clientName: "client-1", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 10, + dryRun: false, + eventSource: "source-a", + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts new file mode 100644 index 0000000..007ddb4 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts @@ -0,0 +1,320 @@ +const mockPutMessageStatusSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + putMessageStatusSubscription: mockPutMessageStatusSubscription, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveRegion = jest.fn().mockReturnValue("region"); +const mockResolveEventSource = jest.fn().mockReturnValue("source-a"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveEventSource: mockResolveEventSource, + resolveRegion: mockResolveRegion, +})); + +import * as cli from "src/entrypoint/cli/put-message-status"; + +describe("put-message-status CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockPutMessageStatusSubscription.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + mockResolveEventSource.mockReset(); + mockResolveEventSource.mockReturnValue("source-a"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("rejects non-https endpoints", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: api-endpoint must start with https://", + ); + expect(process.exitCode).toBe(1); + expect(mockPutMessageStatusSubscription).not.toHaveBeenCalled(); + }); + + it("writes subscription and logs response", async () => { + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + "--event-source", + "source-a", + "--api-key-header-name", + "x-api-key", + ]); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalledWith({ + clientName: "Client One", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: false, + eventSource: "source-a", + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); + + it("handles errors in runCli", async () => { + mockResolveBucketName.mockImplementation(() => { + throw new Error("Boom"); + }); + + await cli.runCli([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith(new Error("Boom")); + expect(process.exitCode).toBe(1); + }); + + it("executes when run as main module", async () => { + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + true, + ); + + expect(runCliSpy).toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("does not execute when not main module", async () => { + const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue(); + + await cli.runIfMain( + [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ], + false, + ); + + expect(runCliSpy).not.toHaveBeenCalled(); + runCliSpy.mockRestore(); + }); + + it("uses process.argv when no args are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.runCli(); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalled(); + }); + + it("uses default args in main when none are provided", async () => { + process.argv = [ + "node", + "script", + "--client-name", + "Client Two", + "--client-id", + "client-2", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]; + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main(); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalled(); + }); + + it("defaults client-name to client-id when not provided", async () => { + mockPutMessageStatusSubscription.mockResolvedValue([ + { SubscriptionType: "MessageStatus" }, + ]); + mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]); + + await cli.main([ + "node", + "script", + "--client-id", + "client-1", + "--api-endpoint", + "https://example.com", + "--api-key", + "secret", + "--message-statuses", + "DELIVERED", + "--rate-limit", + "10", + "--dry-run", + "false", + "--bucket-name", + "bucket-1", + ]); + + expect(mockPutMessageStatusSubscription).toHaveBeenCalledWith({ + clientName: "client-1", + clientId: "client-1", + apiEndpoint: "https://example.com", + apiKeyHeaderName: "x-api-key", + apiKey: "secret", + statuses: ["DELIVERED"], + rateLimit: 10, + dryRun: false, + eventSource: "source-a", + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/s3.test.ts b/tools/client-subscriptions-management/src/__tests__/s3.test.ts new file mode 100644 index 0000000..04a9037 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/s3.test.ts @@ -0,0 +1,68 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { S3Repository } from "src/repository/s3"; + +describe("S3Repository", () => { + it("returns string content from S3", async () => { + const send = jest.fn().mockResolvedValue({ + Body: { transformToString: jest.fn().mockResolvedValue("content") }, + }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("throws when body is missing", async () => { + const send = jest.fn().mockResolvedValue({}); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response body is missing", + ); + }); + + it("returns undefined when object is missing", async () => { + const send = jest + .fn() + .mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).resolves.toBeUndefined(); + }); + + it("rethrows non-NoSuchKey errors", async () => { + const send = jest.fn().mockRejectedValue(new Error("Denied")); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow("Denied"); + }); + + it("writes object to S3", async () => { + const send = jest.fn().mockResolvedValue({}); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await repository.putRawData("payload", "key.json"); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand); + }); +}); diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts new file mode 100644 index 0000000..93488be --- /dev/null +++ b/tools/client-subscriptions-management/src/container.ts @@ -0,0 +1,32 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import { S3Repository } from "src/repository/s3"; +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; + +type RepositoryOptions = { + bucketName: string; + region?: string; + eventSource?: string; +}; + +export const createS3Client = ( + region?: string, + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + return new S3Client({ region, endpoint, forcePathStyle }); +}; + +export const createClientSubscriptionRepository = ( + options: RepositoryOptions, +): ClientSubscriptionRepository => { + const s3Repository = new S3Repository( + options.bucketName, + createS3Client(options.region), + ); + const configurationBuilder = new ClientSubscriptionConfigurationBuilder( + options.eventSource, + ); + return new ClientSubscriptionRepository(s3Repository, configurationBuilder); +}; diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts new file mode 100644 index 0000000..2d2c0b1 --- /dev/null +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -0,0 +1,131 @@ +import { normalizeClientName } from "src/entrypoint/cli/helper"; +import type { + ChannelStatusSubscriptionArgs, + MessageStatusSubscriptionArgs, +} from "src/repository/client-subscriptions"; +import type { + ChannelStatusSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; + +const DEFAULT_EVENT_SOURCE = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; + +// eslint-disable-next-line import-x/prefer-default-export +export class ClientSubscriptionConfigurationBuilder { + constructor( + private readonly eventSource: string | undefined = DEFAULT_EVENT_SOURCE, + ) {} + + private resolveEventSource(override?: string): string { + const source = override ?? this.eventSource; + if (!source) { + throw new Error( + "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.", + ); + } + return source; + } + + messageStatus( + args: MessageStatusSubscriptionArgs, + ): MessageStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + clientId, + clientName, + eventSource, + rateLimit, + statuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + return { + Name: normalizedClientName, + SubscriptionType: "MessageStatus", + ClientId: clientId, + Statuses: statuses, + Description: `Message Status Subscription for ${clientName}`, + EventSource: JSON.stringify([this.resolveEventSource(eventSource)]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["MessageStatus"], + }), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: normalizedClientName, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; + } + + channelStatus( + args: ChannelStatusSubscriptionArgs, + ): ChannelStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + channelStatuses, + channelType, + clientId, + clientName, + eventSource, + rateLimit, + supplierStatuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + return { + Name: `${normalizedClientName}-${channelType}`, + SubscriptionType: "ChannelStatus", + ClientId: clientId, + ChannelType: channelType, + ChannelStatuses: channelStatuses ?? [], + SupplierStatuses: supplierStatuses ?? [], + Description: `Channel Status Subscription for ${clientName} - ${channelType}`, + EventSource: JSON.stringify([this.resolveEventSource(eventSource)]), + EventDetail: JSON.stringify({ + clientId: [clientId], + type: ["ChannelStatus"], + channel: [channelType], + }), + Targets: [ + { + Type: "API", + TargetId: "SendToWebhook", + Name: `${normalizedClientName}-${channelType}`, + InputTransformer: { + InputPaths: "$.detail.event", + InputHeaders: { + "x-hmac-sha256-signature": + "$.detail.headers.x-hmac-sha256-signature", + }, + }, + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; + } +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts new file mode 100644 index 0000000..5ec837a --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts @@ -0,0 +1,67 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + }, + "client-id": { + type: "string", + demandOption: true, + }, + region: { + type: "string", + demandOption: false, + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const bucketName = resolveBucketName(argv["bucket-name"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + }); + + const result = await clientSubscriptionRepository.getClientSubscriptions( + argv["client-id"], + ); + + if (result) { + console.log(formatSubscriptionFileResponse(result)); + } else { + console.log(`No configuration exists for client: ${argv["client-id"]}`); + } +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +}; + +export const runIfMain = async ( + args: string[] = process.argv, + isMain: boolean = require.main === module, +) => { + if (isMain) { + await runCli(args); + } +}; + +(async () => { + await runIfMain(); +})(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts new file mode 100644 index 0000000..e3cdf8e --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -0,0 +1,58 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; + +export const formatSubscriptionFileResponse = ( + subscriptions: ClientSubscriptionConfiguration, +) => + subscriptions.map((subscription) => ({ + clientId: subscription.ClientId, + subscriptionType: subscription.SubscriptionType, + ...(subscription.SubscriptionType === "ChannelStatus" + ? { + channelType: subscription.ChannelType, + channelStatuses: subscription.ChannelStatuses, + supplierStatuses: subscription.SupplierStatuses, + } + : {}), + ...(subscription.SubscriptionType === "MessageStatus" + ? { + statuses: subscription.Statuses, + } + : {}), + clientApiEndpoint: subscription.Targets[0].InvocationEndpoint, + clientApiKey: subscription.Targets[0].APIKey.HeaderValue, + rateLimit: subscription.Targets[0].InvocationRateLimit, + })); + +export const normalizeClientName = (name: string): string => + name.replaceAll(/\s+/g, "-").toLowerCase(); + +export const resolveBucketName = ( + bucketArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string => { + const bucketName = bucketArg ?? env.CLIENT_SUBSCRIPTION_BUCKET_NAME; + if (!bucketName) { + throw new Error( + "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)", + ); + } + return bucketName; +}; + +export const resolveEventSource = ( + eventSourceArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string => { + const eventSource = eventSourceArg ?? env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; + if (!eventSource) { + throw new Error( + "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)", + ); + } + return eventSource; +}; + +export const resolveRegion = ( + regionArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts new file mode 100644 index 0000000..d4472e7 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -0,0 +1,145 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveEventSource, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + }, + "client-name": { + type: "string", + demandOption: false, + }, + "client-id": { + type: "string", + demandOption: true, + }, + "api-endpoint": { + type: "string", + demandOption: true, + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + }, + "api-key": { + type: "string", + demandOption: true, + }, + "channel-statuses": { + string: true, + type: "array", + demandOption: false, + choices: CHANNEL_STATUSES, + }, + "supplier-statuses": { + string: true, + type: "array", + demandOption: false, + choices: SUPPLIER_STATUSES, + }, + "channel-type": { + type: "string", + demandOption: true, + choices: CHANNEL_TYPES, + }, + "rate-limit": { + type: "number", + demandOption: true, + }, + "dry-run": { + type: "boolean", + demandOption: true, + }, + region: { + type: "string", + demandOption: false, + }, + "event-source": { + type: "string", + demandOption: false, + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const channelStatuses = argv["channel-statuses"]; + const supplierStatuses = argv["supplier-statuses"]; + if (!channelStatuses?.length && !supplierStatuses?.length) { + console.error( + "Error: at least one of --channel-statuses or --supplier-statuses must be provided", + ); + process.exitCode = 1; + return; + } + + const bucketName = resolveBucketName(argv["bucket-name"]); + const eventSource = resolveEventSource(argv["event-source"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + eventSource, + }); + + const result = + await clientSubscriptionRepository.putChannelStatusSubscription({ + clientName: argv["client-name"] ?? argv["client-id"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + channelType: argv["channel-type"], + channelStatuses, + supplierStatuses, + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource, + }); + + console.log(formatSubscriptionFileResponse(result)); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +}; + +export const runIfMain = async ( + args: string[] = process.argv, + isMain: boolean = require.main === module, +) => { + if (isMain) { + await runCli(args); + } +}; + +(async () => { + await runIfMain(); +})(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts new file mode 100644 index 0000000..5e12aa1 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -0,0 +1,118 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { MESSAGE_STATUSES } from "@nhs-notify-client-callbacks/models"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveEventSource, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + }, + "client-name": { + type: "string", + demandOption: false, + }, + "client-id": { + type: "string", + demandOption: true, + }, + "api-endpoint": { + type: "string", + demandOption: true, + }, + "api-key": { + type: "string", + demandOption: true, + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + }, + "message-statuses": { + string: true, + type: "array", + demandOption: true, + choices: MESSAGE_STATUSES, + }, + "rate-limit": { + type: "number", + demandOption: true, + }, + "dry-run": { + type: "boolean", + demandOption: true, + }, + region: { + type: "string", + demandOption: false, + }, + "event-source": { + type: "string", + demandOption: false, + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const bucketName = resolveBucketName(argv["bucket-name"]); + const eventSource = resolveEventSource(argv["event-source"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + eventSource, + }); + + const result = + await clientSubscriptionRepository.putMessageStatusSubscription({ + clientName: argv["client-name"] ?? argv["client-id"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + statuses: argv["message-statuses"], + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource, + }); + + console.log(formatSubscriptionFileResponse(result)); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +}; + +export const runIfMain = async ( + args: string[] = process.argv, + isMain: boolean = require.main === module, +) => { + if (isMain) { + await runCli(args); + } +}; + +(async () => { + await runIfMain(); +})(); diff --git a/tools/client-subscriptions-management/src/index.ts b/tools/client-subscriptions-management/src/index.ts new file mode 100644 index 0000000..bec05b8 --- /dev/null +++ b/tools/client-subscriptions-management/src/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import-x/prefer-default-export +export { createClientSubscriptionRepository } from "src/container"; diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts new file mode 100644 index 0000000..2132bf0 --- /dev/null +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + type Channel, + type ChannelStatus, + type ClientSubscriptionConfiguration, + MESSAGE_STATUSES, + type MessageStatus, + SUPPLIER_STATUSES, + type SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; +import { S3Repository } from "src/repository/s3"; + +export type MessageStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + statuses: MessageStatus[]; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: string; + eventSource?: string; +}; + +const messageStatusSubscriptionArgsSchema = z.object({ + clientName: z.string(), + clientId: z.string(), + apiKey: z.string(), + apiEndpoint: z.string(), + statuses: z.array(z.enum(MESSAGE_STATUSES)), + rateLimit: z.number(), + dryRun: z.boolean(), + apiKeyHeaderName: z.string().optional().default("x-api-key"), + eventSource: z.string().optional(), +}); + +export type ChannelStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + channelStatuses?: ChannelStatus[]; + supplierStatuses?: SupplierStatus[]; + channelType: Channel; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: string; + eventSource?: string; +}; + +const channelStatusSubscriptionArgsSchema = z.object({ + clientName: z.string(), + clientId: z.string(), + apiKey: z.string(), + apiEndpoint: z.string(), + channelStatuses: z.array(z.enum(CHANNEL_STATUSES)).min(1).optional(), + supplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)).min(1).optional(), + channelType: z.enum(CHANNEL_TYPES), + rateLimit: z.number(), + dryRun: z.boolean(), + apiKeyHeaderName: z.string().optional().default("x-api-key"), + eventSource: z.string().optional(), +}); + +export class ClientSubscriptionRepository { + constructor( + private readonly s3Repository: S3Repository, + private readonly configurationBuilder: ClientSubscriptionConfigurationBuilder, + ) {} + + async getClientSubscriptions( + clientId: string, + ): Promise { + const rawFile = await this.s3Repository.getObject( + `client_subscriptions/${clientId}.json`, + ); + + if (rawFile !== undefined) { + return JSON.parse(rawFile) as unknown as ClientSubscriptionConfiguration; + } + return undefined; + } + + async putMessageStatusSubscription( + subscriptionArgs: MessageStatusSubscriptionArgs, + ) { + const parsedSubscriptionArgs = + messageStatusSubscriptionArgsSchema.parse(subscriptionArgs); + + const { clientId } = parsedSubscriptionArgs; + const subscriptions = (await this.getClientSubscriptions(clientId)) ?? []; + + const indexOfMessageStatusSubscription = subscriptions.findIndex( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + + if (indexOfMessageStatusSubscription !== -1) { + subscriptions.splice(indexOfMessageStatusSubscription, 1); + } + + const messageStatusConfig = this.configurationBuilder.messageStatus( + parsedSubscriptionArgs, + ); + + const newConfigFile: ClientSubscriptionConfiguration = [ + ...subscriptions, + messageStatusConfig, + ]; + + if (!parsedSubscriptionArgs.dryRun) { + await this.s3Repository.putRawData( + JSON.stringify(newConfigFile), + `client_subscriptions/${clientId}.json`, + ); + } + + return newConfigFile; + } + + async putChannelStatusSubscription( + subscriptionArgs: ChannelStatusSubscriptionArgs, + ): Promise { + const parsedSubscriptionArgs = + channelStatusSubscriptionArgsSchema.parse(subscriptionArgs); + + if ( + !parsedSubscriptionArgs.channelStatuses?.length && + !parsedSubscriptionArgs.supplierStatuses?.length + ) { + throw new Error( + "Validation failed: at least one of channelStatuses or supplierStatuses must be provided", + ); + } + + const { clientId } = parsedSubscriptionArgs; + const subscriptions = (await this.getClientSubscriptions(clientId)) ?? []; + + const indexOfChannelStatusSubscription = subscriptions.findIndex( + (subscription) => + subscription.SubscriptionType === "ChannelStatus" && + subscription.ChannelType === parsedSubscriptionArgs.channelType, + ); + + if (indexOfChannelStatusSubscription !== -1) { + subscriptions.splice(indexOfChannelStatusSubscription, 1); + } + + const channelStatusConfig = this.configurationBuilder.channelStatus( + parsedSubscriptionArgs, + ); + + const newConfigFile: ClientSubscriptionConfiguration = [ + ...subscriptions, + channelStatusConfig, + ]; + + if (!parsedSubscriptionArgs.dryRun) { + await this.s3Repository.putRawData( + JSON.stringify(newConfigFile), + `client_subscriptions/${clientId}.json`, + ); + } + + return newConfigFile; + } +} diff --git a/tools/client-subscriptions-management/src/repository/s3.ts b/tools/client-subscriptions-management/src/repository/s3.ts new file mode 100644 index 0000000..a306298 --- /dev/null +++ b/tools/client-subscriptions-management/src/repository/s3.ts @@ -0,0 +1,49 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + PutObjectCommandInput, + S3Client, +} from "@aws-sdk/client-s3"; + +// eslint-disable-next-line import-x/prefer-default-export +export class S3Repository { + constructor( + private readonly bucketName: string, + private readonly s3Client: S3Client, + ) {} + + async getObject(key: string): Promise { + const params = { + Bucket: this.bucketName, + Key: key, + }; + try { + const { Body } = await this.s3Client.send(new GetObjectCommand(params)); + + if (!Body) { + throw new Error("Response body is missing"); + } + + return await Body.transformToString(); + } catch (error) { + if (error instanceof NoSuchKey) { + return undefined; + } + throw error; + } + } + + async putRawData( + fileData: PutObjectCommandInput["Body"], + key: string, + ): Promise { + const params = { + Bucket: this.bucketName, + Key: key, + Body: fileData, + }; + + await this.s3Client.send(new PutObjectCommand(params)); + } +} diff --git a/tools/client-subscriptions-management/tsconfig.json b/tools/client-subscriptions-management/tsconfig.json new file mode 100644 index 0000000..5b308bc --- /dev/null +++ b/tools/client-subscriptions-management/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "paths": { + "src/*": [ + "src/*" + ] + }, + "rootDir": "." + }, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42cdff9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "noEmit": true + }, + "extends": "./tsconfig.base.json", + "include": [ + "lambdas/*/src/**/*", + "scripts/*/src/**/*", + "tools/*/src/**/*", + "src/**/*", + "tests/**/*" + ] +}