diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml
new file mode 100644
index 0000000..9f0d603
--- /dev/null
+++ b/.github/actions/acceptance-tests/action.yaml
@@ -0,0 +1,53 @@
+name: Acceptance tests
+description: "Run acceptance tests for this repo"
+
+inputs:
+ testType:
+ description: Type of test to run
+ required: true
+
+ targetEnvironment:
+ description: Name of the environment under test
+ required: true
+
+ targetAccountGroup:
+ description: Name of the account group under test
+ default: nhs-notify-client-callbacks-dev
+ required: true
+
+ targetComponent:
+ description: Name of the component under test
+ required: true
+
+runs:
+ using: "composite"
+
+ steps:
+ - name: Fetch terraform output
+ uses: actions/download-artifact@v4
+ with:
+ name: terraform-output-${{ inputs.targetComponent }}
+
+ - name: Get Node version
+ id: nodejs_version
+ shell: bash
+ run: |
+ echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT
+
+ - name: "Repo setup"
+ uses: ./.github/actions/node-install
+ with:
+ GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
+
+ - name: "Set PR NUMBER environment variable"
+ shell: bash
+ run: |
+ echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV
+
+ - name: Run test - ${{ inputs.testType }}
+ shell: bash
+ env:
+ PROJECT: nhs
+ COMPONENT: ${{ inputs.targetComponent }}
+ run: |
+ make test-${{ inputs.testType }}
diff --git a/.github/actions/node-install/action.yaml b/.github/actions/node-install/action.yaml
new file mode 100644
index 0000000..b1ed2d0
--- /dev/null
+++ b/.github/actions/node-install/action.yaml
@@ -0,0 +1,24 @@
+name: 'npm install and setup'
+description: 'Setup node, authenticate github package repository and perform clean npm install'
+
+inputs:
+ GITHUB_TOKEN:
+ description: "Token for access to github package registry"
+ required: true
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Use Node.js'
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: '.tool-versions'
+ registry-url: 'https://npm.pkg.github.com'
+ scope: '@nhsdigital'
+
+ - name: 'Install dependencies'
+ shell: bash
+ env:
+ NODE_AUTH_TOKEN: ${{ inputs.GITHUB_TOKEN }}
+ run: |
+ npm ci
diff --git a/.github/actions/test-types.json b/.github/actions/test-types.json
new file mode 100644
index 0000000..4fe0a8a
--- /dev/null
+++ b/.github/actions/test-types.json
@@ -0,0 +1,3 @@
+[
+ "integration"
+]
diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml
index bb88afb..cd4d89e 100644
--- a/.github/workflows/cicd-1-pull-request.yaml
+++ b/.github/workflows/cicd-1-pull-request.yaml
@@ -174,9 +174,11 @@ jobs:
--overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
acceptance-stage: # Recommended maximum execution time is 10 minutes
name: "Acceptance stage"
- needs: [metadata, build-stage]
+ needs: [metadata, build-stage, pr-create-dynamic-environment]
uses: ./.github/workflows/stage-4-acceptance.yaml
- if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
+ if: >-
+ contains(fromJSON('["success", "skipped"]'), needs.pr-create-dynamic-environment.result) &&
+ (needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
with:
build_datetime: "${{ needs.metadata.outputs.build_datetime }}"
build_timestamp: "${{ needs.metadata.outputs.build_timestamp }}"
diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml
index c6dc58e..4ae997e 100644
--- a/.github/workflows/stage-4-acceptance.yaml
+++ b/.github/workflows/stage-4-acceptance.yaml
@@ -36,139 +36,37 @@ on:
required: true
type: string
+permissions:
+ id-token: write
+ contents: read
+
jobs:
- environment-set-up:
- name: "Environment set up"
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Create infractructure"
- run: |
- echo "Creating infractructure..."
- - name: "Update database"
- run: |
- echo "Updating database..."
- - name: "Deploy application"
- run: |
- echo "Deploying application..."
- test-contract:
- name: "Contract test"
- runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run contract test"
- run: |
- make test-contract
- - name: "Save result"
- run: |
- echo "Nothing to save"
- test-security:
- name: "Security test"
+ run-acceptance-tests:
+ name: Run Acceptance Tests
runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run security test"
- run: |
- make test-security
- - name: "Save result"
- run: |
- echo "Nothing to save"
- test-ui:
- name: "UI test"
- runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run UI test"
- run: |
- make test-ui
- - name: "Save result"
- run: |
- echo "Nothing to save"
- test-ui-performance:
- name: "UI performance test"
- runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run UI performance test"
- run: |
- make test-ui-performance
- - name: "Save result"
- run: |
- echo "Nothing to save"
- test-integration:
- name: "Integration test"
- runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run integration test"
- run: |
- make test-integration
- - name: "Save result"
- run: |
- echo "Nothing to save"
- test-accessibility:
- name: "Accessibility test"
- runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run accessibility test"
- run: |
- make test-accessibility
- - name: "Save result"
- run: |
- echo "Nothing to save"
- test-load:
- name: "Load test"
- runs-on: ubuntu-latest
- needs: environment-set-up
- timeout-minutes: 10
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Run load tests"
- run: |
- make test-load
- - name: "Save result"
- run: |
- echo "Nothing to save"
- environment-tear-down:
- name: "Environment tear down"
- runs-on: ubuntu-latest
- needs:
- [
- test-accessibility,
- test-contract,
- test-integration,
- test-load,
- test-security,
- test-ui-performance,
- test-ui,
- ]
- if: always()
- timeout-minutes: 5
- steps:
- - name: "Checkout code"
- uses: actions/checkout@v4
- - name: "Tear down environment"
- run: |
- echo "Tearing down environment..."
+ - uses: actions/checkout@v4
+
+ - name: "Use Node.js"
+ uses: actions/setup-node@v4
+ with:
+ node-version: "${{ inputs.nodejs_version }}"
+ registry-url: "https://npm.pkg.github.com"
+ scope: "@nhsdigital"
+
+ - name: Trigger Acceptance Tests
+ shell: bash
+ env:
+ APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }}
+ APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TARGET_ENVIRONMENT: ${{ inputs.target_environment }}
+ run: |
+ .github/scripts/dispatch_internal_repo_workflow.sh \
+ --targetWorkflow "dispatch-contextual-tests-dynamic-env.yaml" \
+ --infraRepoName "nhs-notify-client-callbacks" \
+ --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \
+ --overrideProjectName "nhs" \
+ --targetEnvironment "$TARGET_ENVIRONMENT" \
+ --targetAccountGroup "nhs-notify-client-callbacks-dev" \
+ --targetComponent "callbacks"
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 9d3ce40..22c3292 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -239,6 +239,20 @@ export default defineConfig([
],
},
},
+ {
+ files: ["tools/client-subscriptions-management/**/*.ts"],
+ rules: {
+ "no-console": "off",
+ "import-x/first": "off",
+ },
+ },
+ {
+ files: ["lambdas/client-transform-filter-lambda/**/*.ts"],
+ rules: {
+ "no-console": "off",
+ "import-x/first": "off",
+ },
+ },
// misc rule overrides
{
diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md
index baaa7d2..1c3f444 100644
--- a/infrastructure/terraform/components/callbacks/README.md
+++ b/infrastructure/terraform/components/callbacks/README.md
@@ -47,7 +47,11 @@
| [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-sqs.zip | n/a |
## Outputs
-No outputs.
+| Name | Description |
+|------|-------------|
+| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts |
+| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) |
+| [mock\_webhook\_url](#output\_mock\_webhook\_url) | URL endpoint for mock webhook (for TEST\_WEBHOOK\_URL environment variable) |
diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
index 2fff974..386e237 100644
--- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
+++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
@@ -35,8 +35,11 @@ module "client_transform_filter_lambda" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = {
- ENVIRONMENT = var.environment
- METRICS_NAMESPACE = "nhs-notify-client-callbacks"
+ ENVIRONMENT = var.environment
+ METRICS_NAMESPACE = "nhs-notify-client-callbacks-metrics"
+ CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.id
+ CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"
+ CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60"
}
}
diff --git a/infrastructure/terraform/components/callbacks/outputs.tf b/infrastructure/terraform/components/callbacks/outputs.tf
new file mode 100644
index 0000000..b042e36
--- /dev/null
+++ b/infrastructure/terraform/components/callbacks/outputs.tf
@@ -0,0 +1,29 @@
+##
+# Deployment details
+##
+
+output "deployment" {
+ description = "Deployment details used for post-deployment scripts"
+ value = {
+ aws_region = var.region
+ aws_account_id = var.aws_account_id
+ project = var.project
+ environment = var.environment
+ group = var.group
+ component = var.component
+ }
+}
+
+##
+# Mock Webhook Lambda Outputs (test/dev environments only)
+##
+
+output "mock_webhook_lambda_log_group_name" {
+ description = "CloudWatch log group name for mock webhook lambda (for integration test queries)"
+ value = var.deploy_mock_webhook ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null
+}
+
+output "mock_webhook_url" {
+ description = "URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable)"
+ value = var.deploy_mock_webhook ? aws_lambda_function_url.mock_webhook[0].function_url : null
+}
diff --git a/jest.config.base.ts b/jest.config.base.ts
index f057e3e..52c1d02 100644
--- a/jest.config.base.ts
+++ b/jest.config.base.ts
@@ -5,7 +5,7 @@ export const baseJestConfig: Config = {
clearMocks: true,
collectCoverage: true,
coverageDirectory: "./.reports/unit/coverage",
- coverageProvider: "v8",
+ coverageProvider: "babel",
coveragePathIgnorePatterns: ["/__tests__/", "/node_modules/"],
transform: { "^.+\\.ts$": "ts-jest" },
testPathIgnorePatterns: [".build"],
diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json
index 5cc0128..4a4be91 100644
--- a/lambdas/client-transform-filter-lambda/package.json
+++ b/lambdas/client-transform-filter-lambda/package.json
@@ -1,5 +1,6 @@
{
"dependencies": {
+ "@aws-sdk/client-s3": "^3.821.0",
"@nhs-notify-client-callbacks/models": "*",
"aws-embedded-metrics": "^4.2.1",
"cloudevents": "^8.0.2",
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts
new file mode 100644
index 0000000..0c495c8
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts
@@ -0,0 +1,11 @@
+import { resolveCacheTtlMs } from "services/config-loader-service";
+
+describe("cache ttl configuration", () => {
+ it("falls back to default TTL when invalid", () => {
+ const ttlMs = resolveCacheTtlMs({
+ CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "not-a-number",
+ } as NodeJS.ProcessEnv);
+
+ expect(ttlMs).toBe(60_000);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts
new file mode 100644
index 0000000..a60973f
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts
@@ -0,0 +1,11 @@
+import { resolveCacheTtlMs } from "services/config-loader-service";
+
+describe("cache ttl configuration", () => {
+ it("uses the configured TTL when valid", () => {
+ const ttlMs = resolveCacheTtlMs({
+ CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS: "120",
+ } as NodeJS.ProcessEnv);
+
+ expect(ttlMs).toBe(120_000);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts
new file mode 100644
index 0000000..bb11ccb
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts
@@ -0,0 +1,140 @@
+// eslint-disable-next-line unicorn/no-useless-undefined
+const mockLoadClientConfig = jest.fn().mockResolvedValue(undefined);
+const mockConfigLoader = jest.fn().mockImplementation(() => ({
+ loadClientConfig: mockLoadClientConfig,
+}));
+
+jest.mock("services/config-loader", () => ({
+ ConfigLoader: mockConfigLoader,
+}));
+
+jest.mock("aws-embedded-metrics", () => ({
+ createMetricsLogger: jest.fn(() => ({
+ setNamespace: jest.fn(),
+ setDimensions: jest.fn(),
+ putMetric: jest.fn(),
+ flush: jest.fn().mockResolvedValue(undefined as unknown),
+ })),
+ Unit: {
+ Count: "Count",
+ Milliseconds: "Milliseconds",
+ },
+}));
+
+import type { SQSRecord } from "aws-lambda";
+import { EventTypes } from "@nhs-notify-client-callbacks/models";
+import { configLoaderService, handler } from "..";
+
+const makeSqsRecord = (body: object): SQSRecord => ({
+ messageId: "sqs-id",
+ receiptHandle: "receipt",
+ body: JSON.stringify(body),
+ attributes: {
+ ApproximateReceiveCount: "1",
+ SentTimestamp: "1519211230",
+ SenderId: "ABCDEFGHIJ",
+ ApproximateFirstReceiveTimestamp: "1519211230",
+ },
+ messageAttributes: {},
+ md5OfBody: "md5",
+ eventSource: "aws:sqs",
+ eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue",
+ awsRegion: "eu-west-2",
+});
+
+const validEvent = {
+ specversion: "1.0",
+ id: "event-id",
+ source: "/nhs/england/notify/development/primary/data-plane/client-callbacks",
+ subject: "customer/test/message/msg-123",
+ type: EventTypes.MESSAGE_STATUS_PUBLISHED,
+ time: "2025-01-01T10:00:00.000Z",
+ datacontenttype: "application/json",
+ dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json",
+ traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01",
+ data: {
+ messageId: "msg-123",
+ messageReference: "ref-123",
+ messageStatus: "DELIVERED",
+ channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-1",
+ },
+};
+
+describe("config prefix resolution", () => {
+ beforeEach(() => {
+ mockLoadClientConfig.mockClear();
+ mockConfigLoader.mockClear();
+ configLoaderService.reset(); // force lazy re-creation of ConfigLoader on next call
+ process.env.METRICS_NAMESPACE = "test-namespace";
+ process.env.ENVIRONMENT = "test";
+ });
+
+ afterEach(() => {
+ delete process.env.METRICS_NAMESPACE;
+ delete process.env.ENVIRONMENT;
+ });
+
+ it("uses the default prefix when env is not set", async () => {
+ const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket";
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+
+ await handler([makeSqsRecord(validEvent)]);
+
+ expect(mockConfigLoader).toHaveBeenCalledWith(
+ expect.objectContaining({
+ keyPrefix: "client_subscriptions/",
+ }),
+ );
+
+ if (originalBucket === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket;
+ }
+
+ if (originalPrefix === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix;
+ }
+ });
+
+ it("uses the configured prefix when env is set", async () => {
+ const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "bucket";
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/";
+
+ await handler([makeSqsRecord(validEvent)]);
+
+ expect(mockConfigLoader).toHaveBeenCalledWith(
+ expect.objectContaining({
+ keyPrefix: "custom_prefix/",
+ }),
+ );
+
+ if (originalBucket === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket;
+ }
+
+ if (originalPrefix === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = originalPrefix;
+ }
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts
new file mode 100644
index 0000000..7d123b5
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts
@@ -0,0 +1,250 @@
+/**
+ * Integration-style test for the complete handler flow including S3 config loading and
+ * subscription filtering. Uses the real ConfigLoader + ConfigCache + filter pipeline
+ * with a mocked S3Client.
+ */
+// Mock S3Client before importing the handler
+const mockSend = jest.fn();
+jest.mock("@aws-sdk/client-s3", () => {
+ const actual = jest.requireActual("@aws-sdk/client-s3");
+ return {
+ ...actual,
+ S3Client: jest.fn().mockImplementation(() => ({
+ send: mockSend,
+ })),
+ };
+});
+
+jest.mock("aws-embedded-metrics", () => ({
+ createMetricsLogger: jest.fn(() => ({
+ setNamespace: jest.fn(),
+ setDimensions: jest.fn(),
+ putMetric: jest.fn(),
+ flush: jest.fn().mockResolvedValue(undefined as unknown),
+ })),
+ Unit: {
+ Count: "Count",
+ Milliseconds: "Milliseconds",
+ },
+}));
+
+import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
+import type { SQSRecord } from "aws-lambda";
+import { EventTypes } from "@nhs-notify-client-callbacks/models";
+import { createS3Client } from "services/config-loader-service";
+import { configLoaderService, handler } from "..";
+
+const makeSqsRecord = (body: object): SQSRecord => ({
+ messageId: "sqs-id",
+ receiptHandle: "receipt",
+ body: JSON.stringify(body),
+ attributes: {
+ ApproximateReceiveCount: "1",
+ SentTimestamp: "1519211230",
+ SenderId: "ABCDEFGHIJ",
+ ApproximateFirstReceiveTimestamp: "1519211230",
+ },
+ messageAttributes: {},
+ md5OfBody: "md5",
+ eventSource: "aws:sqs",
+ eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:queue",
+ awsRegion: "eu-west-2",
+});
+
+const createValidConfig = (clientId: string) => [
+ {
+ Name: `${clientId}-message`,
+ ClientId: clientId,
+ Description: "Message status subscription",
+ EventSource: JSON.stringify([]),
+ EventDetail: JSON.stringify({}),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: `${clientId}-target`,
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com/webhook",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED", "FAILED"],
+ },
+];
+
+const validMessageStatusEvent = (clientId: string, messageStatus: string) => ({
+ specversion: "1.0",
+ id: "event-id",
+ source: "/nhs/england/notify/development/primary/data-plane/client-callbacks",
+ subject: `customer/test/message/msg-123`,
+ type: EventTypes.MESSAGE_STATUS_PUBLISHED,
+ time: "2025-01-01T10:00:00.000Z",
+ datacontenttype: "application/json",
+ dataschema: "https://nhs.uk/schemas/notify/message-status-data.v1.json",
+ traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01",
+ data: {
+ messageId: "msg-123",
+ messageReference: "ref-123",
+ messageStatus,
+ channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId,
+ },
+});
+
+describe("Lambda handler with S3 subscription filtering", () => {
+ beforeAll(() => {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/";
+ process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60";
+ process.env.METRICS_NAMESPACE = "test-namespace";
+ process.env.ENVIRONMENT = "test";
+ });
+
+ beforeEach(() => {
+ mockSend.mockClear();
+ // Reset loader and clear cache for clean state between tests
+ configLoaderService.reset(
+ createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }),
+ );
+ });
+
+ afterAll(() => {
+ configLoaderService.reset();
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+ delete process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS;
+ delete process.env.METRICS_NAMESPACE;
+ delete process.env.ENVIRONMENT;
+ });
+
+ it("passes event through when client config matches subscription", async () => {
+ mockSend.mockResolvedValue({
+ Body: {
+ transformToString: jest
+ .fn()
+ .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))),
+ },
+ });
+
+ const result = await handler([
+ makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")),
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
+ });
+
+ it("filters out event when status is not in subscription", async () => {
+ mockSend.mockResolvedValue({
+ Body: {
+ transformToString: jest
+ .fn()
+ .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))),
+ },
+ });
+
+ const result = await handler([
+ makeSqsRecord(validMessageStatusEvent("client-1", "CREATED")),
+ ]);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it("filters out event when client has no configuration in S3", async () => {
+ mockSend.mockRejectedValue(
+ new NoSuchKey({ message: "Not found", $metadata: {} }),
+ );
+
+ const result = await handler([
+ makeSqsRecord(validMessageStatusEvent("client-no-config", "DELIVERED")),
+ ]);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it("passes matching events and filters non-matching in the same batch", async () => {
+ // First call (client-1 DELIVERED) → match
+ // Second call (client-1 CREATED) → no match
+ // Both share the same client config (cached after first call)
+ mockSend.mockResolvedValue({
+ Body: {
+ transformToString: jest
+ .fn()
+ .mockResolvedValue(JSON.stringify(createValidConfig("client-1"))),
+ },
+ });
+
+ const result = await handler([
+ makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")),
+ makeSqsRecord(validMessageStatusEvent("client-1", "CREATED")),
+ ]);
+
+ // Only the DELIVERED event passes the filter
+ expect(result).toHaveLength(1);
+ expect((result[0].data as { messageStatus: string }).messageStatus).toBe(
+ "DELIVERED",
+ );
+ });
+
+ it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", async () => {
+ configLoaderService.reset();
+ const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+
+ await expect(
+ handler([
+ makeSqsRecord(validMessageStatusEvent("client-1", "DELIVERED")),
+ ]),
+ ).rejects.toThrow("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required");
+
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET =
+ originalBucket ?? "test-bucket";
+ });
+
+ it("loads configs for multiple distinct clients in parallel and deduplicates S3 fetches", async () => {
+ mockSend.mockImplementation((cmd: { input: { Key: string } }) => {
+ const clientId = cmd.input.Key.replace(
+ "client_subscriptions/",
+ "",
+ ).replace(".json", "");
+ return Promise.resolve({
+ Body: {
+ transformToString: jest
+ .fn()
+ .mockResolvedValue(JSON.stringify(createValidConfig(clientId))),
+ },
+ });
+ });
+
+ const result = await handler([
+ makeSqsRecord(validMessageStatusEvent("client-a", "DELIVERED")),
+ makeSqsRecord(validMessageStatusEvent("client-b", "DELIVERED")),
+ makeSqsRecord(validMessageStatusEvent("client-a", "DELIVERED")), // duplicate client
+ ]);
+
+ // All three events match their respective configs
+ expect(result).toHaveLength(3);
+ // S3 fetched once per distinct client (client-a and client-b), not once per event
+ expect(mockSend).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts
new file mode 100644
index 0000000..ae483b7
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts
@@ -0,0 +1,32 @@
+import { createS3Client } from "services/config-loader-service";
+
+describe("createS3Client", () => {
+ it("sets forcePathStyle=true when endpoint contains localhost", () => {
+ const env = { AWS_ENDPOINT_URL: "http://localhost:4566" };
+ const client = createS3Client(env);
+
+ // Access the config through the client's config property
+ const { config } = client as any;
+ expect(config.endpoint).toBeDefined();
+ expect(config.forcePathStyle).toBe(true);
+ });
+
+ it("does not set forcePathStyle=true when endpoint does not contain localhost", () => {
+ const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" };
+ const client = createS3Client(env);
+
+ const { config } = client as any;
+ expect(config.endpoint).toBeDefined();
+ // S3Client converts undefined to false, so we just check it's not true
+ expect(config.forcePathStyle).not.toBe(true);
+ });
+
+ it("does not set forcePathStyle=true when endpoint is not set", () => {
+ const env = {};
+ const client = createS3Client(env);
+
+ const { config } = client as any;
+ // S3Client converts undefined to false, so we just check it's not true
+ expect(config.forcePathStyle).not.toBe(true);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
index 13de95b..dcb4599 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
@@ -9,9 +9,71 @@ import type {
} from "@nhs-notify-client-callbacks/models";
import type { Logger } from "services/logger";
import type { CallbackMetrics } from "services/metrics";
+import type { ConfigLoader } from "services/config-loader";
import { ObservabilityService } from "services/observability";
+import { ConfigLoaderService } from "services/config-loader-service";
import { createHandler } from "..";
+const createPassthroughConfigLoader = (): ConfigLoader =>
+ ({
+ loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [
+ {
+ SubscriptionType: "MessageStatus",
+ Name: "unit-test-message",
+ ClientId: clientId,
+ Description: "Pass-through for unit tests",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [],
+ Statuses: [
+ "DELIVERED",
+ "FAILED",
+ "PENDING",
+ "SENDING",
+ "TECHNICAL_FAILURE",
+ "PERMANENT_FAILURE",
+ ],
+ },
+ {
+ SubscriptionType: "ChannelStatus",
+ Name: "unit-test-nhsapp",
+ ClientId: clientId,
+ Description: "Pass-through for unit tests",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [],
+ ChannelType: "NHSAPP",
+ ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"],
+ SupplierStatuses: [
+ "delivered",
+ "permanent_failure",
+ "temporary_failure",
+ ],
+ },
+ {
+ SubscriptionType: "ChannelStatus",
+ Name: "unit-test-sms",
+ ClientId: clientId,
+ Description: "Pass-through for unit tests",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [],
+ ChannelType: "SMS",
+ ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"],
+ SupplierStatuses: [
+ "delivered",
+ "permanent_failure",
+ "temporary_failure",
+ ],
+ },
+ ]),
+ }) as unknown as ConfigLoader;
+
+const makeStubConfigLoaderService = (): ConfigLoaderService => {
+ const loader = createPassthroughConfigLoader();
+ return { getLoader: () => loader } as unknown as ConfigLoaderService;
+};
+
describe("Lambda handler", () => {
const mockLogger = {
info: jest.fn(),
@@ -29,6 +91,8 @@ describe("Lambda handler", () => {
emitTransformationFailure: jest.fn(),
emitDeliveryInitiated: jest.fn(),
emitValidationError: jest.fn(),
+ emitFilteringStarted: jest.fn(),
+ emitFilteringMatched: jest.fn(),
} as unknown as CallbackMetrics;
const mockMetricsLogger = {
@@ -38,6 +102,7 @@ describe("Lambda handler", () => {
const handler = createHandler({
createObservabilityService: () =>
new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger),
+ createConfigLoaderService: makeStubConfigLoaderService,
});
beforeEach(() => {
@@ -174,7 +239,7 @@ describe("Lambda handler", () => {
};
await expect(handler([sqsMessage])).rejects.toThrow(
- 'Validation failed: type: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1"',
+ "Validation failed: type: Invalid option",
);
});
@@ -260,6 +325,61 @@ describe("Lambda handler", () => {
);
});
+ it("should use 'Unknown error' message when a non-Error is thrown during SQS message parsing", async () => {
+ const faultyMetrics = {
+ emitEventReceived: jest.fn(),
+ emitValidationError: jest.fn(),
+ emitTransformationFailure: jest.fn(),
+ emitDeliveryInitiated: jest.fn(),
+ emitTransformationSuccess: jest.fn(),
+ };
+ const faultyLogger = {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ child: jest.fn().mockReturnThis(),
+ addContext: jest.fn(),
+ clearContext: jest.fn(),
+ };
+ const faultyObservability = {
+ recordProcessingStarted: jest.fn(() => {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ throw "non-error-thrown";
+ }),
+ getLogger: jest.fn().mockReturnValue(faultyLogger),
+ getMetrics: jest.fn().mockReturnValue(faultyMetrics),
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ flush: jest.fn().mockResolvedValue(undefined),
+ } as unknown as ObservabilityService;
+
+ const faultyHandler = createHandler({
+ createObservabilityService: () => faultyObservability,
+ createConfigLoaderService: makeStubConfigLoaderService,
+ });
+
+ const sqsMessage: SQSRecord = {
+ messageId: "sqs-msg-id-non-error",
+ receiptHandle: "receipt-handle-non-error",
+ body: JSON.stringify(validMessageStatusEvent),
+ attributes: {
+ ApproximateReceiveCount: "1",
+ SentTimestamp: "1519211230",
+ SenderId: "ABCDEFGHIJ",
+ ApproximateFirstReceiveTimestamp: "1519211230",
+ },
+ messageAttributes: {},
+ md5OfBody: "mock-md5",
+ eventSource: "aws:sqs",
+ eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue",
+ awsRegion: "eu-west-2",
+ };
+
+ await expect(faultyHandler([sqsMessage])).rejects.toThrow(
+ "Failed to parse SQS message body as JSON: Unknown error",
+ );
+ });
+
it("should process empty batch successfully", async () => {
const result = await handler([]);
@@ -392,7 +512,14 @@ describe("createHandler default wiring", () => {
});
expect(state.testHandler).toBeDefined();
+ const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
const result = await state.testHandler!([]);
+ if (originalBucket === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket;
+ }
expect(state.createMetricLogger).toHaveBeenCalledTimes(1);
expect(state.CallbackMetrics).toHaveBeenCalledWith(state.mockMetricsLogger);
@@ -405,6 +532,7 @@ describe("createHandler default wiring", () => {
expect(state.processEvents).toHaveBeenCalledWith(
[],
state.mockObservabilityInstance,
+ expect.any(Object),
);
expect(result).toEqual(["ok"]);
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts
index 03206bf..e8b9308 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/callback-logger.test.ts
@@ -21,7 +21,7 @@ describe("callback-logger", () => {
});
describe("logCallbackGenerated", () => {
- describe("MESSAGE_STATUS_TRANSITIONED events", () => {
+ describe("MESSAGE_STATUS_PUBLISHED events", () => {
const messageStatusPayload: ClientCallbackPayload = {
data: [
{
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts
new file mode 100644
index 0000000..4341818
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts
@@ -0,0 +1,86 @@
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+import { ConfigCache } from "services/config-cache";
+
+describe("ConfigCache", () => {
+ it("stores and retrieves configuration", () => {
+ const cache = new ConfigCache(60_000);
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "test",
+ ClientId: "client-1",
+ Description: "Test",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [],
+ SubscriptionType: "MessageStatus" as const,
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ cache.set("client-1", config);
+ const result = cache.get("client-1");
+
+ expect(result).toEqual(config);
+ });
+
+ it("returns undefined for non-existent key", () => {
+ const cache = new ConfigCache(60_000);
+ const result = cache.get("non-existent");
+
+ expect(result).toBeUndefined();
+ });
+
+ it("returns undefined for expired entries", () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2025-01-01T10:00:00Z"));
+
+ const cache = new ConfigCache(1000); // 1 second TTL
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "test",
+ ClientId: "client-1",
+ Description: "Test",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [],
+ SubscriptionType: "MessageStatus" as const,
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ cache.set("client-1", config);
+
+ // Advance time past expiry
+ jest.advanceTimersByTime(1500);
+
+ const result = cache.get("client-1");
+
+ expect(result).toBeUndefined();
+
+ jest.useRealTimers();
+ });
+
+ it("clears all entries", () => {
+ const cache = new ConfigCache(60_000);
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "test",
+ ClientId: "client-1",
+ Description: "Test",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [],
+ SubscriptionType: "MessageStatus" as const,
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ cache.set("client-1", config);
+ cache.set("client-2", config);
+
+ cache.clear();
+
+ expect(cache.get("client-1")).toBeUndefined();
+ expect(cache.get("client-2")).toBeUndefined();
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts
new file mode 100644
index 0000000..753a1e8
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts
@@ -0,0 +1,76 @@
+import {
+ ConfigLoaderService,
+ createS3Client,
+} from "services/config-loader-service";
+
+describe("ConfigLoaderService", () => {
+ const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+
+ beforeEach(() => {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
+ });
+
+ afterEach(() => {
+ if (originalBucket === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket;
+ }
+ });
+
+ describe("getLoader", () => {
+ it("returns the same loader instance on subsequent calls (lazy singleton)", () => {
+ const service = new ConfigLoaderService();
+ const first = service.getLoader();
+ const second = service.getLoader();
+ expect(first).toBe(second);
+ });
+
+ it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ const service = new ConfigLoaderService();
+ expect(() => service.getLoader()).toThrow(
+ "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required",
+ );
+ });
+ });
+
+ describe("reset", () => {
+ it("clears the cached loader so a new one is created on next getLoader call", () => {
+ const service = new ConfigLoaderService();
+ const before = service.getLoader();
+ service.reset();
+ const after = service.getLoader();
+ expect(after).not.toBe(before);
+ });
+
+ it("initialises a new loader with a custom S3Client when provided", () => {
+ const customClient = createS3Client({
+ AWS_ENDPOINT_URL: "http://localhost:4566",
+ });
+ const service = new ConfigLoaderService();
+ service.reset(customClient);
+ // Should not throw and the loader should be available immediately
+ expect(() => service.getLoader()).not.toThrow();
+ });
+
+ it("uses the configured key prefix when CLIENT_SUBSCRIPTION_CONFIG_PREFIX is set", () => {
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/";
+ const customClient = createS3Client({
+ AWS_ENDPOINT_URL: "http://localhost:4566",
+ });
+ const service = new ConfigLoaderService();
+ expect(() => service.reset(customClient)).not.toThrow();
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
+ });
+
+ it("throws when S3Client is provided but CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", () => {
+ delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ const customClient = createS3Client();
+ const service = new ConfigLoaderService();
+ expect(() => service.reset(customClient)).toThrow(
+ "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required",
+ );
+ });
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts
new file mode 100644
index 0000000..29dc52a
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts
@@ -0,0 +1,140 @@
+import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3";
+import { ConfigCache } from "services/config-cache";
+import { ConfigLoader } from "services/config-loader";
+import { ConfigValidationError } from "services/validators/config-validator";
+
+const mockBody = (json: string) => ({
+ transformToString: jest.fn().mockResolvedValue(json),
+});
+
+const createValidConfig = (clientId: string) => [
+ {
+ Name: `${clientId}-message`,
+ ClientId: clientId,
+ Description: "Message status subscription",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: [clientId],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: `${clientId}-target`,
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com/webhook",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+];
+
+const createLoader = (send: jest.Mock) =>
+ new ConfigLoader({
+ bucketName: "bucket",
+ keyPrefix: "client_subscriptions/",
+ s3Client: { send } as unknown as S3Client,
+ cache: new ConfigCache(60_000),
+ });
+
+describe("ConfigLoader", () => {
+ it("loads and validates client configuration from S3", async () => {
+ const send = jest.fn().mockResolvedValue({
+ Body: mockBody(JSON.stringify(createValidConfig("client-1"))),
+ });
+ const loader = createLoader(send);
+
+ const result = await loader.loadClientConfig("client-1");
+
+ expect(result).toEqual(createValidConfig("client-1"));
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
+ expect(send.mock.calls[0][0].input).toEqual({
+ Bucket: "bucket",
+ Key: "client_subscriptions/client-1.json",
+ });
+ });
+
+ it("returns cached configuration on subsequent calls", async () => {
+ const send = jest.fn().mockResolvedValue({
+ Body: mockBody(JSON.stringify(createValidConfig("client-1"))),
+ });
+ const loader = createLoader(send);
+
+ await loader.loadClientConfig("client-1");
+ await loader.loadClientConfig("client-1");
+
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns undefined when the configuration file is missing", async () => {
+ const send = jest
+ .fn()
+ .mockRejectedValue(
+ new NoSuchKey({ message: "Not found", $metadata: {} }),
+ );
+ const loader = createLoader(send);
+
+ await expect(loader.loadClientConfig("client-1")).resolves.toBeUndefined();
+ });
+
+ it("throws when configuration fails validation", async () => {
+ const send = jest.fn().mockResolvedValue({
+ Body: mockBody(JSON.stringify([{ SubscriptionType: "MessageStatus" }])),
+ });
+ const loader = createLoader(send);
+
+ await expect(loader.loadClientConfig("client-1")).rejects.toThrow(
+ ConfigValidationError,
+ );
+ });
+
+ it("throws when S3 response body is empty", async () => {
+ const send = jest.fn().mockResolvedValue({});
+ const loader = createLoader(send);
+
+ await expect(loader.loadClientConfig("client-1")).rejects.toThrow(
+ ConfigValidationError,
+ );
+ });
+
+ it("wraps S3 errors as ConfigValidationError", async () => {
+ const send = jest.fn().mockRejectedValue(new Error("S3 access denied"));
+ const loader = createLoader(send);
+
+ const error = await loader
+ .loadClientConfig("client-1")
+ .catch((error_) => error_);
+ expect(error).toBeInstanceOf(ConfigValidationError);
+ expect(error.issues).toEqual([
+ { path: "config", message: "S3 access denied" },
+ ]);
+ });
+
+ it("wraps non-Error values thrown by S3 as ConfigValidationError", async () => {
+ const send = jest.fn().mockRejectedValue("unexpected string error");
+ const loader = createLoader(send);
+
+ const error = await loader
+ .loadClientConfig("client-1")
+ .catch((error_) => error_);
+ expect(error).toBeInstanceOf(ConfigValidationError);
+ expect(error.issues).toEqual([
+ { path: "config", message: "unexpected string error" },
+ ]);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts
new file mode 100644
index 0000000..bffc5f8
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts
@@ -0,0 +1,118 @@
+import { S3Client } from "@aws-sdk/client-s3";
+import { ConfigCache } from "services/config-cache";
+import { ConfigLoader } from "services/config-loader";
+
+describe("config update integration", () => {
+ it("reloads configuration after cache expiry", async () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2025-01-01T10:00:00Z"));
+
+ const send = jest
+ .fn()
+ .mockResolvedValueOnce({
+ Body: {
+ transformToString: jest.fn().mockResolvedValue(
+ JSON.stringify([
+ {
+ Name: "client-message",
+ ClientId: "client-1",
+ Description: "Message status subscription",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ]),
+ ),
+ },
+ })
+ .mockResolvedValueOnce({
+ Body: {
+ transformToString: jest.fn().mockResolvedValue(
+ JSON.stringify([
+ {
+ Name: "client-message",
+ ClientId: "client-1",
+ Description: "Message status subscription",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["FAILED"],
+ },
+ ]),
+ ),
+ },
+ });
+
+ const loader = new ConfigLoader({
+ bucketName: "bucket",
+ keyPrefix: "client_subscriptions/",
+ s3Client: { send } as unknown as S3Client,
+ cache: new ConfigCache(1000),
+ });
+
+ const first = await loader.loadClientConfig("client-1");
+ const firstMessage = first?.find(
+ (subscription) => subscription.SubscriptionType === "MessageStatus",
+ );
+ expect(firstMessage?.Statuses).toEqual(["DELIVERED"]);
+
+ jest.advanceTimersByTime(1500);
+
+ const second = await loader.loadClientConfig("client-1");
+ const secondMessage = second?.find(
+ (subscription) => subscription.SubscriptionType === "MessageStatus",
+ );
+ expect(secondMessage?.Statuses).toEqual(["FAILED"]);
+
+ jest.useRealTimers();
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts
index 57ef1fe..bd4ad4c 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts
@@ -1,8 +1,10 @@
import {
+ ConfigValidationError,
ErrorType,
LambdaError,
TransformationError,
ValidationError,
+ formatValidationIssuePath,
getEventError,
wrapUnknownError,
} from "services/error-handler";
@@ -127,6 +129,60 @@ describe("TransformationError", () => {
});
});
+describe("formatValidationIssuePath", () => {
+ it("returns empty string for empty path", () => {
+ expect(formatValidationIssuePath([])).toBe("");
+ });
+
+ it("returns string segment directly at root", () => {
+ expect(formatValidationIssuePath(["traceparent"])).toBe("traceparent");
+ });
+
+ it("uses dot notation for nested string segments", () => {
+ expect(formatValidationIssuePath(["data", "clientId"])).toBe(
+ "data.clientId",
+ );
+ });
+
+ it("uses bracket notation for numeric segments", () => {
+ expect(formatValidationIssuePath([0])).toBe("[0]");
+ });
+
+ it("combines bracket and dot notation for mixed paths", () => {
+ expect(formatValidationIssuePath(["channels", 0, "type"])).toBe(
+ "channels[0].type",
+ );
+ });
+});
+
+describe("ConfigValidationError", () => {
+ it("should create error with issues array", () => {
+ const issues = [
+ {
+ path: "[0].Name",
+ message: "Expected Name to be unique",
+ },
+ ];
+ const error = new ConfigValidationError(issues);
+
+ expect(error.message).toBe(
+ "Client subscription configuration validation failed",
+ );
+ expect(error.issues).toBe(issues);
+ expect(error.errorType).toBe(ErrorType.VALIDATION_ERROR);
+ expect(error.retryable).toBe(false);
+ expect(error.correlationId).toBeUndefined();
+ expect(error.name).toBe("ConfigValidationError");
+ });
+
+ it("should be instanceof LambdaError and Error", () => {
+ const error = new ConfigValidationError([]);
+ expect(error).toBeInstanceOf(ConfigValidationError);
+ expect(error).toBeInstanceOf(LambdaError);
+ expect(error).toBeInstanceOf(Error);
+ });
+});
+
describe("wrapUnknownError", () => {
it("should return LambdaError as-is", () => {
const originalError = new ValidationError("Original", "corr-123");
@@ -298,4 +354,23 @@ describe("getEventError", () => {
expect(mockMetrics.emitTransformationFailure).toHaveBeenCalled();
expect(mockMetrics.emitValidationError).not.toHaveBeenCalled();
});
+
+ it("should return ConfigValidationError and emit validation metric", () => {
+ const error = new ConfigValidationError([
+ {
+ path: "[0].Name",
+ message: "Expected Name to be unique",
+ },
+ ]);
+
+ const result = getEventError(error, mockMetrics, mockEventLogger);
+
+ expect(result).toBe(error);
+ expect(mockEventLogger.error).toHaveBeenCalledWith(
+ "Client config validation failed",
+ { error },
+ );
+ expect(mockMetrics.emitValidationError).toHaveBeenCalled();
+ expect(mockMetrics.emitTransformationFailure).not.toHaveBeenCalled();
+ });
});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts
new file mode 100644
index 0000000..be8d9cd
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts
@@ -0,0 +1,100 @@
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+import { matchesEventPattern } from "services/filters/event-pattern";
+
+const createSubscription = (
+ eventSource: string[],
+ eventDetail: Record,
+): ClientSubscriptionConfiguration[number] => ({
+ Name: "test",
+ ClientId: "client-1",
+ Description: "Test subscription",
+ EventSource: JSON.stringify(eventSource),
+ EventDetail: JSON.stringify(eventDetail),
+ Targets: [],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+});
+
+describe("matchesEventPattern", () => {
+ it("matches when source and detail match", () => {
+ const subscription = createSubscription(["source-a"], {
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ });
+
+ const result = matchesEventPattern(subscription, "source-a", {
+ clientId: "client-1",
+ type: "MessageStatus",
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("matches when sources list is empty", () => {
+ const subscription = createSubscription([], {
+ clientId: ["client-1"],
+ });
+
+ const result = matchesEventPattern(subscription, "any-source", {
+ clientId: "client-1",
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("does not match when source is different", () => {
+ const subscription = createSubscription(["source-a"], {
+ clientId: ["client-1"],
+ });
+
+ const result = matchesEventPattern(subscription, "source-b", {
+ clientId: "client-1",
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("does not match when detail value is different", () => {
+ const subscription = createSubscription(["source-a"], {
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ });
+
+ const result = matchesEventPattern(subscription, "source-a", {
+ clientId: "client-1",
+ type: "ChannelStatus",
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("does not match when detail key is missing in event", () => {
+ const subscription = createSubscription(["source-a"], {
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ channel: ["EMAIL"],
+ });
+
+ const result = matchesEventPattern(subscription, "source-a", {
+ clientId: "client-1",
+ type: "MessageStatus",
+ // channel is missing
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("does not match when detail value is undefined", () => {
+ const subscription = createSubscription(["source-a"], {
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ });
+
+ const result = matchesEventPattern(subscription, "source-a", {
+ clientId: "client-1",
+ type: undefined,
+ });
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts
new file mode 100644
index 0000000..682d0ff
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/subscription-filter.test.ts
@@ -0,0 +1,1186 @@
+import type {
+ ChannelStatusData,
+ ClientSubscriptionConfiguration,
+ MessageStatusData,
+ StatusPublishEvent,
+} from "@nhs-notify-client-callbacks/models";
+import { EventTypes } from "@nhs-notify-client-callbacks/models";
+import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter";
+import { matchesMessageStatusSubscription } from "services/filters/message-status-filter";
+
+const createBaseEvent = (
+ type: string,
+ source: string,
+ notifyData: T,
+): StatusPublishEvent => ({
+ specversion: "1.0",
+ id: "event-id",
+ source,
+ subject: "subject",
+ type,
+ time: "2025-01-01T10:00:00Z",
+ datacontenttype: "application/json",
+ dataschema: "schema",
+ traceparent: "traceparent",
+ data: notifyData,
+});
+
+describe("subscription filters", () => {
+ it("matches message status subscriptions by client, status, and event pattern", () => {
+ const notifyData: MessageStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ messageStatus: "DELIVERED",
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-1",
+ };
+ const event = createBaseEvent(
+ EventTypes.MESSAGE_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-message",
+ ClientId: "client-1",
+ Description: "Message config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ expect(
+ matchesMessageStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("rejects message status subscriptions when event source mismatches", () => {
+ const notifyData: MessageStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ messageStatus: "DELIVERED",
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-1",
+ };
+ const event = createBaseEvent(
+ EventTypes.MESSAGE_STATUS_PUBLISHED,
+ "source-b",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-message",
+ ClientId: "client-1",
+ Description: "Message config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ expect(
+ matchesMessageStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("matches channel status subscriptions by channel and supplier status", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ supplierStatus: "read",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("rejects channel status subscriptions when channel does not match", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "SMS",
+ channelStatus: "DELIVERED",
+ supplierStatus: "read",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects channel status subscriptions when event source mismatches", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ supplierStatus: "read",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-b",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects message status subscriptions when clientId does not match", () => {
+ const notifyData: MessageStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ messageStatus: "DELIVERED",
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-2",
+ };
+ const event = createBaseEvent(
+ EventTypes.MESSAGE_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-message",
+ ClientId: "client-1",
+ Description: "Message config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ expect(
+ matchesMessageStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects message status subscriptions when status does not match", () => {
+ const notifyData: MessageStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ messageStatus: "FAILED",
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-1",
+ };
+ const event = createBaseEvent(
+ EventTypes.MESSAGE_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-message",
+ ClientId: "client-1",
+ Description: "Message config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ expect(
+ matchesMessageStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects message status subscriptions when status has not changed", () => {
+ const notifyData: MessageStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ messageStatus: "DELIVERED",
+ previousMessageStatus: "DELIVERED",
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-1",
+ };
+ const event = createBaseEvent(
+ EventTypes.MESSAGE_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-message",
+ ClientId: "client-1",
+ Description: "Message config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ expect(
+ matchesMessageStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("matches message status subscriptions when status has changed", () => {
+ const notifyData: MessageStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ messageStatus: "DELIVERED",
+ previousMessageStatus: "PENDING_ENRICHMENT",
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId: "client-1",
+ };
+ const event = createBaseEvent(
+ EventTypes.MESSAGE_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-message",
+ ClientId: "client-1",
+ Description: "Message config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ ];
+
+ expect(
+ matchesMessageStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("rejects channel status subscriptions when clientId does not match", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ supplierStatus: "read",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-2",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects channel status subscriptions when channelStatus does not match", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "FAILED",
+ previousChannelStatus: "SENDING",
+ supplierStatus: "read",
+ previousSupplierStatus: "read",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects channel status subscriptions when supplierStatus does not match", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ previousChannelStatus: "DELIVERED",
+ supplierStatus: "rejected",
+ previousSupplierStatus: "notified",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("rejects channel status subscriptions when neither status changed", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ previousChannelStatus: "DELIVERED", // No change
+ supplierStatus: "read",
+ previousSupplierStatus: "read", // No change
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+
+ it("matches when only channelStatus changed and is subscribed (OR logic)", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ previousChannelStatus: "SENDING", // Changed
+ supplierStatus: "notified",
+ previousSupplierStatus: "notified", // No change
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"], // Not subscribed to NOTIFIED
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("matches when only supplierStatus changed and is subscribed (OR logic)", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "SENDING",
+ previousChannelStatus: "SENDING", // No change
+ supplierStatus: "read",
+ previousSupplierStatus: "notified", // Changed
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"], // Not subscribed to SENDING
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("matches with empty supplierStatuses array when channelStatus changed", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ previousChannelStatus: "SENDING", // Changed
+ supplierStatus: "read",
+ previousSupplierStatus: "notified", // Changed
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: [], // Empty array = not subscribed to any supplier status changes
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("matches with empty channelStatuses array when supplierStatus changed", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ previousChannelStatus: "SENDING", // Changed
+ supplierStatus: "read",
+ previousSupplierStatus: "notified", // Changed
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: [], // Empty array = not subscribed to any channel status changes
+ SupplierStatuses: ["read"],
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(true);
+ });
+
+ it("rejects with both arrays empty", () => {
+ const notifyData: ChannelStatusData = {
+ messageId: "message-id",
+ messageReference: "reference",
+ channel: "EMAIL",
+ channelStatus: "DELIVERED",
+ previousChannelStatus: "SENDING", // Changed
+ supplierStatus: "read",
+ previousSupplierStatus: "notified", // Changed
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId: "client-1",
+ };
+
+ const event = createBaseEvent(
+ EventTypes.CHANNEL_STATUS_PUBLISHED,
+ "source-a",
+ notifyData,
+ );
+
+ const config: ClientSubscriptionConfiguration = [
+ {
+ Name: "client-1-email",
+ ClientId: "client-1",
+ Description: "Channel config",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: [], // Empty
+ SupplierStatuses: [], // Empty
+ },
+ ];
+
+ expect(
+ matchesChannelStatusSubscription(config, { event, notifyData }),
+ ).toBe(false);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts
index 5e6b146..32c8a24 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/logger.test.ts
@@ -299,6 +299,16 @@ describe("extractCorrelationId", () => {
expect(correlationId).toBeUndefined();
});
+
+ it("should return undefined when id is present but not a string", () => {
+ const event = {
+ id: 42,
+ };
+
+ const correlationId = extractCorrelationId(event);
+
+ expect(correlationId).toBeUndefined();
+ });
});
describe("logLifecycleEvent", () => {
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts
index b0e4578..bdbcc3a 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/metrics.test.ts
@@ -131,4 +131,28 @@ describe("CallbackMetrics", () => {
);
});
});
+
+ describe("emitFilteringStarted", () => {
+ it("should emit FilteringStarted metric", () => {
+ callbackMetrics.emitFilteringStarted();
+
+ expect(mockPutMetric).toHaveBeenCalledWith(
+ "FilteringStarted",
+ 1,
+ Unit.Count,
+ );
+ });
+ });
+
+ describe("emitFilteringMatched", () => {
+ it("should emit FilteringMatched metric", () => {
+ callbackMetrics.emitFilteringMatched();
+
+ expect(mockPutMetric).toHaveBeenCalledWith(
+ "FilteringMatched",
+ 1,
+ Unit.Count,
+ );
+ });
+ });
});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts
new file mode 100644
index 0000000..1ded580
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts
@@ -0,0 +1,263 @@
+import type {
+ Channel,
+ ChannelStatus,
+ ChannelStatusData,
+ ClientSubscriptionConfiguration,
+ MessageStatus,
+ MessageStatusData,
+ StatusPublishEvent,
+ SupplierStatus,
+} from "@nhs-notify-client-callbacks/models";
+import { EventTypes } from "@nhs-notify-client-callbacks/models";
+import { TransformationError } from "services/error-handler";
+import { evaluateSubscriptionFilters } from "services/subscription-filter";
+
+const createMessageStatusEvent = (
+ clientId: string,
+ status: MessageStatus,
+): StatusPublishEvent => ({
+ specversion: "1.0",
+ id: "event-id",
+ source: "source-a",
+ subject: "subject",
+ type: EventTypes.MESSAGE_STATUS_PUBLISHED,
+ time: "2025-01-01T10:00:00Z",
+ datacontenttype: "application/json",
+ dataschema: "schema",
+ traceparent: "traceparent",
+ data: {
+ messageId: "msg-123",
+ messageReference: "ref-123",
+ messageStatus: status,
+ channels: [],
+ timestamp: "2025-01-01T10:00:00Z",
+ routingPlan: {
+ id: "plan-id",
+ name: "plan-name",
+ version: "1",
+ createdDate: "2025-01-01T10:00:00Z",
+ },
+ clientId,
+ },
+});
+
+const createChannelStatusEvent = (
+ clientId: string,
+ channel: Channel,
+ channelStatus: ChannelStatus,
+ supplierStatus: SupplierStatus,
+ previousChannelStatus?: ChannelStatus,
+ previousSupplierStatus?: SupplierStatus,
+): StatusPublishEvent => ({
+ specversion: "1.0",
+ id: "event-id",
+ source: "source-a",
+ subject: "subject",
+ type: EventTypes.CHANNEL_STATUS_PUBLISHED,
+ time: "2025-01-01T10:00:00Z",
+ datacontenttype: "application/json",
+ dataschema: "schema",
+ traceparent: "traceparent",
+ data: {
+ messageId: "msg-123",
+ messageReference: "ref-123",
+ channel,
+ channelStatus,
+ previousChannelStatus,
+ supplierStatus,
+ previousSupplierStatus,
+ cascadeType: "primary" as const,
+ cascadeOrder: 1,
+ timestamp: "2025-01-01T10:00:00Z",
+ retryCount: 0,
+ clientId,
+ },
+});
+
+const createMessageStatusConfig = (
+ clientId: string,
+ statuses: MessageStatus[],
+): ClientSubscriptionConfiguration => [
+ {
+ Name: "client-message",
+ ClientId: clientId,
+ Description: "Message status subscription",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: [clientId],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: statuses,
+ },
+];
+
+const createChannelStatusConfig = (
+ clientId: string,
+ channelType: Channel,
+ channelStatuses: ChannelStatus[],
+ supplierStatuses: SupplierStatus[],
+): ClientSubscriptionConfiguration => [
+ {
+ Name: `client-${channelType}`,
+ ClientId: clientId,
+ Description: `${channelType} channel status subscription`,
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: [clientId],
+ type: ["ChannelStatus"],
+ channel: [channelType],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: channelType,
+ ChannelStatuses: channelStatuses,
+ SupplierStatuses: supplierStatuses,
+ },
+];
+
+describe("evaluateSubscriptionFilters", () => {
+ describe("when config is undefined", () => {
+ it("returns not matched with Unknown subscription type", () => {
+ const event = createMessageStatusEvent("client-1", "DELIVERED");
+ // eslint-disable-next-line unicorn/no-useless-undefined -- Testing explicit undefined config
+ const result = evaluateSubscriptionFilters(event, undefined);
+
+ expect(result).toEqual({
+ matched: false,
+ subscriptionType: "Unknown",
+ });
+ });
+ });
+
+ describe("when event is MessageStatus", () => {
+ it("returns matched true when status matches subscription", () => {
+ const event = createMessageStatusEvent("client-1", "DELIVERED");
+ const config = createMessageStatusConfig("client-1", ["DELIVERED"]);
+
+ const result = evaluateSubscriptionFilters(event, config);
+
+ expect(result).toEqual({
+ matched: true,
+ subscriptionType: "MessageStatus",
+ });
+ });
+
+ it("returns matched false when status does not match subscription", () => {
+ const event = createMessageStatusEvent("client-1", "FAILED");
+ const config = createMessageStatusConfig("client-1", ["DELIVERED"]);
+
+ const result = evaluateSubscriptionFilters(event, config);
+
+ expect(result).toEqual({
+ matched: false,
+ subscriptionType: "MessageStatus",
+ });
+ });
+ });
+
+ describe("when event is ChannelStatus", () => {
+ it("returns matched true when channel and statuses match subscription", () => {
+ const event = createChannelStatusEvent(
+ "client-1",
+ "EMAIL",
+ "DELIVERED",
+ "delivered",
+ "SENDING",
+ "notified",
+ );
+ const config = createChannelStatusConfig(
+ "client-1",
+ "EMAIL",
+ ["DELIVERED"],
+ ["delivered"],
+ );
+
+ const result = evaluateSubscriptionFilters(event, config);
+
+ expect(result).toEqual({
+ matched: true,
+ subscriptionType: "ChannelStatus",
+ });
+ });
+
+ it("returns matched false when channel status does not match subscription", () => {
+ const event = createChannelStatusEvent(
+ "client-1",
+ "EMAIL",
+ "FAILED",
+ "delivered",
+ "FAILED", // previousChannelStatus (no change)
+ "delivered", // previousSupplierStatus (no change)
+ );
+ const config = createChannelStatusConfig(
+ "client-1",
+ "EMAIL",
+ ["DELIVERED"],
+ ["delivered"],
+ );
+
+ const result = evaluateSubscriptionFilters(event, config);
+
+ expect(result).toEqual({
+ matched: false,
+ subscriptionType: "ChannelStatus",
+ });
+ });
+ });
+
+ describe("when event type is unknown", () => {
+ it("throws a TransformationError", () => {
+ const event = {
+ ...createMessageStatusEvent("client-1", "DELIVERED"),
+ type: "unknown-event-type",
+ } as StatusPublishEvent;
+ const config = createMessageStatusConfig("client-1", ["DELIVERED"]);
+
+ expect(() => evaluateSubscriptionFilters(event, config)).toThrow(
+ new TransformationError("Unsupported event type: unknown-event-type"),
+ );
+ });
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts
new file mode 100644
index 0000000..49e3158
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/transformers/event-transformer.test.ts
@@ -0,0 +1,106 @@
+import {
+ type ChannelStatusData,
+ type MessageStatusData,
+ type StatusPublishEvent,
+} from "@nhs-notify-client-callbacks/models";
+import { TransformationError } from "services/error-handler";
+import { transformEvent } from "services/transformers/event-transformer";
+
+const baseEvent = {
+ specversion: "1.0",
+ source: "/nhs/england/notify/development/primary/data-plane/messaging",
+ subject: "customer/client-abc-123/message/msg-789-xyz",
+ time: "2026-02-05T14:30:00.000Z",
+ datacontenttype: "application/json",
+ traceparent: "00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01",
+};
+
+const messageStatusEvent: StatusPublishEvent = {
+ ...baseEvent,
+ id: "msg-event-id-001",
+ dataschema: "https://notify.nhs.uk/schemas/message-status-published-v1.json",
+ type: "uk.nhs.notify.message.status.PUBLISHED.v1",
+ data: {
+ clientId: "client-abc-123",
+ messageId: "msg-789-xyz",
+ messageReference: "client-ref-12345",
+ messageStatus: "DELIVERED",
+ channels: [{ type: "NHSAPP", channelStatus: "DELIVERED" }],
+ timestamp: "2026-02-05T14:29:55Z",
+ routingPlan: {
+ id: "routing-plan-123",
+ name: "NHS App",
+ version: "v1",
+ createdDate: "2023-11-17T14:27:51.413Z",
+ },
+ },
+};
+
+const channelStatusEvent: StatusPublishEvent = {
+ ...baseEvent,
+ id: "ch-event-id-001",
+ dataschema: "https://notify.nhs.uk/schemas/channel-status-published-v1.json",
+ type: "uk.nhs.notify.channel.status.PUBLISHED.v1",
+ data: {
+ clientId: "client-abc-123",
+ messageId: "msg-789-xyz",
+ messageReference: "client-ref-12345",
+ channel: "NHSAPP",
+ channelStatus: "DELIVERED",
+ supplierStatus: "delivered",
+ cascadeType: "primary",
+ cascadeOrder: 1,
+ retryCount: 0,
+ timestamp: "2026-02-05T14:29:55Z",
+ },
+};
+
+describe("event-transformer", () => {
+ describe("transformEvent", () => {
+ it("transforms a message status event", () => {
+ const result = transformEvent(messageStatusEvent, "corr-id-001");
+
+ expect(result.data[0].type).toBe("MessageStatus");
+ });
+
+ it("transforms a channel status event", () => {
+ const result = transformEvent(channelStatusEvent, "corr-id-002");
+
+ expect(result.data[0].type).toBe("ChannelStatus");
+ });
+
+ it("throws TransformationError for unsupported event type", () => {
+ const unsupportedEvent = {
+ ...messageStatusEvent,
+ type: "uk.nhs.notify.unsupported.event.v1",
+ } as unknown as StatusPublishEvent;
+
+ expect(() => transformEvent(unsupportedEvent, "corr-id-003")).toThrow(
+ TransformationError,
+ );
+
+ expect(() => transformEvent(unsupportedEvent, "corr-id-003")).toThrow(
+ "Unsupported event type: uk.nhs.notify.unsupported.event.v1",
+ );
+ });
+
+ it("includes correlationId in TransformationError when provided", () => {
+ const unsupportedEvent = {
+ ...messageStatusEvent,
+ type: "uk.nhs.notify.unknown.v1",
+ } as unknown as StatusPublishEvent;
+
+ let caughtError: unknown;
+ try {
+ transformEvent(unsupportedEvent, "test-correlation-id");
+ } catch (error) {
+ caughtError = error;
+ }
+
+ expect(caughtError).toBeInstanceOf(TransformationError);
+ expect((caughtError as TransformationError).message).toBe(
+ "Unsupported event type: uk.nhs.notify.unknown.v1",
+ );
+ });
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts
new file mode 100644
index 0000000..8fb189d
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts
@@ -0,0 +1,138 @@
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+import {
+ ConfigValidationError,
+ validateClientConfig,
+} from "services/validators/config-validator";
+
+const createValidConfig = (): ClientSubscriptionConfiguration => [
+ {
+ Name: "client-message",
+ ClientId: "client-1",
+ Description: "Message status subscription",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "MessageStatus",
+ Statuses: ["DELIVERED"],
+ },
+ {
+ Name: "client-channel",
+ ClientId: "client-1",
+ Description: "Channel status subscription",
+ EventSource: JSON.stringify(["source-a"]),
+ EventDetail: JSON.stringify({
+ clientId: ["client-1"],
+ type: ["ChannelStatus"],
+ channel: ["EMAIL"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "target",
+ Name: "target",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ SubscriptionType: "ChannelStatus",
+ ChannelType: "EMAIL",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["read"],
+ },
+];
+
+describe("validateClientConfig", () => {
+ it("returns the config when valid", () => {
+ const config = createValidConfig();
+
+ expect(validateClientConfig(config)).toEqual(config);
+ });
+
+ it("throws when config is not an array", () => {
+ expect(() => validateClientConfig({})).toThrow(ConfigValidationError);
+ });
+
+ it("throws when invocation endpoint is not https", () => {
+ const config = createValidConfig();
+ config[0].Targets[0].InvocationEndpoint = "http://example.com";
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+
+ it("throws when subscription names are not unique", () => {
+ const config = createValidConfig();
+ config[1].Name = config[0].Name;
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+
+ it("throws when EventSource is invalid JSON", () => {
+ const config = createValidConfig();
+ config[0].EventSource = "not-json";
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+
+ it("throws when EventSource is valid JSON but not an array", () => {
+ const config = createValidConfig();
+ config[0].EventSource = JSON.stringify({ not: "array" });
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+
+ it("throws when EventDetail is invalid JSON", () => {
+ const config = createValidConfig();
+ config[0].EventDetail = "not-json";
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+
+ it("throws when EventDetail is valid JSON but not a record of string arrays", () => {
+ const config = createValidConfig();
+ config[0].EventDetail = JSON.stringify({ key: "not-array" });
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+
+ it("throws when InvocationEndpoint is not a valid URL", () => {
+ const config = createValidConfig();
+ config[0].Targets[0].InvocationEndpoint = "not-a-url";
+
+ expect(() => validateClientConfig(config)).toThrow(ConfigValidationError);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts
index ec9d424..8792bc4 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts
@@ -70,7 +70,7 @@ describe("event-validator", () => {
};
expect(() => validateStatusPublishEvent(invalidEvent)).toThrow(
- 'Validation failed: type: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1"',
+ "Validation failed: type: Invalid option",
);
});
});
@@ -199,7 +199,7 @@ describe("event-validator", () => {
};
expect(() => validateStatusPublishEvent(invalidEvent)).toThrow(
- "Validation failed: channels.0.type: Invalid input: expected string, received undefined",
+ "Validation failed: channels[0].type: Invalid input: expected string, received undefined",
);
});
@@ -213,7 +213,7 @@ describe("event-validator", () => {
};
expect(() => validateStatusPublishEvent(invalidEvent)).toThrow(
- "Validation failed: channels.0.channelStatus: Invalid input: expected string, received undefined",
+ "Validation failed: channels[0].channelStatus: Invalid input: expected string, received undefined",
);
});
});
@@ -318,6 +318,34 @@ describe("event-validator", () => {
jest.unmock("cloudevents");
});
+
+ it("should format generic Error exceptions during validation", () => {
+ jest.resetModules();
+
+ jest.isolateModules(() => {
+ const { ValidationError: RealCloudEventsValidationError } =
+ jest.requireActual("cloudevents");
+
+ jest.doMock("cloudevents", () => ({
+ CloudEvent: jest.fn(() => {
+ throw new Error("generic processing error");
+ }),
+ ValidationError: RealCloudEventsValidationError,
+ }));
+
+ const moduleUnderTest = jest.requireActual(
+ "services/validators/event-validator",
+ );
+
+ expect(() =>
+ moduleUnderTest.validateStatusPublishEvent({
+ specversion: "1.0",
+ }),
+ ).toThrow("generic processing error");
+ });
+
+ jest.unmock("cloudevents");
+ });
});
});
});
diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts
index 3f2dc6a..4e1f20a 100644
--- a/lambdas/client-transform-filter-lambda/src/handler.ts
+++ b/lambdas/client-transform-filter-lambda/src/handler.ts
@@ -9,6 +9,8 @@ import { transformEvent } from "services/transformers/event-transformer";
import { extractCorrelationId } from "services/logger";
import { ValidationError, getEventError } from "services/error-handler";
import type { ObservabilityService } from "services/observability";
+import type { ConfigLoader } from "services/config-loader";
+import { evaluateSubscriptionFilters } from "services/subscription-filter";
const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10;
@@ -21,6 +23,8 @@ class BatchStats {
failed = 0;
+ filtered = 0;
+
processed = 0;
recordSuccess(): void {
@@ -33,10 +37,15 @@ class BatchStats {
this.processed += 1;
}
+ recordFiltered(): void {
+ this.filtered += 1;
+ }
+
toObject() {
return {
successful: this.successful,
failed: this.failed,
+ filtered: this.filtered,
processed: this.processed,
};
}
@@ -117,6 +126,58 @@ function recordDeliveryInitiated(
}
}
+async function filterBatch(
+ transformedEvents: TransformedEvent[],
+ configLoader: ConfigLoader,
+ observability: ObservabilityService,
+ stats: BatchStats,
+): Promise {
+ observability.recordFilteringStarted({ batchSize: transformedEvents.length });
+
+ const uniqueClientIds = new Set(
+ transformedEvents.map((e) => e.data.clientId),
+ );
+
+ const configEntries = await pMap(
+ uniqueClientIds,
+ async (clientId) => {
+ const config = await configLoader.loadClientConfig(clientId);
+ return [clientId, config] as const;
+ },
+ { concurrency: BATCH_CONCURRENCY },
+ );
+
+ const configByClientId = new Map(configEntries);
+
+ const filtered: TransformedEvent[] = [];
+
+ for (const event of transformedEvents) {
+ const { clientId } = event.data;
+ const config = configByClientId.get(clientId);
+ const filterResult = evaluateSubscriptionFilters(event, config);
+
+ if (filterResult.matched) {
+ filtered.push(event);
+ observability.recordFilteringMatched({
+ clientId,
+ eventType: event.type,
+ subscriptionType: filterResult.subscriptionType,
+ });
+ } else {
+ stats.recordFiltered();
+ observability
+ .getLogger()
+ .info("Event filtered out - no matching subscription", {
+ clientId,
+ eventType: event.type,
+ subscriptionType: filterResult.subscriptionType,
+ });
+ }
+ }
+
+ return filtered;
+}
+
async function transformBatch(
sqsRecords: SQSRecord[],
observability: ObservabilityService,
@@ -146,6 +207,7 @@ async function transformBatch(
export async function processEvents(
event: SQSRecord[],
observability: ObservabilityService,
+ configLoader: ConfigLoader,
): Promise {
const startTime = Date.now();
const stats = new BatchStats();
@@ -153,6 +215,13 @@ export async function processEvents(
try {
const transformedEvents = await transformBatch(event, observability, stats);
+ const filteredEvents = await filterBatch(
+ transformedEvents,
+ configLoader,
+ observability,
+ stats,
+ );
+
const processingTime = Date.now() - startTime;
observability.logBatchProcessingCompleted({
...stats.toObject(),
@@ -160,10 +229,10 @@ export async function processEvents(
processingTimeMs: processingTime,
});
- recordDeliveryInitiated(transformedEvents, observability);
+ recordDeliveryInitiated(filteredEvents, observability);
await observability.flush();
- return transformedEvents;
+ return filteredEvents;
} catch (error) {
stats.recordFailure();
diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts
index 757b83c..5ef8e19 100644
--- a/lambdas/client-transform-filter-lambda/src/index.ts
+++ b/lambdas/client-transform-filter-lambda/src/index.ts
@@ -2,10 +2,14 @@ import type { SQSRecord } from "aws-lambda";
import { Logger } from "services/logger";
import { CallbackMetrics, createMetricLogger } from "services/metrics";
import { ObservabilityService } from "services/observability";
+import { ConfigLoaderService } from "services/config-loader-service";
import { type TransformedEvent, processEvents } from "handler";
+export const configLoaderService = new ConfigLoaderService();
+
export interface HandlerDependencies {
- createObservabilityService: () => ObservabilityService;
+ createObservabilityService?: () => ObservabilityService;
+ createConfigLoaderService?: () => ConfigLoaderService;
}
function createDefaultObservabilityService(): ObservabilityService {
@@ -16,16 +20,23 @@ function createDefaultObservabilityService(): ObservabilityService {
return new ObservabilityService(logger, metrics, metricsLogger);
}
+function createDefaultConfigLoaderService(): ConfigLoaderService {
+ return configLoaderService;
+}
+
export function createHandler(
dependencies: Partial = {},
): (event: SQSRecord[]) => Promise {
const createObservabilityService =
dependencies.createObservabilityService ??
createDefaultObservabilityService;
+ const configLoader = (
+ dependencies.createConfigLoaderService ?? createDefaultConfigLoaderService
+ )();
return async (event: SQSRecord[]): Promise => {
const observability = createObservabilityService();
- return processEvents(event, observability);
+ return processEvents(event, observability, configLoader.getLoader());
};
}
diff --git a/lambdas/client-transform-filter-lambda/src/services/config-cache.ts b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts
new file mode 100644
index 0000000..e371fdd
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts
@@ -0,0 +1,37 @@
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+
+type CacheEntry = {
+ value: ClientSubscriptionConfiguration;
+ expiresAt: number;
+};
+
+export class ConfigCache {
+ private readonly cache = new Map();
+
+ constructor(private readonly ttlMs: number) {}
+
+ get(clientId: string): ClientSubscriptionConfiguration | undefined {
+ const entry = this.cache.get(clientId);
+ if (!entry) {
+ return undefined;
+ }
+
+ if (entry.expiresAt <= Date.now()) {
+ this.cache.delete(clientId);
+ return undefined;
+ }
+
+ return entry.value;
+ }
+
+ set(clientId: string, value: ClientSubscriptionConfiguration): void {
+ this.cache.set(clientId, {
+ value,
+ expiresAt: Date.now() + this.ttlMs,
+ });
+ }
+
+ clear(): void {
+ this.cache.clear();
+ }
+}
diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts
new file mode 100644
index 0000000..b0af71b
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts
@@ -0,0 +1,77 @@
+import { S3Client } from "@aws-sdk/client-s3";
+import { ConfigCache } from "services/config-cache";
+import { ConfigLoader } from "services/config-loader";
+
+const DEFAULT_CACHE_TTL_SECONDS = 60;
+
+export const resolveCacheTtlMs = (
+ env: NodeJS.ProcessEnv = process.env,
+): number => {
+ const configuredTtlSeconds = Number.parseInt(
+ env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`,
+ 10,
+ );
+ const cacheTtlSeconds = Number.isFinite(configuredTtlSeconds)
+ ? configuredTtlSeconds
+ : DEFAULT_CACHE_TTL_SECONDS;
+ return cacheTtlSeconds * 1000;
+};
+
+export const createS3Client = (
+ env: NodeJS.ProcessEnv = process.env,
+): S3Client => {
+ const endpoint = env.AWS_ENDPOINT_URL;
+ const forcePathStyle = endpoint?.includes("localhost") ? true : undefined;
+ return new S3Client({ endpoint, forcePathStyle });
+};
+
+export class ConfigLoaderService {
+ private readonly cache: ConfigCache;
+
+ private loader: ConfigLoader | undefined;
+
+ constructor(cacheTtlMs: number = resolveCacheTtlMs()) {
+ this.cache = new ConfigCache(cacheTtlMs);
+ }
+
+ getLoader(): ConfigLoader {
+ const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ if (!bucketName) {
+ throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required");
+ }
+
+ if (this.loader) {
+ return this.loader;
+ }
+
+ this.loader = new ConfigLoader({
+ bucketName,
+ keyPrefix:
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ??
+ "client_subscriptions/",
+ s3Client: createS3Client(),
+ cache: this.cache,
+ });
+
+ return this.loader;
+ }
+
+ reset(s3Client?: S3Client): void {
+ this.loader = undefined;
+ this.cache.clear();
+ if (s3Client) {
+ const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ if (!bucketName) {
+ throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required");
+ }
+ this.loader = new ConfigLoader({
+ bucketName,
+ keyPrefix:
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ??
+ "client_subscriptions/",
+ s3Client,
+ cache: this.cache,
+ });
+ }
+ }
+}
diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts
new file mode 100644
index 0000000..d95e141
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts
@@ -0,0 +1,81 @@
+import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3";
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+import { ConfigCache } from "services/config-cache";
+import { logger } from "services/logger";
+import {
+ ConfigValidationError,
+ validateClientConfig,
+} from "services/validators/config-validator";
+
+type ConfigLoaderOptions = {
+ bucketName: string;
+ keyPrefix: string;
+ s3Client: S3Client;
+ cache: ConfigCache;
+};
+
+function throwAsConfigError(error: unknown, clientId: string): never {
+ if (error instanceof ConfigValidationError) {
+ logger.error("Config validation failed with schema violations", {
+ clientId,
+ validationErrors: error.issues,
+ });
+ throw error;
+ }
+
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error("Failed to load config from S3", { clientId });
+ throw new ConfigValidationError([{ path: "config", message }]);
+}
+
+export class ConfigLoader {
+ constructor(private readonly options: ConfigLoaderOptions) {}
+
+ async loadClientConfig(
+ clientId: string,
+ ): Promise {
+ const cached = this.options.cache.get(clientId);
+ if (cached) {
+ logger.debug("Config loaded from cache", { clientId, cacheHit: true });
+ return cached;
+ }
+
+ logger.debug("Config not in cache, fetching from S3", {
+ clientId,
+ cacheHit: false,
+ });
+
+ try {
+ const response = await this.options.s3Client.send(
+ new GetObjectCommand({
+ Bucket: this.options.bucketName,
+ Key: `${this.options.keyPrefix}${clientId}.json`,
+ }),
+ );
+
+ if (!response.Body) {
+ throw new Error("S3 response body was empty");
+ }
+
+ const rawConfig = await response.Body.transformToString();
+ const parsedConfig = JSON.parse(rawConfig) as unknown;
+ const validated = validateClientConfig(parsedConfig);
+ this.options.cache.set(clientId, validated);
+ logger.info("Config loaded successfully from S3", {
+ clientId,
+ subscriptionCount: validated.length,
+ });
+ return validated;
+ } catch (error) {
+ if (error instanceof NoSuchKey) {
+ logger.info(
+ "No config found in S3 for client - events will be filtered out",
+ { clientId },
+ );
+ return undefined;
+ }
+ throwAsConfigError(error, clientId);
+ return undefined;
+ }
+ }
+}
diff --git a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts
index 26e99d2..8eef206 100644
--- a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts
@@ -44,6 +44,38 @@ export class TransformationError extends LambdaError {
}
}
+export type ValidationIssue = {
+ path: string;
+ message: string;
+};
+
+export function formatValidationIssuePath(path: (string | number)[]): string {
+ let formatted = "";
+
+ for (const segment of path) {
+ if (typeof segment === "number") {
+ formatted = `${formatted}[${segment}]`;
+ } else if (formatted) {
+ formatted = `${formatted}.${segment}`;
+ } else {
+ formatted = segment;
+ }
+ }
+
+ return formatted;
+}
+
+export class ConfigValidationError extends LambdaError {
+ constructor(public readonly issues: ValidationIssue[]) {
+ super(
+ ErrorType.VALIDATION_ERROR,
+ "Client subscription configuration validation failed",
+ undefined,
+ false,
+ );
+ }
+}
+
function serializeUnknownError(error: unknown): string {
if (typeof error === "string") {
return error;
@@ -104,6 +136,14 @@ export function getEventError(
? error.correlationId
: "unknown";
+ if (error instanceof ConfigValidationError) {
+ eventLogger.error("Client config validation failed", {
+ error,
+ });
+ metrics.emitValidationError();
+ return error;
+ }
+
if (error instanceof ValidationError) {
eventLogger.error("Event validation failed", {
correlationId,
diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts
new file mode 100644
index 0000000..244f57b
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts
@@ -0,0 +1,108 @@
+import type {
+ ChannelStatusData,
+ ChannelStatusSubscriptionConfiguration,
+ ClientSubscriptionConfiguration,
+ StatusPublishEvent,
+} from "@nhs-notify-client-callbacks/models";
+import { logger } from "services/logger";
+import { matchesEventPattern } from "services/filters/event-pattern";
+
+type FilterContext = {
+ event: StatusPublishEvent;
+ notifyData: ChannelStatusData;
+};
+
+const isChannelStatusSubscription = (
+ subscription: ClientSubscriptionConfiguration[number],
+): subscription is ChannelStatusSubscriptionConfiguration =>
+ subscription.SubscriptionType === "ChannelStatus";
+
+export const matchesChannelStatusSubscription = (
+ config: ClientSubscriptionConfiguration,
+ context: FilterContext,
+): boolean => {
+ const { event, notifyData } = context;
+
+ const matched = config
+ .filter((sub) => isChannelStatusSubscription(sub))
+ .some((subscription) => {
+ if (subscription.ClientId !== notifyData.clientId) {
+ return false;
+ }
+
+ if (subscription.ChannelType !== notifyData.channel) {
+ logger.debug("Channel status filter rejected: channel type mismatch", {
+ clientId: notifyData.clientId,
+ channel: notifyData.channel,
+ expectedChannel: subscription.ChannelType,
+ });
+ return false;
+ }
+
+ // Check if supplier status changed AND client is subscribed to it
+ const supplierStatusChanged =
+ notifyData.previousSupplierStatus !== notifyData.supplierStatus;
+ const clientSubscribedSupplierStatus =
+ subscription.SupplierStatuses.includes(notifyData.supplierStatus);
+
+ // Check if channel status changed AND client is subscribed to it
+ const channelStatusChanged =
+ notifyData.previousChannelStatus !== notifyData.channelStatus;
+ const clientSubscribedChannelStatus =
+ subscription.ChannelStatuses.includes(notifyData.channelStatus);
+
+ const statusMatch =
+ (supplierStatusChanged && clientSubscribedSupplierStatus) ||
+ (channelStatusChanged && clientSubscribedChannelStatus);
+
+ if (!statusMatch) {
+ logger.debug(
+ "Channel status filter rejected: no matching status change for subscription",
+ {
+ clientId: notifyData.clientId,
+ channelStatus: notifyData.channelStatus,
+ previousChannelStatus: notifyData.previousChannelStatus,
+ channelStatusChanged,
+ clientSubscribedChannelStatus,
+ supplierStatus: notifyData.supplierStatus,
+ previousSupplierStatus: notifyData.previousSupplierStatus,
+ supplierStatusChanged,
+ clientSubscribedSupplierStatus,
+ subscribedChannelStatuses: subscription.ChannelStatuses,
+ subscribedSupplierStatuses: subscription.SupplierStatuses,
+ },
+ );
+ return false;
+ }
+
+ const patternMatch = matchesEventPattern(subscription, event.source, {
+ channel: notifyData.channel,
+ clientId: notifyData.clientId,
+ type: "ChannelStatus",
+ });
+
+ if (!patternMatch) {
+ logger.debug("Channel status filter rejected: event pattern mismatch", {
+ clientId: notifyData.clientId,
+ eventSource: event.source,
+ subscriptionName: subscription.Name,
+ });
+ }
+
+ return patternMatch;
+ });
+
+ if (matched) {
+ logger.debug("Channel status filter matched", {
+ clientId: notifyData.clientId,
+ channel: notifyData.channel,
+ channelStatus: notifyData.channelStatus,
+ previousChannelStatus: notifyData.previousChannelStatus,
+ supplierStatus: notifyData.supplierStatus,
+ previousSupplierStatus: notifyData.previousSupplierStatus,
+ eventSource: event.source,
+ });
+ }
+
+ return matched;
+};
diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts
new file mode 100644
index 0000000..bfd906b
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts
@@ -0,0 +1,49 @@
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+
+// Parsed representation of a subscription's EventSource / EventDetail filter criteria.
+// Each key in `detail` maps to the list of allowed values for that event attribute.
+type SubscriptionFilter = {
+ sources: string[];
+ detail: Record;
+};
+
+const parseSubscriptionFilter = (
+ subscription: ClientSubscriptionConfiguration[number],
+): SubscriptionFilter => {
+ const sources = JSON.parse(subscription.EventSource) as string[];
+ const detail = JSON.parse(subscription.EventDetail) as Record<
+ string,
+ string[]
+ >;
+ return { sources, detail };
+};
+
+const matchesEventSource = (sources: string[], source: string): boolean =>
+ sources.length === 0 || sources.includes(source);
+
+// Checks that every attribute required by the subscription's detail filter is
+// present in the event AND has one of the subscription's allowed values.
+const matchesEventDetail = (
+ allowedDetailValues: Record,
+ eventDetail: Record,
+): boolean =>
+ Object.entries(allowedDetailValues).every(([key, allowedValues]) => {
+ // eslint-disable-next-line security/detect-object-injection
+ const eventValue = eventDetail[key];
+ if (!eventValue) {
+ return false;
+ }
+ return allowedValues.includes(eventValue);
+ });
+
+export const matchesEventPattern = (
+ subscription: ClientSubscriptionConfiguration[number],
+ eventSource: string,
+ eventDetail: Record,
+): boolean => {
+ const subscriptionFilter = parseSubscriptionFilter(subscription);
+ return (
+ matchesEventSource(subscriptionFilter.sources, eventSource) &&
+ matchesEventDetail(subscriptionFilter.detail, eventDetail)
+ );
+};
diff --git a/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts
new file mode 100644
index 0000000..6e92f95
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts
@@ -0,0 +1,80 @@
+import type {
+ ClientSubscriptionConfiguration,
+ MessageStatusData,
+ MessageStatusSubscriptionConfiguration,
+ StatusPublishEvent,
+} from "@nhs-notify-client-callbacks/models";
+import { logger } from "services/logger";
+import { matchesEventPattern } from "services/filters/event-pattern";
+
+type FilterContext = {
+ event: StatusPublishEvent;
+ notifyData: MessageStatusData;
+};
+
+const isMessageStatusSubscription = (
+ subscription: ClientSubscriptionConfiguration[number],
+): subscription is MessageStatusSubscriptionConfiguration =>
+ subscription.SubscriptionType === "MessageStatus";
+
+export const matchesMessageStatusSubscription = (
+ config: ClientSubscriptionConfiguration,
+ context: FilterContext,
+): boolean => {
+ const { event, notifyData } = context;
+
+ const matched = config
+ .filter((sub) => isMessageStatusSubscription(sub))
+ .some((subscription) => {
+ if (subscription.ClientId !== notifyData.clientId) {
+ return false;
+ }
+
+ // Check if message status changed AND client is subscribed to it
+ const messageStatusChanged =
+ notifyData.previousMessageStatus !== notifyData.messageStatus;
+ const clientSubscribedStatus = subscription.Statuses.includes(
+ notifyData.messageStatus,
+ );
+
+ if (!messageStatusChanged || !clientSubscribedStatus) {
+ logger.debug(
+ "Message status filter rejected: no matching status change for subscription",
+ {
+ clientId: notifyData.clientId,
+ messageStatus: notifyData.messageStatus,
+ previousMessageStatus: notifyData.previousMessageStatus,
+ messageStatusChanged,
+ clientSubscribedStatus,
+ expectedStatuses: subscription.Statuses,
+ },
+ );
+ return false;
+ }
+
+ const patternMatch = matchesEventPattern(subscription, event.source, {
+ clientId: notifyData.clientId,
+ type: "MessageStatus",
+ });
+
+ if (!patternMatch) {
+ logger.debug("Message status filter rejected: event pattern mismatch", {
+ clientId: notifyData.clientId,
+ eventSource: event.source,
+ subscriptionName: subscription.Name,
+ });
+ }
+
+ return patternMatch;
+ });
+
+ if (matched) {
+ logger.debug("Message status filter matched", {
+ clientId: notifyData.clientId,
+ messageStatus: notifyData.messageStatus,
+ eventSource: event.source,
+ });
+ }
+
+ return matched;
+};
diff --git a/lambdas/client-transform-filter-lambda/src/services/logger.ts b/lambdas/client-transform-filter-lambda/src/services/logger.ts
index 84b7be3..1149d02 100644
--- a/lambdas/client-transform-filter-lambda/src/services/logger.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/logger.ts
@@ -79,6 +79,8 @@ export function logLifecycleEvent(
| "processing-started"
| "transformation-started"
| "transformation-completed"
+ | "filtering-started"
+ | "filtering-matched"
| "delivery-initiated"
| "batch-processing-completed",
context: LogContext,
diff --git a/lambdas/client-transform-filter-lambda/src/services/metrics.ts b/lambdas/client-transform-filter-lambda/src/services/metrics.ts
index f77b487..398c5ec 100644
--- a/lambdas/client-transform-filter-lambda/src/services/metrics.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/metrics.ts
@@ -39,4 +39,12 @@ export class CallbackMetrics {
emitValidationError(): void {
this.metrics.putMetric("ValidationErrors", 1, Unit.Count);
}
+
+ emitFilteringStarted(): void {
+ this.metrics.putMetric("FilteringStarted", 1, Unit.Count);
+ }
+
+ emitFilteringMatched(): void {
+ this.metrics.putMetric("FilteringMatched", 1, Unit.Count);
+ }
}
diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts
index bb6126d..d59850a 100644
--- a/lambdas/client-transform-filter-lambda/src/services/observability.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts
@@ -39,9 +39,24 @@ export class ObservabilityService {
logLifecycleEvent(this.logger, "transformation-started", context);
}
+ recordFilteringStarted(context: { batchSize: number }): void {
+ logLifecycleEvent(this.logger, "filtering-started", context);
+ this.metrics.emitFilteringStarted();
+ }
+
+ recordFilteringMatched(context: {
+ clientId: string;
+ eventType: string;
+ subscriptionType: string;
+ }): void {
+ logLifecycleEvent(this.logger, "filtering-matched", context);
+ this.metrics.emitFilteringMatched();
+ }
+
logBatchProcessingCompleted(context: {
successful: number;
failed: number;
+ filtered: number;
processed: number;
batchSize: number;
processingTimeMs: number;
diff --git a/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts
new file mode 100644
index 0000000..33eddf8
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts
@@ -0,0 +1,47 @@
+import type {
+ ChannelStatusData,
+ ClientSubscriptionConfiguration,
+ MessageStatusData,
+ StatusPublishEvent,
+} from "@nhs-notify-client-callbacks/models";
+import { EventTypes } from "@nhs-notify-client-callbacks/models";
+import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter";
+import { matchesMessageStatusSubscription } from "services/filters/message-status-filter";
+import { TransformationError } from "services/error-handler";
+import { logger } from "services/logger";
+
+type FilterResult = {
+ matched: boolean;
+ subscriptionType: "MessageStatus" | "ChannelStatus" | "Unknown";
+};
+
+export const evaluateSubscriptionFilters = (
+ event: StatusPublishEvent,
+ config: ClientSubscriptionConfiguration | undefined,
+): FilterResult => {
+ if (!config) {
+ logger.debug("No config available for filtering", {
+ eventType: event.type,
+ });
+ return { matched: false, subscriptionType: "Unknown" };
+ }
+
+ if (event.type === EventTypes.MESSAGE_STATUS_PUBLISHED) {
+ const notifyData = event.data as MessageStatusData;
+ return {
+ matched: matchesMessageStatusSubscription(config, { event, notifyData }),
+ subscriptionType: "MessageStatus",
+ };
+ }
+
+ if (event.type === EventTypes.CHANNEL_STATUS_PUBLISHED) {
+ const notifyData = event.data as ChannelStatusData;
+ return {
+ matched: matchesChannelStatusSubscription(config, { event, notifyData }),
+ subscriptionType: "ChannelStatus",
+ };
+ }
+
+ logger.warn("Unknown event type for filtering", { eventType: event.type });
+ throw new TransformationError(`Unsupported event type: ${event.type}`);
+};
diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts
new file mode 100644
index 0000000..7b196d6
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts
@@ -0,0 +1,157 @@
+import { z } from "zod";
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+import {
+ CHANNEL_STATUSES,
+ CHANNEL_TYPES,
+ MESSAGE_STATUSES,
+ SUPPLIER_STATUSES,
+} from "@nhs-notify-client-callbacks/models";
+import {
+ ConfigValidationError,
+ type ValidationIssue,
+ formatValidationIssuePath,
+} from "services/error-handler";
+
+export { ConfigValidationError } from "services/error-handler";
+
+const jsonStringArraySchema = z.array(z.string());
+const jsonRecordSchema = z.record(z.string(), z.array(z.string()));
+
+const eventSourceSchema = z.string().superRefine((value, ctx) => {
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ const result = jsonStringArraySchema.safeParse(parsed);
+ if (!result.success) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Expected JSON array of strings",
+ });
+ }
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ message: "Expected valid JSON array",
+ });
+ }
+});
+
+const eventDetailSchema = z.string().superRefine((value, ctx) => {
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ const result = jsonRecordSchema.safeParse(parsed);
+ if (!result.success) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Expected JSON object of string arrays",
+ });
+ }
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ message: "Expected valid JSON object",
+ });
+ }
+});
+
+const httpsUrlSchema = z.string().refine(
+ (value) => {
+ try {
+ const parsed = new URL(value);
+ return parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+ },
+ {
+ message: "Expected HTTPS URL",
+ },
+);
+
+const targetSchema = z.object({
+ Type: z.literal("API"),
+ TargetId: z.string(),
+ Name: z.string(),
+ InputTransformer: z.object({
+ InputPaths: z.string(),
+ InputHeaders: z.object({
+ "x-hmac-sha256-signature": z.string(),
+ }),
+ }),
+ InvocationEndpoint: httpsUrlSchema,
+ InvocationMethod: z.literal("POST"),
+ InvocationRateLimit: z.number(),
+ APIKey: z.object({
+ HeaderName: z.string(),
+ HeaderValue: z.string(),
+ }),
+});
+
+const baseSubscriptionSchema = z.object({
+ Name: z.string(),
+ ClientId: z.string(),
+ Description: z.string(),
+ EventSource: eventSourceSchema,
+ EventDetail: eventDetailSchema,
+ Targets: z.array(targetSchema).min(1),
+});
+
+const messageStatusSchema = baseSubscriptionSchema.extend({
+ SubscriptionType: z.literal("MessageStatus"),
+ Statuses: z.array(z.enum(MESSAGE_STATUSES)),
+});
+
+const channelStatusSchema = baseSubscriptionSchema.extend({
+ SubscriptionType: z.literal("ChannelStatus"),
+ ChannelType: z.enum(CHANNEL_TYPES),
+ ChannelStatuses: z.array(z.enum(CHANNEL_STATUSES)),
+ SupplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)),
+});
+
+const subscriptionSchema = z.discriminatedUnion("SubscriptionType", [
+ messageStatusSchema,
+ channelStatusSchema,
+]);
+
+const configSchema = z.array(subscriptionSchema).superRefine((config, ctx) => {
+ const seenNames = new Set();
+
+ for (const [index, subscription] of config.entries()) {
+ if (seenNames.has(subscription.Name)) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Expected Name to be unique",
+ path: [index, "Name"],
+ });
+ } else {
+ seenNames.add(subscription.Name);
+ }
+ }
+});
+
+export const validateClientConfig = (
+ rawConfig: unknown,
+): ClientSubscriptionConfiguration => {
+ const result = configSchema.safeParse(rawConfig);
+
+ if (!result.success) {
+ const issues: ValidationIssue[] = result.error.issues.map((issue) => {
+ const pathSegments = issue.path.filter(
+ (segment): segment is string | number =>
+ typeof segment === "string" || typeof segment === "number",
+ );
+
+ return {
+ path: formatValidationIssuePath(pathSegments),
+ message: issue.message,
+ };
+ });
+ throw new ConfigValidationError(issues);
+ }
+
+ return result.data;
+};
+
+export {
+ type ChannelStatusSubscriptionConfiguration,
+ type MessageStatusSubscriptionConfiguration,
+} from "@nhs-notify-client-callbacks/models";
diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts
index 82180eb..03e3780 100644
--- a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts
@@ -7,7 +7,10 @@ import {
EventTypes,
type StatusPublishEvent,
} from "@nhs-notify-client-callbacks/models";
-import { ValidationError } from "services/error-handler";
+import {
+ ValidationError,
+ formatValidationIssuePath,
+} from "services/error-handler";
import { extractCorrelationId } from "services/logger";
const NHSNotifyExtensionsSchema = z.object({
@@ -66,7 +69,15 @@ function formatValidationError(error: unknown, event: unknown): never {
message = `CloudEvents validation failed: ${error.message}`;
} else if (error instanceof z.ZodError) {
const issues = error.issues
- .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
+ .map((issue) => {
+ const path = formatValidationIssuePath(
+ issue.path.filter(
+ (segment): segment is string | number =>
+ typeof segment === "string" || typeof segment === "number",
+ ),
+ );
+ return path ? `${path}: ${issue.message}` : issue.message;
+ })
.join(", ");
message = `Validation failed: ${issues}`;
} else if (error instanceof Error) {
diff --git a/package-lock.json b/package-lock.json
index 6ba1c04..8a324f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,9 +9,11 @@
"lambdas/client-transform-filter-lambda",
"src/models",
"lambdas/mock-webhook-lambda",
- "tests/integration"
+ "tests/integration",
+ "tools/client-subscriptions-management"
],
"devDependencies": {
+ "@aws-sdk/client-s3": "^3.821.0",
"@stylistic/eslint-plugin": "^3.1.0",
"@tsconfig/node22": "^22.0.2",
"@types/jest": "^29.5.14",
@@ -47,6 +49,7 @@
"name": "nhs-notify-client-transform-filter-lambda",
"version": "0.0.1",
"dependencies": {
+ "@aws-sdk/client-s3": "^3.821.0",
"@nhs-notify-client-callbacks/models": "*",
"aws-embedded-metrics": "^4.2.1",
"cloudevents": "^8.0.2",
@@ -64,6 +67,19 @@
"typescript": "^5.8.2"
}
},
+ "lambdas/client-transform-filter-lambda/node_modules/p-map": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"lambdas/mock-webhook-lambda": {
"name": "nhs-notify-mock-webhook-lambda",
"version": "0.0.1",
@@ -84,9 +100,7 @@
}
},
"lambdas/mock-webhook-lambda/node_modules/@types/node": {
- "version": "22.19.13",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
- "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
+ "version": "22.19.11",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -95,15 +109,23 @@
},
"lambdas/mock-webhook-lambda/node_modules/undici-types": {
"version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
- "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
@@ -114,10 +136,61 @@
"node": ">=16.0.0"
}
},
+ "node_modules/@aws-crypto/crc32c": {
+ "version": "5.2.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser": {
+ "version": "5.2.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
- "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
@@ -131,8 +204,6 @@
},
"node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
- "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -143,8 +214,6 @@
},
"node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
- "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
@@ -156,8 +225,6 @@
},
"node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": {
"version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
- "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
@@ -169,8 +236,6 @@
},
"node_modules/@aws-crypto/sha256-js": {
"version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
- "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
@@ -183,8 +248,6 @@
},
"node_modules/@aws-crypto/supports-web-crypto": {
"version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
- "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -192,8 +255,6 @@
},
"node_modules/@aws-crypto/util": {
"version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
- "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.222.0",
@@ -203,8 +264,6 @@
},
"node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
- "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -215,8 +274,6 @@
},
"node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
- "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
@@ -228,8 +285,6 @@
},
"node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": {
"version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
- "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
@@ -240,48 +295,111 @@
}
},
"node_modules/@aws-sdk/client-cloudwatch-logs": {
- "version": "3.1000.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1000.0.tgz",
- "integrity": "sha512-8/YP++CiBIh5jADEmPfBCHYWErHNYlG5Ome5h82F/yB+x6i9ARF/Y/u95Z9IHwO25CDvxTPKH0U66h7HFL8tcg==",
+ "version": "3.991.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/credential-provider-node": "^3.972.9",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.991.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/eventstream-serde-browser": "^4.2.8",
+ "@smithy/eventstream-serde-config-resolver": "^4.3.8",
+ "@smithy/eventstream-serde-node": "^4.2.8",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.991.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-eventbridge": {
+ "version": "3.1001.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.1001.0.tgz",
+ "integrity": "sha512-asySfaKnDTxhMtxCX1dvjDPfJwrQ5xy/tzdmFHmRyURNhIhXG3dwishJ6ROXzOrY7hFCiz+OTWWjZ+IJbrfzkA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/credential-provider-node": "^3.972.14",
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/credential-provider-node": "^3.972.15",
"@aws-sdk/middleware-host-header": "^3.972.6",
"@aws-sdk/middleware-logger": "^3.972.6",
"@aws-sdk/middleware-recursion-detection": "^3.972.6",
- "@aws-sdk/middleware-user-agent": "^3.972.15",
+ "@aws-sdk/middleware-user-agent": "^3.972.16",
"@aws-sdk/region-config-resolver": "^3.972.6",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.4",
"@aws-sdk/types": "^3.973.4",
"@aws-sdk/util-endpoints": "^3.996.3",
"@aws-sdk/util-user-agent-browser": "^3.972.6",
- "@aws-sdk/util-user-agent-node": "^3.973.0",
+ "@aws-sdk/util-user-agent-node": "^3.973.1",
"@smithy/config-resolver": "^4.4.9",
- "@smithy/core": "^3.23.6",
- "@smithy/eventstream-serde-browser": "^4.2.10",
- "@smithy/eventstream-serde-config-resolver": "^4.3.10",
- "@smithy/eventstream-serde-node": "^4.2.10",
- "@smithy/fetch-http-handler": "^5.3.11",
+ "@smithy/core": "^3.23.7",
+ "@smithy/fetch-http-handler": "^5.3.12",
"@smithy/hash-node": "^4.2.10",
"@smithy/invalid-dependency": "^4.2.10",
"@smithy/middleware-content-length": "^4.2.10",
- "@smithy/middleware-endpoint": "^4.4.20",
- "@smithy/middleware-retry": "^4.4.37",
+ "@smithy/middleware-endpoint": "^4.4.21",
+ "@smithy/middleware-retry": "^4.4.38",
"@smithy/middleware-serde": "^4.2.11",
"@smithy/middleware-stack": "^4.2.10",
"@smithy/node-config-provider": "^4.3.10",
- "@smithy/node-http-handler": "^4.4.12",
+ "@smithy/node-http-handler": "^4.4.13",
"@smithy/protocol-http": "^5.3.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"@smithy/url-parser": "^4.2.10",
"@smithy/util-base64": "^4.3.1",
"@smithy/util-body-length-browser": "^4.2.1",
"@smithy/util-body-length-node": "^4.2.2",
- "@smithy/util-defaults-mode-browser": "^4.3.36",
- "@smithy/util-defaults-mode-node": "^4.2.39",
+ "@smithy/util-defaults-mode-browser": "^4.3.37",
+ "@smithy/util-defaults-mode-node": "^4.2.40",
"@smithy/util-endpoints": "^3.3.1",
"@smithy/util-middleware": "^4.2.10",
"@smithy/util-retry": "^4.2.10",
@@ -292,49 +410,206 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/client-eventbridge/node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.996.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz",
+ "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.4",
+ "@smithy/types": "^4.13.0",
+ "@smithy/url-parser": "^4.2.10",
+ "@smithy/util-endpoints": "^3.3.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.996.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha1-browser": "5.2.0",
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.12",
+ "@aws-sdk/credential-provider-node": "^3.972.11",
+ "@aws-sdk/middleware-bucket-endpoint": "^3.972.3",
+ "@aws-sdk/middleware-expect-continue": "^3.972.3",
+ "@aws-sdk/middleware-flexible-checksums": "^3.972.10",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-location-constraint": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.12",
+ "@aws-sdk/middleware-ssec": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.12",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/signature-v4-multi-region": "3.996.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.996.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.11",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.2",
+ "@smithy/eventstream-serde-browser": "^4.2.8",
+ "@smithy/eventstream-serde-config-resolver": "^4.3.8",
+ "@smithy/eventstream-serde-node": "^4.2.8",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-blob-browser": "^4.2.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/hash-stream-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/md5-js": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.16",
+ "@smithy/middleware-retry": "^4.4.33",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.5",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.32",
+ "@smithy/util-defaults-mode-node": "^4.2.35",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-stream": "^4.5.12",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-sdk-s3": "^3.972.12",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.996.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/client-sqs": {
- "version": "3.1000.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1000.0.tgz",
- "integrity": "sha512-fGp197WE/wy05DNAKLokN21RwhH17go631U6GT/t3BwHv7DBd5oI4OLT5TLy0dc4freAd3ib3XET1OEc1TG/3Q==",
+ "version": "3.990.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/credential-provider-node": "^3.972.14",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/credential-provider-node": "^3.972.9",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-sdk-sqs": "^3.972.7",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/md5-js": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-sts": {
+ "version": "3.1001.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1001.0.tgz",
+ "integrity": "sha512-1HVxJcad+BTMVQ4lN2jw4SzyVqnIRZ7mb8YjwqMQ6p1MjuklSriVUXKtYFyxLVJnqaw61nFv9F8oHMOK69p6BQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/credential-provider-node": "^3.972.15",
"@aws-sdk/middleware-host-header": "^3.972.6",
"@aws-sdk/middleware-logger": "^3.972.6",
"@aws-sdk/middleware-recursion-detection": "^3.972.6",
- "@aws-sdk/middleware-sdk-sqs": "^3.972.11",
- "@aws-sdk/middleware-user-agent": "^3.972.15",
+ "@aws-sdk/middleware-user-agent": "^3.972.16",
"@aws-sdk/region-config-resolver": "^3.972.6",
"@aws-sdk/types": "^3.973.4",
"@aws-sdk/util-endpoints": "^3.996.3",
"@aws-sdk/util-user-agent-browser": "^3.972.6",
- "@aws-sdk/util-user-agent-node": "^3.973.0",
+ "@aws-sdk/util-user-agent-node": "^3.973.1",
"@smithy/config-resolver": "^4.4.9",
- "@smithy/core": "^3.23.6",
- "@smithy/fetch-http-handler": "^5.3.11",
+ "@smithy/core": "^3.23.7",
+ "@smithy/fetch-http-handler": "^5.3.12",
"@smithy/hash-node": "^4.2.10",
"@smithy/invalid-dependency": "^4.2.10",
- "@smithy/md5-js": "^4.2.10",
"@smithy/middleware-content-length": "^4.2.10",
- "@smithy/middleware-endpoint": "^4.4.20",
- "@smithy/middleware-retry": "^4.4.37",
+ "@smithy/middleware-endpoint": "^4.4.21",
+ "@smithy/middleware-retry": "^4.4.38",
"@smithy/middleware-serde": "^4.2.11",
"@smithy/middleware-stack": "^4.2.10",
"@smithy/node-config-provider": "^4.3.10",
- "@smithy/node-http-handler": "^4.4.12",
+ "@smithy/node-http-handler": "^4.4.13",
"@smithy/protocol-http": "^5.3.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"@smithy/url-parser": "^4.2.10",
"@smithy/util-base64": "^4.3.1",
"@smithy/util-body-length-browser": "^4.2.1",
"@smithy/util-body-length-node": "^4.2.2",
- "@smithy/util-defaults-mode-browser": "^4.3.36",
- "@smithy/util-defaults-mode-node": "^4.2.39",
+ "@smithy/util-defaults-mode-browser": "^4.3.37",
+ "@smithy/util-defaults-mode-node": "^4.2.40",
"@smithy/util-endpoints": "^3.3.1",
"@smithy/util-middleware": "^4.2.10",
"@smithy/util-retry": "^4.2.10",
@@ -345,20 +620,36 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.996.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz",
+ "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.4",
+ "@smithy/types": "^4.13.0",
+ "@smithy/url-parser": "^4.2.10",
+ "@smithy/util-endpoints": "^3.3.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/core": {
- "version": "3.973.15",
- "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.15.tgz",
- "integrity": "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==",
+ "version": "3.973.16",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.16.tgz",
+ "integrity": "sha512-Nasoyb5K4jfvncTKQyA13q55xHoz9as01NVYP05B0Kzux/X5UhMn3qXsZDyWOSXkfSCAIrMBKmVVWbI0vUapdQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.4",
- "@aws-sdk/xml-builder": "^3.972.8",
- "@smithy/core": "^3.23.6",
+ "@aws-sdk/xml-builder": "^3.972.9",
+ "@smithy/core": "^3.23.7",
"@smithy/node-config-provider": "^4.3.10",
"@smithy/property-provider": "^4.2.10",
"@smithy/protocol-http": "^5.3.10",
"@smithy/signature-v4": "^5.3.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"@smithy/util-base64": "^4.3.1",
"@smithy/util-middleware": "^4.2.10",
@@ -369,13 +660,24 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/crc64-nvme": {
+ "version": "3.972.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.13.tgz",
- "integrity": "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==",
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.14.tgz",
+ "integrity": "sha512-PvnBY9rwBuLh9MEsAng28DG+WKl+txerKgf4BU9IPAqYI7FBIo1x6q/utLf4KLyQYgSy1TLQnbQuXx5xfBGASg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
+ "@aws-sdk/core": "^3.973.16",
"@aws-sdk/types": "^3.973.4",
"@smithy/property-provider": "^4.2.10",
"@smithy/types": "^4.13.0",
@@ -386,20 +688,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.15",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.15.tgz",
- "integrity": "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==",
+ "version": "3.972.16",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.16.tgz",
+ "integrity": "sha512-m/QAcvw5OahqGPjeAnKtgfWgjLxeWOYj7JSmxKK6PLyKp2S/t2TAHI6EELEzXnIz28RMgbQLukJkVAqPASVAGQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
+ "@aws-sdk/core": "^3.973.16",
"@aws-sdk/types": "^3.973.4",
- "@smithy/fetch-http-handler": "^5.3.11",
- "@smithy/node-http-handler": "^4.4.12",
+ "@smithy/fetch-http-handler": "^5.3.12",
+ "@smithy/node-http-handler": "^4.4.13",
"@smithy/property-provider": "^4.2.10",
"@smithy/protocol-http": "^5.3.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
- "@smithy/util-stream": "^4.5.15",
+ "@smithy/util-stream": "^4.5.16",
"tslib": "^2.6.2"
},
"engines": {
@@ -407,19 +709,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.13.tgz",
- "integrity": "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/credential-provider-env": "^3.972.13",
- "@aws-sdk/credential-provider-http": "^3.972.15",
- "@aws-sdk/credential-provider-login": "^3.972.13",
- "@aws-sdk/credential-provider-process": "^3.972.13",
- "@aws-sdk/credential-provider-sso": "^3.972.13",
- "@aws-sdk/credential-provider-web-identity": "^3.972.13",
- "@aws-sdk/nested-clients": "^3.996.3",
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.14.tgz",
+ "integrity": "sha512-EGA7ufqNpZKZcD0RwM6gRDEQgwAf19wQ99R1ptdWYDJAnpcMcWiFyT0RIrgiZFLD28CwJmYjnra75hChnEveWA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/credential-provider-env": "^3.972.14",
+ "@aws-sdk/credential-provider-http": "^3.972.16",
+ "@aws-sdk/credential-provider-login": "^3.972.14",
+ "@aws-sdk/credential-provider-process": "^3.972.14",
+ "@aws-sdk/credential-provider-sso": "^3.972.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.14",
+ "@aws-sdk/nested-clients": "^3.996.4",
"@aws-sdk/types": "^3.973.4",
"@smithy/credential-provider-imds": "^4.2.10",
"@smithy/property-provider": "^4.2.10",
@@ -432,13 +734,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.13.tgz",
- "integrity": "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==",
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.14.tgz",
+ "integrity": "sha512-P2kujQHAoV7irCTv6EGyReKFofkHCjIK+F0ZYf5UxeLeecrCwtrDkHoO2Vjsv/eRUumaKblD8czuk3CLlzwGDw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/nested-clients": "^3.996.3",
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/nested-clients": "^3.996.4",
"@aws-sdk/types": "^3.973.4",
"@smithy/property-provider": "^4.2.10",
"@smithy/protocol-http": "^5.3.10",
@@ -451,17 +753,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.14",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.14.tgz",
- "integrity": "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==",
+ "version": "3.972.15",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.15.tgz",
+ "integrity": "sha512-59NBJgTcQ2FC94T+SWkN5UQgViFtrLnkswSKhG5xbjPAotOXnkEF2Bf0bfUV1F3VaXzqAPZJoZ3bpg4rr8XD5Q==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/credential-provider-env": "^3.972.13",
- "@aws-sdk/credential-provider-http": "^3.972.15",
- "@aws-sdk/credential-provider-ini": "^3.972.13",
- "@aws-sdk/credential-provider-process": "^3.972.13",
- "@aws-sdk/credential-provider-sso": "^3.972.13",
- "@aws-sdk/credential-provider-web-identity": "^3.972.13",
+ "@aws-sdk/credential-provider-env": "^3.972.14",
+ "@aws-sdk/credential-provider-http": "^3.972.16",
+ "@aws-sdk/credential-provider-ini": "^3.972.14",
+ "@aws-sdk/credential-provider-process": "^3.972.14",
+ "@aws-sdk/credential-provider-sso": "^3.972.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.14",
"@aws-sdk/types": "^3.973.4",
"@smithy/credential-provider-imds": "^4.2.10",
"@smithy/property-provider": "^4.2.10",
@@ -474,12 +776,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.13.tgz",
- "integrity": "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==",
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.14.tgz",
+ "integrity": "sha512-KAF5LBkJInUPaR9dJDw8LqmbPDRTLyXyRoWVGcJQ+DcN9rxVKBRzAK+O4dTIvQtQ7xaIDZ2kY7zUmDlz6CCXdw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
+ "@aws-sdk/core": "^3.973.16",
"@aws-sdk/types": "^3.973.4",
"@smithy/property-provider": "^4.2.10",
"@smithy/shared-ini-file-loader": "^4.4.5",
@@ -491,14 +793,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.13.tgz",
- "integrity": "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==",
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.14.tgz",
+ "integrity": "sha512-LQzIYrNABnZzkyuIguFa3VVOox9UxPpRW6PL+QYtRHaGl1Ux/+Zi54tAVK31VdeBKPKU3cxqeu8dbOgNqy+naw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/nested-clients": "^3.996.3",
- "@aws-sdk/token-providers": "3.999.0",
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/nested-clients": "^3.996.4",
+ "@aws-sdk/token-providers": "3.1001.0",
"@aws-sdk/types": "^3.973.4",
"@smithy/property-provider": "^4.2.10",
"@smithy/shared-ini-file-loader": "^4.4.5",
@@ -510,13 +812,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.13.tgz",
- "integrity": "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==",
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.14.tgz",
+ "integrity": "sha512-rOwB3vXHHHnGvAOjTgQETxVAsWjgF61XlbGd/ulvYo7EpdXs8cbIHE3PGih9tTj/65ZOegSqZGFqLaKntaI9Kw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/nested-clients": "^3.996.3",
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/nested-clients": "^3.996.4",
"@aws-sdk/types": "^3.973.4",
"@smithy/property-provider": "^4.2.10",
"@smithy/shared-ini-file-loader": "^4.4.5",
@@ -527,10 +829,60 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/middleware-bucket-endpoint": {
+ "version": "3.972.3",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-arn-parser": "^3.972.2",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-expect-continue": {
+ "version": "3.972.3",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-flexible-checksums": {
+ "version": "3.972.10",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@aws-crypto/crc32c": "5.2.0",
+ "@aws-crypto/util": "5.2.0",
+ "@aws-sdk/core": "^3.973.12",
+ "@aws-sdk/crc64-nvme": "3.972.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.12",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/middleware-host-header": {
"version": "3.972.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz",
- "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.4",
@@ -542,10 +894,20 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/middleware-location-constraint": {
+ "version": "3.972.3",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/middleware-logger": {
"version": "3.972.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz",
- "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.4",
@@ -558,8 +920,6 @@
},
"node_modules/@aws-sdk/middleware-recursion-detection": {
"version": "3.972.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz",
- "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.4",
@@ -572,17 +932,24 @@
"node": ">=20.0.0"
}
},
- "node_modules/@aws-sdk/middleware-sdk-sqs": {
- "version": "3.972.11",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.11.tgz",
- "integrity": "sha512-Y4dryR0y7wN3hBayLOVSRuP3FeTs8KbNEL4orW/hKpf4jsrneDpI2RifUQVhiyb3QkC83bpeKaOSa0waHiPvcg==",
- "dev": true,
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.16",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.16.tgz",
+ "integrity": "sha512-U4K1rqyJYvT/zgTI3+rN+MToa51dFnnq1VSsVJuJWPNEKcEnuZVqf7yTpkJJMkYixVW5TTi1dgupd+nmJ0JyWw==",
"license": "Apache-2.0",
"dependencies": {
+ "@aws-sdk/core": "^3.973.16",
"@aws-sdk/types": "^3.973.4",
- "@smithy/smithy-client": "^4.12.0",
+ "@aws-sdk/util-arn-parser": "^3.972.2",
+ "@smithy/core": "^3.23.7",
+ "@smithy/node-config-provider": "^4.3.10",
+ "@smithy/protocol-http": "^5.3.10",
+ "@smithy/signature-v4": "^5.3.10",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
- "@smithy/util-hex-encoding": "^4.2.1",
+ "@smithy/util-config-provider": "^4.2.1",
+ "@smithy/util-middleware": "^4.2.10",
+ "@smithy/util-stream": "^4.5.16",
"@smithy/util-utf8": "^4.2.1",
"tslib": "^2.6.2"
},
@@ -590,16 +957,44 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/middleware-sdk-sqs": {
+ "version": "3.972.7",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-ssec": {
+ "version": "3.972.3",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.972.15",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.15.tgz",
- "integrity": "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==",
+ "version": "3.972.16",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.16.tgz",
+ "integrity": "sha512-AmVxtxn8ZkNJbuPu3KKfW9IkJgTgcEtgSwbo0NVcAb31iGvLgHXj2nbbyrUDfh2fx8otXmqL+qw1lRaTi+V3vA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
+ "@aws-sdk/core": "^3.973.16",
"@aws-sdk/types": "^3.973.4",
"@aws-sdk/util-endpoints": "^3.996.3",
- "@smithy/core": "^3.23.6",
+ "@smithy/core": "^3.23.7",
"@smithy/protocol-http": "^5.3.10",
"@smithy/types": "^4.13.0",
"tslib": "^2.6.2"
@@ -608,45 +1003,59 @@
"node": ">=20.0.0"
}
},
- "node_modules/@aws-sdk/nested-clients": {
+ "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": {
"version": "3.996.3",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz",
- "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.4",
+ "@smithy/types": "^4.13.0",
+ "@smithy/url-parser": "^4.2.10",
+ "@smithy/util-endpoints": "^3.3.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.996.4",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.4.tgz",
+ "integrity": "sha512-NowB1HfOnWC4kwZOnTg8E8rSL0U+RSjSa++UtEV4ipoH6JOjMLnHyGilqwl+Pe1f0Al6v9yMkSJ/8Ot0f578CQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.15",
+ "@aws-sdk/core": "^3.973.16",
"@aws-sdk/middleware-host-header": "^3.972.6",
"@aws-sdk/middleware-logger": "^3.972.6",
"@aws-sdk/middleware-recursion-detection": "^3.972.6",
- "@aws-sdk/middleware-user-agent": "^3.972.15",
+ "@aws-sdk/middleware-user-agent": "^3.972.16",
"@aws-sdk/region-config-resolver": "^3.972.6",
"@aws-sdk/types": "^3.973.4",
"@aws-sdk/util-endpoints": "^3.996.3",
"@aws-sdk/util-user-agent-browser": "^3.972.6",
- "@aws-sdk/util-user-agent-node": "^3.973.0",
+ "@aws-sdk/util-user-agent-node": "^3.973.1",
"@smithy/config-resolver": "^4.4.9",
- "@smithy/core": "^3.23.6",
- "@smithy/fetch-http-handler": "^5.3.11",
+ "@smithy/core": "^3.23.7",
+ "@smithy/fetch-http-handler": "^5.3.12",
"@smithy/hash-node": "^4.2.10",
"@smithy/invalid-dependency": "^4.2.10",
"@smithy/middleware-content-length": "^4.2.10",
- "@smithy/middleware-endpoint": "^4.4.20",
- "@smithy/middleware-retry": "^4.4.37",
+ "@smithy/middleware-endpoint": "^4.4.21",
+ "@smithy/middleware-retry": "^4.4.38",
"@smithy/middleware-serde": "^4.2.11",
"@smithy/middleware-stack": "^4.2.10",
"@smithy/node-config-provider": "^4.3.10",
- "@smithy/node-http-handler": "^4.4.12",
+ "@smithy/node-http-handler": "^4.4.13",
"@smithy/protocol-http": "^5.3.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"@smithy/url-parser": "^4.2.10",
"@smithy/util-base64": "^4.3.1",
"@smithy/util-body-length-browser": "^4.2.1",
"@smithy/util-body-length-node": "^4.2.2",
- "@smithy/util-defaults-mode-browser": "^4.3.36",
- "@smithy/util-defaults-mode-node": "^4.2.39",
+ "@smithy/util-defaults-mode-browser": "^4.3.37",
+ "@smithy/util-defaults-mode-node": "^4.2.40",
"@smithy/util-endpoints": "^3.3.1",
"@smithy/util-middleware": "^4.2.10",
"@smithy/util-retry": "^4.2.10",
@@ -657,10 +1066,24 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.996.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz",
+ "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.4",
+ "@smithy/types": "^4.13.0",
+ "@smithy/url-parser": "^4.2.10",
+ "@smithy/util-endpoints": "^3.3.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/region-config-resolver": {
"version": "3.972.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz",
- "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.4",
@@ -673,14 +1096,31 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.4",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.4.tgz",
+ "integrity": "sha512-MGa8ro0onekYIiesHX60LwKdkxK3Kd61p7TTbLwZemBqlnD9OLrk9sXZdFOIxXanJ+3AaJnV/jiX866eD/4PDg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-sdk-s3": "^3.972.16",
+ "@aws-sdk/types": "^3.973.4",
+ "@smithy/protocol-http": "^5.3.10",
+ "@smithy/signature-v4": "^5.3.10",
+ "@smithy/types": "^4.13.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/token-providers": {
- "version": "3.999.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.999.0.tgz",
- "integrity": "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==",
+ "version": "3.1001.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1001.0.tgz",
+ "integrity": "sha512-09XAq/uIYgeZhohuGRrR/R+ek3+ljFNdzWCXdqb9rlIERDjSfNiLjTtpHgSK1xTPmC5G4yWoEAyMfTXiggS6wA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.15",
- "@aws-sdk/nested-clients": "^3.996.3",
+ "@aws-sdk/core": "^3.973.16",
+ "@aws-sdk/nested-clients": "^3.996.4",
"@aws-sdk/types": "^3.973.4",
"@smithy/property-provider": "^4.2.10",
"@smithy/shared-ini-file-loader": "^4.4.5",
@@ -693,8 +1133,6 @@
},
"node_modules/@aws-sdk/types": {
"version": "3.973.4",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz",
- "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -704,16 +1142,25 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.2",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/util-endpoints": {
- "version": "3.996.3",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz",
- "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==",
+ "version": "3.990.0",
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.4",
- "@smithy/types": "^4.13.0",
- "@smithy/url-parser": "^4.2.10",
- "@smithy/util-endpoints": "^3.3.1",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -722,8 +1169,6 @@
},
"node_modules/@aws-sdk/util-locate-window": {
"version": "3.965.4",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz",
- "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -734,8 +1179,6 @@
},
"node_modules/@aws-sdk/util-user-agent-browser": {
"version": "3.972.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz",
- "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.4",
@@ -745,12 +1188,12 @@
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.973.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.0.tgz",
- "integrity": "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==",
+ "version": "3.973.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.1.tgz",
+ "integrity": "sha512-kmgbDqT7aCBEVrqESM2JUjbf0zhDUQ7wnt3q1RuVS+3mglrcfVb2bwkbmf38npOyyPGtQPV5dWN3m+sSFAVAgQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/middleware-user-agent": "^3.972.15",
+ "@aws-sdk/middleware-user-agent": "^3.972.16",
"@aws-sdk/types": "^3.973.4",
"@smithy/node-config-provider": "^4.3.10",
"@smithy/types": "^4.13.0",
@@ -769,13 +1212,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.8.tgz",
- "integrity": "sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg==",
+ "version": "3.972.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz",
+ "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
- "fast-xml-parser": "5.3.6",
+ "fast-xml-parser": "5.4.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -784,19 +1227,17 @@
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3",
- "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
- "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
- "version": "7.29.0",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -805,7 +1246,7 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.29.0",
+ "version": "7.27.5",
"dev": true,
"license": "MIT",
"engines": {
@@ -813,20 +1254,20 @@
}
},
"node_modules/@babel/core": {
- "version": "7.29.0",
+ "version": "7.27.4",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-compilation-targets": "^7.28.6",
- "@babel/helper-module-transforms": "^7.28.6",
- "@babel/helpers": "^7.28.6",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/traverse": "^7.29.0",
- "@babel/types": "^7.29.0",
- "@jridgewell/remapping": "^2.3.5",
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.27.3",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.27.3",
+ "@babel/helpers": "^7.27.4",
+ "@babel/parser": "^7.27.4",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.27.4",
+ "@babel/types": "^7.27.3",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -850,14 +1291,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.29.1",
+ "version": "7.27.5",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.0",
- "@babel/types": "^7.29.0",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
+ "@babel/parser": "^7.27.5",
+ "@babel/types": "^7.27.3",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
},
"engines": {
@@ -865,11 +1306,11 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.28.6",
+ "version": "7.27.2",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.6",
+ "@babel/compat-data": "^7.27.2",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -887,34 +1328,26 @@
"semver": "bin/semver.js"
}
},
- "node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
+ "version": "7.27.3",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
@@ -924,7 +1357,7 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.28.6",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"engines": {
@@ -940,7 +1373,7 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"engines": {
@@ -956,23 +1389,23 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.6",
+ "version": "7.27.6",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.27.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.29.0",
+ "version": "7.27.5",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.27.3"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1029,11 +1462,11 @@
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.28.6",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.28.6"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1065,11 +1498,11 @@
}
},
"node_modules/@babel/plugin-syntax-jsx": {
- "version": "7.28.6",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.28.6"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1173,11 +1606,11 @@
}
},
"node_modules/@babel/plugin-syntax-typescript": {
- "version": "7.28.6",
+ "version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.28.6"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1187,42 +1620,50 @@
}
},
"node_modules/@babel/template": {
- "version": "7.28.6",
+ "version": "7.27.2",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.29.0",
+ "version": "7.27.4",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0",
- "debug": "^4.3.1"
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.27.3",
+ "@babel/parser": "^7.27.4",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.27.3",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
},
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/traverse/node_modules/globals": {
+ "version": "11.12.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@babel/types": {
- "version": "7.29.0",
+ "version": "7.27.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1255,19 +1696,17 @@
},
"node_modules/@datastructures-js/heap": {
"version": "4.3.7",
- "resolved": "https://registry.npmjs.org/@datastructures-js/heap/-/heap-4.3.7.tgz",
- "integrity": "sha512-Dx4un7Uj0dVxkfoq4RkpzsY2OrvNJgQYZ3n3UlGdl88RxxdHd7oTi21/l3zoxUUe0sXFuNUrfmWqlHzqnoN6Ug==",
"license": "MIT"
},
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.12",
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.0",
"cpu": [
- "x64"
+ "arm64"
],
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "darwin"
],
"engines": {
"node": ">=18"
@@ -1290,17 +1729,6 @@
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
- "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"dev": true,
@@ -1322,6 +1750,26 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/@eslint/config-helpers": {
"version": "0.4.2",
"dev": true,
@@ -1345,18 +1793,18 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.4",
+ "version": "3.3.1",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.14.0",
+ "ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.1",
- "minimatch": "^3.1.3",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -1366,6 +1814,34 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/@eslint/js": {
"version": "9.39.3",
"dev": true,
@@ -1406,17 +1882,29 @@
}
},
"node_modules/@humanfs/node": {
- "version": "0.16.7",
+ "version": "0.16.6",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
- "@humanwhocodes/retry": "^0.4.0"
+ "@humanwhocodes/retry": "^0.3.0"
},
"engines": {
"node": ">=18.18.0"
}
},
+ "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
+ "version": "0.3.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"dev": true,
@@ -1477,7 +1965,7 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
- "version": "3.14.2",
+ "version": "3.14.1",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1819,25 +2307,28 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
+ "version": "0.3.8",
"dev": true,
"license": "MIT",
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
+ "engines": {
+ "node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
"dev": true,
"license": "MIT",
"engines": {
@@ -1845,12 +2336,12 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
+ "version": "1.5.0",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
+ "version": "0.3.25",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1858,6 +2349,46 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "15.3.4",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "fast-glob": "3.3.1"
+ }
+ },
+ "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/@nhs-notify-client-callbacks/models": {
"resolved": "src/models",
"link": true
@@ -1896,8 +2427,6 @@
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
- "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@pkgr/core": {
@@ -1911,8 +2440,15 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/@sinclair/typebox": {
- "version": "0.27.10",
+ "version": "0.27.8",
"dev": true,
"license": "MIT"
},
@@ -1934,8 +2470,6 @@
},
"node_modules/@smithy/abort-controller": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz",
- "integrity": "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -1945,10 +2479,29 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@smithy/chunked-blob-reader": {
+ "version": "5.2.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader-native": {
+ "version": "4.2.2",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-base64": "^4.3.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@smithy/config-resolver": {
"version": "4.4.9",
- "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.9.tgz",
- "integrity": "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.10",
@@ -1963,9 +2516,9 @@
}
},
"node_modules/@smithy/core": {
- "version": "3.23.6",
- "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.6.tgz",
- "integrity": "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==",
+ "version": "3.23.7",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.7.tgz",
+ "integrity": "sha512-/+ldRdtiO5Cb26afAZOG1FZM0x7D4AYdjpyOv2OScJw+4C7X+OLdRnNKF5UyUE0VpPgSKr3rnF/kvprRA4h2kg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/middleware-serde": "^4.2.11",
@@ -1974,7 +2527,7 @@
"@smithy/util-base64": "^4.3.1",
"@smithy/util-body-length-browser": "^4.2.1",
"@smithy/util-middleware": "^4.2.10",
- "@smithy/util-stream": "^4.5.15",
+ "@smithy/util-stream": "^4.5.16",
"@smithy/util-utf8": "^4.2.1",
"@smithy/uuid": "^1.1.1",
"tslib": "^2.6.2"
@@ -1985,8 +2538,6 @@
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.10.tgz",
- "integrity": "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.10",
@@ -2000,14 +2551,12 @@
}
},
"node_modules/@smithy/eventstream-codec": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.10.tgz",
- "integrity": "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==",
+ "version": "4.2.8",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
- "@smithy/types": "^4.13.0",
- "@smithy/util-hex-encoding": "^4.2.1",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -2015,13 +2564,11 @@
}
},
"node_modules/@smithy/eventstream-serde-browser": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.10.tgz",
- "integrity": "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==",
+ "version": "4.2.8",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/eventstream-serde-universal": "^4.2.10",
- "@smithy/types": "^4.13.0",
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -2029,12 +2576,10 @@
}
},
"node_modules/@smithy/eventstream-serde-config-resolver": {
- "version": "4.3.10",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.10.tgz",
- "integrity": "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==",
+ "version": "4.3.8",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -2042,13 +2587,11 @@
}
},
"node_modules/@smithy/eventstream-serde-node": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.10.tgz",
- "integrity": "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==",
+ "version": "4.2.8",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/eventstream-serde-universal": "^4.2.10",
- "@smithy/types": "^4.13.0",
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -2056,13 +2599,11 @@
}
},
"node_modules/@smithy/eventstream-serde-universal": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.10.tgz",
- "integrity": "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==",
+ "version": "4.2.8",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/eventstream-codec": "^4.2.10",
- "@smithy/types": "^4.13.0",
+ "@smithy/eventstream-codec": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -2070,29 +2611,52 @@
}
},
"node_modules/@smithy/fetch-http-handler": {
- "version": "5.3.11",
- "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz",
- "integrity": "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==",
+ "version": "5.3.12",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.12.tgz",
+ "integrity": "sha512-muS5tFw+A/uo+U+yig06vk1776UFM+aAp9hFM8efI4ZcHhTcgv6NTeK4x7ltHeMPBwnhEjcf0MULTyxNkSNxDw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/protocol-http": "^5.3.10",
"@smithy/querystring-builder": "^4.2.10",
"@smithy/types": "^4.13.0",
- "@smithy/util-base64": "^4.3.1",
+ "@smithy/util-base64": "^4.3.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-blob-browser": {
+ "version": "4.2.10",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/chunked-blob-reader": "^5.2.1",
+ "@smithy/chunked-blob-reader-native": "^4.2.2",
+ "@smithy/types": "^4.12.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-node": {
+ "version": "4.2.10",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.13.0",
+ "@smithy/util-buffer-from": "^4.2.1",
+ "@smithy/util-utf8": "^4.2.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
- "node_modules/@smithy/hash-node": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz",
- "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==",
+ "node_modules/@smithy/hash-stream-node": {
+ "version": "4.2.9",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.0",
- "@smithy/util-buffer-from": "^4.2.1",
+ "@smithy/types": "^4.12.1",
"@smithy/util-utf8": "^4.2.1",
"tslib": "^2.6.2"
},
@@ -2102,8 +2666,6 @@
},
"node_modules/@smithy/invalid-dependency": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz",
- "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2115,8 +2677,6 @@
},
"node_modules/@smithy/is-array-buffer": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz",
- "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2126,14 +2686,11 @@
}
},
"node_modules/@smithy/md5-js": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.10.tgz",
- "integrity": "sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==",
- "dev": true,
+ "version": "4.2.8",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.0",
- "@smithy/util-utf8": "^4.2.1",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -2142,8 +2699,6 @@
},
"node_modules/@smithy/middleware-content-length": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz",
- "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/protocol-http": "^5.3.10",
@@ -2155,12 +2710,12 @@
}
},
"node_modules/@smithy/middleware-endpoint": {
- "version": "4.4.20",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.20.tgz",
- "integrity": "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==",
+ "version": "4.4.21",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.21.tgz",
+ "integrity": "sha512-CoVGZaqIC0tEjz0ga3ciwCMA5fd/4lIOwO2wx0fH+cTi1zxSFZnMJbIiIF9G1d4vRSDyTupDrpS3FKBBJGkRZg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.6",
+ "@smithy/core": "^3.23.7",
"@smithy/middleware-serde": "^4.2.11",
"@smithy/node-config-provider": "^4.3.10",
"@smithy/shared-ini-file-loader": "^4.4.5",
@@ -2174,15 +2729,15 @@
}
},
"node_modules/@smithy/middleware-retry": {
- "version": "4.4.37",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.37.tgz",
- "integrity": "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==",
+ "version": "4.4.38",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.38.tgz",
+ "integrity": "sha512-WdHvdhjE6Fj78vxFwDKFDwlqGOGRUWrwGeuENUbTVE46Su9mnQM+dXHtbnCaQvwuSYrRsjpe8zUsFpwUp/azlA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.10",
"@smithy/protocol-http": "^5.3.10",
"@smithy/service-error-classification": "^4.2.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"@smithy/util-middleware": "^4.2.10",
"@smithy/util-retry": "^4.2.10",
@@ -2195,8 +2750,6 @@
},
"node_modules/@smithy/middleware-serde": {
"version": "4.2.11",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.11.tgz",
- "integrity": "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/protocol-http": "^5.3.10",
@@ -2209,8 +2762,6 @@
},
"node_modules/@smithy/middleware-stack": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.10.tgz",
- "integrity": "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2222,8 +2773,6 @@
},
"node_modules/@smithy/node-config-provider": {
"version": "4.3.10",
- "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.10.tgz",
- "integrity": "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/property-provider": "^4.2.10",
@@ -2236,9 +2785,9 @@
}
},
"node_modules/@smithy/node-http-handler": {
- "version": "4.4.12",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.12.tgz",
- "integrity": "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==",
+ "version": "4.4.13",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.13.tgz",
+ "integrity": "sha512-o8CP8w6tlUA0lk+Qfwm6Ed0jCWk3bEY6iBOJjdBaowbXKCSClk8zIHQvUL6RUZMvuNafF27cbRCMYqw6O1v4aA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/abort-controller": "^4.2.10",
@@ -2253,8 +2802,6 @@
},
"node_modules/@smithy/property-provider": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.10.tgz",
- "integrity": "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2266,8 +2813,6 @@
},
"node_modules/@smithy/protocol-http": {
"version": "5.3.10",
- "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz",
- "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2279,8 +2824,6 @@
},
"node_modules/@smithy/querystring-builder": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.10.tgz",
- "integrity": "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2293,8 +2836,6 @@
},
"node_modules/@smithy/querystring-parser": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.10.tgz",
- "integrity": "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2306,8 +2847,6 @@
},
"node_modules/@smithy/service-error-classification": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.10.tgz",
- "integrity": "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0"
@@ -2318,8 +2857,6 @@
},
"node_modules/@smithy/shared-ini-file-loader": {
"version": "4.4.5",
- "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.5.tgz",
- "integrity": "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2331,8 +2868,6 @@
},
"node_modules/@smithy/signature-v4": {
"version": "5.3.10",
- "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz",
- "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.1",
@@ -2349,17 +2884,17 @@
}
},
"node_modules/@smithy/smithy-client": {
- "version": "4.12.0",
- "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.0.tgz",
- "integrity": "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==",
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.1.tgz",
+ "integrity": "sha512-Xf9UFHlAihewfkmLNZ6I/Ek6kcYBKoU3cbRS9Z4q++9GWoW0YFbAHs7wMbuXm+nGuKHZ5OKheZMuDdaWPv8DJw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.6",
- "@smithy/middleware-endpoint": "^4.4.20",
+ "@smithy/core": "^3.23.7",
+ "@smithy/middleware-endpoint": "^4.4.21",
"@smithy/middleware-stack": "^4.2.10",
"@smithy/protocol-http": "^5.3.10",
"@smithy/types": "^4.13.0",
- "@smithy/util-stream": "^4.5.15",
+ "@smithy/util-stream": "^4.5.16",
"tslib": "^2.6.2"
},
"engines": {
@@ -2368,8 +2903,6 @@
},
"node_modules/@smithy/types": {
"version": "4.13.0",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
- "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2380,8 +2913,6 @@
},
"node_modules/@smithy/url-parser": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.10.tgz",
- "integrity": "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/querystring-parser": "^4.2.10",
@@ -2394,8 +2925,6 @@
},
"node_modules/@smithy/util-base64": {
"version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz",
- "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.1",
@@ -2408,8 +2937,6 @@
},
"node_modules/@smithy/util-body-length-browser": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz",
- "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2420,8 +2947,6 @@
},
"node_modules/@smithy/util-body-length-node": {
"version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz",
- "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2432,8 +2957,6 @@
},
"node_modules/@smithy/util-buffer-from": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz",
- "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.1",
@@ -2445,8 +2968,6 @@
},
"node_modules/@smithy/util-config-provider": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz",
- "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2456,13 +2977,13 @@
}
},
"node_modules/@smithy/util-defaults-mode-browser": {
- "version": "4.3.36",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.36.tgz",
- "integrity": "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==",
+ "version": "4.3.37",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.37.tgz",
+ "integrity": "sha512-JlPZhV1kQCGNJgofRTU6E8kHrjCKsb6cps8gco8QDVaFl7biFYzHg0p1x89ytIWyVyCkY3nOpO8tJPM47Vqlww==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/property-provider": "^4.2.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"tslib": "^2.6.2"
},
@@ -2471,16 +2992,16 @@
}
},
"node_modules/@smithy/util-defaults-mode-node": {
- "version": "4.2.39",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.39.tgz",
- "integrity": "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==",
+ "version": "4.2.40",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.40.tgz",
+ "integrity": "sha512-BM5cPEsyxHdYYO4Da77E94lenhaVPNUzBTyCGDkcw/n/mE8Q1cfHwr+n/w2bNPuUsPC30WaW5/hGKWOTKqw8kw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/config-resolver": "^4.4.9",
"@smithy/credential-provider-imds": "^4.2.10",
"@smithy/node-config-provider": "^4.3.10",
"@smithy/property-provider": "^4.2.10",
- "@smithy/smithy-client": "^4.12.0",
+ "@smithy/smithy-client": "^4.12.1",
"@smithy/types": "^4.13.0",
"tslib": "^2.6.2"
},
@@ -2490,8 +3011,6 @@
},
"node_modules/@smithy/util-endpoints": {
"version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.1.tgz",
- "integrity": "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.10",
@@ -2504,8 +3023,6 @@
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz",
- "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2516,8 +3033,6 @@
},
"node_modules/@smithy/util-middleware": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz",
- "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
@@ -2529,8 +3044,6 @@
},
"node_modules/@smithy/util-retry": {
"version": "4.2.10",
- "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.10.tgz",
- "integrity": "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/service-error-classification": "^4.2.10",
@@ -2542,13 +3055,13 @@
}
},
"node_modules/@smithy/util-stream": {
- "version": "4.5.15",
- "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.15.tgz",
- "integrity": "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==",
+ "version": "4.5.16",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.16.tgz",
+ "integrity": "sha512-c7awZV6cxY0czgDDSr+Bz0XfRtg8AwW2BWhrHhLJISrpmwv8QzA2qzTllWyMVNdy1+UJr9vCm29hzuh3l8TTFw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/fetch-http-handler": "^5.3.11",
- "@smithy/node-http-handler": "^4.4.12",
+ "@smithy/fetch-http-handler": "^5.3.12",
+ "@smithy/node-http-handler": "^4.4.13",
"@smithy/types": "^4.13.0",
"@smithy/util-base64": "^4.3.1",
"@smithy/util-buffer-from": "^4.2.1",
@@ -2562,8 +3075,6 @@
},
"node_modules/@smithy/util-uri-escape": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz",
- "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2574,8 +3085,6 @@
},
"node_modules/@smithy/util-utf8": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz",
- "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.1",
@@ -2585,10 +3094,20 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@smithy/util-waiter": {
+ "version": "4.2.9",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/abort-controller": "^4.2.9",
+ "@smithy/types": "^4.12.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@smithy/uuid": {
"version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz",
- "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -2615,6 +3134,28 @@
"eslint": ">=8.40.0"
}
},
+ "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"dev": true,
@@ -2624,7 +3165,7 @@
}
},
"node_modules/@tsconfig/node10": {
- "version": "1.0.12",
+ "version": "1.0.11",
"dev": true,
"license": "MIT"
},
@@ -2644,12 +3185,12 @@
"license": "MIT"
},
"node_modules/@tsconfig/node22": {
- "version": "22.0.5",
+ "version": "22.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/@types/aws-lambda": {
- "version": "8.10.160",
+ "version": "8.10.150",
"dev": true,
"license": "MIT"
},
@@ -2683,11 +3224,11 @@
}
},
"node_modules/@types/babel__traverse": {
- "version": "7.28.0",
+ "version": "7.20.7",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.2"
+ "@babel/types": "^7.20.7"
}
},
"node_modules/@types/estree": {
@@ -2748,12 +3289,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/@types/node": {
- "version": "25.3.0",
+ "version": "24.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "undici-types": "~7.8.0"
}
},
"node_modules/@types/stack-utils": {
@@ -2767,7 +3315,7 @@
"license": "MIT"
},
"node_modules/@types/yargs": {
- "version": "17.0.35",
+ "version": "17.0.33",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2806,14 +3354,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
- "version": "7.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/@typescript-eslint/parser": {
"version": "8.56.1",
"dev": true,
@@ -2998,28 +3538,16 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
- "version": "1.11.1",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@unrs/resolver-binding-linux-x64-musl": {
- "version": "1.11.1",
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.9.2",
"cpu": [
- "x64"
+ "arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "darwin"
]
},
"node_modules/abab": {
@@ -3028,7 +3556,7 @@
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
- "version": "8.16.0",
+ "version": "8.15.0",
"dev": true,
"license": "MIT",
"bin": {
@@ -3056,7 +3584,7 @@
}
},
"node_modules/acorn-walk": {
- "version": "8.3.5",
+ "version": "8.3.4",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3079,8 +3607,6 @@
},
"node_modules/aggregate-error": {
"version": "3.1.0",
- "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
- "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
"license": "MIT",
"dependencies": {
"clean-stack": "^2.0.0",
@@ -3092,15 +3618,13 @@
},
"node_modules/aggregate-error/node_modules/indent-string": {
"version": "4.0.0",
- "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
- "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ajv": {
- "version": "6.14.0",
+ "version": "6.12.6",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3116,8 +3640,6 @@
},
"node_modules/ajv-formats": {
"version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
- "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
@@ -3133,8 +3655,6 @@
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3149,8 +3669,6 @@
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-escapes": {
@@ -3167,9 +3685,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/ansi-escapes/node_modules/type-fest": {
+ "version": "0.21.3",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3177,7 +3705,6 @@
},
"node_modules/ansi-styles": {
"version": "4.3.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3201,17 +3728,6 @@
"node": ">= 8"
}
},
- "node_modules/anymatch/node_modules/picomatch": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/arg": {
"version": "4.1.3",
"dev": true,
@@ -3266,6 +3782,49 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/array.prototype.flat": {
"version": "1.3.3",
"dev": true,
@@ -3300,6 +3859,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/arraybuffer.prototype.slice": {
"version": "1.0.4",
"dev": true,
@@ -3335,8 +3911,6 @@
},
"node_modules/async-wait-until": {
"version": "2.0.31",
- "resolved": "https://registry.npmjs.org/async-wait-until/-/async-wait-until-2.0.31.tgz",
- "integrity": "sha512-9VCfHvc4f36oT6sG5p16aKc9zojf3wF4FrjNDxU3Db51SJ1bQ5lWAWtQDDZPysTwSLKBDzNZ083qPkTIj6XnrA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3355,8 +3929,6 @@
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
@@ -3377,8 +3949,6 @@
},
"node_modules/aws-embedded-metrics": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/aws-embedded-metrics/-/aws-embedded-metrics-4.2.1.tgz",
- "integrity": "sha512-uzydBXlGQVTB2sZ9ACCQZM3y0u4wdvxxRKFL9LP6RdfI2GcOrCcAsz65UKQvX9iagxFhah322VvvatgP8E7MIg==",
"license": "Apache-2.0",
"dependencies": {
"@datastructures-js/heap": "^4.0.2"
@@ -3388,7 +3958,7 @@
}
},
"node_modules/axe-core": {
- "version": "4.11.1",
+ "version": "4.10.3",
"dev": true,
"license": "MPL-2.0",
"engines": {
@@ -3476,7 +4046,7 @@
}
},
"node_modules/babel-preset-current-node-syntax": {
- "version": "1.2.0",
+ "version": "1.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3497,7 +4067,7 @@
"@babel/plugin-syntax-top-level-await": "^7.14.5"
},
"peerDependencies": {
- "@babel/core": "^7.0.0 || ^8.0.0-0"
+ "@babel/core": "^7.0.0"
}
},
"node_modules/babel-preset-jest": {
@@ -3516,28 +4086,12 @@
}
},
"node_modules/balanced-match": {
- "version": "4.0.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.10.0",
+ "version": "1.0.2",
"dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
+ "license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
- "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
- "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
@@ -3545,12 +4099,10 @@
},
"node_modules/bowser": {
"version": "2.14.1",
- "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
- "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "5.0.3",
+ "version": "5.0.4",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3560,6 +4112,14 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/brace-expansion/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
@@ -3572,7 +4132,7 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
+ "version": "4.25.0",
"dev": true,
"funding": [
{
@@ -3590,11 +4150,10 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "caniuse-lite": "^1.0.30001718",
+ "electron-to-chromium": "^1.5.160",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
@@ -3704,7 +4263,7 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001774",
+ "version": "1.0.30001724",
"dev": true,
"funding": [
{
@@ -3746,7 +4305,7 @@
}
},
"node_modules/ci-info": {
- "version": "4.4.0",
+ "version": "4.2.0",
"dev": true,
"funding": [
{
@@ -3785,16 +4344,17 @@
},
"node_modules/clean-stack": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
- "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
+ "node_modules/client-subscriptions-management": {
+ "resolved": "tools/client-subscriptions-management",
+ "link": true
+ },
"node_modules/cliui": {
"version": "8.0.1",
- "dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -3807,8 +4367,6 @@
},
"node_modules/cloudevents": {
"version": "8.0.3",
- "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-8.0.3.tgz",
- "integrity": "sha512-wTixKNjfLeyj9HQpESvLVVO4xgdqdvX4dTeg1IZ2SCunu/fxVzCamcIZneEyj31V82YolFCKwVeSkr8zResB0Q==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.11.0",
@@ -3824,8 +4382,6 @@
},
"node_modules/cloudevents/node_modules/ajv": {
"version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3840,8 +4396,6 @@
},
"node_modules/cloudevents/node_modules/json-schema-traverse": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/co": {
@@ -3854,13 +4408,12 @@
}
},
"node_modules/collect-v8-coverage": {
- "version": "1.0.3",
+ "version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3871,7 +4424,6 @@
},
"node_modules/color-name": {
"version": "1.1.4",
- "dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -3886,13 +4438,18 @@
}
},
"node_modules/comment-parser": {
- "version": "1.4.5",
+ "version": "1.4.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12.0.0"
}
},
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"dev": true,
@@ -3904,11 +4461,11 @@
"license": "MIT"
},
"node_modules/core-js-compat": {
- "version": "3.48.0",
+ "version": "3.43.0",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.28.1"
+ "browserslist": "^4.25.0"
},
"funding": {
"type": "opencollective",
@@ -4065,12 +4622,12 @@
}
},
"node_modules/decimal.js": {
- "version": "10.6.0",
+ "version": "10.5.0",
"dev": true,
"license": "MIT"
},
"node_modules/dedent": {
- "version": "1.7.1",
+ "version": "1.6.0",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -4143,7 +4700,7 @@
}
},
"node_modules/diff": {
- "version": "4.0.4",
+ "version": "4.0.2",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -4158,6 +4715,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/dom-serializer": {
"version": "2.0.0",
"dev": true,
@@ -4244,7 +4814,7 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.302",
+ "version": "1.5.173",
"dev": true,
"license": "ISC"
},
@@ -4260,12 +4830,11 @@
}
},
"node_modules/emoji-regex": {
- "version": "9.2.2",
- "dev": true,
+ "version": "8.0.0",
"license": "MIT"
},
"node_modules/entities": {
- "version": "7.0.1",
+ "version": "6.0.1",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -4276,7 +4845,7 @@
}
},
"node_modules/error-ex": {
- "version": "1.3.4",
+ "version": "1.3.2",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4284,7 +4853,7 @@
}
},
"node_modules/es-abstract": {
- "version": "1.24.1",
+ "version": "1.24.0",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4364,6 +4933,34 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-iterator-helpers": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.0.3",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.6",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.4",
+ "safe-array-concat": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
@@ -4416,7 +5013,7 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.12",
+ "version": "0.25.0",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -4426,37 +5023,35 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.12",
- "@esbuild/android-arm": "0.25.12",
- "@esbuild/android-arm64": "0.25.12",
- "@esbuild/android-x64": "0.25.12",
- "@esbuild/darwin-arm64": "0.25.12",
- "@esbuild/darwin-x64": "0.25.12",
- "@esbuild/freebsd-arm64": "0.25.12",
- "@esbuild/freebsd-x64": "0.25.12",
- "@esbuild/linux-arm": "0.25.12",
- "@esbuild/linux-arm64": "0.25.12",
- "@esbuild/linux-ia32": "0.25.12",
- "@esbuild/linux-loong64": "0.25.12",
- "@esbuild/linux-mips64el": "0.25.12",
- "@esbuild/linux-ppc64": "0.25.12",
- "@esbuild/linux-riscv64": "0.25.12",
- "@esbuild/linux-s390x": "0.25.12",
- "@esbuild/linux-x64": "0.25.12",
- "@esbuild/netbsd-arm64": "0.25.12",
- "@esbuild/netbsd-x64": "0.25.12",
- "@esbuild/openbsd-arm64": "0.25.12",
- "@esbuild/openbsd-x64": "0.25.12",
- "@esbuild/openharmony-arm64": "0.25.12",
- "@esbuild/sunos-x64": "0.25.12",
- "@esbuild/win32-arm64": "0.25.12",
- "@esbuild/win32-ia32": "0.25.12",
- "@esbuild/win32-x64": "0.25.12"
+ "@esbuild/aix-ppc64": "0.25.0",
+ "@esbuild/android-arm": "0.25.0",
+ "@esbuild/android-arm64": "0.25.0",
+ "@esbuild/android-x64": "0.25.0",
+ "@esbuild/darwin-arm64": "0.25.0",
+ "@esbuild/darwin-x64": "0.25.0",
+ "@esbuild/freebsd-arm64": "0.25.0",
+ "@esbuild/freebsd-x64": "0.25.0",
+ "@esbuild/linux-arm": "0.25.0",
+ "@esbuild/linux-arm64": "0.25.0",
+ "@esbuild/linux-ia32": "0.25.0",
+ "@esbuild/linux-loong64": "0.25.0",
+ "@esbuild/linux-mips64el": "0.25.0",
+ "@esbuild/linux-ppc64": "0.25.0",
+ "@esbuild/linux-riscv64": "0.25.0",
+ "@esbuild/linux-s390x": "0.25.0",
+ "@esbuild/linux-x64": "0.25.0",
+ "@esbuild/netbsd-arm64": "0.25.0",
+ "@esbuild/netbsd-x64": "0.25.0",
+ "@esbuild/openbsd-arm64": "0.25.0",
+ "@esbuild/openbsd-x64": "0.25.0",
+ "@esbuild/sunos-x64": "0.25.0",
+ "@esbuild/win32-arm64": "0.25.0",
+ "@esbuild/win32-ia32": "0.25.0",
+ "@esbuild/win32-x64": "0.25.0"
}
},
"node_modules/escalade": {
"version": "3.2.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4606,7 +5201,7 @@
}
},
"node_modules/eslint-config-airbnb-extended/node_modules/globals": {
- "version": "16.5.0",
+ "version": "16.2.0",
"dev": true,
"license": "MIT",
"engines": {
@@ -4653,6 +5248,28 @@
}
}
},
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
"node_modules/eslint-import-resolver-typescript": {
"version": "4.4.4",
"dev": true,
@@ -4686,6 +5303,34 @@
}
}
},
+ "node_modules/eslint-module-utils": {
+ "version": "2.12.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
"node_modules/eslint-plugin-html": {
"version": "8.1.4",
"dev": true,
@@ -4697,6 +5342,40 @@
"node": ">=16.0.0"
}
},
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
"node_modules/eslint-plugin-import-x": {
"version": "4.16.1",
"dev": true,
@@ -4732,6 +5411,50 @@
}
}
},
+ "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/eslint-plugin-jest": {
"version": "28.14.0",
"dev": true,
@@ -4796,6 +5519,31 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
}
},
+ "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/eslint-plugin-no-relative-import-paths": {
"version": "v1.6.1",
"dev": true,
@@ -4830,6 +5578,104 @@
}
}
},
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/resolve": {
+ "version": "2.0.0-next.5",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/eslint-plugin-security": {
"version": "3.0.1",
"dev": true,
@@ -4948,7 +5794,7 @@
}
},
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
- "version": "16.5.0",
+ "version": "16.2.0",
"dev": true,
"license": "MIT",
"engines": {
@@ -4974,6 +5820,26 @@
}
},
"node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"dev": true,
"license": "Apache-2.0",
@@ -4984,6 +5850,25 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/espree": {
"version": "10.4.0",
"dev": true,
@@ -5000,6 +5885,17 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/esprima": {
"version": "4.0.1",
"dev": true,
@@ -5013,7 +5909,7 @@
}
},
"node_modules/esquery": {
- "version": "1.7.0",
+ "version": "1.6.0",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -5141,8 +6037,6 @@
},
"node_modules/fast-uri": {
"version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@@ -5155,10 +6049,22 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
+ "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/fast-xml-parser": {
- "version": "5.3.6",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
- "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
+ "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
"funding": [
{
"type": "github",
@@ -5167,6 +6073,7 @@
],
"license": "MIT",
"dependencies": {
+ "fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
@@ -5174,7 +6081,7 @@
}
},
"node_modules/fastq": {
- "version": "1.20.1",
+ "version": "1.19.1",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -5189,22 +6096,6 @@
"bser": "2.1.1"
}
},
- "node_modules/fdir": {
- "version": "6.5.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"dev": true,
@@ -5284,7 +6175,7 @@
}
},
"node_modules/form-data": {
- "version": "4.0.5",
+ "version": "4.0.4",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5303,6 +6194,18 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -5342,13 +6245,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/generator-function": {
- "version": "2.0.1",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"dev": true,
@@ -5359,7 +6255,6 @@
},
"node_modules/get-caller-file": {
"version": "2.0.5",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5434,7 +6329,7 @@
}
},
"node_modules/get-tsconfig": {
- "version": "4.13.6",
+ "version": "4.10.1",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5474,6 +6369,26 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/globals": {
"version": "14.0.0",
"dev": true,
@@ -5628,7 +6543,7 @@
"license": "MIT"
},
"node_modules/htmlparser2": {
- "version": "10.1.0",
+ "version": "10.0.0",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
@@ -5641,8 +6556,8 @@
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
- "domutils": "^3.2.2",
- "entities": "^7.0.1"
+ "domutils": "^3.2.1",
+ "entities": "^6.0.0"
}
},
"node_modules/http-proxy-agent": {
@@ -5690,7 +6605,7 @@
}
},
"node_modules/ignore": {
- "version": "5.3.2",
+ "version": "7.0.5",
"dev": true,
"license": "MIT",
"engines": {
@@ -5777,8 +6692,6 @@
},
"node_modules/is-arguments": {
"version": "1.2.0",
- "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
- "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5971,7 +6884,6 @@
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5986,12 +6898,11 @@
}
},
"node_modules/is-generator-function": {
- "version": "1.1.2",
+ "version": "1.1.0",
"license": "MIT",
"dependencies": {
- "call-bound": "^1.0.4",
- "generator-function": "^2.0.0",
- "get-proto": "^1.0.1",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.0",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
@@ -6259,7 +7170,7 @@
}
},
"node_modules/istanbul-reports": {
- "version": "3.2.0",
+ "version": "3.1.7",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6270,6 +7181,24 @@
"node": ">=8"
}
},
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/jest": {
"version": "29.7.0",
"dev": true,
@@ -6810,17 +7739,6 @@
"node": ">=8"
}
},
- "node_modules/jest-util/node_modules/picomatch": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/jest-validate": {
"version": "29.7.0",
"dev": true,
@@ -6900,7 +7818,7 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
+ "version": "4.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6967,8 +7885,6 @@
},
"node_modules/json-bigint": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
- "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
@@ -7154,7 +8070,7 @@
}
},
"node_modules/lodash": {
- "version": "4.17.23",
+ "version": "4.17.21",
"dev": true,
"license": "MIT"
},
@@ -7168,6 +8084,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"dev": true,
@@ -7235,17 +8164,6 @@
"node": ">=8.6"
}
},
- "node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/mime-db": {
"version": "1.52.0",
"dev": true,
@@ -7273,8 +8191,16 @@
"node": ">=6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
- "version": "10.2.3",
+ "version": "10.2.4",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -7312,7 +8238,7 @@
"license": "MIT"
},
"node_modules/napi-postinstall": {
- "version": "0.3.4",
+ "version": "0.2.4",
"dev": true,
"license": "MIT",
"bin": {
@@ -7358,7 +8284,7 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.27",
+ "version": "2.0.19",
"dev": true,
"license": "MIT"
},
@@ -7382,10 +8308,20 @@
}
},
"node_modules/nwsapi": {
- "version": "2.2.23",
+ "version": "2.2.20",
"dev": true,
"license": "MIT"
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"dev": true,
@@ -7424,6 +8360,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/object.fromentries": {
"version": "2.0.8",
"dev": true,
@@ -7441,6 +8393,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/object.values": {
"version": "1.2.1",
"dev": true,
@@ -7460,8 +8427,6 @@
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
- "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
- "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -7549,21 +8514,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/p-map": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
- "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
- "license": "MIT",
- "dependencies": {
- "aggregate-error": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/p-try": {
"version": "2.2.0",
"dev": true,
@@ -7611,17 +8561,6 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
- "node_modules/parse5/node_modules/entities": {
- "version": "6.0.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
"node_modules/path-exists": {
"version": "4.0.0",
"dev": true,
@@ -7657,11 +8596,11 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
+ "version": "2.3.1",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=12"
+ "node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -7669,8 +8608,6 @@
},
"node_modules/pino": {
"version": "9.14.0",
- "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
- "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
@@ -7691,8 +8628,6 @@
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
- "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
@@ -7700,8 +8635,6 @@
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
- "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
- "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pirates": {
@@ -7795,7 +8728,7 @@
}
},
"node_modules/prettier": {
- "version": "3.8.1",
+ "version": "3.6.0",
"dev": true,
"license": "MIT",
"peer": true,
@@ -7846,8 +8779,6 @@
},
"node_modules/process": {
"version": "0.11.10",
- "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
- "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
@@ -7855,8 +8786,6 @@
},
"node_modules/process-warning": {
"version": "5.0.0",
- "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
- "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
@@ -7881,6 +8810,25 @@
"node": ">= 6"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/psl": {
"version": "1.15.0",
"dev": true,
@@ -7941,8 +8889,6 @@
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
- "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/react-is": {
@@ -7952,8 +8898,6 @@
},
"node_modules/real-require": {
"version": "0.2.0",
- "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
@@ -8054,7 +8998,6 @@
},
"node_modules/require-directory": {
"version": "2.1.1",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8062,8 +9005,6 @@
},
"node_modules/require-from-string": {
"version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8075,11 +9016,11 @@
"license": "MIT"
},
"node_modules/resolve": {
- "version": "1.22.11",
+ "version": "1.22.10",
"dev": true,
"license": "MIT",
"dependencies": {
- "is-core-module": "^2.16.1",
+ "is-core-module": "^2.16.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
@@ -8225,8 +9166,6 @@
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
- "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
- "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
@@ -8421,8 +9360,6 @@
},
"node_modules/sonic-boom": {
"version": "4.2.1",
- "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
- "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
@@ -8447,8 +9384,6 @@
},
"node_modules/split2": {
"version": "4.2.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
- "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
@@ -8512,7 +9447,6 @@
},
"node_modules/string-width": {
"version": "4.2.3",
- "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -8523,11 +9457,6 @@
"node": ">=8"
}
},
- "node_modules/string-width/node_modules/emoji-regex": {
- "version": "8.0.0",
- "dev": true,
- "license": "MIT"
- },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"dev": true,
@@ -8541,6 +9470,45 @@
"node": ">= 0.4"
}
},
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
"node_modules/string.prototype.trim": {
"version": "1.2.10",
"dev": true,
@@ -8596,7 +9564,6 @@
},
"node_modules/strip-ansi": {
"version": "6.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -8622,9 +9589,12 @@
}
},
"node_modules/strip-indent": {
- "version": "4.1.1",
+ "version": "4.0.0",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.1"
+ },
"engines": {
"node": ">=12"
},
@@ -8709,10 +9679,28 @@
"node": ">=8"
}
},
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/thread-stream": {
"version": "3.1.0",
- "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
- "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
@@ -8733,6 +9721,33 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"dev": true,
@@ -8902,18 +9917,52 @@
}
}
},
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tsconfig-paths/node_modules/json5": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/tsconfig-paths/node_modules/strip-bom": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
- "version": "4.21.0",
+ "version": "4.20.3",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "~0.27.0",
+ "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
@@ -8926,23 +9975,23 @@
"fsevents": "~2.3.3"
}
},
- "node_modules/tsx/node_modules/@esbuild/linux-x64": {
- "version": "0.27.3",
+ "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.5",
"cpu": [
- "x64"
+ "arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
- "version": "0.27.3",
+ "version": "0.25.5",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -8953,32 +10002,31 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.3",
- "@esbuild/android-arm": "0.27.3",
- "@esbuild/android-arm64": "0.27.3",
- "@esbuild/android-x64": "0.27.3",
- "@esbuild/darwin-arm64": "0.27.3",
- "@esbuild/darwin-x64": "0.27.3",
- "@esbuild/freebsd-arm64": "0.27.3",
- "@esbuild/freebsd-x64": "0.27.3",
- "@esbuild/linux-arm": "0.27.3",
- "@esbuild/linux-arm64": "0.27.3",
- "@esbuild/linux-ia32": "0.27.3",
- "@esbuild/linux-loong64": "0.27.3",
- "@esbuild/linux-mips64el": "0.27.3",
- "@esbuild/linux-ppc64": "0.27.3",
- "@esbuild/linux-riscv64": "0.27.3",
- "@esbuild/linux-s390x": "0.27.3",
- "@esbuild/linux-x64": "0.27.3",
- "@esbuild/netbsd-arm64": "0.27.3",
- "@esbuild/netbsd-x64": "0.27.3",
- "@esbuild/openbsd-arm64": "0.27.3",
- "@esbuild/openbsd-x64": "0.27.3",
- "@esbuild/openharmony-arm64": "0.27.3",
- "@esbuild/sunos-x64": "0.27.3",
- "@esbuild/win32-arm64": "0.27.3",
- "@esbuild/win32-ia32": "0.27.3",
- "@esbuild/win32-x64": "0.27.3"
+ "@esbuild/aix-ppc64": "0.25.5",
+ "@esbuild/android-arm": "0.25.5",
+ "@esbuild/android-arm64": "0.25.5",
+ "@esbuild/android-x64": "0.25.5",
+ "@esbuild/darwin-arm64": "0.25.5",
+ "@esbuild/darwin-x64": "0.25.5",
+ "@esbuild/freebsd-arm64": "0.25.5",
+ "@esbuild/freebsd-x64": "0.25.5",
+ "@esbuild/linux-arm": "0.25.5",
+ "@esbuild/linux-arm64": "0.25.5",
+ "@esbuild/linux-ia32": "0.25.5",
+ "@esbuild/linux-loong64": "0.25.5",
+ "@esbuild/linux-mips64el": "0.25.5",
+ "@esbuild/linux-ppc64": "0.25.5",
+ "@esbuild/linux-riscv64": "0.25.5",
+ "@esbuild/linux-s390x": "0.25.5",
+ "@esbuild/linux-x64": "0.25.5",
+ "@esbuild/netbsd-arm64": "0.25.5",
+ "@esbuild/netbsd-x64": "0.25.5",
+ "@esbuild/openbsd-arm64": "0.25.5",
+ "@esbuild/openbsd-x64": "0.25.5",
+ "@esbuild/sunos-x64": "0.25.5",
+ "@esbuild/win32-arm64": "0.25.5",
+ "@esbuild/win32-ia32": "0.25.5",
+ "@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/type-check": {
@@ -9000,17 +10048,6 @@
"node": ">=4"
}
},
- "node_modules/type-fest": {
- "version": "0.21.3",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"dev": true,
@@ -9082,7 +10119,7 @@
}
},
"node_modules/typescript": {
- "version": "5.9.3",
+ "version": "5.8.3",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -9145,7 +10182,7 @@
}
},
"node_modules/undici-types": {
- "version": "7.18.2",
+ "version": "7.8.0",
"dev": true,
"license": "MIT"
},
@@ -9158,40 +10195,40 @@
}
},
"node_modules/unrs-resolver": {
- "version": "1.11.1",
+ "version": "1.9.2",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "napi-postinstall": "^0.3.0"
+ "napi-postinstall": "^0.2.4"
},
"funding": {
"url": "https://opencollective.com/unrs-resolver"
},
"optionalDependencies": {
- "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
- "@unrs/resolver-binding-android-arm64": "1.11.1",
- "@unrs/resolver-binding-darwin-arm64": "1.11.1",
- "@unrs/resolver-binding-darwin-x64": "1.11.1",
- "@unrs/resolver-binding-freebsd-x64": "1.11.1",
- "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
- "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
- "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
- "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
- "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
- "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
- "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
- "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
- "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
- "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
- "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
- "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
- "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
- "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
+ "@unrs/resolver-binding-android-arm-eabi": "1.9.2",
+ "@unrs/resolver-binding-android-arm64": "1.9.2",
+ "@unrs/resolver-binding-darwin-arm64": "1.9.2",
+ "@unrs/resolver-binding-darwin-x64": "1.9.2",
+ "@unrs/resolver-binding-freebsd-x64": "1.9.2",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.9.2",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.9.2",
+ "@unrs/resolver-binding-linux-x64-musl": "1.9.2",
+ "@unrs/resolver-binding-wasm32-wasi": "1.9.2",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.9.2"
}
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
+ "version": "1.1.3",
"dev": true,
"funding": [
{
@@ -9238,8 +10275,6 @@
},
"node_modules/util": {
"version": "0.12.5",
- "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
- "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@@ -9251,8 +10286,6 @@
},
"node_modules/uuid": {
"version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
@@ -9442,7 +10475,7 @@
}
},
"node_modules/which-typed-array": {
- "version": "1.1.20",
+ "version": "1.1.19",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
@@ -9475,7 +10508,6 @@
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -9507,7 +10539,7 @@
}
},
"node_modules/ws": {
- "version": "8.19.0",
+ "version": "8.18.2",
"dev": true,
"license": "MIT",
"engines": {
@@ -9549,7 +10581,6 @@
},
"node_modules/y18n": {
"version": "5.0.8",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -9562,7 +10593,6 @@
},
"node_modules/yargs": {
"version": "17.7.2",
- "dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
@@ -9579,7 +10609,6 @@
},
"node_modules/yargs-parser": {
"version": "21.1.1",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@@ -9625,7 +10654,12 @@
"name": "nhs-notify-client-callbacks-integration-tests",
"version": "0.0.1",
"dependencies": {
- "@aws-sdk/client-cloudwatch-logs": "^3.991.0"
+ "@aws-sdk/client-cloudwatch-logs": "^3.991.0",
+ "@aws-sdk/client-eventbridge": "^3.990.0",
+ "@aws-sdk/client-s3": "^3.821.0",
+ "@aws-sdk/client-sqs": "^3.990.0",
+ "@aws-sdk/client-sts": "^3.821.0",
+ "async-wait-until": "^2.0.12"
},
"devDependencies": {
"@aws-sdk/client-sqs": "^3.990.0",
@@ -9636,6 +10670,35 @@
"jest": "^29.7.0",
"typescript": "^5.8.2"
}
+ },
+ "tools/client-subscriptions-management": {
+ "version": "0.0.1",
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.821.0",
+ "@nhs-notify-client-callbacks/models": "*",
+ "yargs": "^17.7.2",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.10",
+ "@types/yargs": "^17.0.24",
+ "eslint": "^9.27.0",
+ "tsx": "^4.19.3",
+ "typescript": "^5.8.2"
+ }
+ },
+ "tools/client-subscriptions-management/node_modules/@types/node": {
+ "version": "22.19.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "tools/client-subscriptions-management/node_modules/undici-types": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index c031567..5fc917e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",
+ "@aws-sdk/client-s3": "^3.821.0",
"@tsconfig/node22": "^22.0.2",
"@types/jest": "^29.5.14",
"@typescript-eslint/parser": "^8.56.1",
@@ -35,7 +36,8 @@
"pretty-format": {
"react-is": "19.0.0"
},
- "minimatch": "^10.2.2"
+ "minimatch@^3.0.0": "3.1.5",
+ "minimatch": "10.2.4"
},
"scripts": {
"generate-dependencies": "npm run generate-dependencies --workspaces --if-present",
@@ -45,12 +47,16 @@
"test:integration": "npm run test:integration --workspace tests/integration",
"test:unit": "npm run test:unit --workspaces",
"typecheck": "npm run typecheck --workspaces",
- "verify": "npm run lint && npm run typecheck && npm run test:unit"
+ "verify": "npm run lint && npm run typecheck && npm run test:unit",
+ "subscriptions:get": "npm run get-by-client-id --workspace tools/client-subscriptions-management --",
+ "subscriptions:put-channel-status": "npm run put-channel-status --workspace tools/client-subscriptions-management --",
+ "subscriptions:put-message-status": "npm run put-message-status --workspace tools/client-subscriptions-management --"
},
"workspaces": [
"lambdas/client-transform-filter-lambda",
"src/models",
"lambdas/mock-webhook-lambda",
- "tests/integration"
+ "tests/integration",
+ "tools/client-subscriptions-management"
]
}
diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties
index be946be..6a2f270 100644
--- a/scripts/config/sonar-scanner.properties
+++ b/scripts/config/sonar-scanner.properties
@@ -5,5 +5,5 @@ sonar.qualitygate.wait=true
sonar.sourceEncoding=UTF-8
sonar.terraform.provider.aws.version=5.54.1
sonar.cpd.exclusions=**.test.*, src/models/**
-sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, src/models/**, **/jest.config.ts
+sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, src/models/**, scripts/**/src/__tests__/**, tools/**/src/__tests__/**, **/jest.config.*
sonar.javascript.lcov.reportPaths=lcov.info
diff --git a/scripts/deploy_client_subscriptions.sh b/scripts/deploy_client_subscriptions.sh
new file mode 100644
index 0000000..be8683d
--- /dev/null
+++ b/scripts/deploy_client_subscriptions.sh
@@ -0,0 +1,136 @@
+#!/bin/bash
+
+set -euo pipefail
+
+usage() {
+ cat < \
+ [--terraform-apply] \
+ [--environment --group --project --tf-region ] \
+ --
+
+Examples:
+ # Message status subscription
+ ./scripts/deploy_client_subscriptions.sh \
+ --subscription-type message \
+ --terraform-apply \
+ --environment dev \
+ --group dev \
+ -- \
+ --bucket-name my-bucket \
+ --client-name "Test Client" \
+ --client-id client-123 \
+ --message-statuses DELIVERED FAILED \
+ --api-endpoint https://webhook.example.com \
+ --api-key 1234.4321 \
+ --dry-run false \
+ --rate-limit 20
+
+ # Channel status subscription
+ ./scripts/deploy_client_subscriptions.sh \
+ --subscription-type channel \
+ --terraform-apply \
+ --environment dev \
+ --group dev \
+ -- \
+ --bucket-name my-bucket \
+ --client-name "Test Client" \
+ --client-id client-123 \
+ --channel-type NHSAPP \
+ --channel-statuses DELIVERED FAILED \
+ --supplier-statuses delivered failed \
+ --api-endpoint https://webhook.example.com \
+ --api-key 1234.4321 \
+ --dry-run false \
+ --rate-limit 20
+EOF
+}
+
+subscription_type=""
+terraform_apply="false"
+environment=""
+group=""
+project="nhs"
+tf_region=""
+forward_args=()
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --subscription-type)
+ subscription_type="$2"
+ shift 2
+ ;;
+ --terraform-apply)
+ terraform_apply="true"
+ shift
+ ;;
+ --environment)
+ environment="$2"
+ shift 2
+ ;;
+ --group)
+ group="$2"
+ shift 2
+ ;;
+ --project)
+ project="$2"
+ shift 2
+ ;;
+ --tf-region)
+ tf_region="$2"
+ shift 2
+ ;;
+ --help)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ forward_args+=("$@")
+ break
+ ;;
+ *)
+ forward_args+=("$1")
+ shift
+ ;;
+ esac
+done
+
+if [ -z "$subscription_type" ]; then
+ echo "Error: --subscription-type is required"
+ usage
+ exit 1
+fi
+
+if [ "$subscription_type" != "message" ] && [ "$subscription_type" != "channel" ]; then
+ echo "Error: --subscription-type must be 'message' or 'channel'"
+ usage
+ exit 1
+fi
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+echo "[deploy-client-subscriptions] Uploading subscription config ($subscription_type)..."
+
+if [ "$subscription_type" = "message" ]; then
+ npm --workspace tools/client-subscriptions-management run put-message-status -- "${forward_args[@]}"
+else
+ npm --workspace tools/client-subscriptions-management run put-channel-status -- "${forward_args[@]}"
+fi
+
+if [ "$terraform_apply" = "true" ]; then
+ if [ -z "$environment" ] || [ -z "$group" ]; then
+ echo "Error: --environment and --group are required for terraform apply"
+ exit 1
+ fi
+
+ echo "[deploy-client-subscriptions] Running terraform apply for callbacks component..."
+ if [ -n "$tf_region" ]; then
+ make terraform-apply component=callbacks environment="$environment" group="$group" project="$project" region="$tf_region"
+ else
+ make terraform-apply component=callbacks environment="$environment" group="$group" project="$project"
+ fi
+fi
diff --git a/src/models/src/channel-types.ts b/src/models/src/channel-types.ts
index d4526fb..d50c7c7 100644
--- a/src/models/src/channel-types.ts
+++ b/src/models/src/channel-types.ts
@@ -1 +1,3 @@
-export type Channel = "NHSAPP" | "EMAIL" | "SMS" | "LETTER";
+export const CHANNEL_TYPES = ["NHSAPP", "EMAIL", "SMS", "LETTER"] as const;
+
+export type Channel = (typeof CHANNEL_TYPES)[number];
diff --git a/src/models/src/client-config.ts b/src/models/src/client-config.ts
index e3f5c56..6b10f90 100644
--- a/src/models/src/client-config.ts
+++ b/src/models/src/client-config.ts
@@ -1,3 +1,10 @@
+import type { Channel } from "./channel-types";
+import type {
+ ChannelStatus,
+ MessageStatus,
+ SupplierStatus,
+} from "./status-types";
+
export type ClientSubscriptionConfiguration = (
| MessageStatusSubscriptionConfiguration
| ChannelStatusSubscriptionConfiguration
@@ -29,14 +36,16 @@ interface SubscriptionConfigurationBase {
}[];
}
-export interface MessageStatusSubscriptionConfiguration extends SubscriptionConfigurationBase {
+export interface MessageStatusSubscriptionConfiguration
+ extends SubscriptionConfigurationBase {
SubscriptionType: "MessageStatus";
- Statuses: string[];
+ Statuses: MessageStatus[];
}
-export interface ChannelStatusSubscriptionConfiguration extends SubscriptionConfigurationBase {
+export interface ChannelStatusSubscriptionConfiguration
+ extends SubscriptionConfigurationBase {
SubscriptionType: "ChannelStatus";
- ChannelType: string;
- ChannelStatuses: string[];
- SupplierStatuses: string[];
+ ChannelType: Channel;
+ ChannelStatuses: ChannelStatus[];
+ SupplierStatuses: SupplierStatus[];
}
diff --git a/src/models/src/index.ts b/src/models/src/index.ts
index d26c42d..c01a868 100644
--- a/src/models/src/index.ts
+++ b/src/models/src/index.ts
@@ -1,4 +1,5 @@
export type { ChannelStatusData } from "./channel-status-data";
+export { CHANNEL_TYPES } from "./channel-types";
export type { Channel } from "./channel-types";
export type {
CallbackItem,
@@ -19,6 +20,11 @@ export type { MessageStatusData } from "./message-status-data";
export type { RoutingPlan } from "./routing-plan";
export { EventTypes } from "./status-publish-event";
export type { StatusPublishEvent } from "./status-publish-event";
+export {
+ CHANNEL_STATUSES,
+ MESSAGE_STATUSES,
+ SUPPLIER_STATUSES,
+} from "./status-types";
export type {
ChannelStatus,
MessageStatus,
diff --git a/src/models/src/status-types.ts b/src/models/src/status-types.ts
index 2d2bfef..b2e9e5c 100644
--- a/src/models/src/status-types.ts
+++ b/src/models/src/status-types.ts
@@ -1,41 +1,50 @@
-export type MessageStatus =
- | "FAILED"
- | "PENDING_ENRICHMENT"
- | "DELIVERED"
- | "ENRICHED"
- | "SENDING";
+export const MESSAGE_STATUSES = [
+ "FAILED",
+ "PENDING_ENRICHMENT",
+ "DELIVERED",
+ "ENRICHED",
+ "SENDING",
+] as const;
-export type ChannelStatus =
- | "ASSIGNING_BATCH"
- | "CREATED"
- | "SENDING"
- | "DELIVERED"
- | "FAILED"
- | "RETRY"
- | "SKIPPED"
- | "STALE_PDS";
+export type MessageStatus = (typeof MESSAGE_STATUSES)[number];
-export type SupplierStatus =
- | "accepted"
- | "cancelled"
- | "created"
- | "delivered"
- | "dispatched"
- | "enclosed"
- | "failed"
- | "forwarded"
- | "pending"
- | "printed"
- | "read"
- | "notification_attempted"
- | "notified"
- | "rejected"
- | "returned"
- | "sending"
- | "sent"
- | "received"
- | "permanent_failure"
- | "temporary_failure"
- | "technical_failure"
- | "unnotified"
- | "unknown";
+export const CHANNEL_STATUSES = [
+ "ASSIGNING_BATCH",
+ "CREATED",
+ "SENDING",
+ "DELIVERED",
+ "FAILED",
+ "RETRY",
+ "SKIPPED",
+ "STALE_PDS",
+] as const;
+
+export type ChannelStatus = (typeof CHANNEL_STATUSES)[number];
+
+export const SUPPLIER_STATUSES = [
+ "accepted",
+ "cancelled",
+ "created",
+ "delivered",
+ "dispatched",
+ "enclosed",
+ "failed",
+ "forwarded",
+ "pending",
+ "printed",
+ "read",
+ "notification_attempted",
+ "notified",
+ "rejected",
+ "returned",
+ "sending",
+ "sent",
+ "received",
+ "permanent_failure",
+ "temporary_failure",
+ "technical_failure",
+ "unnotified",
+ "unknown",
+] as const;
+
+export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number];
diff --git a/tests/integration/helpers/aws-helpers.ts b/tests/integration/helpers/aws-helpers.ts
new file mode 100644
index 0000000..75d8f9c
--- /dev/null
+++ b/tests/integration/helpers/aws-helpers.ts
@@ -0,0 +1,58 @@
+import { S3Client } from "@aws-sdk/client-s3";
+
+export type DeploymentDetails = {
+ region: string;
+ environment: string;
+ project: string;
+ component: string;
+ accountId: string;
+};
+
+/**
+ * Reads deployment context from environment variables
+ *
+ * Requires: AWS_REGION, PR_NUMBER, PROJECT, COMPONENT, AWS_ACCOUNT_ID
+ */
+export function getDeploymentDetails(): DeploymentDetails {
+ const region = process.env.AWS_REGION ?? "eu-west-2";
+ const environment = process.env.PR_NUMBER;
+ const project = process.env.PROJECT;
+ const component = process.env.COMPONENT;
+ const accountId = process.env.AWS_ACCOUNT_ID;
+
+ if (!environment) {
+ throw new Error("PR_NUMBER environment variable must be set");
+ }
+ if (!project) {
+ throw new Error("PROJECT environment variable must be set");
+ }
+ if (!component) {
+ throw new Error("COMPONENT environment variable must be set");
+ }
+ if (!accountId) {
+ throw new Error("AWS_ACCOUNT_ID environment variable must be set");
+ }
+
+ return { region, environment, project, component, accountId };
+}
+
+/**
+ * Builds the subscription config S3 bucket name from deployment details.
+ */
+export function buildSubscriptionConfigBucketName({
+ accountId,
+ component,
+ environment,
+ project,
+ region,
+}: DeploymentDetails): string {
+ return `${project}-${accountId}-${region}-${environment}-${component}-subscription-config`;
+}
+
+/**
+ * Creates an S3 client configured for the given region.
+ */
+export function createS3Client(): S3Client {
+ const region = process.env.AWS_REGION ?? "eu-west-2";
+ return new S3Client({ region });
+}
diff --git a/tests/integration/helpers/index.ts b/tests/integration/helpers/index.ts
index b0718c3..e2b912e 100644
--- a/tests/integration/helpers/index.ts
+++ b/tests/integration/helpers/index.ts
@@ -1 +1,2 @@
+export * from "./aws-helpers";
export * from "./cloudwatch-helpers";
diff --git a/tests/integration/infrastructure-exists.test.ts b/tests/integration/infrastructure-exists.test.ts
new file mode 100644
index 0000000..62a8186
--- /dev/null
+++ b/tests/integration/infrastructure-exists.test.ts
@@ -0,0 +1,30 @@
+import { HeadBucketCommand } from "@aws-sdk/client-s3";
+import type { S3Client } from "@aws-sdk/client-s3";
+import {
+ buildSubscriptionConfigBucketName,
+ createS3Client,
+ getDeploymentDetails,
+} from "helpers";
+
+describe("Infrastructure exists", () => {
+ let s3Client: S3Client;
+ let bucketName: string;
+
+ beforeAll(async () => {
+ const deploymentDetails = getDeploymentDetails();
+ bucketName = buildSubscriptionConfigBucketName(deploymentDetails);
+ s3Client = createS3Client();
+ });
+
+ afterAll(() => {
+ s3Client?.destroy();
+ });
+
+ it("should confirm the subscription config S3 bucket exists", async () => {
+ const response = await s3Client.send(
+ new HeadBucketCommand({ Bucket: bucketName }),
+ );
+
+ expect(response.$metadata.httpStatusCode).toBe(200);
+ });
+});
diff --git a/tests/integration/jest.config.ts b/tests/integration/jest.config.ts
index 065243c..e52fedf 100644
--- a/tests/integration/jest.config.ts
+++ b/tests/integration/jest.config.ts
@@ -7,4 +7,7 @@ export default {
...(nodeJestConfig.coveragePathIgnorePatterns ?? []),
"/helpers/",
],
+ moduleNameMapper: {
+ "^helpers$": "/helpers/index",
+ },
};
diff --git a/tests/integration/package.json b/tests/integration/package.json
index c9e2212..def62d6 100644
--- a/tests/integration/package.json
+++ b/tests/integration/package.json
@@ -10,7 +10,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@aws-sdk/client-cloudwatch-logs": "^3.991.0"
+ "@aws-sdk/client-cloudwatch-logs": "^3.991.0",
+ "@aws-sdk/client-eventbridge": "^3.990.0",
+ "@aws-sdk/client-s3": "^3.821.0",
+ "@aws-sdk/client-sts": "^3.821.0",
+ "@aws-sdk/client-sqs": "^3.990.0",
+ "async-wait-until": "^2.0.12"
},
"devDependencies": {
"@aws-sdk/client-sqs": "^3.990.0",
diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json
index c0ab68d..a5cc2b8 100644
--- a/tests/integration/tsconfig.json
+++ b/tests/integration/tsconfig.json
@@ -1,7 +1,12 @@
{
"compilerOptions": {
"baseUrl": ".",
- "isolatedModules": true
+ "isolatedModules": true,
+ "paths": {
+ "helpers": [
+ "./helpers/index"
+ ]
+ }
},
"extends": "../../tsconfig.base.json",
"include": [
diff --git a/tools/client-subscriptions-management/README.md b/tools/client-subscriptions-management/README.md
new file mode 100644
index 0000000..ef13a69
--- /dev/null
+++ b/tools/client-subscriptions-management/README.md
@@ -0,0 +1,65 @@
+# client-subscriptions-management
+
+TypeScript CLI utility for managing NHS Notify client subscription configuration in S3.
+
+## Usage
+
+From the repository root run:
+
+```bash
+npm --workspace tools/client-subscriptions-management [options]
+```
+
+Set the bucket name via `--bucket-name` or the `CLIENT_SUBSCRIPTION_BUCKET_NAME` environment variable.
+
+Set the event source via `--event-source` or the `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable. This is **required** for `put-message-status` and `put-channel-status` commands.
+
+## Commands
+
+### Get Client Subscriptions By Client ID
+
+```bash
+npm --workspace tools/client-subscriptions-management get-by-client-id \
+ --bucket-name my-bucket \
+ --client-id client-123
+```
+
+### Put Message Status Subscription
+
+```bash
+npm --workspace tools/client-subscriptions-management put-message-status \
+ --bucket-name my-bucket \
+ --client-id client-123 \
+ --message-statuses DELIVERED FAILED \
+ --api-endpoint https://webhook.example.com \
+ --api-key-header-name x-api-key \
+ --api-key 1234.4321 \
+ --dry-run false \
+ --rate-limit 20
+```
+
+Optional: `--client-name "Test Client"` (defaults to client-id if not provided)
+
+Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable
+
+### Put Channel Status Subscription
+
+```bash
+npm --workspace tools/client-subscriptions-management put-channel-status \
+ --bucket-name my-bucket \
+ --client-id client-123 \
+ --channel-type EMAIL \
+ --channel-statuses DELIVERED FAILED \
+ --supplier-statuses READ REJECTED \
+ --api-endpoint https://webhook.example.com \
+ --api-key-header-name x-api-key \
+ --api-key 1234.4321 \
+ --dry-run false \
+ --rate-limit 20
+```
+
+Optional: `--client-name "Test Client"` (defaults to client-id if not provided)
+
+Required: `--event-source ` or `CLIENT_SUBSCRIPTION_EVENT_SOURCE` environment variable
+
+**Note**: At least one of `--channel-statuses` or `--supplier-statuses` must be provided.
diff --git a/tools/client-subscriptions-management/jest.config.ts b/tools/client-subscriptions-management/jest.config.ts
new file mode 100644
index 0000000..679cd1c
--- /dev/null
+++ b/tools/client-subscriptions-management/jest.config.ts
@@ -0,0 +1,20 @@
+import type { Config } from "jest";
+
+const jestConfig: Config = {
+ preset: "ts-jest",
+ clearMocks: true,
+ collectCoverage: true,
+ coverageDirectory: "./.reports/unit/coverage",
+ coverageProvider: "babel",
+ coveragePathIgnorePatterns: ["/__tests__/"],
+ transform: { "^.+\\.ts$": "ts-jest" },
+ testPathIgnorePatterns: [String.raw`\.build`],
+ testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
+ testEnvironment: "node",
+ modulePaths: ["/src"],
+ moduleNameMapper: {
+ "^src/(.*)$": "/src/$1",
+ },
+};
+
+export default jestConfig;
diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json
new file mode 100644
index 0000000..33540bd
--- /dev/null
+++ b/tools/client-subscriptions-management/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "client-subscriptions-management",
+ "version": "0.0.1",
+ "private": true,
+ "main": "src/index.ts",
+ "scripts": {
+ "get-by-client-id": "tsx ./src/entrypoint/cli/get-client-subscriptions.ts",
+ "put-channel-status": "tsx ./src/entrypoint/cli/put-channel-status.ts",
+ "put-message-status": "tsx ./src/entrypoint/cli/put-message-status.ts",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.821.0",
+ "@nhs-notify-client-callbacks/models": "*",
+ "yargs": "^17.7.2",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.10",
+ "@types/yargs": "^17.0.24",
+ "eslint": "^9.27.0",
+ "tsx": "^4.19.3",
+ "typescript": "^5.8.2"
+ }
+}
diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts
new file mode 100644
index 0000000..8862c58
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts
@@ -0,0 +1,142 @@
+const originalEventSource = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE;
+process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = "env-source";
+
+import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder";
+
+afterAll(() => {
+ if (originalEventSource === undefined) {
+ delete process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE;
+ } else {
+ process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = originalEventSource;
+ }
+});
+
+describe("ClientSubscriptionConfigurationBuilder", () => {
+ it("builds message status subscription with default event source", () => {
+ const builder = new ClientSubscriptionConfigurationBuilder();
+
+ const result = builder.messageStatus({
+ apiEndpoint: "https://example.com/webhook",
+ apiKey: "secret",
+ apiKeyHeaderName: "x-api-key",
+ clientId: "client-1",
+ clientName: "Client One",
+ rateLimit: 10,
+ statuses: ["DELIVERED"],
+ dryRun: false,
+ });
+
+ expect(result).toMatchObject({
+ Name: "client-one",
+ SubscriptionType: "MessageStatus",
+ ClientId: "client-1",
+ Statuses: ["DELIVERED"],
+ EventSource: JSON.stringify(["env-source"]),
+ });
+ });
+
+ it("builds message status subscription with explicit event source", () => {
+ const builder = new ClientSubscriptionConfigurationBuilder(
+ "default-source",
+ );
+
+ const result = builder.messageStatus({
+ apiEndpoint: "https://example.com/webhook",
+ apiKey: "secret",
+ clientId: "client-1",
+ clientName: "Client One",
+ rateLimit: 10,
+ statuses: ["FAILED"],
+ dryRun: false,
+ eventSource: "explicit-source",
+ });
+
+ expect(result.EventSource).toBe(JSON.stringify(["explicit-source"]));
+ });
+
+ it("builds channel status subscription with explicit event source", () => {
+ const builder = new ClientSubscriptionConfigurationBuilder(
+ "default-source",
+ );
+
+ const result = builder.channelStatus({
+ apiEndpoint: "https://example.com/webhook",
+ apiKey: "secret",
+ clientId: "client-1",
+ clientName: "Client One",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 20,
+ dryRun: false,
+ eventSource: "explicit-source",
+ });
+
+ expect(result).toMatchObject({
+ Name: "client-one-SMS",
+ SubscriptionType: "ChannelStatus",
+ ClientId: "client-1",
+ ChannelType: "SMS",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["delivered"],
+ EventSource: JSON.stringify(["explicit-source"]),
+ });
+ });
+
+ it("defaults channelStatuses and supplierStatuses to [] when not provided", () => {
+ const builder = new ClientSubscriptionConfigurationBuilder(
+ "default-source",
+ );
+
+ const result = builder.channelStatus({
+ apiEndpoint: "https://example.com/webhook",
+ apiKey: "secret",
+ clientId: "client-1",
+ clientName: "Client One",
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ });
+
+ expect(result.ChannelStatuses).toEqual([]);
+ expect(result.SupplierStatuses).toEqual([]);
+ });
+
+ it("throws if no event source is available for messageStatus", () => {
+ const builder = new ClientSubscriptionConfigurationBuilder("");
+
+ expect(() =>
+ builder.messageStatus({
+ apiEndpoint: "https://example.com/webhook",
+ apiKey: "secret",
+ clientId: "client-1",
+ clientName: "Client One",
+ rateLimit: 10,
+ statuses: ["DELIVERED"],
+ dryRun: false,
+ }),
+ ).toThrow(
+ "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.",
+ );
+ });
+
+ it("throws if no event source is available for channelStatus", () => {
+ const builder = new ClientSubscriptionConfigurationBuilder("");
+
+ expect(() =>
+ builder.channelStatus({
+ apiEndpoint: "https://example.com/webhook",
+ apiKey: "secret",
+ clientId: "client-1",
+ clientName: "Client One",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 20,
+ dryRun: false,
+ }),
+ ).toThrow(
+ "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.",
+ );
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts
new file mode 100644
index 0000000..d13ee08
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/client-subscriptions.test.ts
@@ -0,0 +1,388 @@
+import { z } from "zod";
+import { ClientSubscriptionRepository } from "src/repository/client-subscriptions";
+import type {
+ ChannelStatusSubscriptionConfiguration,
+ ClientSubscriptionConfiguration,
+ MessageStatusSubscriptionConfiguration,
+} from "@nhs-notify-client-callbacks/models";
+import type { S3Repository } from "src/repository/s3";
+import type { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder";
+
+const createRepository = (
+ overrides?: Partial<{
+ getObject: jest.Mock;
+ putRawData: jest.Mock;
+ messageStatus: jest.Mock;
+ channelStatus: jest.Mock;
+ }>,
+) => {
+ const s3Repository = {
+ getObject: overrides?.getObject ?? jest.fn(),
+ putRawData: overrides?.putRawData ?? jest.fn(),
+ } as unknown as S3Repository;
+
+ const configurationBuilder = {
+ messageStatus: overrides?.messageStatus ?? jest.fn(),
+ channelStatus: overrides?.channelStatus ?? jest.fn(),
+ } as unknown as ClientSubscriptionConfigurationBuilder;
+
+ const repository = new ClientSubscriptionRepository(
+ s3Repository,
+ configurationBuilder,
+ );
+
+ return { repository, s3Repository, configurationBuilder };
+};
+
+describe("ClientSubscriptionRepository", () => {
+ const baseTarget: MessageStatusSubscriptionConfiguration["Targets"][number] =
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: "client-1",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature": "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com/webhook",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ };
+
+ const messageSubscription: MessageStatusSubscriptionConfiguration = {
+ Name: "client-1",
+ SubscriptionType: "MessageStatus",
+ ClientId: "client-1",
+ Statuses: ["DELIVERED"],
+ Description: "Message subscription",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [baseTarget],
+ };
+
+ const channelSubscription: ChannelStatusSubscriptionConfiguration = {
+ Name: "client-1-SMS",
+ SubscriptionType: "ChannelStatus",
+ ClientId: "client-1",
+ ChannelType: "SMS",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["delivered"],
+ Description: "Channel subscription",
+ EventSource: "[]",
+ EventDetail: "{}",
+ Targets: [baseTarget],
+ };
+
+ it("returns parsed subscriptions when file exists", async () => {
+ const storedConfig: ClientSubscriptionConfiguration = [messageSubscription];
+ const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig));
+ const { repository } = createRepository({ getObject });
+
+ const result = await repository.getClientSubscriptions("client-1");
+
+ expect(result).toEqual(storedConfig);
+ });
+
+ it("returns undefined when config file is missing", async () => {
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ const getObject = jest.fn().mockResolvedValue(undefined);
+ const { repository } = createRepository({ getObject });
+
+ await expect(
+ repository.getClientSubscriptions("client-1"),
+ ).resolves.toBeUndefined();
+ });
+
+ it("replaces existing message subscription", async () => {
+ const storedConfig: ClientSubscriptionConfiguration = [
+ channelSubscription,
+ messageSubscription,
+ ];
+ const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig));
+ const putRawData = jest.fn();
+ const newMessage: MessageStatusSubscriptionConfiguration = {
+ ...messageSubscription,
+ Statuses: ["FAILED"],
+ };
+ const messageStatus = jest.fn().mockReturnValue(newMessage);
+
+ const { repository } = createRepository({
+ getObject,
+ putRawData,
+ messageStatus,
+ });
+
+ const result = await repository.putMessageStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ statuses: ["FAILED"],
+ rateLimit: 10,
+ dryRun: false,
+ });
+
+ expect(result).toEqual([channelSubscription, newMessage]);
+ expect(putRawData).toHaveBeenCalledWith(
+ JSON.stringify([channelSubscription, newMessage]),
+ "client_subscriptions/client-1.json",
+ );
+ });
+
+ it("skips S3 write when dry run is enabled", async () => {
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ const getObject = jest.fn().mockResolvedValue(undefined);
+ const putRawData = jest.fn();
+ const messageStatus = jest.fn().mockReturnValue(messageSubscription);
+
+ const { repository } = createRepository({
+ getObject,
+ putRawData,
+ messageStatus,
+ });
+
+ await repository.putMessageStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ statuses: ["DELIVERED"],
+ rateLimit: 10,
+ dryRun: true,
+ });
+
+ expect(putRawData).not.toHaveBeenCalled();
+ });
+
+ it("replaces existing channel subscription for the channel type", async () => {
+ const storedConfig: ClientSubscriptionConfiguration = [
+ channelSubscription,
+ messageSubscription,
+ ];
+ const getObject = jest.fn().mockResolvedValue(JSON.stringify(storedConfig));
+ const putRawData = jest.fn();
+ const newChannel: ChannelStatusSubscriptionConfiguration = {
+ ...channelSubscription,
+ ChannelStatuses: ["FAILED"],
+ };
+ const channelStatus = jest.fn().mockReturnValue(newChannel);
+
+ const { repository } = createRepository({
+ getObject,
+ putRawData,
+ channelStatus,
+ });
+
+ const result = await repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelStatuses: ["FAILED"],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ });
+
+ expect(result).toEqual([messageSubscription, newChannel]);
+ expect(putRawData).toHaveBeenCalledWith(
+ JSON.stringify([messageSubscription, newChannel]),
+ "client_subscriptions/client-1.json",
+ );
+ });
+
+ it("skips S3 write for channel status dry run", async () => {
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ const getObject = jest.fn().mockResolvedValue(undefined);
+ const putRawData = jest.fn();
+ const channelStatus = jest.fn().mockReturnValue(channelSubscription);
+
+ const { repository } = createRepository({
+ getObject,
+ putRawData,
+ channelStatus,
+ });
+
+ await repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: true,
+ });
+
+ expect(putRawData).not.toHaveBeenCalled();
+ });
+
+ describe("validation", () => {
+ it("throws validation error for invalid message status", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ repository.putMessageStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ statuses: ["INVALID_STATUS" as never],
+ rateLimit: 10,
+ dryRun: false,
+ }),
+ ).rejects.toThrow(z.ZodError);
+ });
+
+ it("throws validation error for missing required fields in message subscription", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ repository.putMessageStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ // @ts-expect-error Testing missing field
+ statuses: undefined,
+ rateLimit: 10,
+ dryRun: false,
+ }),
+ ).rejects.toThrow(z.ZodError);
+ });
+
+ it("throws validation error for invalid channel type", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ channelType: "INVALID_CHANNEL" as never,
+ rateLimit: 10,
+ dryRun: false,
+ }),
+ ).rejects.toThrow(z.ZodError);
+ });
+
+ it("throws validation error for invalid channel status", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelStatuses: ["INVALID_STATUS" as never],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ }),
+ ).rejects.toThrow(z.ZodError);
+ });
+
+ it("throws validation error for invalid supplier status", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["INVALID_STATUS" as never],
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ }),
+ ).rejects.toThrow(z.ZodError);
+ });
+
+ it("throws validation error when neither channelStatuses nor supplierStatuses are provided", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ }),
+ ).rejects.toThrow(
+ /at least one of channelStatuses or supplierStatuses must be provided/,
+ );
+ });
+
+ it("applies default value for apiKeyHeaderName on message subscription", async () => {
+ const getObject = jest.fn().mockResolvedValue(undefined as never);
+ const messageStatus = jest.fn().mockReturnValue(messageSubscription);
+
+ const { configurationBuilder, repository } = createRepository({
+ getObject,
+ messageStatus,
+ });
+
+ await repository.putMessageStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ statuses: ["DELIVERED"],
+ rateLimit: 10,
+ dryRun: false,
+ });
+
+ expect(configurationBuilder.messageStatus).toHaveBeenCalledWith(
+ expect.objectContaining({
+ apiKeyHeaderName: "x-api-key",
+ }),
+ );
+ });
+
+ it("applies default value for apiKeyHeaderName on channel subscription", async () => {
+ const getObject = jest.fn().mockResolvedValue(undefined as never);
+ const channelStatus = jest.fn().mockReturnValue(channelSubscription);
+
+ const { configurationBuilder, repository } = createRepository({
+ getObject,
+ channelStatus,
+ });
+
+ await repository.putChannelStatusSubscription({
+ clientName: "Client 1",
+ clientId: "client-1",
+ apiKey: "secret",
+ apiEndpoint: "https://example.com/webhook",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ });
+
+ expect(configurationBuilder.channelStatus).toHaveBeenCalledWith(
+ expect.objectContaining({
+ apiKeyHeaderName: "x-api-key",
+ }),
+ );
+ });
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/constants.test.ts b/tools/client-subscriptions-management/src/__tests__/constants.test.ts
new file mode 100644
index 0000000..9d53f53
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/constants.test.ts
@@ -0,0 +1,28 @@
+import {
+ CHANNEL_STATUSES,
+ CHANNEL_TYPES,
+ MESSAGE_STATUSES,
+ SUPPLIER_STATUSES,
+} from "@nhs-notify-client-callbacks/models";
+
+describe("constants", () => {
+ it("exposes message statuses", () => {
+ expect(MESSAGE_STATUSES).toContain("DELIVERED");
+ expect(MESSAGE_STATUSES).toContain("FAILED");
+ });
+
+ it("exposes channel statuses", () => {
+ expect(CHANNEL_STATUSES).toContain("SENDING");
+ expect(CHANNEL_STATUSES).toContain("SKIPPED");
+ });
+
+ it("exposes supplier statuses", () => {
+ expect(SUPPLIER_STATUSES).toContain("delivered");
+ expect(SUPPLIER_STATUSES).toContain("unknown");
+ });
+
+ it("exposes channel types", () => {
+ expect(CHANNEL_TYPES).toContain("SMS");
+ expect(CHANNEL_TYPES).toContain("EMAIL");
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts
new file mode 100644
index 0000000..ec19865
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/container-s3-config.test.ts
@@ -0,0 +1,32 @@
+import { createS3Client } from "src/container";
+
+describe("createS3Client", () => {
+ it("sets forcePathStyle=true when endpoint contains localhost", () => {
+ const env = { AWS_ENDPOINT_URL: "http://localhost:4566" };
+ const client = createS3Client("eu-west-2", env);
+
+ // Access the config through the client's config property
+ const { config } = client as any;
+ expect(config.endpoint).toBeDefined();
+ expect(config.forcePathStyle).toBe(true);
+ });
+
+ it("does not set forcePathStyle=true when endpoint does not contain localhost", () => {
+ const env = { AWS_ENDPOINT_URL: "https://custom-s3.example.com" };
+ const client = createS3Client("eu-west-2", env);
+
+ const { config } = client as any;
+ expect(config.endpoint).toBeDefined();
+ // S3Client converts undefined to false, so we just check it's not true
+ expect(config.forcePathStyle).not.toBe(true);
+ });
+
+ it("does not set forcePathStyle=true when endpoint is not set", () => {
+ const env = {};
+ const client = createS3Client("eu-west-2", env);
+
+ const { config } = client as any;
+ // S3Client converts undefined to false, so we just check it's not true
+ expect(config.forcePathStyle).not.toBe(true);
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/container.test.ts b/tools/client-subscriptions-management/src/__tests__/container.test.ts
new file mode 100644
index 0000000..108697e
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/container.test.ts
@@ -0,0 +1,40 @@
+import { S3Client } from "@aws-sdk/client-s3";
+
+const mockS3Repository = jest.fn();
+const mockBuilder = jest.fn();
+const mockRepository = jest.fn();
+
+jest.mock("src/repository/s3", () => ({
+ S3Repository: mockS3Repository,
+}));
+
+jest.mock("src/domain/client-subscription-builder", () => ({
+ ClientSubscriptionConfigurationBuilder: mockBuilder,
+}));
+
+jest.mock("src/repository/client-subscriptions", () => ({
+ ClientSubscriptionRepository: mockRepository,
+}));
+
+import { createClientSubscriptionRepository } from "src/container";
+
+describe("createClientSubscriptionRepository", () => {
+ it("creates repository with provided options", () => {
+ const repoInstance = { repo: true };
+ mockRepository.mockReturnValue(repoInstance);
+
+ const result = createClientSubscriptionRepository({
+ bucketName: "bucket-1",
+ region: "eu-west-2",
+ eventSource: "event-source",
+ });
+
+ expect(mockS3Repository).toHaveBeenCalledWith(
+ "bucket-1",
+ expect.any(S3Client),
+ );
+ expect(mockBuilder).toHaveBeenCalledWith("event-source");
+ expect(mockRepository).toHaveBeenCalledTimes(1);
+ expect(result).toBe(repoInstance);
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts
new file mode 100644
index 0000000..39d838c
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts
@@ -0,0 +1,175 @@
+const mockGetClientSubscriptions = jest.fn();
+const mockCreateRepository = jest.fn().mockReturnValue({
+ getClientSubscriptions: mockGetClientSubscriptions,
+});
+const mockFormatSubscriptionFileResponse = jest.fn();
+const mockResolveBucketName = jest.fn().mockReturnValue("bucket");
+const mockResolveRegion = jest.fn().mockReturnValue("region");
+
+jest.mock("src/container", () => ({
+ createClientSubscriptionRepository: mockCreateRepository,
+}));
+
+jest.mock("src/entrypoint/cli/helper", () => ({
+ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse,
+ resolveBucketName: mockResolveBucketName,
+ resolveRegion: mockResolveRegion,
+}));
+
+import * as cli from "src/entrypoint/cli/get-client-subscriptions";
+
+describe("get-client-subscriptions CLI", () => {
+ const originalLog = console.log;
+ const originalError = console.error;
+ const originalExitCode = process.exitCode;
+ const originalArgv = process.argv;
+
+ beforeEach(() => {
+ mockGetClientSubscriptions.mockReset();
+ mockFormatSubscriptionFileResponse.mockReset();
+ mockResolveBucketName.mockReset();
+ mockResolveBucketName.mockReturnValue("bucket");
+ mockResolveRegion.mockReset();
+ mockResolveRegion.mockReturnValue("region");
+ console.log = jest.fn();
+ console.error = jest.fn();
+ delete process.exitCode;
+ });
+
+ afterAll(() => {
+ console.log = originalLog;
+ console.error = originalError;
+ process.exitCode = originalExitCode;
+ process.argv = originalArgv;
+ });
+
+ it("prints formatted config when subscription exists", async () => {
+ mockGetClientSubscriptions.mockResolvedValue([
+ { SubscriptionType: "MessageStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main([
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(mockCreateRepository).toHaveBeenCalled();
+ expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1");
+ expect(console.log).toHaveBeenCalledWith(["formatted"]);
+ });
+
+ it("prints message when no configuration exists", async () => {
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ mockGetClientSubscriptions.mockResolvedValue(undefined);
+
+ await cli.main([
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.log).toHaveBeenCalledWith(
+ "No configuration exists for client: client-1",
+ );
+ });
+
+ it("handles errors in runCli", async () => {
+ mockResolveBucketName.mockImplementation(() => {
+ throw new Error("Boom");
+ });
+
+ await cli.runCli([
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.error).toHaveBeenCalledWith(new Error("Boom"));
+ expect(process.exitCode).toBe(1);
+ });
+
+ it("executes when run as main module", async () => {
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ mockGetClientSubscriptions.mockResolvedValue(undefined);
+ const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue();
+
+ await cli.runIfMain(
+ [
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--bucket-name",
+ "bucket-1",
+ ],
+ true,
+ );
+
+ expect(runCliSpy).toHaveBeenCalled();
+ runCliSpy.mockRestore();
+ });
+
+ it("does not execute when not main module", async () => {
+ const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue();
+
+ await cli.runIfMain(
+ [
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--bucket-name",
+ "bucket-1",
+ ],
+ false,
+ );
+
+ expect(runCliSpy).not.toHaveBeenCalled();
+ runCliSpy.mockRestore();
+ });
+
+ it("uses process.argv when no args are provided", async () => {
+ process.argv = [
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--bucket-name",
+ "bucket-1",
+ ];
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ mockGetClientSubscriptions.mockResolvedValue(undefined);
+
+ await cli.runCli();
+
+ expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-1");
+ });
+
+ it("uses default args in main when none are provided", async () => {
+ process.argv = [
+ "node",
+ "script",
+ "--client-id",
+ "client-2",
+ "--bucket-name",
+ "bucket-2",
+ ];
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ mockGetClientSubscriptions.mockResolvedValue(undefined);
+
+ await cli.main();
+
+ expect(mockGetClientSubscriptions).toHaveBeenCalledWith("client-2");
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/helper.test.ts b/tools/client-subscriptions-management/src/__tests__/helper.test.ts
new file mode 100644
index 0000000..b7f9145
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/helper.test.ts
@@ -0,0 +1,174 @@
+import type {
+ ChannelStatusSubscriptionConfiguration,
+ ClientSubscriptionConfiguration,
+ MessageStatusSubscriptionConfiguration,
+} from "@nhs-notify-client-callbacks/models";
+import {
+ formatSubscriptionFileResponse,
+ normalizeClientName,
+ resolveBucketName,
+ resolveEventSource,
+ resolveRegion,
+} from "src/entrypoint/cli/helper";
+
+describe("cli helper", () => {
+ const messageSubscription: MessageStatusSubscriptionConfiguration = {
+ Name: "client-a",
+ SubscriptionType: "MessageStatus",
+ ClientId: "client-a",
+ Statuses: ["DELIVERED"],
+ Description: "Message subscription",
+ EventSource: '["source-a"]',
+ EventDetail: "{}",
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: "client-a",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com/webhook",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ };
+
+ const channelSubscription: ChannelStatusSubscriptionConfiguration = {
+ Name: "client-a-sms",
+ SubscriptionType: "ChannelStatus",
+ ClientId: "client-a",
+ ChannelType: "SMS",
+ ChannelStatuses: ["DELIVERED"],
+ SupplierStatuses: ["delivered"],
+ Description: "Channel subscription",
+ EventSource: '["source-a"]',
+ EventDetail: "{}",
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: "client-a-sms",
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: "https://example.com/webhook",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 20,
+ APIKey: {
+ HeaderName: "x-api-key",
+ HeaderValue: "secret",
+ },
+ },
+ ],
+ };
+
+ it("formats subscription output", () => {
+ const config: ClientSubscriptionConfiguration = [
+ messageSubscription,
+ channelSubscription,
+ ];
+
+ const result = formatSubscriptionFileResponse(config);
+
+ expect(result).toEqual([
+ {
+ clientId: "client-a",
+ subscriptionType: "MessageStatus",
+ statuses: ["DELIVERED"],
+ clientApiEndpoint: "https://example.com/webhook",
+ clientApiKey: "secret",
+ rateLimit: 10,
+ },
+ {
+ clientId: "client-a",
+ subscriptionType: "ChannelStatus",
+ channelType: "SMS",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ clientApiEndpoint: "https://example.com/webhook",
+ clientApiKey: "secret",
+ rateLimit: 20,
+ },
+ ]);
+ });
+
+ it("normalizes client name", () => {
+ expect(normalizeClientName("My Client Name")).toBe("my-client-name");
+ });
+
+ it("resolves bucket name from argument", () => {
+ expect(resolveBucketName("bucket-1")).toBe("bucket-1");
+ });
+
+ it("resolves bucket name from env", () => {
+ expect(
+ resolveBucketName(undefined, {
+ CLIENT_SUBSCRIPTION_BUCKET_NAME: "bucket-env",
+ } as NodeJS.ProcessEnv),
+ ).toBe("bucket-env");
+ });
+
+ it("throws when bucket name is missing", () => {
+ expect(() => resolveBucketName(undefined, {} as NodeJS.ProcessEnv)).toThrow(
+ "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)",
+ );
+ });
+
+ it("resolves event source from argument", () => {
+ expect(resolveEventSource("my-source")).toBe("my-source");
+ });
+
+ it("resolves event source from env", () => {
+ expect(
+ resolveEventSource(undefined, {
+ CLIENT_SUBSCRIPTION_EVENT_SOURCE: "env-source",
+ } as NodeJS.ProcessEnv),
+ ).toBe("env-source");
+ });
+
+ it("throws when event source is missing", () => {
+ expect(() =>
+ resolveEventSource(undefined, {} as NodeJS.ProcessEnv),
+ ).toThrow(
+ "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)",
+ );
+ });
+
+ it("resolves region from argument", () => {
+ expect(resolveRegion("eu-west-2")).toBe("eu-west-2");
+ });
+
+ it("resolves region from AWS_REGION", () => {
+ expect(
+ resolveRegion(undefined, {
+ AWS_REGION: "eu-west-1",
+ } as NodeJS.ProcessEnv),
+ ).toBe("eu-west-1");
+ });
+
+ it("resolves region from AWS_DEFAULT_REGION", () => {
+ expect(
+ resolveRegion(undefined, {
+ AWS_DEFAULT_REGION: "eu-west-3",
+ } as NodeJS.ProcessEnv),
+ ).toBe("eu-west-3");
+ });
+
+ it("returns undefined when region is not set", () => {
+ expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined();
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts
new file mode 100644
index 0000000..b4423ba
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/put-channel-status.test.ts
@@ -0,0 +1,385 @@
+const mockPutChannelStatusSubscription = jest.fn();
+const mockCreateRepository = jest.fn().mockReturnValue({
+ putChannelStatusSubscription: mockPutChannelStatusSubscription,
+});
+const mockFormatSubscriptionFileResponse = jest.fn();
+const mockResolveBucketName = jest.fn().mockReturnValue("bucket");
+const mockResolveRegion = jest.fn().mockReturnValue("region");
+const mockResolveEventSource = jest.fn().mockReturnValue("source-a");
+
+jest.mock("src/container", () => ({
+ createClientSubscriptionRepository: mockCreateRepository,
+}));
+
+jest.mock("src/entrypoint/cli/helper", () => ({
+ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse,
+ resolveBucketName: mockResolveBucketName,
+ resolveEventSource: mockResolveEventSource,
+ resolveRegion: mockResolveRegion,
+}));
+
+import * as cli from "src/entrypoint/cli/put-channel-status";
+
+describe("put-channel-status CLI", () => {
+ const originalLog = console.log;
+ const originalError = console.error;
+ const originalExitCode = process.exitCode;
+ const originalArgv = process.argv;
+
+ beforeEach(() => {
+ mockPutChannelStatusSubscription.mockReset();
+ mockFormatSubscriptionFileResponse.mockReset();
+ mockResolveBucketName.mockReset();
+ mockResolveBucketName.mockReturnValue("bucket");
+ mockResolveRegion.mockReset();
+ mockResolveRegion.mockReturnValue("region");
+ mockResolveEventSource.mockReset();
+ mockResolveEventSource.mockReturnValue("source-a");
+ console.log = jest.fn();
+ console.error = jest.fn();
+ delete process.exitCode;
+ });
+
+ afterAll(() => {
+ console.log = originalLog;
+ console.error = originalError;
+ process.exitCode = originalExitCode;
+ process.argv = originalArgv;
+ });
+
+ it("rejects non-https endpoints", async () => {
+ await cli.main([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "http://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "true",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.error).toHaveBeenCalledWith(
+ "Error: api-endpoint must start with https://",
+ );
+ expect(process.exitCode).toBe(1);
+ expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled();
+ });
+
+ it("rejects when neither channel-statuses nor supplier-statuses are provided", async () => {
+ await cli.main([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "true",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.error).toHaveBeenCalledWith(
+ "Error: at least one of --channel-statuses or --supplier-statuses must be provided",
+ );
+ expect(process.exitCode).toBe(1);
+ expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled();
+ });
+
+ it("writes channel subscription and logs response", async () => {
+ mockPutChannelStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "ChannelStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ "--event-source",
+ "source-a",
+ "--api-key-header-name",
+ "x-api-key",
+ ]);
+
+ expect(mockPutChannelStatusSubscription).toHaveBeenCalledWith({
+ clientName: "Client One",
+ clientId: "client-1",
+ apiEndpoint: "https://example.com",
+ apiKeyHeaderName: "x-api-key",
+ apiKey: "secret",
+ channelType: "SMS",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ rateLimit: 10,
+ dryRun: false,
+ eventSource: "source-a",
+ });
+ expect(console.log).toHaveBeenCalledWith(["formatted"]);
+ });
+
+ it("handles errors in runCli", async () => {
+ mockResolveBucketName.mockImplementation(() => {
+ throw new Error("Boom");
+ });
+
+ await cli.runCli([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.error).toHaveBeenCalledWith(new Error("Boom"));
+ expect(process.exitCode).toBe(1);
+ });
+
+ it("executes when run as main module", async () => {
+ mockPutChannelStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "ChannelStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+ const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue();
+
+ await cli.runIfMain(
+ [
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ],
+ true,
+ );
+
+ expect(runCliSpy).toHaveBeenCalled();
+ runCliSpy.mockRestore();
+ });
+
+ it("does not execute when not main module", async () => {
+ const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue();
+
+ await cli.runIfMain(
+ [
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ],
+ false,
+ );
+
+ expect(runCliSpy).not.toHaveBeenCalled();
+ runCliSpy.mockRestore();
+ });
+
+ it("uses process.argv when no args are provided", async () => {
+ process.argv = [
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ];
+ mockPutChannelStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "ChannelStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.runCli();
+
+ expect(mockPutChannelStatusSubscription).toHaveBeenCalled();
+ });
+
+ it("uses default args in main when none are provided", async () => {
+ process.argv = [
+ "node",
+ "script",
+ "--client-name",
+ "Client Two",
+ "--client-id",
+ "client-2",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ];
+ mockPutChannelStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "ChannelStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main();
+
+ expect(mockPutChannelStatusSubscription).toHaveBeenCalled();
+ });
+
+ it("defaults client-name to client-id when not provided", async () => {
+ mockPutChannelStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "ChannelStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main([
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--channel-statuses",
+ "DELIVERED",
+ "--supplier-statuses",
+ "delivered",
+ "--channel-type",
+ "SMS",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(mockPutChannelStatusSubscription).toHaveBeenCalledWith({
+ clientName: "client-1",
+ clientId: "client-1",
+ apiEndpoint: "https://example.com",
+ apiKeyHeaderName: "x-api-key",
+ apiKey: "secret",
+ channelStatuses: ["DELIVERED"],
+ supplierStatuses: ["delivered"],
+ channelType: "SMS",
+ rateLimit: 10,
+ dryRun: false,
+ eventSource: "source-a",
+ });
+ expect(console.log).toHaveBeenCalledWith(["formatted"]);
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts
new file mode 100644
index 0000000..007ddb4
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/put-message-status.test.ts
@@ -0,0 +1,320 @@
+const mockPutMessageStatusSubscription = jest.fn();
+const mockCreateRepository = jest.fn().mockReturnValue({
+ putMessageStatusSubscription: mockPutMessageStatusSubscription,
+});
+const mockFormatSubscriptionFileResponse = jest.fn();
+const mockResolveBucketName = jest.fn().mockReturnValue("bucket");
+const mockResolveRegion = jest.fn().mockReturnValue("region");
+const mockResolveEventSource = jest.fn().mockReturnValue("source-a");
+
+jest.mock("src/container", () => ({
+ createClientSubscriptionRepository: mockCreateRepository,
+}));
+
+jest.mock("src/entrypoint/cli/helper", () => ({
+ formatSubscriptionFileResponse: mockFormatSubscriptionFileResponse,
+ resolveBucketName: mockResolveBucketName,
+ resolveEventSource: mockResolveEventSource,
+ resolveRegion: mockResolveRegion,
+}));
+
+import * as cli from "src/entrypoint/cli/put-message-status";
+
+describe("put-message-status CLI", () => {
+ const originalLog = console.log;
+ const originalError = console.error;
+ const originalExitCode = process.exitCode;
+ const originalArgv = process.argv;
+
+ beforeEach(() => {
+ mockPutMessageStatusSubscription.mockReset();
+ mockFormatSubscriptionFileResponse.mockReset();
+ mockResolveBucketName.mockReset();
+ mockResolveBucketName.mockReturnValue("bucket");
+ mockResolveRegion.mockReset();
+ mockResolveRegion.mockReturnValue("region");
+ mockResolveEventSource.mockReset();
+ mockResolveEventSource.mockReturnValue("source-a");
+ console.log = jest.fn();
+ console.error = jest.fn();
+ delete process.exitCode;
+ });
+
+ afterAll(() => {
+ console.log = originalLog;
+ console.error = originalError;
+ process.exitCode = originalExitCode;
+ process.argv = originalArgv;
+ });
+
+ it("rejects non-https endpoints", async () => {
+ await cli.main([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "http://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "true",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.error).toHaveBeenCalledWith(
+ "Error: api-endpoint must start with https://",
+ );
+ expect(process.exitCode).toBe(1);
+ expect(mockPutMessageStatusSubscription).not.toHaveBeenCalled();
+ });
+
+ it("writes subscription and logs response", async () => {
+ mockPutMessageStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "MessageStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ "--event-source",
+ "source-a",
+ "--api-key-header-name",
+ "x-api-key",
+ ]);
+
+ expect(mockPutMessageStatusSubscription).toHaveBeenCalledWith({
+ clientName: "Client One",
+ clientId: "client-1",
+ apiEndpoint: "https://example.com",
+ apiKeyHeaderName: "x-api-key",
+ apiKey: "secret",
+ statuses: ["DELIVERED"],
+ rateLimit: 10,
+ dryRun: false,
+ eventSource: "source-a",
+ });
+ expect(console.log).toHaveBeenCalledWith(["formatted"]);
+ });
+
+ it("handles errors in runCli", async () => {
+ mockResolveBucketName.mockImplementation(() => {
+ throw new Error("Boom");
+ });
+
+ await cli.runCli([
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(console.error).toHaveBeenCalledWith(new Error("Boom"));
+ expect(process.exitCode).toBe(1);
+ });
+
+ it("executes when run as main module", async () => {
+ mockPutMessageStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "MessageStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+ const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue();
+
+ await cli.runIfMain(
+ [
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ],
+ true,
+ );
+
+ expect(runCliSpy).toHaveBeenCalled();
+ runCliSpy.mockRestore();
+ });
+
+ it("does not execute when not main module", async () => {
+ const runCliSpy = jest.spyOn(cli, "runCli").mockResolvedValue();
+
+ await cli.runIfMain(
+ [
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ],
+ false,
+ );
+
+ expect(runCliSpy).not.toHaveBeenCalled();
+ runCliSpy.mockRestore();
+ });
+
+ it("uses process.argv when no args are provided", async () => {
+ process.argv = [
+ "node",
+ "script",
+ "--client-name",
+ "Client One",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ];
+ mockPutMessageStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "MessageStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.runCli();
+
+ expect(mockPutMessageStatusSubscription).toHaveBeenCalled();
+ });
+
+ it("uses default args in main when none are provided", async () => {
+ process.argv = [
+ "node",
+ "script",
+ "--client-name",
+ "Client Two",
+ "--client-id",
+ "client-2",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ];
+ mockPutMessageStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "MessageStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main();
+
+ expect(mockPutMessageStatusSubscription).toHaveBeenCalled();
+ });
+
+ it("defaults client-name to client-id when not provided", async () => {
+ mockPutMessageStatusSubscription.mockResolvedValue([
+ { SubscriptionType: "MessageStatus" },
+ ]);
+ mockFormatSubscriptionFileResponse.mockReturnValue(["formatted"]);
+
+ await cli.main([
+ "node",
+ "script",
+ "--client-id",
+ "client-1",
+ "--api-endpoint",
+ "https://example.com",
+ "--api-key",
+ "secret",
+ "--message-statuses",
+ "DELIVERED",
+ "--rate-limit",
+ "10",
+ "--dry-run",
+ "false",
+ "--bucket-name",
+ "bucket-1",
+ ]);
+
+ expect(mockPutMessageStatusSubscription).toHaveBeenCalledWith({
+ clientName: "client-1",
+ clientId: "client-1",
+ apiEndpoint: "https://example.com",
+ apiKeyHeaderName: "x-api-key",
+ apiKey: "secret",
+ statuses: ["DELIVERED"],
+ rateLimit: 10,
+ dryRun: false,
+ eventSource: "source-a",
+ });
+ expect(console.log).toHaveBeenCalledWith(["formatted"]);
+ });
+});
diff --git a/tools/client-subscriptions-management/src/__tests__/s3.test.ts b/tools/client-subscriptions-management/src/__tests__/s3.test.ts
new file mode 100644
index 0000000..04a9037
--- /dev/null
+++ b/tools/client-subscriptions-management/src/__tests__/s3.test.ts
@@ -0,0 +1,68 @@
+import {
+ GetObjectCommand,
+ NoSuchKey,
+ PutObjectCommand,
+ S3Client,
+} from "@aws-sdk/client-s3";
+import { S3Repository } from "src/repository/s3";
+
+describe("S3Repository", () => {
+ it("returns string content from S3", async () => {
+ const send = jest.fn().mockResolvedValue({
+ Body: { transformToString: jest.fn().mockResolvedValue("content") },
+ });
+ const repository = new S3Repository("bucket", {
+ send,
+ } as unknown as S3Client);
+
+ const result = await repository.getObject("key.json");
+
+ expect(result).toBe("content");
+ expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
+ });
+
+ it("throws when body is missing", async () => {
+ const send = jest.fn().mockResolvedValue({});
+ const repository = new S3Repository("bucket", {
+ send,
+ } as unknown as S3Client);
+
+ await expect(repository.getObject("key.json")).rejects.toThrow(
+ "Response body is missing",
+ );
+ });
+
+ it("returns undefined when object is missing", async () => {
+ const send = jest
+ .fn()
+ .mockRejectedValue(
+ new NoSuchKey({ message: "Not found", $metadata: {} }),
+ );
+ const repository = new S3Repository("bucket", {
+ send,
+ } as unknown as S3Client);
+
+ await expect(repository.getObject("key.json")).resolves.toBeUndefined();
+ });
+
+ it("rethrows non-NoSuchKey errors", async () => {
+ const send = jest.fn().mockRejectedValue(new Error("Denied"));
+ const repository = new S3Repository("bucket", {
+ send,
+ } as unknown as S3Client);
+
+ await expect(repository.getObject("key.json")).rejects.toThrow("Denied");
+ });
+
+ it("writes object to S3", async () => {
+ const send = jest.fn().mockResolvedValue({});
+ const repository = new S3Repository("bucket", {
+ send,
+ } as unknown as S3Client);
+
+ await repository.putRawData("payload", "key.json");
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand);
+ });
+});
diff --git a/tools/client-subscriptions-management/src/container.ts b/tools/client-subscriptions-management/src/container.ts
new file mode 100644
index 0000000..93488be
--- /dev/null
+++ b/tools/client-subscriptions-management/src/container.ts
@@ -0,0 +1,32 @@
+import { S3Client } from "@aws-sdk/client-s3";
+import { ClientSubscriptionRepository } from "src/repository/client-subscriptions";
+import { S3Repository } from "src/repository/s3";
+import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder";
+
+type RepositoryOptions = {
+ bucketName: string;
+ region?: string;
+ eventSource?: string;
+};
+
+export const createS3Client = (
+ region?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): S3Client => {
+ const endpoint = env.AWS_ENDPOINT_URL;
+ const forcePathStyle = endpoint?.includes("localhost") ? true : undefined;
+ return new S3Client({ region, endpoint, forcePathStyle });
+};
+
+export const createClientSubscriptionRepository = (
+ options: RepositoryOptions,
+): ClientSubscriptionRepository => {
+ const s3Repository = new S3Repository(
+ options.bucketName,
+ createS3Client(options.region),
+ );
+ const configurationBuilder = new ClientSubscriptionConfigurationBuilder(
+ options.eventSource,
+ );
+ return new ClientSubscriptionRepository(s3Repository, configurationBuilder);
+};
diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts
new file mode 100644
index 0000000..2d2c0b1
--- /dev/null
+++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts
@@ -0,0 +1,131 @@
+import { normalizeClientName } from "src/entrypoint/cli/helper";
+import type {
+ ChannelStatusSubscriptionArgs,
+ MessageStatusSubscriptionArgs,
+} from "src/repository/client-subscriptions";
+import type {
+ ChannelStatusSubscriptionConfiguration,
+ MessageStatusSubscriptionConfiguration,
+} from "@nhs-notify-client-callbacks/models";
+
+const DEFAULT_EVENT_SOURCE = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE;
+
+// eslint-disable-next-line import-x/prefer-default-export
+export class ClientSubscriptionConfigurationBuilder {
+ constructor(
+ private readonly eventSource: string | undefined = DEFAULT_EVENT_SOURCE,
+ ) {}
+
+ private resolveEventSource(override?: string): string {
+ const source = override ?? this.eventSource;
+ if (!source) {
+ throw new Error(
+ "Event source is required. Set the CLIENT_SUBSCRIPTION_EVENT_SOURCE environment variable or pass it as an argument.",
+ );
+ }
+ return source;
+ }
+
+ messageStatus(
+ args: MessageStatusSubscriptionArgs,
+ ): MessageStatusSubscriptionConfiguration {
+ const {
+ apiEndpoint,
+ apiKey,
+ apiKeyHeaderName = "x-api-key",
+ clientId,
+ clientName,
+ eventSource,
+ rateLimit,
+ statuses,
+ } = args;
+ const normalizedClientName = normalizeClientName(clientName);
+ return {
+ Name: normalizedClientName,
+ SubscriptionType: "MessageStatus",
+ ClientId: clientId,
+ Statuses: statuses,
+ Description: `Message Status Subscription for ${clientName}`,
+ EventSource: JSON.stringify([this.resolveEventSource(eventSource)]),
+ EventDetail: JSON.stringify({
+ clientId: [clientId],
+ type: ["MessageStatus"],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: normalizedClientName,
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: apiEndpoint,
+ InvocationMethod: "POST",
+ InvocationRateLimit: rateLimit,
+ APIKey: {
+ HeaderName: apiKeyHeaderName,
+ HeaderValue: apiKey,
+ },
+ },
+ ],
+ };
+ }
+
+ channelStatus(
+ args: ChannelStatusSubscriptionArgs,
+ ): ChannelStatusSubscriptionConfiguration {
+ const {
+ apiEndpoint,
+ apiKey,
+ apiKeyHeaderName = "x-api-key",
+ channelStatuses,
+ channelType,
+ clientId,
+ clientName,
+ eventSource,
+ rateLimit,
+ supplierStatuses,
+ } = args;
+ const normalizedClientName = normalizeClientName(clientName);
+ return {
+ Name: `${normalizedClientName}-${channelType}`,
+ SubscriptionType: "ChannelStatus",
+ ClientId: clientId,
+ ChannelType: channelType,
+ ChannelStatuses: channelStatuses ?? [],
+ SupplierStatuses: supplierStatuses ?? [],
+ Description: `Channel Status Subscription for ${clientName} - ${channelType}`,
+ EventSource: JSON.stringify([this.resolveEventSource(eventSource)]),
+ EventDetail: JSON.stringify({
+ clientId: [clientId],
+ type: ["ChannelStatus"],
+ channel: [channelType],
+ }),
+ Targets: [
+ {
+ Type: "API",
+ TargetId: "SendToWebhook",
+ Name: `${normalizedClientName}-${channelType}`,
+ InputTransformer: {
+ InputPaths: "$.detail.event",
+ InputHeaders: {
+ "x-hmac-sha256-signature":
+ "$.detail.headers.x-hmac-sha256-signature",
+ },
+ },
+ InvocationEndpoint: apiEndpoint,
+ InvocationMethod: "POST",
+ InvocationRateLimit: rateLimit,
+ APIKey: {
+ HeaderName: apiKeyHeaderName,
+ HeaderValue: apiKey,
+ },
+ },
+ ],
+ };
+ }
+}
diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts
new file mode 100644
index 0000000..5ec837a
--- /dev/null
+++ b/tools/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts
@@ -0,0 +1,67 @@
+import yargs from "yargs/yargs";
+import { hideBin } from "yargs/helpers";
+import { createClientSubscriptionRepository } from "src/container";
+import {
+ formatSubscriptionFileResponse,
+ resolveBucketName,
+ resolveRegion,
+} from "src/entrypoint/cli/helper";
+
+export const parseArgs = (args: string[]) =>
+ yargs(hideBin(args))
+ .options({
+ "bucket-name": {
+ type: "string",
+ demandOption: false,
+ },
+ "client-id": {
+ type: "string",
+ demandOption: true,
+ },
+ region: {
+ type: "string",
+ demandOption: false,
+ },
+ })
+ .parseSync();
+
+export async function main(args: string[] = process.argv) {
+ const argv = parseArgs(args);
+ const bucketName = resolveBucketName(argv["bucket-name"]);
+ const clientSubscriptionRepository = createClientSubscriptionRepository({
+ bucketName,
+ region: resolveRegion(argv.region),
+ });
+
+ const result = await clientSubscriptionRepository.getClientSubscriptions(
+ argv["client-id"],
+ );
+
+ if (result) {
+ console.log(formatSubscriptionFileResponse(result));
+ } else {
+ console.log(`No configuration exists for client: ${argv["client-id"]}`);
+ }
+}
+
+export const runCli = async (args: string[] = process.argv) => {
+ try {
+ await main(args);
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+};
+
+export const runIfMain = async (
+ args: string[] = process.argv,
+ isMain: boolean = require.main === module,
+) => {
+ if (isMain) {
+ await runCli(args);
+ }
+};
+
+(async () => {
+ await runIfMain();
+})();
diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts
new file mode 100644
index 0000000..e3cdf8e
--- /dev/null
+++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts
@@ -0,0 +1,58 @@
+import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
+
+export const formatSubscriptionFileResponse = (
+ subscriptions: ClientSubscriptionConfiguration,
+) =>
+ subscriptions.map((subscription) => ({
+ clientId: subscription.ClientId,
+ subscriptionType: subscription.SubscriptionType,
+ ...(subscription.SubscriptionType === "ChannelStatus"
+ ? {
+ channelType: subscription.ChannelType,
+ channelStatuses: subscription.ChannelStatuses,
+ supplierStatuses: subscription.SupplierStatuses,
+ }
+ : {}),
+ ...(subscription.SubscriptionType === "MessageStatus"
+ ? {
+ statuses: subscription.Statuses,
+ }
+ : {}),
+ clientApiEndpoint: subscription.Targets[0].InvocationEndpoint,
+ clientApiKey: subscription.Targets[0].APIKey.HeaderValue,
+ rateLimit: subscription.Targets[0].InvocationRateLimit,
+ }));
+
+export const normalizeClientName = (name: string): string =>
+ name.replaceAll(/\s+/g, "-").toLowerCase();
+
+export const resolveBucketName = (
+ bucketArg?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): string => {
+ const bucketName = bucketArg ?? env.CLIENT_SUBSCRIPTION_BUCKET_NAME;
+ if (!bucketName) {
+ throw new Error(
+ "Bucket name is required (use --bucket-name or CLIENT_SUBSCRIPTION_BUCKET_NAME)",
+ );
+ }
+ return bucketName;
+};
+
+export const resolveEventSource = (
+ eventSourceArg?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): string => {
+ const eventSource = eventSourceArg ?? env.CLIENT_SUBSCRIPTION_EVENT_SOURCE;
+ if (!eventSource) {
+ throw new Error(
+ "Event source is required (use --event-source or CLIENT_SUBSCRIPTION_EVENT_SOURCE)",
+ );
+ }
+ return eventSource;
+};
+
+export const resolveRegion = (
+ regionArg?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION;
diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts
new file mode 100644
index 0000000..d4472e7
--- /dev/null
+++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts
@@ -0,0 +1,145 @@
+import yargs from "yargs/yargs";
+import { hideBin } from "yargs/helpers";
+import {
+ CHANNEL_STATUSES,
+ CHANNEL_TYPES,
+ SUPPLIER_STATUSES,
+} from "@nhs-notify-client-callbacks/models";
+import { createClientSubscriptionRepository } from "src/container";
+import {
+ formatSubscriptionFileResponse,
+ resolveBucketName,
+ resolveEventSource,
+ resolveRegion,
+} from "src/entrypoint/cli/helper";
+
+export const parseArgs = (args: string[]) =>
+ yargs(hideBin(args))
+ .options({
+ "bucket-name": {
+ type: "string",
+ demandOption: false,
+ },
+ "client-name": {
+ type: "string",
+ demandOption: false,
+ },
+ "client-id": {
+ type: "string",
+ demandOption: true,
+ },
+ "api-endpoint": {
+ type: "string",
+ demandOption: true,
+ },
+ "api-key-header-name": {
+ type: "string",
+ default: "x-api-key",
+ demandOption: false,
+ },
+ "api-key": {
+ type: "string",
+ demandOption: true,
+ },
+ "channel-statuses": {
+ string: true,
+ type: "array",
+ demandOption: false,
+ choices: CHANNEL_STATUSES,
+ },
+ "supplier-statuses": {
+ string: true,
+ type: "array",
+ demandOption: false,
+ choices: SUPPLIER_STATUSES,
+ },
+ "channel-type": {
+ type: "string",
+ demandOption: true,
+ choices: CHANNEL_TYPES,
+ },
+ "rate-limit": {
+ type: "number",
+ demandOption: true,
+ },
+ "dry-run": {
+ type: "boolean",
+ demandOption: true,
+ },
+ region: {
+ type: "string",
+ demandOption: false,
+ },
+ "event-source": {
+ type: "string",
+ demandOption: false,
+ },
+ })
+ .parseSync();
+
+export async function main(args: string[] = process.argv) {
+ const argv = parseArgs(args);
+ const apiEndpoint = argv["api-endpoint"];
+ if (!/^https:\/\//.test(apiEndpoint)) {
+ console.error("Error: api-endpoint must start with https://");
+ process.exitCode = 1;
+ return;
+ }
+
+ const channelStatuses = argv["channel-statuses"];
+ const supplierStatuses = argv["supplier-statuses"];
+ if (!channelStatuses?.length && !supplierStatuses?.length) {
+ console.error(
+ "Error: at least one of --channel-statuses or --supplier-statuses must be provided",
+ );
+ process.exitCode = 1;
+ return;
+ }
+
+ const bucketName = resolveBucketName(argv["bucket-name"]);
+ const eventSource = resolveEventSource(argv["event-source"]);
+ const clientSubscriptionRepository = createClientSubscriptionRepository({
+ bucketName,
+ region: resolveRegion(argv.region),
+ eventSource,
+ });
+
+ const result =
+ await clientSubscriptionRepository.putChannelStatusSubscription({
+ clientName: argv["client-name"] ?? argv["client-id"],
+ clientId: argv["client-id"],
+ apiEndpoint,
+ apiKeyHeaderName: argv["api-key-header-name"],
+ apiKey: argv["api-key"],
+ channelType: argv["channel-type"],
+ channelStatuses,
+ supplierStatuses,
+ rateLimit: argv["rate-limit"],
+ dryRun: argv["dry-run"],
+ eventSource,
+ });
+
+ console.log(formatSubscriptionFileResponse(result));
+}
+
+export const runCli = async (args: string[] = process.argv) => {
+ try {
+ await main(args);
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+};
+
+export const runIfMain = async (
+ args: string[] = process.argv,
+ isMain: boolean = require.main === module,
+) => {
+ if (isMain) {
+ await runCli(args);
+ }
+};
+
+(async () => {
+ await runIfMain();
+})();
diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts
new file mode 100644
index 0000000..5e12aa1
--- /dev/null
+++ b/tools/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts
@@ -0,0 +1,118 @@
+import yargs from "yargs/yargs";
+import { hideBin } from "yargs/helpers";
+import { MESSAGE_STATUSES } from "@nhs-notify-client-callbacks/models";
+import { createClientSubscriptionRepository } from "src/container";
+import {
+ formatSubscriptionFileResponse,
+ resolveBucketName,
+ resolveEventSource,
+ resolveRegion,
+} from "src/entrypoint/cli/helper";
+
+export const parseArgs = (args: string[]) =>
+ yargs(hideBin(args))
+ .options({
+ "bucket-name": {
+ type: "string",
+ demandOption: false,
+ },
+ "client-name": {
+ type: "string",
+ demandOption: false,
+ },
+ "client-id": {
+ type: "string",
+ demandOption: true,
+ },
+ "api-endpoint": {
+ type: "string",
+ demandOption: true,
+ },
+ "api-key": {
+ type: "string",
+ demandOption: true,
+ },
+ "api-key-header-name": {
+ type: "string",
+ default: "x-api-key",
+ demandOption: false,
+ },
+ "message-statuses": {
+ string: true,
+ type: "array",
+ demandOption: true,
+ choices: MESSAGE_STATUSES,
+ },
+ "rate-limit": {
+ type: "number",
+ demandOption: true,
+ },
+ "dry-run": {
+ type: "boolean",
+ demandOption: true,
+ },
+ region: {
+ type: "string",
+ demandOption: false,
+ },
+ "event-source": {
+ type: "string",
+ demandOption: false,
+ },
+ })
+ .parseSync();
+
+export async function main(args: string[] = process.argv) {
+ const argv = parseArgs(args);
+ const apiEndpoint = argv["api-endpoint"];
+ if (!/^https:\/\//.test(apiEndpoint)) {
+ console.error("Error: api-endpoint must start with https://");
+ process.exitCode = 1;
+ return;
+ }
+
+ const bucketName = resolveBucketName(argv["bucket-name"]);
+ const eventSource = resolveEventSource(argv["event-source"]);
+ const clientSubscriptionRepository = createClientSubscriptionRepository({
+ bucketName,
+ region: resolveRegion(argv.region),
+ eventSource,
+ });
+
+ const result =
+ await clientSubscriptionRepository.putMessageStatusSubscription({
+ clientName: argv["client-name"] ?? argv["client-id"],
+ clientId: argv["client-id"],
+ apiEndpoint,
+ apiKeyHeaderName: argv["api-key-header-name"],
+ apiKey: argv["api-key"],
+ statuses: argv["message-statuses"],
+ rateLimit: argv["rate-limit"],
+ dryRun: argv["dry-run"],
+ eventSource,
+ });
+
+ console.log(formatSubscriptionFileResponse(result));
+}
+
+export const runCli = async (args: string[] = process.argv) => {
+ try {
+ await main(args);
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+};
+
+export const runIfMain = async (
+ args: string[] = process.argv,
+ isMain: boolean = require.main === module,
+) => {
+ if (isMain) {
+ await runCli(args);
+ }
+};
+
+(async () => {
+ await runIfMain();
+})();
diff --git a/tools/client-subscriptions-management/src/index.ts b/tools/client-subscriptions-management/src/index.ts
new file mode 100644
index 0000000..bec05b8
--- /dev/null
+++ b/tools/client-subscriptions-management/src/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import-x/prefer-default-export
+export { createClientSubscriptionRepository } from "src/container";
diff --git a/tools/client-subscriptions-management/src/repository/client-subscriptions.ts b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts
new file mode 100644
index 0000000..2132bf0
--- /dev/null
+++ b/tools/client-subscriptions-management/src/repository/client-subscriptions.ts
@@ -0,0 +1,169 @@
+import { z } from "zod";
+import {
+ CHANNEL_STATUSES,
+ CHANNEL_TYPES,
+ type Channel,
+ type ChannelStatus,
+ type ClientSubscriptionConfiguration,
+ MESSAGE_STATUSES,
+ type MessageStatus,
+ SUPPLIER_STATUSES,
+ type SupplierStatus,
+} from "@nhs-notify-client-callbacks/models";
+import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder";
+import { S3Repository } from "src/repository/s3";
+
+export type MessageStatusSubscriptionArgs = {
+ clientName: string;
+ clientId: string;
+ apiKey: string;
+ apiEndpoint: string;
+ statuses: MessageStatus[];
+ rateLimit: number;
+ dryRun: boolean;
+ apiKeyHeaderName?: string;
+ eventSource?: string;
+};
+
+const messageStatusSubscriptionArgsSchema = z.object({
+ clientName: z.string(),
+ clientId: z.string(),
+ apiKey: z.string(),
+ apiEndpoint: z.string(),
+ statuses: z.array(z.enum(MESSAGE_STATUSES)),
+ rateLimit: z.number(),
+ dryRun: z.boolean(),
+ apiKeyHeaderName: z.string().optional().default("x-api-key"),
+ eventSource: z.string().optional(),
+});
+
+export type ChannelStatusSubscriptionArgs = {
+ clientName: string;
+ clientId: string;
+ apiKey: string;
+ apiEndpoint: string;
+ channelStatuses?: ChannelStatus[];
+ supplierStatuses?: SupplierStatus[];
+ channelType: Channel;
+ rateLimit: number;
+ dryRun: boolean;
+ apiKeyHeaderName?: string;
+ eventSource?: string;
+};
+
+const channelStatusSubscriptionArgsSchema = z.object({
+ clientName: z.string(),
+ clientId: z.string(),
+ apiKey: z.string(),
+ apiEndpoint: z.string(),
+ channelStatuses: z.array(z.enum(CHANNEL_STATUSES)).min(1).optional(),
+ supplierStatuses: z.array(z.enum(SUPPLIER_STATUSES)).min(1).optional(),
+ channelType: z.enum(CHANNEL_TYPES),
+ rateLimit: z.number(),
+ dryRun: z.boolean(),
+ apiKeyHeaderName: z.string().optional().default("x-api-key"),
+ eventSource: z.string().optional(),
+});
+
+export class ClientSubscriptionRepository {
+ constructor(
+ private readonly s3Repository: S3Repository,
+ private readonly configurationBuilder: ClientSubscriptionConfigurationBuilder,
+ ) {}
+
+ async getClientSubscriptions(
+ clientId: string,
+ ): Promise {
+ const rawFile = await this.s3Repository.getObject(
+ `client_subscriptions/${clientId}.json`,
+ );
+
+ if (rawFile !== undefined) {
+ return JSON.parse(rawFile) as unknown as ClientSubscriptionConfiguration;
+ }
+ return undefined;
+ }
+
+ async putMessageStatusSubscription(
+ subscriptionArgs: MessageStatusSubscriptionArgs,
+ ) {
+ const parsedSubscriptionArgs =
+ messageStatusSubscriptionArgsSchema.parse(subscriptionArgs);
+
+ const { clientId } = parsedSubscriptionArgs;
+ const subscriptions = (await this.getClientSubscriptions(clientId)) ?? [];
+
+ const indexOfMessageStatusSubscription = subscriptions.findIndex(
+ (subscription) => subscription.SubscriptionType === "MessageStatus",
+ );
+
+ if (indexOfMessageStatusSubscription !== -1) {
+ subscriptions.splice(indexOfMessageStatusSubscription, 1);
+ }
+
+ const messageStatusConfig = this.configurationBuilder.messageStatus(
+ parsedSubscriptionArgs,
+ );
+
+ const newConfigFile: ClientSubscriptionConfiguration = [
+ ...subscriptions,
+ messageStatusConfig,
+ ];
+
+ if (!parsedSubscriptionArgs.dryRun) {
+ await this.s3Repository.putRawData(
+ JSON.stringify(newConfigFile),
+ `client_subscriptions/${clientId}.json`,
+ );
+ }
+
+ return newConfigFile;
+ }
+
+ async putChannelStatusSubscription(
+ subscriptionArgs: ChannelStatusSubscriptionArgs,
+ ): Promise {
+ const parsedSubscriptionArgs =
+ channelStatusSubscriptionArgsSchema.parse(subscriptionArgs);
+
+ if (
+ !parsedSubscriptionArgs.channelStatuses?.length &&
+ !parsedSubscriptionArgs.supplierStatuses?.length
+ ) {
+ throw new Error(
+ "Validation failed: at least one of channelStatuses or supplierStatuses must be provided",
+ );
+ }
+
+ const { clientId } = parsedSubscriptionArgs;
+ const subscriptions = (await this.getClientSubscriptions(clientId)) ?? [];
+
+ const indexOfChannelStatusSubscription = subscriptions.findIndex(
+ (subscription) =>
+ subscription.SubscriptionType === "ChannelStatus" &&
+ subscription.ChannelType === parsedSubscriptionArgs.channelType,
+ );
+
+ if (indexOfChannelStatusSubscription !== -1) {
+ subscriptions.splice(indexOfChannelStatusSubscription, 1);
+ }
+
+ const channelStatusConfig = this.configurationBuilder.channelStatus(
+ parsedSubscriptionArgs,
+ );
+
+ const newConfigFile: ClientSubscriptionConfiguration = [
+ ...subscriptions,
+ channelStatusConfig,
+ ];
+
+ if (!parsedSubscriptionArgs.dryRun) {
+ await this.s3Repository.putRawData(
+ JSON.stringify(newConfigFile),
+ `client_subscriptions/${clientId}.json`,
+ );
+ }
+
+ return newConfigFile;
+ }
+}
diff --git a/tools/client-subscriptions-management/src/repository/s3.ts b/tools/client-subscriptions-management/src/repository/s3.ts
new file mode 100644
index 0000000..a306298
--- /dev/null
+++ b/tools/client-subscriptions-management/src/repository/s3.ts
@@ -0,0 +1,49 @@
+import {
+ GetObjectCommand,
+ NoSuchKey,
+ PutObjectCommand,
+ PutObjectCommandInput,
+ S3Client,
+} from "@aws-sdk/client-s3";
+
+// eslint-disable-next-line import-x/prefer-default-export
+export class S3Repository {
+ constructor(
+ private readonly bucketName: string,
+ private readonly s3Client: S3Client,
+ ) {}
+
+ async getObject(key: string): Promise {
+ const params = {
+ Bucket: this.bucketName,
+ Key: key,
+ };
+ try {
+ const { Body } = await this.s3Client.send(new GetObjectCommand(params));
+
+ if (!Body) {
+ throw new Error("Response body is missing");
+ }
+
+ return await Body.transformToString();
+ } catch (error) {
+ if (error instanceof NoSuchKey) {
+ return undefined;
+ }
+ throw error;
+ }
+ }
+
+ async putRawData(
+ fileData: PutObjectCommandInput["Body"],
+ key: string,
+ ): Promise {
+ const params = {
+ Bucket: this.bucketName,
+ Key: key,
+ Body: fileData,
+ };
+
+ await this.s3Client.send(new PutObjectCommand(params));
+ }
+}
diff --git a/tools/client-subscriptions-management/tsconfig.json b/tools/client-subscriptions-management/tsconfig.json
new file mode 100644
index 0000000..5b308bc
--- /dev/null
+++ b/tools/client-subscriptions-management/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "outDir": "dist",
+ "paths": {
+ "src/*": [
+ "src/*"
+ ]
+ },
+ "rootDir": "."
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..42cdff9
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "extends": "./tsconfig.base.json",
+ "include": [
+ "lambdas/*/src/**/*",
+ "scripts/*/src/**/*",
+ "tools/*/src/**/*",
+ "src/**/*",
+ "tests/**/*"
+ ]
+}