diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml new file mode 100644 index 0000000..922232e --- /dev/null +++ b/.github/actions/acceptance-tests/action.yaml @@ -0,0 +1,54 @@ +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 environment variables" + shell: bash + run: | + echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV + echo "ENVIRONMENT=${{ 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..61b5183 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -101,6 +101,7 @@ export default defineConfig([ }, ], "unicorn/no-null": 0, + "unicorn/no-useless-undefined": 0, "unicorn/prefer-module": 0, "unicorn/import-style": [ 2, @@ -239,6 +240,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/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.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts new file mode 100644 index 0000000..b8851d5 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts @@ -0,0 +1,239 @@ +/** + * Component 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) => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: clientId, + Targets: [ + { + Type: "API", + TargetId: "00000000-0000-4000-8000-000000000001", + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: ["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.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 13de95b..bda75a6 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,64 @@ 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 ".."; +jest.mock("aws-embedded-metrics"); + +const createPassthroughConfigLoader = (): ConfigLoader => + ({ + loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [ + { + SubscriptionType: "MessageStatus", + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: clientId, + Targets: [], + MessageStatuses: [ + "DELIVERED", + "FAILED", + "PENDING", + "SENDING", + "TECHNICAL_FAILURE", + "PERMANENT_FAILURE", + ], + }, + { + SubscriptionType: "ChannelStatus", + SubscriptionId: "00000000-0000-0000-0000-000000000002", + ClientId: clientId, + Targets: [], + ChannelType: "NHSAPP", + ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"], + SupplierStatuses: [ + "delivered", + "permanent_failure", + "temporary_failure", + ], + }, + { + SubscriptionType: "ChannelStatus", + SubscriptionId: "00000000-0000-0000-0000-000000000003", + ClientId: clientId, + 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 +84,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 +95,7 @@ describe("Lambda handler", () => { const handler = createHandler({ createObservabilityService: () => new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), + createConfigLoaderService: makeStubConfigLoaderService, }); beforeEach(() => { @@ -174,7 +232,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 +318,60 @@ 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), + 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 +504,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 +524,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..cab5893 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -0,0 +1,77 @@ +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 = [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: "client-1", + Targets: [], + SubscriptionType: "MessageStatus" as const, + MessageStatuses: ["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 = [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: "client-1", + Targets: [], + SubscriptionType: "MessageStatus" as const, + MessageStatuses: ["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 = [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: "client-1", + Targets: [], + SubscriptionType: "MessageStatus" as const, + MessageStatuses: ["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..a5741d2 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts @@ -0,0 +1,169 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigLoader } from "services/config-loader"; +import { + ConfigLoaderService, + createS3Client, + resolveCacheTtlMs, +} from "services/config-loader-service"; + +const mockS3Client = jest.mocked(S3Client); +const mockConfigLoader = jest.mocked(ConfigLoader); + +jest.mock("@aws-sdk/client-s3", () => ({ + S3Client: jest.fn(), +})); + +jest.mock("services/config-loader", () => ({ + ConfigLoader: jest.fn(), +})); + +describe("ConfigLoaderService", () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + + beforeEach(() => { + mockConfigLoader.mockClear(); + 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; + } + + if (originalPrefix === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix; + } + }); + + 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", + ); + }); + + it("uses the default key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is not set", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + const service = new ConfigLoaderService(); + service.getLoader(); + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ keyPrefix: "client_subscriptions/" }), + ); + }); + + it("uses the configured key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is set", () => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + const service = new ConfigLoaderService(); + service.getLoader(); + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ keyPrefix: "custom_prefix/" }), + ); + }); + }); + + 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(); + service.reset(customClient); + expect(mockConfigLoader).toHaveBeenCalledWith( + expect.objectContaining({ keyPrefix: "custom_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", + ); + }); + }); +}); + +describe("createS3Client", () => { + beforeEach(() => { + mockS3Client.mockClear(); + }); + + it("sets forcePathStyle=true when endpoint contains localhost", () => { + createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }); + + expect(mockS3Client).toHaveBeenCalledWith({ + endpoint: "http://localhost:4566", + forcePathStyle: true, + }); + }); + + it("does not set forcePathStyle when endpoint does not contain localhost", () => { + createS3Client({ AWS_ENDPOINT_URL: "https://custom-s3.example.com" }); + + expect(mockS3Client).toHaveBeenCalledWith({ + endpoint: "https://custom-s3.example.com", + forcePathStyle: undefined, + }); + }); + + it("does not set forcePathStyle when endpoint is not set", () => { + createS3Client({}); + + expect(mockS3Client).toHaveBeenCalledWith({ + endpoint: undefined, + forcePathStyle: undefined, + }); + }); +}); + +describe("resolveCacheTtlMs", () => { + it("falls back to default TTL when value is not a number", () => { + const ttlMs = resolveCacheTtlMs({ + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "not-a-number", + } as NodeJS.ProcessEnv); + + expect(ttlMs).toBe(60_000); + }); + + 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__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts new file mode 100644 index 0000000..044035d --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -0,0 +1,135 @@ +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"; + +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockBody = (json: string) => ({ + transformToString: jest.fn().mockResolvedValue(json), +}); + +const createValidConfig = (clientId: string) => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: clientId, + Targets: [ + { + Type: "API", + TargetId: "00000000-0000-4000-8000-000000000001", + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: ["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.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts new file mode 100644 index 0000000..3cf95b9 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts @@ -0,0 +1,90 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; + +describe("config update component", () => { + 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([ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: "client-1", + Targets: [ + { + Type: "API", + TargetId: "target", + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: ["DELIVERED"], + }, + ]), + ), + }, + }) + .mockResolvedValueOnce({ + Body: { + transformToString: jest.fn().mockResolvedValue( + JSON.stringify([ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: "client-1", + Targets: [ + { + Type: "API", + TargetId: "target", + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: ["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?.MessageStatuses).toEqual(["DELIVERED"]); + + jest.advanceTimersByTime(1500); + + const second = await loader.loadClientConfig("client-1"); + const secondMessage = second?.find( + (subscription) => subscription.SubscriptionType === "MessageStatus", + ); + expect(secondMessage?.MessageStatuses).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..b7c1555 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].SubscriptionId", + message: "Expected SubscriptionId 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].SubscriptionId", + message: "Expected SubscriptionId 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/channel-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts new file mode 100644 index 0000000..8c6eefa --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/channel-status-filter.test.ts @@ -0,0 +1,286 @@ +import type { + ChannelStatus, + ChannelStatusData, + ClientSubscriptionConfiguration, + StatusPublishEvent, + SupplierStatus, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; + +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +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, +}); + +const createChannelStatusConfig = ( + channelStatuses: ChannelStatus[], + supplierStatuses: SupplierStatus[], + clientId = "client-1", +): ClientSubscriptionConfiguration => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: clientId, + Targets: [ + { + Type: "API", + TargetId: "target", + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "ChannelStatus", + ChannelType: "EMAIL", + ChannelStatuses: channelStatuses, + SupplierStatuses: supplierStatuses, + }, +]; + +const createChannelStatusData = ( + overrides: Partial = {}, +): 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", + ...overrides, +}); + +describe("matchesChannelStatusSubscription", () => { + it("matches by channel and supplier status", () => { + const notifyData = createChannelStatusData(); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("rejects when channel does not match", () => { + const notifyData = createChannelStatusData({ channel: "SMS" }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when clientId does not match", () => { + const notifyData = createChannelStatusData({ clientId: "client-2" }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when channelStatus does not match", () => { + const notifyData = createChannelStatusData({ + channelStatus: "FAILED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "read", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when supplierStatus does not match", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "rejected", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("rejects when neither status changed", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "DELIVERED", + supplierStatus: "read", + previousSupplierStatus: "read", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(false); + }); + + it("matches when only channelStatus changed and is subscribed (OR logic)", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "notified", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("matches when only supplierStatus changed and is subscribed (OR logic)", () => { + const notifyData = createChannelStatusData({ + channelStatus: "SENDING", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("matches with empty supplierStatuses when channelStatus changed", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig(["DELIVERED"], []), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("matches with empty channelStatuses when supplierStatus changed", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription( + createChannelStatusConfig([], ["read"]), + { event, notifyData }, + ), + ).toBe(true); + }); + + it("rejects with both channelStatuses and supplierStatuses empty", () => { + const notifyData = createChannelStatusData({ + channelStatus: "DELIVERED", + previousChannelStatus: "SENDING", + supplierStatus: "read", + previousSupplierStatus: "notified", + }); + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesChannelStatusSubscription(createChannelStatusConfig([], []), { + event, + notifyData, + }), + ).toBe(false); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts new file mode 100644 index 0000000..ee3a070 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/message-status-filter.test.ts @@ -0,0 +1,175 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatus, + MessageStatusData, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +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, +}); + +const createMessageStatusConfig = ( + statuses: MessageStatus[], + clientId = "client-1", +): ClientSubscriptionConfiguration => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: clientId, + Targets: [ + { + Type: "API", + TargetId: "target", + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: statuses, + }, +]; + +const createMessageStatusData = ( + overrides: Partial = {}, +): 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", + ...overrides, +}); + +describe("matchesMessageStatusSubscription", () => { + it("matches by client, status, and event pattern", () => { + const notifyData = createMessageStatusData(); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(true); + }); + + it("rejects when clientId does not match", () => { + const notifyData = createMessageStatusData({ clientId: "client-2" }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("rejects when status does not match", () => { + const notifyData = createMessageStatusData({ messageStatus: "FAILED" }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("rejects when status has not changed", () => { + const notifyData = createMessageStatusData({ + messageStatus: "DELIVERED", + previousMessageStatus: "DELIVERED", + }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(false); + }); + + it("matches when status has changed", () => { + const notifyData = createMessageStatusData({ + messageStatus: "DELIVERED", + previousMessageStatus: "PENDING_ENRICHMENT", + }); + const event = createBaseEvent( + EventTypes.MESSAGE_STATUS_PUBLISHED, + "source-a", + notifyData, + ); + expect( + matchesMessageStatusSubscription( + createMessageStatusConfig(["DELIVERED"]), + { + event, + notifyData, + }, + ), + ).toBe(true); + }); +}); 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 6c94fbf..94f1f47 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 @@ -309,6 +309,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..2df22a2 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -0,0 +1,242 @@ +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"; + +jest.mock("services/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +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 => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: clientId, + Targets: [ + { + Type: "API", + TargetId: "target", + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: statuses, + }, +]; + +const createChannelStatusConfig = ( + clientId: string, + channelType: Channel, + channelStatuses: ChannelStatus[], + supplierStatuses: SupplierStatus[], +): ClientSubscriptionConfiguration => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000002", + ClientId: clientId, + Targets: [ + { + Type: "API", + TargetId: "target", + 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"); + 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..ad4f680 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -0,0 +1,81 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { + ConfigValidationError, + validateClientConfig, +} from "services/validators/config-validator"; + +const createValidConfig = (): ClientSubscriptionConfiguration => [ + { + SubscriptionId: "00000000-0000-0000-0000-000000000001", + ClientId: "client-1", + Targets: [ + { + Type: "API", + TargetId: "target", + InvocationEndpoint: "https://example.com", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + SubscriptionType: "MessageStatus", + MessageStatuses: ["DELIVERED"], + }, + { + SubscriptionId: "00000000-0000-0000-0000-000000000002", + ClientId: "client-1", + Targets: [ + { + Type: "API", + TargetId: "target", + 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 IDs are not unique", () => { + const config = createValidConfig(); + config[1].SubscriptionId = config[0].SubscriptionId; + + 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..cb53fd5 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,62 @@ 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); + const targetIds = config?.flatMap((s) => + s.Targets.map((t) => t.TargetId), + ); + observability.recordFilteringMatched({ + clientId, + eventType: event.type, + subscriptionType: filterResult.subscriptionType, + targetIds, + }); + } 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 +211,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 +219,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 +233,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..23a287f --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -0,0 +1,92 @@ +import type { + ChannelStatusData, + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; + +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 { 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; + } + + return true; + }); + + 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, + }); + } + + return matched; +}; 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..e79c33a --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -0,0 +1,65 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatusData, + MessageStatusSubscriptionConfiguration, + StatusPublishEvent, +} from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; + +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 { 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.MessageStatuses.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.MessageStatuses, + }, + ); + return false; + } + + return true; + }); + + if (matched) { + logger.debug("Message status filter matched", { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + }); + } + + 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 d627641..bc6e389 100644 --- a/lambdas/client-transform-filter-lambda/src/services/logger.ts +++ b/lambdas/client-transform-filter-lambda/src/services/logger.ts @@ -82,6 +82,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..e1ee13d 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -39,9 +39,25 @@ 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; + targetIds?: 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..cf476d5 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -0,0 +1,108 @@ +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 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(), + InvocationEndpoint: httpsUrlSchema, + InvocationMethod: z.literal("POST"), + InvocationRateLimit: z.number(), + APIKey: z.object({ + HeaderName: z.string(), + HeaderValue: z.string(), + }), +}); + +const baseSubscriptionSchema = z.object({ + SubscriptionId: z.string().min(1), + ClientId: z.string(), + Targets: z.array(targetSchema).min(1), +}); + +const messageStatusSchema = baseSubscriptionSchema.extend({ + SubscriptionType: z.literal("MessageStatus"), + MessageStatuses: 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 seenSubscriptionIds = new Set(); + + for (const [index, subscription] of config.entries()) { + if (seenSubscriptionIds.has(subscription.SubscriptionId)) { + ctx.addIssue({ + code: "custom", + message: "Expected SubscriptionId to be unique", + path: [index, "SubscriptionId"], + }); + } else { + seenSubscriptionIds.add(subscription.SubscriptionId); + } + } +}); + +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..4bfd023 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,177 @@ } }, "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-cognito-identity": { + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1004.0.tgz", + "integrity": "sha512-iRFVMN0Rlh9tjEuz1c6eQnv9EiYH0uxIvobsn5IvOjsM0PdfsKpGdRKiQIA/OgmpTPfuYyySwaRRtDFH9TMlQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-node": "^3.972.18", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.4", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "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 +476,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,24 +686,67 @@ "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==", + "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", - "@aws-sdk/xml-builder": "^3.972.8", - "@smithy/core": "^3.23.6", - "@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/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@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.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.18.tgz", + "integrity": "sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/xml-builder": "^3.972.10", + "@smithy/core": "^3.23.8", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "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-cognito-identity": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.10.tgz", + "integrity": "sha512-R7saD8TvU6En8tFstAgbM9w6wlFxTwXrvMEpheVdGyDMKSxK412aRy87VNb2Mc2By0vL58OIE487afpxOc/rVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -370,14 +754,14 @@ } }, "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.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.16.tgz", + "integrity": "sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -386,20 +770,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.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.18.tgz", + "integrity": "sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" }, "engines": { @@ -407,23 +791,23 @@ } }, "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", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.17.tgz", + "integrity": "sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-env": "^3.972.16", + "@aws-sdk/credential-provider-http": "^3.972.18", + "@aws-sdk/credential-provider-login": "^3.972.17", + "@aws-sdk/credential-provider-process": "^3.972.16", + "@aws-sdk/credential-provider-sso": "^3.972.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.17", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -432,17 +816,17 @@ } }, "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.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.17.tgz", + "integrity": "sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -451,21 +835,21 @@ } }, "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.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.18.tgz", + "integrity": "sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==", "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/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/credential-provider-env": "^3.972.16", + "@aws-sdk/credential-provider-http": "^3.972.18", + "@aws-sdk/credential-provider-ini": "^3.972.17", + "@aws-sdk/credential-provider-process": "^3.972.16", + "@aws-sdk/credential-provider-sso": "^3.972.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.17", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -474,15 +858,15 @@ } }, "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.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.16.tgz", + "integrity": "sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -491,17 +875,17 @@ } }, "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.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.17.tgz", + "integrity": "sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==", "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/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/token-providers": "3.1004.0", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -510,16 +894,16 @@ } }, "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.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.17.tgz", + "integrity": "sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -527,14 +911,97 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1004.0.tgz", + "integrity": "sha512-THsua88i7DrPoO8WCIWLPWb8706s2ytl2ej+WB9sv39VPCJNc7YwGtTA51reziyzlLnJUGHkI+krp0oTHEGaBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1004.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.10", + "@aws-sdk/credential-provider-env": "^3.972.16", + "@aws-sdk/credential-provider-http": "^3.972.18", + "@aws-sdk/credential-provider-ini": "^3.972.17", + "@aws-sdk/credential-provider-login": "^3.972.17", + "@aws-sdk/credential-provider-node": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.16", + "@aws-sdk/credential-provider-sso": "^3.972.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.17", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "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==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/protocol-http": "^5.3.10", + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -542,13 +1009,25 @@ "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==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -557,14 +1036,14 @@ } }, "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==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -572,17 +1051,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,18 +1076,63 @@ "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.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.19.tgz", + "integrity": "sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@smithy/core": "^3.23.6", - "@smithy/protocol-http": "^5.3.10", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@smithy/core": "^3.23.8", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" }, "engines": { @@ -609,48 +1140,64 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "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==", + "version": "3.996.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.7.tgz", + "integrity": "sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==", "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/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/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", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/fetch-http-handler": "^5.3.11", - "@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-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/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.4", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", "@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-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" }, "engines": { @@ -658,14 +1205,31 @@ } }, "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==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "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/config-resolver": "^4.4.9", - "@smithy/node-config-provider": "^4.3.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -674,16 +1238,16 @@ } }, "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.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1004.0.tgz", + "integrity": "sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/nested-clients": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -692,9 +1256,9 @@ } }, "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==", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -704,16 +1268,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 +1295,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" @@ -733,26 +1304,26 @@ } }, "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==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "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.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.4.tgz", + "integrity": "sha512-uqKeLqZ9D3nQjH7HGIERNXK9qnSpUK08l4MlJ5/NZqSSdeJsVANYp437EM9sEzwU28c2xfj2V6qlkqzsgtKs6Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/node-config-provider": "^4.3.10", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -769,13 +1340,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.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", + "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", "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 +1355,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 +1374,7 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", + "version": "7.27.5", "dev": true, "license": "MIT", "engines": { @@ -813,20 +1382,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 +1419,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 +1434,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 +1456,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 +1485,7 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -940,7 +1501,7 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -956,23 +1517,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 +1590,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 +1626,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 +1734,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 +1748,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 +1824,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 +1857,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 +1878,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 +1921,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 +1942,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 +2010,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 +2093,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 +2435,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" + "license": "MIT", + "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 +2464,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 +2477,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 +2555,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 +2568,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" }, @@ -1933,9 +2597,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -1945,17 +2609,38 @@ "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==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -1963,20 +2648,20 @@ } }, "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.9", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", + "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.11", - "@smithy/protocol-http": "^5.3.10", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@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-utf8": "^4.2.1", - "@smithy/uuid": "^1.1.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -1984,15 +2669,15 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", + "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -2000,14 +2685,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 +2698,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 +2710,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 +2721,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 +2733,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 +2745,54 @@ } }, "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.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-node": { + "node_modules/@smithy/hash-blob-browser": { "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", - "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", + "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.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.9", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -2101,9 +2801,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2114,9 +2814,9 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2126,14 +2826,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": { @@ -2141,12 +2838,12 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2155,18 +2852,18 @@ } }, "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.23", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", + "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/core": "^3.23.9", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-middleware": "^4.2.10", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -2174,19 +2871,19 @@ } }, "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.40", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", + "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", "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/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/uuid": "^1.1.1", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -2194,12 +2891,12 @@ } }, "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==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2208,9 +2905,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2221,13 +2918,13 @@ } }, "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==", + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2236,14 +2933,14 @@ } }, "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.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2252,9 +2949,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2265,9 +2962,9 @@ } }, "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==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2278,13 +2975,13 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2292,9 +2989,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2305,9 +3002,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0" @@ -2317,9 +3014,9 @@ } }, "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==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2330,18 +3027,18 @@ } }, "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==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", - "@smithy/protocol-http": "^5.3.10", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-uri-escape": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2349,17 +3046,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.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", + "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", + "@smithy/core": "^3.23.9", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" }, "engines": { @@ -2368,8 +3065,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" @@ -2379,12 +3074,12 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.10", + "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2393,13 +3088,13 @@ } }, "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==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2407,9 +3102,9 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2419,9 +3114,9 @@ } }, "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==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2431,12 +3126,12 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2444,9 +3139,9 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2456,13 +3151,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.39", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", + "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2471,16 +3166,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.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", + "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", "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/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2489,12 +3184,12 @@ } }, "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==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2503,9 +3198,9 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2515,9 +3210,9 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -2528,12 +3223,12 @@ } }, "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==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.10", + "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -2542,18 +3237,18 @@ } }, "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.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", "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.13", + "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -2561,9 +3256,9 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2573,12 +3268,24 @@ } }, "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==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.9", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/abort-controller": "^4.2.9", + "@smithy/types": "^4.12.1", "tslib": "^2.6.2" }, "engines": { @@ -2586,9 +3293,9 @@ } }, "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==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2615,6 +3322,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 +3353,7 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", + "version": "1.0.11", "dev": true, "license": "MIT" }, @@ -2644,12 +3373,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 +3412,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 +3477,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 +3503,7 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.35", + "version": "17.0.33", "dev": true, "license": "MIT", "dependencies": { @@ -2806,14 +3542,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 +3726,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 +3744,7 @@ "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.16.0", + "version": "8.15.0", "dev": true, "license": "MIT", "bin": { @@ -3056,7 +3772,7 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.5", + "version": "8.3.4", "dev": true, "license": "MIT", "dependencies": { @@ -3079,8 +3795,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 +3806,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 +3828,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 +3843,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 +3857,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 +3873,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 +3893,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3197,19 +3912,8 @@ "normalize-path": "^3.0.0", "picomatch": "^2.0.4" }, - "engines": { - "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" + "engines": { + "node": ">= 8" } }, "node_modules/arg": { @@ -3266,6 +3970,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 +4047,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, @@ -3325,6 +4089,15 @@ "dev": true, "license": "MIT" }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -3335,8 +4108,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 +4126,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 +4146,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 +4155,7 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", + "version": "4.10.3", "dev": true, "license": "MPL-2.0", "engines": { @@ -3476,7 +4243,7 @@ } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", + "version": "1.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -3497,7 +4264,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 +4283,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 +4296,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 +4309,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 +4329,7 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.25.0", "dev": true, "funding": [ { @@ -3590,11 +4347,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 +4460,7 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", + "version": "1.0.30001724", "dev": true, "funding": [ { @@ -3746,7 +4502,7 @@ } }, "node_modules/ci-info": { - "version": "4.4.0", + "version": "4.2.0", "dev": true, "funding": [ { @@ -3785,16 +4541,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 +4564,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 +4579,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 +4593,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": { @@ -3855,12 +4606,13 @@ }, "node_modules/collect-v8-coverage": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3871,7 +4623,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3886,13 +4637,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 +4660,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 +4821,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 +4899,7 @@ } }, "node_modules/diff": { - "version": "4.0.4", + "version": "4.0.2", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4158,6 +4914,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 +5013,7 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.302", + "version": "1.5.173", "dev": true, "license": "ISC" }, @@ -4260,12 +5029,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 +5044,7 @@ } }, "node_modules/error-ex": { - "version": "1.3.4", + "version": "1.3.2", "dev": true, "license": "MIT", "dependencies": { @@ -4284,7 +5052,7 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", + "version": "1.24.0", "dev": true, "license": "MIT", "dependencies": { @@ -4364,6 +5132,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 +5212,7 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", + "version": "0.25.0", "hasInstallScript": true, "license": "MIT", "bin": { @@ -4426,37 +5222,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 +5400,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 +5447,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 +5502,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 +5541,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 +5610,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, @@ -4790,10 +5712,35 @@ "string.prototype.includes": "^2.0.1" }, "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + "node": ">=4.0" + }, + "peerDependencies": { + "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": { @@ -4830,6 +5777,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 +5993,7 @@ } }, "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "16.5.0", + "version": "16.2.0", "dev": true, "license": "MIT", "engines": { @@ -4974,6 +6019,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 +6049,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 +6084,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 +6108,7 @@ } }, "node_modules/esquery": { - "version": "1.7.0", + "version": "1.6.0", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5141,8 +6236,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 +6248,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 +6272,7 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { @@ -5174,7 +6280,7 @@ } }, "node_modules/fastq": { - "version": "1.20.1", + "version": "1.19.1", "dev": true, "license": "ISC", "dependencies": { @@ -5189,22 +6295,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 +6374,7 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -5303,6 +6393,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 +6444,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 +6454,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5434,7 +6528,7 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", + "version": "4.10.1", "dev": true, "license": "MIT", "dependencies": { @@ -5474,6 +6568,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 +6742,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 +6755,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 +6804,7 @@ } }, "node_modules/ignore": { - "version": "5.3.2", + "version": "7.0.5", "dev": true, "license": "MIT", "engines": { @@ -5777,8 +6891,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 +7083,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5986,12 +7097,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 +7369,7 @@ } }, "node_modules/istanbul-reports": { - "version": "3.2.0", + "version": "3.1.7", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6270,6 +7380,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 +7938,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 +8017,7 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -6967,8 +8084,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 +8269,7 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.17.21", "dev": true, "license": "MIT" }, @@ -7168,6 +8283,25 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "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 +8369,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 +8396,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 +8443,7 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.3.4", + "version": "0.2.4", "dev": true, "license": "MIT", "bin": { @@ -7358,7 +8489,7 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.19", "dev": true, "license": "MIT" }, @@ -7382,10 +8513,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 +8565,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 +8598,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 +8632,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 +8719,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 +8766,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 +8801,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 +8813,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 +8833,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 +8840,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 +8933,7 @@ } }, "node_modules/prettier": { - "version": "3.8.1", + "version": "3.6.0", "dev": true, "license": "MIT", "peer": true, @@ -7846,8 +8984,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 +8991,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 +9015,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 +9094,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 +9103,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 +9203,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8062,8 +9210,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 +9221,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 +9371,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" @@ -8419,10 +9563,25 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "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 +9606,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 +9669,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8523,11 +9679,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 +9692,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 +9786,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8622,9 +9811,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" }, @@ -8696,6 +9888,44 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/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", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/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/test-exclude": { "version": "6.0.0", "dev": true, @@ -8709,10 +9939,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 +9981,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 +10177,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 +10235,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 +10262,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 +10308,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 +10379,7 @@ } }, "node_modules/typescript": { - "version": "5.9.3", + "version": "5.8.3", "dev": true, "license": "Apache-2.0", "bin": { @@ -9145,7 +10442,7 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", + "version": "7.8.0", "dev": true, "license": "MIT" }, @@ -9158,40 +10455,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 +10535,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 +10546,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 +10735,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 +10768,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9507,7 +10799,7 @@ } }, "node_modules/ws": { - "version": "8.19.0", + "version": "8.18.2", "dev": true, "license": "MIT", "engines": { @@ -9549,7 +10841,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -9562,7 +10853,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -9579,7 +10869,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9625,7 +10914,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 +10930,104 @@ "jest": "^29.7.0", "typescript": "^5.8.2" } + }, + "tools/client-subscriptions-management": { + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sts": "^3.1004.0", + "@aws-sdk/credential-providers": "^3.1004.0", + "@nhs-notify-client-callbacks/models": "*", + "table": "^6.9.0", + "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/@aws-sdk/client-sts": { + "version": "3.1004.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1004.0.tgz", + "integrity": "sha512-fxTiEmAwj91OtrmhafZtmxrUa4wfT1CmnnV45jZ3NCHSTJhZy0MrtNZShxSnuhbF0i/JfsZdst3oxQGzGcCCmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-node": "^3.972.18", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.19", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.4", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "tools/client-subscriptions-management/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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..e254dc1 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/init.mk b/scripts/init.mk index 885d2d3..89a0611 100644 --- a/scripts/init.mk +++ b/scripts/init.mk @@ -46,7 +46,7 @@ _install-dependency: # Install asdf dependency - mandatory: name=[listed in the asdf install ${name} $(or ${version},) _install-dependencies: # Install all the dependencies listed in .tool-versions - for plugin in $$(grep ^[a-z] .tool-versions | sed 's/[[:space:]].*//'); do + for plugin in $$(grep ^[a-z] .tool-versions | sed 's/[[:space:]].*//'); do \ $(MAKE) _install-dependency name=$${plugin}; \ done 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..1afcc2c 100644 --- a/src/models/src/client-config.ts +++ b/src/models/src/client-config.ts @@ -1,24 +1,21 @@ +import type { Channel } from "./channel-types"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "./status-types"; + export type ClientSubscriptionConfiguration = ( | MessageStatusSubscriptionConfiguration | ChannelStatusSubscriptionConfiguration )[]; interface SubscriptionConfigurationBase { - Name: string; + SubscriptionId: string; ClientId: string; - Description: string; - EventSource: string; - EventDetail: string; Targets: { Type: "API"; TargetId: string; - Name: string; - InputTransformer: { - InputPaths: string; - InputHeaders: { - "x-hmac-sha256-signature": string; - }; - }; InvocationEndpoint: string; InvocationMethod: "POST"; InvocationRateLimit: number; @@ -29,14 +26,16 @@ interface SubscriptionConfigurationBase { }[]; } -export interface MessageStatusSubscriptionConfiguration extends SubscriptionConfigurationBase { +export interface MessageStatusSubscriptionConfiguration + extends SubscriptionConfigurationBase { SubscriptionType: "MessageStatus"; - Statuses: string[]; + MessageStatuses: 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..c4c2ebe --- /dev/null +++ b/tests/integration/helpers/aws-helpers.ts @@ -0,0 +1,45 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +export type DeploymentDetails = { + region: string; + environment: string; + accountId: string; +}; + +/** + * Reads deployment context from environment variables + * + * Requires: AWS_REGION, ENVIRONMENT, AWS_ACCOUNT_ID + */ +export function getDeploymentDetails(): DeploymentDetails { + const region = process.env.AWS_REGION ?? "eu-west-2"; + const environment = process.env.ENVIRONMENT; + const accountId = process.env.AWS_ACCOUNT_ID; + + if (!environment) { + throw new Error("ENVIRONMENT environment variable must be set"); + } + if (!accountId) { + throw new Error("AWS_ACCOUNT_ID environment variable must be set"); + } + + return { region, environment, accountId }; +} + +/** + * Builds an S3 bucket name from deployment details and a bucket-specific suffix. + */ +export function buildBucketName( + { accountId, environment, region }: DeploymentDetails, + suffix: string, +): string { + return `nhs-${accountId}-${region}-${environment}-${suffix}`; +} + +/** + * 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..bf53f2c --- /dev/null +++ b/tests/integration/infrastructure-exists.test.ts @@ -0,0 +1,29 @@ +import { HeadBucketCommand } from "@aws-sdk/client-s3"; +import type { S3Client } from "@aws-sdk/client-s3"; +import { buildBucketName, createS3Client, getDeploymentDetails } from "helpers"; + +describe("Infrastructure exists", () => { + let s3Client: S3Client; + let bucketName: string; + + beforeAll(async () => { + const deploymentDetails = getDeploymentDetails(); + bucketName = buildBucketName( + deploymentDetails, + "callbacks-subscription-config", + ); + 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..32a900f --- /dev/null +++ b/tools/client-subscriptions-management/README.md @@ -0,0 +1,117 @@ +# 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 run -- [options] +``` + +## Example + +Deploy a message status subscription to the `dev` environment using a named AWS profile: + +```bash +npm --workspace tools/client-subscriptions-management run deploy -- message \ + --environment dev \ + --profile my-profile \ + --client-id my-client \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.invalid/callbacks \ + --api-key 1234.4321 \ + --rate-limit 20 \ + --dry-run false \ + --terraform-apply \ + --group dev +``` + +## Commands + +### Deploy a Subscription (upload config + optionally apply Terraform) + +Use `deploy` to upload a subscription config to S3 and optionally trigger a Terraform apply in one step. + +#### Message status + +```bash +npm --workspace tools/client-subscriptions-management run deploy -- message \ + --environment dev \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.invalid \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 \ + --terraform-apply \ + --group dev +``` + +#### Channel status + +```bash +npm --workspace tools/client-subscriptions-management run deploy -- channel \ + --environment dev \ + --client-id client-123 \ + --channel-type EMAIL \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses READ REJECTED \ + --api-endpoint https://webhook.example.invalid \ + --api-key-header-name x-api-key \ + --api-key 1234.4321 \ + --dry-run false \ + --rate-limit 20 \ + --terraform-apply \ + --group dev +``` + +Optional for both: `--client-name "Test Client"` (defaults to client-id if not provided), `--project ` (defaults to `nhs`), `--region ` (defaults to `eu-west-2`), `--profile `, `--tf-region `, `--bucket-name ` (override derived bucket name) + +**Note (channel)**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided. + +### Get Client Subscriptions By Client ID + +```bash +npm --workspace tools/client-subscriptions-management run get-by-client-id -- \ + --environment dev \ + --client-id client-123 +``` + +### Put Message Status Subscription (S3 upload only) + +```bash +npm --workspace tools/client-subscriptions-management run put-message-status -- \ + --environment dev \ + --client-id client-123 \ + --message-statuses DELIVERED FAILED \ + --api-endpoint https://webhook.example.invalid \ + --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), `--profile `, `--bucket-name ` + +### Put Channel Status Subscription (S3 upload only) + +```bash +npm --workspace tools/client-subscriptions-management run put-channel-status -- \ + --environment dev \ + --client-id client-123 \ + --channel-type EMAIL \ + --channel-statuses DELIVERED FAILED \ + --supplier-statuses READ REJECTED \ + --api-endpoint https://webhook.example.invalid \ + --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), `--profile `, `--bucket-name ` + +**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..ef4857d --- /dev/null +++ b/tools/client-subscriptions-management/package.json @@ -0,0 +1,32 @@ +{ + "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", + "deploy": "tsx ./src/entrypoint/cli/deploy.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-sts": "^3.1004.0", + "@aws-sdk/credential-providers": "^3.1004.0", + "@nhs-notify-client-callbacks/models": "*", + "table": "^6.9.0", + "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__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts new file mode 100644 index 0000000..d8214e6 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts @@ -0,0 +1,52 @@ +import { createS3Client } from "src/container"; + +const mockFromIni = jest.fn().mockReturnValue({ accessKeyId: "from-ini" }); +jest.mock("@aws-sdk/credential-providers", () => ({ + fromIni: (...args: unknown[]) => mockFromIni(...args), +})); + +describe("createS3Client", () => { + it("sets forcePathStyle=true when endpoint contains localhost", () => { + const env = { AWS_ENDPOINT_URL: "http://localhost:4566" }; + const client = createS3Client("eu-west-2", undefined, 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", undefined, 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", undefined, 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); + }); + + it("uses fromIni credentials when a profile is provided", () => { + const client = createS3Client("eu-west-2", "my-profile", {}); + + const { config } = client as any; + expect(mockFromIni).toHaveBeenCalledWith({ profile: "my-profile" }); + expect(config.credentials).toBeDefined(); + }); + + it("does not use fromIni credentials when profile is undefined", () => { + mockFromIni.mockClear(); + createS3Client("eu-west-2", undefined, {}); + + expect(mockFromIni).not.toHaveBeenCalled(); + }); +}); 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..f264e1e --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts @@ -0,0 +1,44 @@ +import { S3Client } from "@aws-sdk/client-s3"; + +const mockS3Repository = jest.fn(); +const mockBuilderObject = { + messageStatus: jest.fn(), + channelStatus: jest.fn(), +}; +const mockRepository = jest.fn(); + +jest.mock("src/repository/s3", () => ({ + S3Repository: mockS3Repository, +})); + +jest.mock("src/domain/client-subscription-builder", () => ({ + clientSubscriptionBuilder: mockBuilderObject, +})); + +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", + }); + + expect(mockS3Repository).toHaveBeenCalledWith( + "bucket-1", + expect.any(S3Client), + ); + expect(mockRepository).toHaveBeenCalledWith( + mockS3Repository.mock.instances[0], + mockBuilderObject, + ); + expect(result).toBe(repoInstance); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts new file mode 100644 index 0000000..1ff192e --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts @@ -0,0 +1,72 @@ +import { + buildChannelStatusSubscription, + buildMessageStatusSubscription, +} from "src/domain/client-subscription-builder"; + +describe("buildMessageStatusSubscription", () => { + it("builds message status subscription", () => { + const result = buildMessageStatusSubscription({ + 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({ + SubscriptionId: "client-one", + SubscriptionType: "MessageStatus", + ClientId: "client-1", + MessageStatuses: ["DELIVERED"], + }); + expect(result.Targets[0].TargetId).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i, + ); + }); +}); + +describe("buildChannelStatusSubscription", () => { + it("builds channel status subscription", () => { + const result = buildChannelStatusSubscription({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + clientId: "client-1", + clientName: "Client One", + channelStatuses: ["DELIVERED"], + supplierStatuses: ["delivered"], + channelType: "SMS", + rateLimit: 20, + dryRun: false, + }); + + expect(result).toMatchObject({ + SubscriptionId: "client-one-SMS", + SubscriptionType: "ChannelStatus", + ClientId: "client-1", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + }); + expect(result.Targets[0].TargetId).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i, + ); + }); + + it("defaults channelStatuses and supplierStatuses to [] when not provided", () => { + const result = buildChannelStatusSubscription({ + 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([]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts new file mode 100644 index 0000000..a0993ab --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/get-client-subscriptions.test.ts @@ -0,0 +1,173 @@ +const mockGetClientSubscriptions = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + getClientSubscriptions: mockGetClientSubscriptions, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveProfile = jest.fn().mockReturnValue(undefined); +const mockResolveRegion = jest.fn().mockReturnValue("region"); + +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + 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 () => { + 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 () => { + 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", + ]; + 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", + ]; + mockGetClientSubscriptions.mockResolvedValue(undefined); + + await cli.main(); + + expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-2"); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts new file mode 100644 index 0000000..2c3c291 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/helper.test.ts @@ -0,0 +1,166 @@ +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; +import { + deriveBucketName, + formatSubscriptionFileResponse, + normalizeClientName, + resolveBucketName, + resolveProfile, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +jest.mock("@aws-sdk/client-sts", () => ({ + STSClient: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockResolvedValue({ Account: "123456789012" }), + })), + GetCallerIdentityCommand: jest.fn(), +})); + +describe("cli helper", () => { + const messageSubscription: MessageStatusSubscriptionConfiguration = { + SubscriptionId: "client-a", + SubscriptionType: "MessageStatus", + ClientId: "client-a", + MessageStatuses: ["DELIVERED"], + Targets: [ + { + Type: "API", + TargetId: "00000000-0000-4000-8000-000000000001", + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + }; + + const channelSubscription: ChannelStatusSubscriptionConfiguration = { + SubscriptionId: "client-a-sms", + SubscriptionType: "ChannelStatus", + ClientId: "client-a", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + Targets: [ + { + Type: "API", + TargetId: "00000000-0000-4000-8000-000000000002", + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 20, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }, + ], + }; + + it("formats subscription output as a table string", () => { + const config: ClientSubscriptionConfiguration = [ + messageSubscription, + channelSubscription, + ]; + + const result = formatSubscriptionFileResponse(config); + + expect(typeof result).toBe("string"); + // message status row + expect(result).toContain("client-a"); + expect(result).toContain("MessageStatus"); + expect(result).toContain("DELIVERED"); + expect(result).toContain("https://example.com/webhook"); + expect(result).toContain("POST"); + expect(result).toContain("x-api-key"); + expect(result).toContain("secret"); + // channel status row + expect(result).toContain("ChannelStatus"); + expect(result).toContain("SMS"); + }); + + it("normalizes client name", () => { + expect(normalizeClientName("My Client Name")).toBe("my-client-name"); + }); + + it("resolves bucket name from explicit argument", async () => { + await expect(resolveBucketName("bucket-1")).resolves.toBe("bucket-1"); + }); + + it("derives bucket name from environment using STS account ID", async () => { + await expect( + resolveBucketName(undefined, "dev", "eu-west-2"), + ).resolves.toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("uses default region eu-west-2 when region is not provided", async () => { + await expect(resolveBucketName(undefined, "dev")).resolves.toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("throws when neither bucket name nor environment provided", async () => { + await expect(resolveBucketName()).rejects.toThrow( + "Bucket name is required: use --bucket-name to specify directly, or --environment", + ); + }); + + it("derives bucket name correctly", () => { + expect(deriveBucketName("123456789012", "dev", "eu-west-2")).toBe( + "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + ); + }); + + it("derives bucket name with custom project and component", () => { + expect( + deriveBucketName("123456789012", "prod", "eu-west-2", "myproj", "mycomp"), + ).toBe("myproj-123456789012-eu-west-2-prod-mycomp-subscription-config"); + }); + + it("resolves profile from argument", () => { + expect(resolveProfile("my-profile")).toBe("my-profile"); + }); + + it("resolves profile from AWS_PROFILE env", () => { + expect( + resolveProfile(undefined, { + AWS_PROFILE: "env-profile", + } as NodeJS.ProcessEnv), + ).toBe("env-profile"); + }); + + it("returns undefined when profile is not set", () => { + expect(resolveProfile(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); + }); + + 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__/entrypoint/cli/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts new file mode 100644 index 0000000..92b3a2b --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-channel-status.test.ts @@ -0,0 +1,378 @@ +const mockPutChannelStatusSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + putChannelStatusSubscription: mockPutChannelStatusSubscription, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveProfile = jest.fn().mockReturnValue(undefined); +const mockResolveRegion = jest.fn().mockReturnValue("region"); +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + 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"); + 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", + "--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, + }); + 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, + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts new file mode 100644 index 0000000..afdb8bf --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/put-message-status.test.ts @@ -0,0 +1,313 @@ +const mockPutMessageStatusSubscription = jest.fn(); +const mockCreateRepository = jest.fn().mockReturnValue({ + putMessageStatusSubscription: mockPutMessageStatusSubscription, +}); +const mockFormatSubscriptionFileResponse = jest.fn(); +const mockResolveBucketName = jest.fn().mockReturnValue("bucket"); +const mockResolveProfile = jest.fn().mockReturnValue(undefined); +const mockResolveRegion = jest.fn().mockReturnValue("region"); +jest.mock("src/container", () => ({ + createClientSubscriptionRepository: mockCreateRepository, +})); + +jest.mock("src/entrypoint/cli/helper", () => ({ + formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse, + resolveBucketName: mockResolveBucketName, + resolveProfile: mockResolveProfile, + 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"); + 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", + "--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, + }); + 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, + }); + expect(console.log).toHaveBeenCalledWith(["formatted"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts new file mode 100644 index 0000000..93fa6f5 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/repository/client-subscriptions.test.ts @@ -0,0 +1,372 @@ +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 { SubscriptionBuilder } 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 SubscriptionBuilder; + + const repository = new ClientSubscriptionRepository( + s3Repository, + configurationBuilder, + ); + + return { repository, s3Repository, configurationBuilder }; +}; + +describe("ClientSubscriptionRepository", () => { + const baseTarget: MessageStatusSubscriptionConfiguration["Targets"][number] = + { + Type: "API", + TargetId: "00000000-0000-4000-8000-000000000001", + InvocationEndpoint: "https://example.com/webhook", + InvocationMethod: "POST", + InvocationRateLimit: 10, + APIKey: { + HeaderName: "x-api-key", + HeaderValue: "secret", + }, + }; + + const messageSubscription: MessageStatusSubscriptionConfiguration = { + SubscriptionId: "client-1", + SubscriptionType: "MessageStatus", + ClientId: "client-1", + MessageStatuses: ["DELIVERED"], + Targets: [baseTarget], + }; + + const channelSubscription: ChannelStatusSubscriptionConfiguration = { + SubscriptionId: "client-1-SMS", + SubscriptionType: "ChannelStatus", + ClientId: "client-1", + ChannelType: "SMS", + ChannelStatuses: ["DELIVERED"], + SupplierStatuses: ["delivered"], + 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 () => { + 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, + MessageStatuses: ["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 () => { + 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 () => { + 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__/repository/s3.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/s3.test.ts new file mode 100644 index 0000000..04a9037 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/repository/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..ddac500 --- /dev/null +++ b/tools/client-subscriptions-management/src/container.ts @@ -0,0 +1,35 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { fromIni } from "@aws-sdk/credential-providers"; +import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import { S3Repository } from "src/repository/s3"; +import { clientSubscriptionBuilder } from "src/domain/client-subscription-builder"; + +type RepositoryOptions = { + bucketName: string; + region?: string; + profile?: string; +}; + +export const createS3Client = ( + region?: string, + profile?: string, + env: NodeJS.ProcessEnv = process.env, +): S3Client => { + const endpoint = env.AWS_ENDPOINT_URL; + const forcePathStyle = endpoint?.includes("localhost") ? true : undefined; + const credentials = profile ? fromIni({ profile }) : undefined; + return new S3Client({ region, endpoint, forcePathStyle, credentials }); +}; + +export const createClientSubscriptionRepository = ( + options: RepositoryOptions, +): ClientSubscriptionRepository => { + const s3Repository = new S3Repository( + options.bucketName, + createS3Client(options.region, options.profile), + ); + return new ClientSubscriptionRepository( + s3Repository, + clientSubscriptionBuilder, + ); +}; 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..11602f9 --- /dev/null +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -0,0 +1,97 @@ +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"; + +export type SubscriptionBuilder = { + messageStatus( + args: MessageStatusSubscriptionArgs, + ): MessageStatusSubscriptionConfiguration; + channelStatus( + args: ChannelStatusSubscriptionArgs, + ): ChannelStatusSubscriptionConfiguration; +}; + +export function buildMessageStatusSubscription( + args: MessageStatusSubscriptionArgs, +): MessageStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + clientId, + clientName, + rateLimit, + statuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + const subscriptionId = normalizedClientName; + return { + SubscriptionId: subscriptionId, + SubscriptionType: "MessageStatus", + ClientId: clientId, + MessageStatuses: statuses, + Targets: [ + { + Type: "API", + TargetId: crypto.randomUUID(), + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; +} + +export function buildChannelStatusSubscription( + args: ChannelStatusSubscriptionArgs, +): ChannelStatusSubscriptionConfiguration { + const { + apiEndpoint, + apiKey, + apiKeyHeaderName = "x-api-key", + channelStatuses, + channelType, + clientId, + clientName, + rateLimit, + supplierStatuses, + } = args; + const normalizedClientName = normalizeClientName(clientName); + const subscriptionId = `${normalizedClientName}-${channelType}`; + return { + SubscriptionId: subscriptionId, + SubscriptionType: "ChannelStatus", + ClientId: clientId, + ChannelType: channelType, + ChannelStatuses: channelStatuses ?? [], + SupplierStatuses: supplierStatuses ?? [], + Targets: [ + { + Type: "API", + TargetId: crypto.randomUUID(), + InvocationEndpoint: apiEndpoint, + InvocationMethod: "POST", + InvocationRateLimit: rateLimit, + APIKey: { + HeaderName: apiKeyHeaderName, + HeaderValue: apiKey, + }, + }, + ], + }; +} + +export const clientSubscriptionBuilder: SubscriptionBuilder = { + messageStatus: buildMessageStatusSubscription, + channelStatus: buildChannelStatusSubscription, +}; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts new file mode 100644 index 0000000..c1f4665 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/deploy.ts @@ -0,0 +1,305 @@ +import { spawnSync } from "node:child_process"; +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "@nhs-notify-client-callbacks/models"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveProfile, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +const sharedOptions = { + "bucket-name": { + type: "string" as const, + demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + "client-name": { + type: "string" as const, + demandOption: false, + description: "Display name for the client (defaults to client-id)", + }, + "client-id": { + type: "string" as const, + demandOption: true, + description: "Client identifier", + }, + "api-endpoint": { + type: "string" as const, + demandOption: true, + description: "Webhook endpoint URL (must start with https://)", + }, + "api-key": { + type: "string" as const, + demandOption: true, + description: "API key value for authenticating webhook calls", + }, + "api-key-header-name": { + type: "string" as const, + default: "x-api-key", + demandOption: false, + description: "HTTP header name for the API key", + }, + "rate-limit": { + type: "number" as const, + demandOption: true, + description: "Maximum number of webhook calls per second", + }, + "dry-run": { + type: "boolean" as const, + demandOption: true, + description: "Validate config without writing to S3", + }, + region: { + type: "string" as const, + demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + "terraform-apply": { + type: "boolean" as const, + default: false, + demandOption: false, + description: "Run terraform apply after uploading config", + }, + environment: { + type: "string" as const, + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", + }, + group: { + type: "string" as const, + demandOption: false, + description: "Group name (required when --terraform-apply is set)", + }, + project: { + type: "string" as const, + default: "nhs", + demandOption: false, + description: "Project name prefix for derived resource names", + }, + "tf-region": { + type: "string" as const, + demandOption: false, + description: "AWS region override for terraform", + }, + profile: { + type: "string" as const, + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", + }, +} as const; + +function runTerraformApply(argv: { + environment?: string; + group?: string; + project?: string; + "tf-region"?: string; +}) { + const { environment, group, project = "nhs", "tf-region": tfRegion } = argv; + if (!environment || !group) { + console.error( + "Error: --environment and --group are required when --terraform-apply is set", + ); + process.exitCode = 1; + return false; + } + + console.log( + "[deploy-client-subscriptions] Running terraform apply for callbacks component...", + ); + + const makeArgs = [ + "terraform-apply", + `component=callbacks`, + `environment=${environment}`, + `group=${group}`, + `project=${project}`, + ]; + if (tfRegion) { + makeArgs.push(`region=${tfRegion}`); + } + + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("make", makeArgs, { stdio: "inherit" }); + if (result.status !== 0) { + console.error( + `Error: terraform apply failed with exit code ${result.status}`, + ); + process.exitCode = result.status ?? 1; + return false; + } + return true; +} + +export async function main(args: string[] = process.argv) { + await yargs(hideBin(args)) + .command( + "message", + "Deploy a message status subscription", + { + ...sharedOptions, + "message-statuses": { + string: true, + type: "array" as const, + demandOption: true, + choices: MESSAGE_STATUSES, + }, + }, + async (argv) => { + const apiEndpoint = argv["api-endpoint"]; + if (!/^https:\/\//.test(apiEndpoint)) { + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + console.log( + "[deploy-client-subscriptions] Uploading message status subscription config...", + ); + + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + argv.project, + ); + const clientSubscriptionRepository = createClientSubscriptionRepository( + { + bucketName, + region, + profile, + }, + ); + + 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"], + }); + + console.log(formatSubscriptionFileResponse(result)); + + if (argv["terraform-apply"]) { + runTerraformApply(argv); + } + }, + ) + .command( + "channel", + "Deploy a channel status subscription", + { + ...sharedOptions, + "channel-type": { + type: "string" as const, + demandOption: true, + choices: CHANNEL_TYPES, + }, + "channel-statuses": { + string: true, + type: "array" as const, + demandOption: false, + choices: CHANNEL_STATUSES, + }, + "supplier-statuses": { + string: true, + type: "array" as const, + demandOption: false, + choices: SUPPLIER_STATUSES, + }, + }, + async (argv) => { + 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; + } + + console.log( + "[deploy-client-subscriptions] Uploading channel status subscription config...", + ); + + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + argv.project, + ); + const clientSubscriptionRepository = createClientSubscriptionRepository( + { + bucketName, + region, + profile, + }, + ); + + 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"], + }); + + console.log(formatSubscriptionFileResponse(result)); + + if (argv["terraform-apply"]) { + runTerraformApply(argv); + } + }, + ) + .demandCommand(1, "Please specify a command: message or channel") + .strict() + .parseAsync(); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +}; + +(async () => { + if (require.main === module) { + await runCli(); + } +})(); 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..f9ce855 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts @@ -0,0 +1,90 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { createClientSubscriptionRepository } from "src/container"; +import { + formatSubscriptionFileResponse, + resolveBucketName, + resolveProfile, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string", + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", + }, + "client-id": { + type: "string", + demandOption: true, + description: "Client identifier", + }, + region: { + type: "string", + demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string", + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", + }, + }) + .parseSync(); + +export async function main(args: string[] = process.argv) { + const argv = parseArgs(args); + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + 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..d060417 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -0,0 +1,103 @@ +import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; +import { fromIni } from "@aws-sdk/credential-providers"; +import { table } from "table"; +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; + +const SUBSCRIPTION_TABLE_HEADER = [ + "Client ID", + "Subscription Type", + "Statuses", + "Target ID", + "Endpoint", + "Method", + "Rate Limit", + "API Key Header", + "API Key Value", +]; + +const subscriptionStatuses = ( + subscription: ClientSubscriptionConfiguration[number], +): string => { + if (subscription.SubscriptionType === "MessageStatus") { + return subscription.MessageStatuses.join(", "); + } + const statuses = [ + ...subscription.ChannelStatuses, + ...subscription.SupplierStatuses, + ]; + return `${subscription.ChannelType}: ${statuses.join(", ")}`; +}; + +export const formatSubscriptionFileResponse = ( + subscriptions: ClientSubscriptionConfiguration, +): string => { + const rows = subscriptions.flatMap((subscription) => + subscription.Targets.map((target) => [ + subscription.ClientId, + subscription.SubscriptionType, + subscriptionStatuses(subscription), + target.TargetId, + target.InvocationEndpoint, + target.InvocationMethod, + String(target.InvocationRateLimit), + target.APIKey.HeaderName, + target.APIKey.HeaderValue, + ]), + ); + return table([SUBSCRIPTION_TABLE_HEADER, ...rows]); +}; + +export const normalizeClientName = (name: string): string => + name.replaceAll(/\s+/g, "-").toLowerCase(); + +export const resolveProfile = ( + profileArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => profileArg ?? env.AWS_PROFILE; + +export const resolveAccountId = async ( + profile?: string, + region?: string, +): Promise => { + const credentials = profile ? fromIni({ profile }) : undefined; + const client = new STSClient({ region, credentials }); + const { Account } = await client.send(new GetCallerIdentityCommand({})); + if (!Account) { + throw new Error("Unable to determine AWS account ID from STS"); + } + return Account; +}; + +export const deriveBucketName = ( + accountId: string, + environment: string, + region: string, + project = "nhs", + component = "callbacks", +): string => + `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`; + +export const resolveBucketName = async ( + bucketArg?: string, + environment?: string, + region?: string, + profile?: string, + project?: string, +): Promise => { + if (bucketArg) { + return bucketArg; + } + if (!environment) { + throw new Error( + "Bucket name is required: use --bucket-name to specify directly, or --environment (with --region and optionally --profile) to determine this automatically", + ); + } + const resolvedRegion = region ?? "eu-west-2"; + const accountId = await resolveAccountId(profile, resolvedRegion); + return deriveBucketName(accountId, environment, resolvedRegion, project); +}; + +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..4097dd9 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -0,0 +1,169 @@ +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, + resolveProfile, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string", + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", + }, + "client-name": { + type: "string", + demandOption: false, + description: "Display name for the client (defaults to client-id)", + }, + "client-id": { + type: "string", + demandOption: true, + description: "Client identifier", + }, + "api-endpoint": { + type: "string", + demandOption: true, + description: "Webhook endpoint URL (must start with https://)", + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + description: "HTTP header name for the API key", + }, + "api-key": { + type: "string", + demandOption: true, + description: "API key value for authenticating webhook calls", + }, + "channel-statuses": { + string: true, + type: "array", + demandOption: false, + choices: CHANNEL_STATUSES, + description: "Channel statuses to subscribe to", + }, + "supplier-statuses": { + string: true, + type: "array", + demandOption: false, + choices: SUPPLIER_STATUSES, + description: "Supplier statuses to subscribe to", + }, + "channel-type": { + type: "string", + demandOption: true, + choices: CHANNEL_TYPES, + description: "Channel type", + }, + "rate-limit": { + type: "number", + demandOption: true, + description: "Maximum number of webhook calls per second", + }, + "dry-run": { + type: "boolean", + demandOption: true, + description: "Validate config without writing to S3", + }, + region: { + type: "string", + demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string", + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", + }, + }) + .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 region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + 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"], + }); + + 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..8dcdb35 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -0,0 +1,140 @@ +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, + resolveProfile, + resolveRegion, +} from "src/entrypoint/cli/helper"; + +export const parseArgs = (args: string[]) => + yargs(hideBin(args)) + .options({ + "bucket-name": { + type: "string", + demandOption: false, + description: "Explicit S3 bucket name (overrides derived name)", + }, + environment: { + type: "string", + demandOption: false, + description: + "Environment name, used to derive infrastructure resource names when not explicitly provided", + }, + "client-name": { + type: "string", + demandOption: false, + description: "Display name for the client (defaults to client-id)", + }, + "client-id": { + type: "string", + demandOption: true, + description: "Client identifier", + }, + "api-endpoint": { + type: "string", + demandOption: true, + description: "Webhook endpoint URL (must start with https://)", + }, + "api-key": { + type: "string", + demandOption: true, + description: "API key value for authenticating webhook calls", + }, + "api-key-header-name": { + type: "string", + default: "x-api-key", + demandOption: false, + description: "HTTP header name for the API key", + }, + "message-statuses": { + string: true, + type: "array", + demandOption: true, + choices: MESSAGE_STATUSES, + description: "Message statuses to subscribe to", + }, + "rate-limit": { + type: "number", + demandOption: true, + description: "Maximum number of webhook calls per second", + }, + "dry-run": { + type: "boolean", + demandOption: true, + description: "Validate config without writing to S3", + }, + region: { + type: "string", + demandOption: false, + description: "AWS region (defaults to AWS_REGION or eu-west-2)", + }, + profile: { + type: "string", + demandOption: false, + description: "AWS profile to use (overrides AWS_PROFILE)", + }, + }) + .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 region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const bucketName = await resolveBucketName( + argv["bucket-name"], + argv.environment, + region, + profile, + ); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region, + profile, + }); + + 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"], + }); + + 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..48c5629 --- /dev/null +++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts @@ -0,0 +1,165 @@ +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 type { SubscriptionBuilder } 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; +}; + +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"), +}); + +export type ChannelStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + channelStatuses?: ChannelStatus[]; + supplierStatuses?: SupplierStatus[]; + channelType: Channel; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: 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"), +}); + +export class ClientSubscriptionRepository { + constructor( + private readonly s3Repository: S3Repository, + private readonly configurationBuilder: SubscriptionBuilder, + ) {} + + 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/**/*" + ] +}