diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf index ebbb3b0..59e4e06 100644 --- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf @@ -35,6 +35,9 @@ module "client_transform_filter_lambda" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { + CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.id + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" } } diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json index f288265..6dcfe5e 100644 --- a/lambdas/client-transform-filter-lambda/package.json +++ b/lambdas/client-transform-filter-lambda/package.json @@ -1,6 +1,8 @@ { "dependencies": { - "esbuild": "^0.25.0" + "@aws-sdk/client-s3": "^3.821.0", + "pino": "^9.6.0", + "zod": "^4.1.13" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", @@ -8,7 +10,8 @@ "@types/jest": "^29.5.14", "jest": "^29.7.0", "jest-mock-extended": "^3.0.7", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "esbuild": "^0.25.0" }, "name": "nhs-notify-client-transform-filter-lambda", "private": true, 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..3e36e3e --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-invalid.test.ts @@ -0,0 +1,11 @@ +import { resolveCacheTtlMs } from ".."; + +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..13aa374 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.cache-ttl-valid.test.ts @@ -0,0 +1,11 @@ +import { resolveCacheTtlMs } from ".."; + +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..aefb26a --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.config-prefix.test.ts @@ -0,0 +1,94 @@ +/* eslint-disable import-x/first */ +// 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, +})); + +import { EventTypes } from "models/status-transition-event"; +import { handler } from ".."; + +describe("config prefix resolution", () => { + beforeEach(() => { + mockLoadClientConfig.mockClear(); + mockConfigLoader.mockClear(); + }); + + 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; + + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + 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", + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + await handler(event); + + 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; + } + }); +}); 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..6f8b95d --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.integration.test.ts @@ -0,0 +1,316 @@ +/** + * Integration-style test for the complete handler flow including S3 config loading + */ + +import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import { EventTypes } from "models/status-transition-event"; + +// 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, + })), + }; +}); + +// eslint-disable-next-line import-x/first -- Must import after jest.mock setup for proper mocking +import { handler } from ".."; + +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", "FAILED"], + }, +]; + +describe("Lambda handler with S3 integration", () => { + 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"; + }); + + beforeEach(() => { + mockSend.mockClear(); + }); + + it("loads config from S3 and evaluates filter for matching event", async () => { + mockSend.mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + 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", + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + + expect(result).toEqual({ + body: { + matched: true, + subscriptionType: "MessageStatus", + }, + }); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("returns matched false when event does not match subscription", async () => { + mockSend.mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: "CREATED", // Not in subscription + 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", + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + + expect(result).toEqual({ + body: { + matched: false, + subscriptionType: "MessageStatus", + }, + }); + }); + + it("returns matched false when client has no configuration", async () => { + mockSend.mockRejectedValue( + new NoSuchKey({ message: "Not found", $metadata: {} }), + ); + + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + 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: "non-existent-client", + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + + expect(result).toEqual({ + body: { + matched: false, + subscriptionType: "Unknown", + }, + }); + }); + + it("parses event from JSON string", async () => { + mockSend.mockResolvedValue({ + Body: Readable.from([JSON.stringify(createValidConfig("client-1"))]), + }); + + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + 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", + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(JSON.stringify(event)); + + expect(result).toEqual({ + body: { + matched: true, + subscriptionType: "MessageStatus", + }, + }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.notifydata.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.notifydata.test.ts new file mode 100644 index 0000000..6f68355 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.notifydata.test.ts @@ -0,0 +1,25 @@ +/* eslint-disable import-x/first */ +const mockParseStatusTransitionEvent = jest.fn(); + +jest.mock("services/validators/status-transition-event-validator", () => ({ + parseStatusTransitionEvent: (event: unknown) => + mockParseStatusTransitionEvent(event), +})); + +import { handler } from ".."; + +describe("Lambda handler notify-data validation", () => { + it("returns empty body when clientId is missing", async () => { + mockParseStatusTransitionEvent.mockReturnValue({ + data: { + "notify-payload": { + "notify-data": {}, + }, + }, + }); + + const result = await handler({ any: "event" }); + + expect(result).toEqual({ body: {} }); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts new file mode 100644 index 0000000..cd4b44b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.reset-loader.test.ts @@ -0,0 +1,65 @@ +import { createS3Client, resetConfigLoader } from ".."; + +describe("resetConfigLoader", () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + beforeEach(() => { + // Ensure bucket is set for tests that need it + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; + }); + + afterEach(() => { + // Clean up after each test + resetConfigLoader(); + + // Restore original env + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + }); + + it("resets the cached loader to undefined when called with no arguments", () => { + // Setup: Create a loader by calling handler (indirectly caches it) + resetConfigLoader(); + + // The loader should be reset (we can't directly test this without exposing internal state, + // but we can test that calling it again with a custom client works) + expect(() => resetConfigLoader()).not.toThrow(); + }); + + it("creates a new loader with custom S3Client when provided", () => { + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + + // Should not throw and should create the loader + resetConfigLoader(customClient); + + // Calling resetConfigLoader again with undefined should clear it + expect(() => resetConfigLoader()).not.toThrow(); + }); + + it("creates a new loader with custom keyPrefix when environment variable is set", () => { + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/"; + const customClient = createS3Client({ + AWS_ENDPOINT_URL: "http://localhost:4566", + }); + + // Should not throw and should create the loader + expect(() => resetConfigLoader(customClient)).not.toThrow(); + + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX; + }); + + it("throws error when S3Client provided but bucket name is missing", () => { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + const customClient = createS3Client(); + + expect(() => resetConfigLoader(customClient)).toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + }); +}); 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..2c0d207 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.s3-config.test.ts @@ -0,0 +1,32 @@ +import { createS3Client } from ".."; + +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 b00cc1c..cd3b343 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -1,7 +1,8 @@ +import { EventTypes } from "models/status-transition-event"; import { handler } from ".."; describe("Lambda handler", () => { - it("extracts from a stringified event", async () => { + it("returns empty body for invalid event structure", async () => { const eventStr = JSON.stringify({ body: { dataschemaversion: "1.0", @@ -10,15 +11,10 @@ describe("Lambda handler", () => { }); const result = await handler(eventStr); - expect(result).toEqual({ - body: { - dataschemaversion: "1.0", - type: "uk.nhs.notify.client-callbacks.test-sid", - }, - }); + expect(result).toEqual({ body: {} }); }); - it("extracts from an array with nested body", async () => { + it("returns empty body for array events without valid structure", async () => { const eventArray = [ { messageId: "123", @@ -32,12 +28,7 @@ describe("Lambda handler", () => { ]; const result = await handler(eventArray); - expect(result).toEqual({ - body: { - dataschemaversion: "1.0", - type: "uk.nhs.notify.client-callbacks.test-sid", - }, - }); + expect(result).toEqual({ body: {} }); }); it("returns empty body if fields are missing", async () => { @@ -46,7 +37,7 @@ describe("Lambda handler", () => { expect(result).toEqual({ body: {} }); }); - it("handles deeply nested fields", async () => { + it("returns empty body for deeply nested invalid structure", async () => { const event = { level1: { level2: { @@ -61,12 +52,7 @@ describe("Lambda handler", () => { }; const result = await handler(event); - expect(result).toEqual({ - body: { - dataschemaversion: "2.0", - type: "nested-type", - }, - }); + expect(result).toEqual({ body: {} }); }); it("handles invalid JSON gracefully", async () => { @@ -74,4 +60,252 @@ describe("Lambda handler", () => { const result = await handler(eventStr); expect(result).toEqual({ body: {} }); }); + + it("returns empty body when notify-data is missing clientId", async () => { + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + // clientId is missing + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + expect(result).toEqual({ body: {} }); + }); + + it("returns empty body when notify-data clientId is not a string", async () => { + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + clientId: 12_345, // clientId is a number, not a string + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + expect(result).toEqual({ body: {} }); + }); + + it("returns empty body when notify-data is not a record", async () => { + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": "not-a-record", // notify-data is a string, not a record + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + expect(result).toEqual({ body: {} }); + }); + + it("returns empty body when notify-payload is null", async () => { + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": null, // notify-payload is null + }, + }; + + const result = await handler(event); + expect(result).toEqual({ body: {} }); + }); + + it("throws when config bucket is missing", async () => { + const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + 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", + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + await expect(handler(event)).rejects.toThrow( + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", + ); + + if (originalBucket === undefined) { + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + } else { + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = originalBucket; + } + }); + + it("extracts correlation ID from valid traceparent format", async () => { + const event = { + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "00-12345678901234567890123456789012-1234567890123456-01", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + // clientId is missing to trigger empty body response + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, + }; + + const result = await handler(event); + expect(result).toEqual({ body: {} }); + // The test coverage goal is to execute the line that extracts the trace-id from traceparent + }); }); 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..44a7c9b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -0,0 +1,85 @@ +import { ConfigCache } from "services/config-cache"; + +describe("ConfigCache", () => { + it("stores and retrieves configuration", () => { + const cache = new ConfigCache(60_000); + const config = [ + { + 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 = [ + { + 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 = [ + { + 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.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts new file mode 100644 index 0000000..6d28928 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -0,0 +1,169 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; +import { ConfigValidationError } from "services/validators/config-validator"; + +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: Readable.from([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: Readable.from([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: Readable.from([ + 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( + "S3 response body was empty", + ); + }); + + it("handles string response body from S3", async () => { + const send = jest.fn().mockResolvedValue({ + Body: JSON.stringify(createValidConfig("client-1")), + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + }); + + it("handles Uint8Array response body from S3", async () => { + const configString = JSON.stringify(createValidConfig("client-1")); + const uint8Array = new TextEncoder().encode(configString); + const send = jest.fn().mockResolvedValue({ + Body: uint8Array, + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + }); + + it("handles readable stream with Buffer chunks", async () => { + const configString = JSON.stringify(createValidConfig("client-1")); + const send = jest.fn().mockResolvedValue({ + Body: Readable.from([Buffer.from(configString)]), + }); + const loader = createLoader(send); + + const result = await loader.loadClientConfig("client-1"); + + expect(result).toEqual(createValidConfig("client-1")); + }); + + it("throws when response body is not readable", async () => { + const send = jest.fn().mockResolvedValue({ + Body: 12_345, + }); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + "Response body is not readable", + ); + }); + + it("rethrows non-NoSuchKey errors", async () => { + const send = jest.fn().mockRejectedValue(new Error("S3 access denied")); + const loader = createLoader(send); + + await expect(loader.loadClientConfig("client-1")).rejects.toThrow( + "S3 access denied", + ); + }); +}); 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..7934b60 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.integration.test.ts @@ -0,0 +1,115 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +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: Readable.from([ + 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: Readable.from([ + 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/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..d19dadb --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/filters/event-pattern.test.ts @@ -0,0 +1,100 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +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/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts new file mode 100644 index 0000000..02ebaa6 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -0,0 +1,678 @@ +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { EventTypes } from "models/status-transition-event"; +import type { ChannelStatusData } from "models/channel-status-data"; +import type { MessageStatusData } from "models/message-status-data"; +import type { ClientSubscriptionConfiguration } from "models/client-config"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; + +const createBaseEvent = ( + type: string, + source: string, + notifyData: T, +): StatusTransitionEvent => ({ + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source, + subject: "subject", + type, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": notifyData, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, +}); + +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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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_TRANSITIONED, + "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 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_TRANSITIONED, + "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", + supplierStatus: "READ", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_TRANSITIONED, + "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", + supplierStatus: "REJECTED", + cascadeType: "primary", + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId: "client-1", + }; + + const event = createBaseEvent( + EventTypes.CHANNEL_STATUS_TRANSITIONED, + "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); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts new file mode 100644 index 0000000..c67f365 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/transform-pipeline.test.ts @@ -0,0 +1,297 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { EventTypes } from "models/status-transition-event"; +import type { MessageStatusData } from "models/message-status-data"; +import type { ChannelStatusData } from "models/channel-status-data"; +import type { Channel } from "models/channel-types"; +import type { + ChannelStatus, + MessageStatus, + SupplierStatus, +} from "models/status-types"; +import { evaluateSubscriptionFilters } from "services/transform-pipeline"; + +const createMessageStatusEvent = ( + clientId: string, + status: MessageStatus, +): StatusTransitionEvent => ({ + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.MESSAGE_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-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, + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, +}); + +const createChannelStatusEvent = ( + clientId: string, + channel: Channel, + channelStatus: ChannelStatus, + supplierStatus: SupplierStatus, +): StatusTransitionEvent => ({ + profileversion: "1.0", + profilepublished: "2025-01-01", + specversion: "1.0", + id: "event-id", + source: "source-a", + subject: "subject", + type: EventTypes.CHANNEL_STATUS_TRANSITIONED, + time: "2025-01-01T10:00:00Z", + recordedtime: "2025-01-01T10:00:00Z", + datacontenttype: "application/json", + dataschema: "schema", + severitynumber: 1, + severitytext: "INFO", + traceparent: "traceparent", + data: { + "notify-payload": { + "notify-data": { + messageId: "msg-123", + messageReference: "ref-123", + channel, + channelStatus, + supplierStatus, + cascadeType: "primary" as const, + cascadeOrder: 1, + timestamp: "2025-01-01T10:00:00Z", + retryCount: 0, + clientId, + }, + "notify-metadata": { + teamResponsible: "Team 1", + notifyDomain: "Delivering", + microservice: "service", + repositoryUrl: "https://example.com/repo", + accountId: "account", + environment: "development", + instance: "instance", + microserviceInstanceId: "instance-id", + microserviceVersion: "1.0", + }, + }, + }, +}); + +const createMessageStatusConfig = ( + clientId: string, + statuses: string[], +): 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: string, + channelStatuses: string[], + supplierStatuses: string[], +): 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", + ); + 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", + ); + 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("returns not matched with Unknown subscription type", () => { + const event = { + ...createMessageStatusEvent("client-1", "DELIVERED"), + type: "unknown-event-type", + } as StatusTransitionEvent; + const config = createMessageStatusConfig("client-1", ["DELIVERED"]); + + const result = evaluateSubscriptionFilters(event, config); + + expect(result).toEqual({ + matched: false, + subscriptionType: "Unknown", + }); + }); + }); +}); 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..10146f1 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -0,0 +1,138 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; +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/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts index 91bfa94..cfdf72a 100644 --- a/lambdas/client-transform-filter-lambda/src/index.ts +++ b/lambdas/client-transform-filter-lambda/src/index.ts @@ -1,57 +1,160 @@ -export const handler = async (event: any) => { - // eslint-disable-next-line no-console - console.log("RAW EVENT:", JSON.stringify(event, null, 2)); - - let parsedEvent: any; - try { - parsedEvent = typeof event === "string" ? JSON.parse(event) : event; - } catch (error) { - // eslint-disable-next-line no-console - console.error("Could not parse event string:", error); - return { body: {} }; +import { S3Client } from "@aws-sdk/client-s3"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { ConfigCache } from "services/config-cache"; +import { ConfigLoader } from "services/config-loader"; +import { createChildLogger } from "services/logger"; +import { evaluateSubscriptionFilters } from "services/transform-pipeline"; +import { parseStatusTransitionEvent } from "services/validators/status-transition-event-validator"; + +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; +}; + +const configCache = new ConfigCache(resolveCacheTtlMs()); + +let cachedLoader: ConfigLoader | undefined; + +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 }); +}; + +const getConfigLoader = (): ConfigLoader => { + if (cachedLoader) { + return cachedLoader; } - let dataschemaversion: string | undefined; - let type: string | undefined; - - function findFields(obj: any) { - if (!obj || typeof obj !== "object") return; - if (!dataschemaversion && "dataschemaversion" in obj) - dataschemaversion = obj.dataschemaversion; - if (!type && "type" in obj) type = obj.type; - - for (const key of Object.keys(obj)) { - // eslint-disable-next-line security/detect-object-injection - const val = obj[key]; - if (typeof val === "string") { - try { - const nested = JSON.parse(val); - findFields(nested); - } catch { - /* empty */ - } - } else if (typeof val === "object") { - findFields(val); + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + + cachedLoader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? "client_subscriptions/", + s3Client: createS3Client(), + cache: configCache, + }); + + return cachedLoader; +}; + +// Exported for testing - resets the cached loader and allows custom S3Client injection +export const resetConfigLoader = (s3Client?: S3Client): void => { + cachedLoader = undefined; + if (s3Client) { + const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + if (!bucketName) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); + } + cachedLoader = new ConfigLoader({ + bucketName, + keyPrefix: + process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? + "client_subscriptions/", + s3Client, + cache: configCache, + }); + } +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const parseEvent = (event: unknown) => { + let parsedEvent: StatusTransitionEvent | undefined; + + if (typeof event === "string") { + try { + const parsed = JSON.parse(event) as unknown; + if (isRecord(parsed)) { + parsedEvent = parseStatusTransitionEvent(parsed); } + } catch { + parsedEvent = undefined; + } + } else if (isRecord(event)) { + parsedEvent = parseStatusTransitionEvent(event); + } + + return parsedEvent; +}; + +const extractCorrelationId = ( + event: Record, +): string | undefined => { + // Extract from CloudEvents traceparent field + const { traceparent } = event; + if (typeof traceparent === "string") { + // traceparent format: "00---" + const parts = traceparent.split("-"); + if (parts.length >= 2) { + return parts[1]; // Return trace-id as correlation ID } } + return undefined; +}; + +export const handler = async (event: unknown) => { + const correlationId = isRecord(event) + ? extractCorrelationId(event) + : undefined; + const requestLogger = createChildLogger({ correlationId }); - if (Array.isArray(parsedEvent)) { - for (const item of parsedEvent) findFields(item); - } else { - findFields(parsedEvent); + requestLogger.debug({ event }, "Received raw event"); + + const parsedEvent = parseEvent(event); + if (!parsedEvent) { + requestLogger.error("Failed to parse event payload"); + return { body: {} }; } - if (!dataschemaversion || !type) { - // eslint-disable-next-line no-console - console.error("Failed to extract payload from event!"); + const notifyPayload = parsedEvent.data?.["notify-payload"]; + const notifyData = notifyPayload?.["notify-data"]; + if (!isRecord(notifyData) || typeof notifyData.clientId !== "string") { + requestLogger.error("Missing notify-data clientId"); return { body: {} }; } + const clientLogger = createChildLogger({ + correlationId, + clientId: notifyData.clientId, + }); + clientLogger.info( + { eventType: parsedEvent.type }, + "Processing status transition event", + ); + + const config = await getConfigLoader().loadClientConfig(notifyData.clientId); + const filterResult = evaluateSubscriptionFilters(parsedEvent, config); + + clientLogger.info( + { + matched: filterResult.matched, + subscriptionType: filterResult.subscriptionType, + }, + "Filter evaluation completed", + ); + return { body: { - dataschemaversion, - type, + matched: filterResult.matched, + subscriptionType: filterResult.subscriptionType, }, }; }; 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..c08b185 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-cache.ts @@ -0,0 +1,37 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; + +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.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts new file mode 100644 index 0000000..2ec766b --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -0,0 +1,95 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import type { ClientSubscriptionConfiguration } from "models/client-config"; +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; +}; + +const isReadableStream = (value: unknown): value is Readable => + typeof value === "object" && value !== null && "on" in value; + +const streamToString = async (value: unknown): Promise => { + if (typeof value === "string") { + return value; + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("utf8"); + } + + if (isReadableStream(value)) { + const chunks: Buffer[] = []; + for await (const chunk of value) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); + } + + throw new Error("Response body is not readable"); +}; + +export class ConfigLoader { + constructor(private readonly options: ConfigLoaderOptions) {} + + async loadClientConfig( + clientId: string, + ): Promise { + const cached = this.options.cache.get(clientId); + if (cached) { + logger.debug({ clientId, cacheHit: true }, "Config loaded from cache"); + return cached; + } + + logger.debug( + { clientId, cacheHit: false }, + "Config not in cache, fetching from S3", + ); + + 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 streamToString(response.Body); + const parsedConfig = JSON.parse(rawConfig) as unknown; + const validated = validateClientConfig(parsedConfig); + this.options.cache.set(clientId, validated); + logger.info( + { clientId, subscriptionCount: validated.length }, + "Config loaded successfully from S3", + ); + return validated; + } catch (error) { + if (error instanceof NoSuchKey) { + logger.info({ clientId }, "No config found in S3 for client"); + return undefined; + } + if (error instanceof ConfigValidationError) { + logger.error( + { clientId, validationErrors: error.issues }, + "Config validation failed with schema violations", + ); + throw error; + } + logger.error({ clientId, error }, "Failed to load config from S3"); + throw error; + } + } +} 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..bc1c8e6 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/channel-status-filter.ts @@ -0,0 +1,103 @@ +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, +} from "models/client-config"; +import type { ChannelStatusData } from "models/channel-status-data"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusTransitionEvent; + 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( + { + clientId: notifyData.clientId, + channel: notifyData.channel, + expectedChannel: subscription.ChannelType, + }, + "Channel status filter rejected: channel type mismatch", + ); + return false; + } + + if (!subscription.ChannelStatuses.includes(notifyData.channelStatus)) { + logger.debug( + { + clientId: notifyData.clientId, + channelStatus: notifyData.channelStatus, + expectedStatuses: subscription.ChannelStatuses, + }, + "Channel status filter rejected: channel status not in subscription", + ); + return false; + } + + if (!subscription.SupplierStatuses.includes(notifyData.supplierStatus)) { + logger.debug( + { + clientId: notifyData.clientId, + supplierStatus: notifyData.supplierStatus, + expectedStatuses: subscription.SupplierStatuses, + }, + "Channel status filter rejected: supplier status not in subscription", + ); + return false; + } + + const patternMatch = matchesEventPattern(subscription, event.source, { + channel: notifyData.channel, + clientId: notifyData.clientId, + type: "ChannelStatus", + }); + + if (!patternMatch) { + logger.debug( + { + clientId: notifyData.clientId, + eventSource: event.source, + subscriptionName: subscription.Name, + }, + "Channel status filter rejected: event pattern mismatch", + ); + } + + return patternMatch; + }); + + if (matched) { + logger.info( + { + clientId: notifyData.clientId, + channel: notifyData.channel, + channelStatus: notifyData.channelStatus, + supplierStatus: notifyData.supplierStatus, + eventSource: event.source, + }, + "Channel status filter matched", + ); + } + + 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..f4a013e --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/event-pattern.ts @@ -0,0 +1,45 @@ +import type { ClientSubscriptionConfiguration } from "models/client-config"; + +type EventPattern = { + sources: string[]; + detail: Record; +}; + +const parseEventPattern = ( + subscription: ClientSubscriptionConfiguration[number], +): EventPattern => { + 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); + +const matchesEventDetail = ( + detail: Record, + eventDetail: Record, +): boolean => + Object.entries(detail).every(([key, values]) => { + // eslint-disable-next-line security/detect-object-injection + const value = eventDetail[key]; + if (!value) { + return false; + } + return values.includes(value); + }); + +export const matchesEventPattern = ( + subscription: ClientSubscriptionConfiguration[number], + eventSource: string, + eventDetail: Record, +): boolean => { + const pattern = parseEventPattern(subscription); + return ( + matchesEventSource(pattern.sources, eventSource) && + matchesEventDetail(pattern.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..6396f42 --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/filters/message-status-filter.ts @@ -0,0 +1,76 @@ +import type { + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "models/client-config"; +import type { MessageStatusData } from "models/message-status-data"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { logger } from "services/logger"; +import { matchesEventPattern } from "services/filters/event-pattern"; + +type FilterContext = { + event: StatusTransitionEvent; + 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; + } + + if (!subscription.Statuses.includes(notifyData.messageStatus)) { + logger.debug( + { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + expectedStatuses: subscription.Statuses, + }, + "Message status filter rejected: status not in subscription", + ); + return false; + } + + const patternMatch = matchesEventPattern(subscription, event.source, { + clientId: notifyData.clientId, + type: "MessageStatus", + }); + + if (!patternMatch) { + logger.debug( + { + clientId: notifyData.clientId, + eventSource: event.source, + subscriptionName: subscription.Name, + }, + "Message status filter rejected: event pattern mismatch", + ); + } + + return patternMatch; + }); + + if (matched) { + logger.info( + { + clientId: notifyData.clientId, + messageStatus: notifyData.messageStatus, + eventSource: event.source, + }, + "Message status filter matched", + ); + } + + return matched; +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/logger.ts b/lambdas/client-transform-filter-lambda/src/services/logger.ts new file mode 100644 index 0000000..a5304dd --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/logger.ts @@ -0,0 +1,14 @@ +import pino from "pino"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + formatters: { + level: (label) => { + return { level: label }; + }, + }, +}); + +export const createChildLogger = (bindings: Record) => { + return logger.child(bindings); +}; diff --git a/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts b/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts new file mode 100644 index 0000000..c238cab --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/transform-pipeline.ts @@ -0,0 +1,49 @@ +import type { ChannelStatusData } from "models/channel-status-data"; +import type { ClientSubscriptionConfiguration } from "models/client-config"; +import type { MessageStatusData } from "models/message-status-data"; +import type { StatusTransitionEvent } from "models/status-transition-event"; +import { EventTypes } from "models/status-transition-event"; +import { matchesChannelStatusSubscription } from "services/filters/channel-status-filter"; +import { matchesMessageStatusSubscription } from "services/filters/message-status-filter"; +import { logger } from "services/logger"; + +type FilterResult = { + matched: boolean; + subscriptionType: "MessageStatus" | "ChannelStatus" | "Unknown"; +}; + +export const evaluateSubscriptionFilters = ( + event: StatusTransitionEvent, + config: ClientSubscriptionConfiguration | undefined, +): FilterResult => { + if (!config) { + logger.debug( + { eventType: event.type }, + "No config available for filtering", + ); + return { matched: false, subscriptionType: "Unknown" }; + } + + if (event.type === EventTypes.MESSAGE_STATUS_TRANSITIONED) { + const notifyData = event.data["notify-payload"][ + "notify-data" + ] as MessageStatusData; + return { + matched: matchesMessageStatusSubscription(config, { event, notifyData }), + subscriptionType: "MessageStatus", + }; + } + + if (event.type === EventTypes.CHANNEL_STATUS_TRANSITIONED) { + const notifyData = event.data["notify-payload"][ + "notify-data" + ] as ChannelStatusData; + return { + matched: matchesChannelStatusSubscription(config, { event, notifyData }), + subscriptionType: "ChannelStatus", + }; + } + + logger.warn({ eventType: event.type }, "Unknown event type for filtering"); + return { matched: false, subscriptionType: "Unknown" }; +}; 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..f8c35be --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -0,0 +1,170 @@ +import { z } from "zod"; +import type { ClientSubscriptionConfiguration } from "models/client-config"; + +type ValidationIssue = { + path: string; + message: string; +}; + +export class ConfigValidationError extends Error { + constructor(public readonly issues: ValidationIssue[]) { + super("Client subscription configuration validation failed"); + } +} + +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.string()), +}); + +const channelStatusSchema = baseSubscriptionSchema.extend({ + SubscriptionType: z.literal("ChannelStatus"), + ChannelType: z.string(), + ChannelStatuses: z.array(z.string()), + SupplierStatuses: z.array(z.string()), +}); + +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); + } + } +}); + +const formatIssuePath = (path: (string | number)[]): string => { + let formatted = "config"; + + for (const segment of path) { + formatted = + typeof segment === "number" + ? `${formatted}[${segment}]` + : `${formatted}.${segment}`; + } + + return formatted; +}; + +export const validateClientConfig = ( + rawConfig: unknown, +): ClientSubscriptionConfiguration => { + const result = configSchema.safeParse(rawConfig); + + if (!result.success) { + const issues = result.error.issues.map((issue) => { + const pathSegments = issue.path.filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ); + + return { + path: formatIssuePath(pathSegments), + message: issue.message, + }; + }); + throw new ConfigValidationError(issues); + } + + return result.data; +}; + +export type { ValidationIssue }; + +export { + type ChannelStatusSubscriptionConfiguration, + type MessageStatusSubscriptionConfiguration, +} from "models/client-config"; diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/status-transition-event-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/status-transition-event-validator.ts new file mode 100644 index 0000000..ef14e6f --- /dev/null +++ b/lambdas/client-transform-filter-lambda/src/services/validators/status-transition-event-validator.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; +import { EventTypes } from "models/status-transition-event"; +import type { StatusTransitionEvent } from "models/status-transition-event"; + +const MESSAGE_STATUSES = [ + "CREATED", + "PENDING_ENRICHMENT", + "ENRICHED", + "SENDING", + "DELIVERED", + "FAILED", +] as const; + +const CHANNEL_STATUSES = [ + "CREATED", + "SENDING", + "DELIVERED", + "FAILED", + "SKIPPED", +] as const; + +const SUPPLIER_STATUSES = [ + "DELIVERED", + "READ", + "NOTIFICATION_ATTEMPTED", + "UNNOTIFIED", + "REJECTED", + "NOTIFIED", + "RECEIVED", + "PERMANENT_FAILURE", + "TEMPORARY_FAILURE", + "TECHNICAL_FAILURE", + "ACCEPTED", + "CANCELLED", + "PENDING_VIRUS_CHECK", + "VALIDATION_FAILED", + "UNKNOWN", +] as const; + +const CHANNEL_TYPES = ["NHSAPP", "EMAIL", "SMS", "LETTER"] as const; + +const notifyMetadataSchema = z.object({ + teamResponsible: z.enum(["Team 1", "Team 2", "Team 3", "Team 4"]), + notifyDomain: z.enum(["Ordering", "Delivering", "Reporting", "Enquiries"]), + microservice: z.string(), + repositoryUrl: z.string(), + accountId: z.string(), + environment: z.enum(["development", "testing", "staging", "production"]), + instance: z.string(), + microserviceInstanceId: z.string(), + microserviceVersion: z.string(), +}); + +const routingPlanSchema = z.object({ + id: z.string(), + name: z.string(), + version: z.string(), + createdDate: z.string(), +}); + +const messageStatusDataSchema = z.object({ + messageId: z.string(), + messageReference: z.string(), + messageStatus: z.enum(MESSAGE_STATUSES), + messageStatusDescription: z.string().optional(), + messageFailureReasonCode: z.string().optional(), + channels: z.array( + z.object({ + type: z.enum(CHANNEL_TYPES), + channelStatus: z.string(), + }), + ), + timestamp: z.string(), + routingPlan: routingPlanSchema, + clientId: z.string(), + previousMessageStatus: z.enum(MESSAGE_STATUSES).optional(), +}); + +const channelStatusDataSchema = z.object({ + messageId: z.string(), + messageReference: z.string(), + channel: z.enum(CHANNEL_TYPES), + channelStatus: z.enum(CHANNEL_STATUSES), + channelStatusDescription: z.string().optional(), + channelFailureReasonCode: z.string().optional(), + supplierStatus: z.enum(SUPPLIER_STATUSES), + cascadeType: z.enum(["primary", "secondary"]), + cascadeOrder: z.number(), + timestamp: z.string(), + retryCount: z.number(), + clientId: z.string(), + previousChannelStatus: z.enum(CHANNEL_STATUSES).optional(), + previousSupplierStatus: z.enum(SUPPLIER_STATUSES).optional(), +}); + +const statusTransitionBaseSchema = z + .object({ + profileversion: z.string(), + profilepublished: z.string(), + specversion: z.string(), + id: z.string(), + source: z.string(), + subject: z.string(), + type: z.string(), + time: z.string(), + recordedtime: z.string(), + datacontenttype: z.string(), + dataschema: z.string(), + severitynumber: z.number(), + severitytext: z.string(), + traceparent: z.string(), + }) + // eslint-disable-next-line sonarjs/deprecation + .passthrough(); + +const messageEventSchema = statusTransitionBaseSchema.extend({ + type: z.literal(EventTypes.MESSAGE_STATUS_TRANSITIONED), + data: z.object({ + "notify-payload": z.object({ + "notify-data": messageStatusDataSchema, + "notify-metadata": notifyMetadataSchema, + }), + }), +}); + +const channelEventSchema = statusTransitionBaseSchema.extend({ + type: z.literal(EventTypes.CHANNEL_STATUS_TRANSITIONED), + data: z.object({ + "notify-payload": z.object({ + "notify-data": channelStatusDataSchema, + "notify-metadata": notifyMetadataSchema, + }), + }), +}); + +const statusTransitionEventSchema = z.discriminatedUnion("type", [ + messageEventSchema, + channelEventSchema, +]); + +export const parseStatusTransitionEvent = ( + event: unknown, +): StatusTransitionEvent | undefined => { + const result = statusTransitionEventSchema.safeParse(event); + return result.success ? result.data : undefined; +}; diff --git a/lambdas/client-transform-filter-lambda/tsconfig.json b/lambdas/client-transform-filter-lambda/tsconfig.json index bbff7bf..64297cf 100644 --- a/lambdas/client-transform-filter-lambda/tsconfig.json +++ b/lambdas/client-transform-filter-lambda/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "baseUrl": "src" + "baseUrl": "src", + "isolatedModules": true }, "extends": "../../tsconfig.base.json", "include": [ diff --git a/package-lock.json b/package-lock.json index aa3caa0..7cf49e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,11 @@ "": { "name": "nhs-notify-client-callbacks", "workspaces": [ - "lambdas/client-transform-filter-lambda" + "lambdas/client-transform-filter-lambda", + "scripts/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", @@ -45,12 +47,15 @@ "name": "nhs-notify-client-transform-filter-lambda", "version": "0.0.1", "dependencies": { - "esbuild": "^0.25.0" + "@aws-sdk/client-s3": "^3.821.0", + "pino": "^9.6.0", + "zod": "^4.1.13" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.148", "@types/jest": "^29.5.14", + "esbuild": "^0.25.0", "jest": "^29.7.0", "jest-mock-extended": "^3.0.7", "typescript": "^5.8.2" @@ -84,1686 +89,3363 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "dev": true, - "license": "MIT", + "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": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.27.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" } }, - "node_modules/@babel/core": { - "version": "7.27.4", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", "dependencies": { - "@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", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "@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/@babel/core/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@aws-crypto/sha1-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" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@babel/generator": { - "version": "7.27.5", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha1-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": { - "@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" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "dev": true, - "license": "MIT", + "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": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "dev": true, - "license": "MIT", + "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": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "dev": true, - "license": "MIT", + "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": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/parser": { - "version": "7.27.5", - "dev": true, - "license": "MIT", + "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": { - "@babel/types": "^7.27.3" - }, - "bin": { - "parser": "bin/babel-parser.js" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "dev": true, - "license": "MIT", + "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": { - "@babel/helper-plugin-utils": "^7.8.0" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/client-s3": { + "version": "3.992.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.992.0.tgz", + "integrity": "sha512-6xfXGCvnWGgy5zZAse64Ru2G2qLKnPY7h8tchlsmGWVcJOWgz7iM3jmsWsQiJ79zH9A8HAPHU+ZD8TYYkwC+0Q==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@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.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", + "@aws-sdk/middleware-expect-continue": "^3.972.3", + "@aws-sdk/middleware-flexible-checksums": "^3.972.8", + "@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.10", + "@aws-sdk/middleware-ssec": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/signature-v4-multi-region": "3.992.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.992.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-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.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-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/util-endpoints": { + "version": "3.992.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.992.0.tgz", + "integrity": "sha512-FHgdMVbTZ2Lu7hEIoGYfkd5UazNSsAgPcupEnh15vsWKFKhuw6w/6tM1k/yNaa7l1wx0Wt1UuK0m+gQ0BJpuvg==", + "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" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/client-sso": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.990.0.tgz", + "integrity": "sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@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.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/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/core": { + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.10.tgz", + "integrity": "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.4", + "@smithy/core": "^3.23.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", + "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.8.tgz", + "integrity": "sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.10.tgz", + "integrity": "sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.12", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.8.tgz", + "integrity": "sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-env": "^3.972.8", + "@aws-sdk/credential-provider-http": "^3.972.10", + "@aws-sdk/credential-provider-login": "^3.972.8", + "@aws-sdk/credential-provider-process": "^3.972.8", + "@aws-sdk/credential-provider-sso": "^3.972.8", + "@aws-sdk/credential-provider-web-identity": "^3.972.8", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.8.tgz", + "integrity": "sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.9.tgz", + "integrity": "sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@aws-sdk/credential-provider-env": "^3.972.8", + "@aws-sdk/credential-provider-http": "^3.972.10", + "@aws-sdk/credential-provider-ini": "^3.972.8", + "@aws-sdk/credential-provider-process": "^3.972.8", + "@aws-sdk/credential-provider-sso": "^3.972.8", + "@aws-sdk/credential-provider-web-identity": "^3.972.8", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.8.tgz", + "integrity": "sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.8.tgz", + "integrity": "sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@aws-sdk/client-sso": "3.990.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/token-providers": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.8.tgz", + "integrity": "sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", + "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@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" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", + "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.8.tgz", + "integrity": "sha512-Hn6gumcN/3/8Fzo9z7N1pA2PRfE8S+qAqdb4g3MqzXjIOIe+VxD7edO/DKAJ1YH11639EGQIHBz0wdOb5btjtw==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@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.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", + "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "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" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz", + "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", + "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", + "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.10.tgz", + "integrity": "sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/core": "^3.23.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.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-ssec": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz", + "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.10.tgz", + "integrity": "sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@smithy/core": "^3.23.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.990.0.tgz", + "integrity": "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==", + "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/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.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/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/region-config-resolver": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", + "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.992.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.992.0.tgz", + "integrity": "sha512-jWoaM89xH2cYOY6O+PWMa0yqjzKlE61Ehea1hJe34kHg9QvZOkcSA5OT9CNaFXsAvafeAAHBhSE8XlDiNaJFuw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.10", + "@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/token-providers": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.990.0.tgz", + "integrity": "sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", + "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.990.0.tgz", + "integrity": "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==", + "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/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" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", + "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.8.tgz", + "integrity": "sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-typescript": { + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", + "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.3.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template": { - "version": "7.27.2", + "node_modules/@babel/compat-data": { + "version": "7.27.5", "dev": true, "license": "MIT", - "dependencies": { - "@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": { + "node_modules/@babel/core": { "version": "7.27.4", "dev": true, "license": "MIT", "dependencies": { + "@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", - "debug": "^4.3.1", - "globals": "^11.1.0" + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.5", "dev": true, "license": "MIT", + "dependencies": { + "@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": { - "node": ">=4" + "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.27.6", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", "dev": true, - "license": "MIT" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { - "node": ">=12" + "node": ">=6.9.0" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], + "node_modules/@babel/helpers": { + "version": "7.27.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], + "node_modules/@babel/parser": { + "version": "7.27.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-ia32": { + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@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.27.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@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.27.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ - "linux" + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", + "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "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.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" + "node_modules/@jest/core/node_modules/ci-info": { + "version": "3.9.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } ], + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/environment": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@jest/expect": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@jest/globals": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], + "node_modules/@jest/reporters": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], + "node_modules/@jest/schemas": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], + "node_modules/@jest/source-map": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], + "node_modules/@jest/test-result": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", + "node_modules/@jest/transform": { + "version": "29.7.0", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "node_modules/@jest/types": { + "version": "29.6.3", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@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/@eslint/config-array/node_modules/minimatch": { + "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", "engines": { - "node": "*" + "node": ">=6.0.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.0.0" } }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", "dev": true, - "license": "Apache-2.0", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@eslint/eslintrc": { + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.4.tgz", + "integrity": "sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==", + "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", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "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.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@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": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.1" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">= 6" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">= 8" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": "*" + "node": ">= 8" } }, - "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "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": { + "version": "0.2.7", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" + "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.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", - "dev": true, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", "license": "Apache-2.0", "dependencies": { - "@types/json-schema": "^7.0.15" + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18.18.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, + "node_modules/@smithy/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.0.tgz", + "integrity": "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18.18.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", "license": "Apache-2.0", - "engines": { - "node": ">=12.22" + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "dev": true, - "license": "ISC", + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "dev": true, - "license": "MIT", + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", "dependencies": { - "sprintf-js": "~1.0.2" + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "dev": true, - "license": "MIT", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", "dependencies": { - "p-locate": "^4.1.0" + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", "dependencies": { - "p-try": "^2.0.0" + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", "dependencies": { - "p-limit": "^2.2.0" + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "dev": true, - "license": "MIT", + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@jest/console": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/core": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.14.tgz", + "integrity": "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.0", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@jest/core/node_modules/ci-info": { - "version": "3.9.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", + "node_modules/@smithy/middleware-retry": { + "version": "4.4.31", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.31.tgz", + "integrity": "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", "dependencies": { - "jest-get-type": "^29.6.3" + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/node-http-handler": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz", + "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==", + "license": "Apache-2.0", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "dev": true, - "license": "MIT", + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "dev": true, - "license": "MIT", + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@smithy/types": "^4.12.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "dev": true, - "license": "MIT", + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "dev": true, - "license": "MIT", + "node_modules/@smithy/smithy-client": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.3.tgz", + "integrity": "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==", + "license": "Apache-2.0", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@smithy/core": "^3.23.0", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.12", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "dev": true, - "license": "MIT", + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "dev": true, - "license": "MIT" + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@next/eslint-plugin-next": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.4.tgz", - "integrity": "sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", "dependencies": { - "fast-glob": "3.3.1" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.30", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.30.tgz", + "integrity": "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==", + "license": "Apache-2.0", "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" + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.33", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.33.tgz", + "integrity": "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8.6.0" + "node": ">=18.0.0" } }, - "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", "dependencies": { - "is-glob": "^4.0.1" + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 6" + "node": ">=18.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "tslib": "^2.6.2" }, "engines": { - "node": ">= 8" + "node": ">=18.0.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">= 8" + "node": ">=18.0.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 8" + "node": ">=18.0.0" } }, - "node_modules/@pkgr/core": { - "version": "0.2.7", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node_modules/@smithy/util-stream": { + "version": "4.5.12", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz", + "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "dev": true, - "license": "MIT" + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", "dependencies": { - "type-detect": "4.0.8" + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@stylistic/eslint-plugin": { @@ -2624,7 +4306,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2632,7 +4313,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2855,6 +4535,15 @@ "dev": true, "license": "MIT" }, + "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" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "dev": true, @@ -3006,6 +4695,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "dev": true, @@ -3241,9 +4936,12 @@ "node": ">=0.8.0" } }, + "node_modules/client-subscriptions-management": { + "resolved": "scripts/client-subscriptions-management", + "link": true + }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3270,7 +4968,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3281,7 +4978,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3715,7 +5411,6 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/entities": { @@ -3904,6 +5599,7 @@ "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -3941,7 +5637,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4919,7 +6614,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -4963,6 +6657,40 @@ "dev": true, "license": "MIT" }, + "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", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "dev": true, @@ -5176,7 +6904,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5791,7 +7518,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7376,6 +9102,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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" + } + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -7552,6 +9287,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "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" + } + }, + "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": { "version": "4.0.7", "dev": true, @@ -7693,6 +9465,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "dev": true, @@ -7782,11 +9570,26 @@ ], "license": "MIT" }, + "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": { "version": "19.0.0", "dev": true, "license": "MIT" }, + "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" + } + }, "node_modules/refa": { "version": "0.12.1", "dev": true, @@ -7886,7 +9689,15 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" @@ -8049,6 +9860,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "dev": true, @@ -8237,6 +10057,15 @@ "node": ">=8" } }, + "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" + } + }, "node_modules/source-map": { "version": "0.6.1", "dev": true, @@ -8254,6 +10083,15 @@ "source-map": "^0.6.0" } }, + "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" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "dev": true, @@ -8314,7 +10152,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8434,7 +10271,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8486,6 +10322,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -8560,6 +10408,15 @@ "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" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8823,9 +10680,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -9742,7 +11597,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9816,7 +11670,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -9829,7 +11682,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -9846,7 +11698,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9870,6 +11721,69 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "scripts/client-subscriptions-management": { + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.821.0", + "ajv": "^8.12.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/node": "^22.10.10", + "@types/yargs": "^17.0.24", + "eslint": "^9.27.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "scripts/client-subscriptions-management/node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "scripts/client-subscriptions-management/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "scripts/client-subscriptions-management/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" + }, + "scripts/client-subscriptions-management/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" } } } diff --git a/package.json b/package.json index 2013466..f859faf 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "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 +48,7 @@ "typecheck": "npm run typecheck --workspaces" }, "workspaces": [ - "lambdas/client-transform-filter-lambda" + "lambdas/client-transform-filter-lambda", + "scripts/client-subscriptions-management" ] } diff --git a/scripts/client-subscriptions-management/README.md b/scripts/client-subscriptions-management/README.md new file mode 100644 index 0000000..b252f9a --- /dev/null +++ b/scripts/client-subscriptions-management/README.md @@ -0,0 +1,55 @@ +# client-subscriptions-management + +TypeScript CLI utility for managing NHS Notify client subscription configuration in S3. + +## Usage + +From the repository root run: + +```bash +npm --workspace scripts/client-subscriptions-management [options] +``` + +Set the bucket name via `--bucket-name` or the `CLIENT_SUBSCRIPTION_BUCKET_NAME` environment variable. + +## Commands + +### Get Client Subscriptions By Client ID + +```bash +npm --workspace scripts/client-subscriptions-management get-by-client-id \ + --bucket-name my-bucket \ + --client-id client-123 +``` + +### Put Message Status Subscription + +```bash +npm --workspace scripts/client-subscriptions-management put-message-status \ + --bucket-name my-bucket \ + --client-name "Test Client" \ + --client-id client-123 \ + --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 +``` + +### Put Channel Status Subscription + +```bash +npm --workspace scripts/client-subscriptions-management put-channel-status \ + --bucket-name my-bucket \ + --client-name "Test Client" \ + --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 +``` diff --git a/scripts/client-subscriptions-management/jest.config.ts b/scripts/client-subscriptions-management/jest.config.ts new file mode 100644 index 0000000..9252d9c --- /dev/null +++ b/scripts/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: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + testEnvironment: "node", + modulePaths: ["/src"], + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, +}; + +export default jestConfig; diff --git a/scripts/client-subscriptions-management/package.json b/scripts/client-subscriptions-management/package.json new file mode 100644 index 0000000..ab18406 --- /dev/null +++ b/scripts/client-subscriptions-management/package.json @@ -0,0 +1,27 @@ +{ + "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", + "ajv": "^8.12.0", + "yargs": "^17.7.2" + }, + "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/scripts/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts b/scripts/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts new file mode 100644 index 0000000..5c6634e --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/client-subscription-builder.test.ts @@ -0,0 +1,86 @@ +const originalEventSource = process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE; +process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE = "env-source"; + +// eslint-disable-next-line import-x/first -- Ensure env is set before module load +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"]), + }); + }); +}); diff --git a/scripts/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts b/scripts/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts new file mode 100644 index 0000000..f163c4c --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/client-subscription-repository.test.ts @@ -0,0 +1,369 @@ +import { ClientSubscriptionRepository } from "src/infra/client-subscription-repository"; +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "src/types"; +import type { S3Repository } from "src/infra/s3-repository"; +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("AJV 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(/Validation failed/); + }); + + 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(/Validation failed/); + }); + + 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(/Validation failed/); + }); + + 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(/Validation failed/); + }); + + 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(/Validation failed/); + }); + + 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/scripts/client-subscriptions-management/src/__tests__/constants.test.ts b/scripts/client-subscriptions-management/src/__tests__/constants.test.ts new file mode 100644 index 0000000..afa7bd5 --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/constants.test.ts @@ -0,0 +1,28 @@ +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + MESSAGE_STATUSES, + SUPPLIER_STATUSES, +} from "src/constants"; + +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/scripts/client-subscriptions-management/src/__tests__/container-s3-config.test.ts b/scripts/client-subscriptions-management/src/__tests__/container-s3-config.test.ts new file mode 100644 index 0000000..ec19865 --- /dev/null +++ b/scripts/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/scripts/client-subscriptions-management/src/__tests__/container.test.ts b/scripts/client-subscriptions-management/src/__tests__/container.test.ts new file mode 100644 index 0000000..fcb5514 --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/container.test.ts @@ -0,0 +1,41 @@ +/* eslint-disable import-x/first */ +import { S3Client } from "@aws-sdk/client-s3"; + +const mockS3Repository = jest.fn(); +const mockBuilder = jest.fn(); +const mockRepository = jest.fn(); + +jest.mock("src/infra/s3-repository", () => ({ + S3Repository: mockS3Repository, +})); + +jest.mock("src/domain/client-subscription-builder", () => ({ + ClientSubscriptionConfigurationBuilder: mockBuilder, +})); + +jest.mock("src/infra/client-subscription-repository", () => ({ + 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/scripts/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts b/scripts/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts new file mode 100644 index 0000000..1cad9ff --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/get-client-subscriptions.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable import-x/first, no-console */ +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/scripts/client-subscriptions-management/src/__tests__/helper.test.ts b/scripts/client-subscriptions-management/src/__tests__/helper.test.ts new file mode 100644 index 0000000..93d4763 --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/helper.test.ts @@ -0,0 +1,153 @@ +import type { + ChannelStatusSubscriptionConfiguration, + ClientSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "src/types"; +import { + formatSubscriptionFileResponse, + normalizeClientName, + resolveBucketName, + 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 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/scripts/client-subscriptions-management/src/__tests__/put-channel-status.test.ts b/scripts/client-subscriptions-management/src/__tests__/put-channel-status.test.ts new file mode 100644 index 0000000..cb5d1bf --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/put-channel-status.test.ts @@ -0,0 +1,308 @@ +/* eslint-disable import-x/first, no-console */ +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"); + +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/put-channel-status"; + +describe("put-channel-status CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockPutChannelStatusSubscription.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("rejects non-https endpoints", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--channel-statuses", + "DELIVERED", + "--supplier-statuses", + "DELIVERED", + "--channel-type", + "SMS", + "--rate-limit", + "10", + "--dry-run", + "true", + "--bucket-name", + "bucket-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + "Error: api-endpoint must start with https://", + ); + expect(process.exitCode).toBe(1); + expect(mockPutChannelStatusSubscription).not.toHaveBeenCalled(); + }); + + it("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(); + }); +}); diff --git a/scripts/client-subscriptions-management/src/__tests__/put-message-status.test.ts b/scripts/client-subscriptions-management/src/__tests__/put-message-status.test.ts new file mode 100644 index 0000000..a3eac8b --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/put-message-status.test.ts @@ -0,0 +1,278 @@ +/* eslint-disable import-x/first, no-console */ +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"); + +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/put-message-status"; + +describe("put-message-status CLI", () => { + const originalLog = console.log; + const originalError = console.error; + const originalExitCode = process.exitCode; + const originalArgv = process.argv; + + beforeEach(() => { + mockPutMessageStatusSubscription.mockReset(); + mockFormatSubscriptionFileResponse.mockReset(); + mockResolveBucketName.mockReset(); + mockResolveBucketName.mockReturnValue("bucket"); + mockResolveRegion.mockReset(); + mockResolveRegion.mockReturnValue("region"); + console.log = jest.fn(); + console.error = jest.fn(); + delete process.exitCode; + }); + + afterAll(() => { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + process.argv = originalArgv; + }); + + it("rejects non-https endpoints", async () => { + await cli.main([ + "node", + "script", + "--client-name", + "Client One", + "--client-id", + "client-1", + "--api-endpoint", + "http://example.com", + "--api-key", + "secret", + "--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", + "--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", + "--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", + "--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", + "--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", + "--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", + "--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(); + }); +}); diff --git a/scripts/client-subscriptions-management/src/__tests__/s3-repository.test.ts b/scripts/client-subscriptions-management/src/__tests__/s3-repository.test.ts new file mode 100644 index 0000000..89dff3e --- /dev/null +++ b/scripts/client-subscriptions-management/src/__tests__/s3-repository.test.ts @@ -0,0 +1,128 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import { S3Repository } from "src/infra/s3-repository"; + +describe("S3Repository", () => { + it("returns string content from S3", async () => { + const send = jest.fn().mockResolvedValue({ Body: "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("returns string content from Uint8Array", async () => { + const send = jest + .fn() + .mockResolvedValue({ Body: new TextEncoder().encode("content") }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + }); + + it("returns string content from readable stream", async () => { + const send = jest + .fn() + .mockResolvedValue({ Body: Readable.from([Buffer.from("content")]) }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + }); + + it("returns string content from string chunks", async () => { + const send = jest + .fn() + .mockResolvedValue({ Body: Readable.from(["content"]) }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + const result = await repository.getObject("key.json"); + + expect(result).toBe("content"); + }); + + it("throws when body is not readable", async () => { + const send = jest.fn().mockResolvedValue({ Body: 123 }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response body is not readable", + ); + }); + + it("throws when body is object without stream interface", async () => { + const send = jest.fn().mockResolvedValue({ Body: {} }); + const repository = new S3Repository("bucket", { + send, + } as unknown as S3Client); + + await expect(repository.getObject("key.json")).rejects.toThrow( + "Response body is not readable", + ); + }); + + 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 is not a readable stream", + ); + }); + + 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/scripts/client-subscriptions-management/src/constants.ts b/scripts/client-subscriptions-management/src/constants.ts new file mode 100644 index 0000000..cb87e2c --- /dev/null +++ b/scripts/client-subscriptions-management/src/constants.ts @@ -0,0 +1,41 @@ +export const MESSAGE_STATUSES = [ + "CREATED", + "PENDING_ENRICHMENT", + "ENRICHED", + "SENDING", + "DELIVERED", + "FAILED", +] as const; + +export const CHANNEL_STATUSES = [ + "CREATED", + "SENDING", + "DELIVERED", + "FAILED", + "SKIPPED", +] as const; + +export const SUPPLIER_STATUSES = [ + "DELIVERED", + "READ", + "NOTIFICATION_ATTEMPTED", + "UNNOTIFIED", + "REJECTED", + "NOTIFIED", + "RECEIVED", + "PERMANENT_FAILURE", + "TEMPORARY_FAILURE", + "TECHNICAL_FAILURE", + "ACCEPTED", + "CANCELLED", + "PENDING_VIRUS_CHECK", + "VALIDATION_FAILED", + "UNKNOWN", +] as const; + +export const CHANNEL_TYPES = ["NHSAPP", "EMAIL", "SMS", "LETTER"] as const; + +export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; +export type ChannelStatus = (typeof CHANNEL_STATUSES)[number]; +export type SupplierStatus = (typeof SUPPLIER_STATUSES)[number]; +export type ChannelType = (typeof CHANNEL_TYPES)[number]; diff --git a/scripts/client-subscriptions-management/src/container.ts b/scripts/client-subscriptions-management/src/container.ts new file mode 100644 index 0000000..1b78f14 --- /dev/null +++ b/scripts/client-subscriptions-management/src/container.ts @@ -0,0 +1,32 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { ClientSubscriptionRepository } from "src/infra/client-subscription-repository"; +import { S3Repository } from "src/infra/s3-repository"; +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/scripts/client-subscriptions-management/src/domain/client-subscription-builder.ts b/scripts/client-subscriptions-management/src/domain/client-subscription-builder.ts new file mode 100644 index 0000000..1ef7379 --- /dev/null +++ b/scripts/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -0,0 +1,121 @@ +import { normalizeClientName } from "src/entrypoint/cli/helper"; +import type { + ChannelStatusSubscriptionArgs, + MessageStatusSubscriptionArgs, +} from "src/infra/client-subscription-repository"; +import type { + ChannelStatusSubscriptionConfiguration, + MessageStatusSubscriptionConfiguration, +} from "src/types"; + +const DEFAULT_EVENT_SOURCE = + process.env.CLIENT_SUBSCRIPTION_EVENT_SOURCE ?? + "Pipe COMMS_ENVIRONMENTcss-client-subscriptions-status-pipe"; + +// eslint-disable-next-line import-x/prefer-default-export +export class ClientSubscriptionConfigurationBuilder { + constructor(private readonly eventSource: string = DEFAULT_EVENT_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([eventSource ?? this.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([eventSource ?? this.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/scripts/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts b/scripts/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts new file mode 100644 index 0000000..207c66f --- /dev/null +++ b/scripts/client-subscriptions-management/src/entrypoint/cli/get-client-subscriptions.ts @@ -0,0 +1,70 @@ +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) { + // eslint-disable-next-line no-console + console.log(formatSubscriptionFileResponse(result)); + } else { + // eslint-disable-next-line no-console + console.log(`No configuration exists for client: ${argv["client-id"]}`); + } +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + // eslint-disable-next-line no-console + 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/scripts/client-subscriptions-management/src/entrypoint/cli/helper.ts b/scripts/client-subscriptions-management/src/entrypoint/cli/helper.ts new file mode 100644 index 0000000..f468410 --- /dev/null +++ b/scripts/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -0,0 +1,45 @@ +import type { ClientSubscriptionConfiguration } from "src/types"; + +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 resolveRegion = ( + regionArg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined => regionArg ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION; diff --git a/scripts/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts b/scripts/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts new file mode 100644 index 0000000..9ed130e --- /dev/null +++ b/scripts/client-subscriptions-management/src/entrypoint/cli/put-channel-status.ts @@ -0,0 +1,136 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + SUPPLIER_STATUSES, +} from "src/constants"; +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-name": { + type: "string", + demandOption: true, + }, + "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: true, + choices: CHANNEL_STATUSES, + }, + "supplier-statuses": { + string: true, + type: "array", + demandOption: true, + 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)) { + // eslint-disable-next-line no-console + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const bucketName = resolveBucketName(argv["bucket-name"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + eventSource: argv["event-source"], + }); + + const result = + await clientSubscriptionRepository.putChannelStatusSubscription({ + clientName: argv["client-name"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + channelType: argv["channel-type"], + channelStatuses: argv["channel-statuses"], + supplierStatuses: argv["supplier-statuses"], + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource: argv["event-source"], + }); + + // eslint-disable-next-line no-console + console.log(formatSubscriptionFileResponse(result)); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + // eslint-disable-next-line no-console + 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/scripts/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts b/scripts/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts new file mode 100644 index 0000000..40c567f --- /dev/null +++ b/scripts/client-subscriptions-management/src/entrypoint/cli/put-message-status.ts @@ -0,0 +1,119 @@ +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; +import { MESSAGE_STATUSES } from "src/constants"; +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-name": { + type: "string", + demandOption: true, + }, + "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, + }, + 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)) { + // eslint-disable-next-line no-console + console.error("Error: api-endpoint must start with https://"); + process.exitCode = 1; + return; + } + + const bucketName = resolveBucketName(argv["bucket-name"]); + const clientSubscriptionRepository = createClientSubscriptionRepository({ + bucketName, + region: resolveRegion(argv.region), + eventSource: argv["event-source"], + }); + + const result = + await clientSubscriptionRepository.putMessageStatusSubscription({ + clientName: argv["client-name"], + clientId: argv["client-id"], + apiEndpoint, + apiKeyHeaderName: argv["api-key-header-name"], + apiKey: argv["api-key"], + statuses: argv.statuses, + rateLimit: argv["rate-limit"], + dryRun: argv["dry-run"], + eventSource: argv["event-source"], + }); + + // eslint-disable-next-line no-console + console.log(formatSubscriptionFileResponse(result)); +} + +export const runCli = async (args: string[] = process.argv) => { + try { + await main(args); + } catch (error) { + // eslint-disable-next-line no-console + 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/scripts/client-subscriptions-management/src/index.ts b/scripts/client-subscriptions-management/src/index.ts new file mode 100644 index 0000000..bec05b8 --- /dev/null +++ b/scripts/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/scripts/client-subscriptions-management/src/infra/client-subscription-repository.ts b/scripts/client-subscriptions-management/src/infra/client-subscription-repository.ts new file mode 100644 index 0000000..fbd2429 --- /dev/null +++ b/scripts/client-subscriptions-management/src/infra/client-subscription-repository.ts @@ -0,0 +1,221 @@ +import Ajv from "ajv"; +import { + CHANNEL_STATUSES, + CHANNEL_TYPES, + type ChannelStatus, + type ChannelType, + MESSAGE_STATUSES, + type MessageStatus, + SUPPLIER_STATUSES, + type SupplierStatus, +} from "src/constants"; +import { ClientSubscriptionConfigurationBuilder } from "src/domain/client-subscription-builder"; +import type { ClientSubscriptionConfiguration } from "src/types"; +import { S3Repository } from "src/infra/s3-repository"; + +export type MessageStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + statuses: MessageStatus[]; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: string; + eventSource?: string; +}; + +const messageStatusSubscriptionArgsSchema = { + type: "object", + properties: { + clientName: { type: "string" }, + clientId: { type: "string" }, + apiKey: { type: "string" }, + apiEndpoint: { type: "string" }, + statuses: { + type: "array", + items: { type: "string", enum: MESSAGE_STATUSES }, + }, + rateLimit: { type: "number" }, + dryRun: { type: "boolean" }, + apiKeyHeaderName: { type: "string" }, + eventSource: { type: "string" }, + }, + required: [ + "clientName", + "clientId", + "apiKey", + "apiEndpoint", + "statuses", + "rateLimit", + "dryRun", + ], + additionalProperties: false, +} as const; + +export type ChannelStatusSubscriptionArgs = { + clientName: string; + clientId: string; + apiKey: string; + apiEndpoint: string; + channelStatuses: ChannelStatus[]; + supplierStatuses: SupplierStatus[]; + channelType: ChannelType; + rateLimit: number; + dryRun: boolean; + apiKeyHeaderName?: string; + eventSource?: string; +}; + +const channelStatusSubscriptionArgsSchema = { + type: "object", + properties: { + clientName: { type: "string" }, + clientId: { type: "string" }, + apiKey: { type: "string" }, + apiEndpoint: { type: "string" }, + channelStatuses: { + type: "array", + items: { type: "string", enum: CHANNEL_STATUSES }, + }, + supplierStatuses: { + type: "array", + items: { type: "string", enum: SUPPLIER_STATUSES }, + }, + channelType: { type: "string", enum: CHANNEL_TYPES }, + rateLimit: { type: "number" }, + dryRun: { type: "boolean" }, + apiKeyHeaderName: { type: "string" }, + eventSource: { type: "string" }, + }, + required: [ + "clientName", + "clientId", + "apiKey", + "apiEndpoint", + "channelStatuses", + "supplierStatuses", + "channelType", + "rateLimit", + "dryRun", + ], + additionalProperties: false, +} as const; + +const ajv = new Ajv({ useDefaults: true }); +const validateMessageStatusArgs = ajv.compile( + messageStatusSubscriptionArgsSchema, +); +const validateChannelStatusArgs = ajv.compile( + channelStatusSubscriptionArgsSchema, +); + +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 = { + apiKeyHeaderName: "x-api-key", + ...subscriptionArgs, + }; + + if (!validateMessageStatusArgs(parsedSubscriptionArgs)) { + throw new Error( + `Validation failed: ${ajv.errorsText(validateMessageStatusArgs.errors)}`, + ); + } + + 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 = { + apiKeyHeaderName: "x-api-key", + ...subscriptionArgs, + }; + + if (!validateChannelStatusArgs(parsedSubscriptionArgs)) { + throw new Error( + `Validation failed: ${ajv.errorsText(validateChannelStatusArgs.errors)}`, + ); + } + + 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/scripts/client-subscriptions-management/src/infra/s3-repository.ts b/scripts/client-subscriptions-management/src/infra/s3-repository.ts new file mode 100644 index 0000000..59a78bf --- /dev/null +++ b/scripts/client-subscriptions-management/src/infra/s3-repository.ts @@ -0,0 +1,73 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + PutObjectCommandInput, + S3Client, +} from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; + +const isReadableStream = (value: unknown): value is Readable => + typeof value === "object" && value !== null && "on" in value; + +const streamToString = async (value: unknown): Promise => { + if (typeof value === "string") { + return value; + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("utf8"); + } + + if (isReadableStream(value)) { + const chunks: Buffer[] = []; + for await (const chunk of value) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); + } + + throw new Error("Response body is not readable"); +}; + +// 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 is not a readable stream"); + } + + return await streamToString(Body); + } 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/scripts/client-subscriptions-management/src/types.ts b/scripts/client-subscriptions-management/src/types.ts new file mode 100644 index 0000000..d24a9f6 --- /dev/null +++ b/scripts/client-subscriptions-management/src/types.ts @@ -0,0 +1,51 @@ +import type { + ChannelStatus, + ChannelType, + MessageStatus, + SupplierStatus, +} from "src/constants"; + +type SubscriptionConfigurationBase = { + Name: string; + ClientId: string; + Description: string; + EventSource: string; + EventDetail: string; + Targets: { + Type: "API"; + TargetId: string; + Name: string; + InputTransformer: { + InputPaths: string; + InputHeaders: { + "x-hmac-sha256-signature": string; + }; + }; + InvocationEndpoint: string; + InvocationMethod: "POST"; + InvocationRateLimit: number; + APIKey: { + HeaderName: string; + HeaderValue: string; + }; + }[]; +}; + +export type ChannelStatusSubscriptionConfiguration = + SubscriptionConfigurationBase & { + SubscriptionType: "ChannelStatus"; + ChannelType: ChannelType; + ChannelStatuses: ChannelStatus[]; + SupplierStatuses: SupplierStatus[]; + }; + +export type MessageStatusSubscriptionConfiguration = + SubscriptionConfigurationBase & { + SubscriptionType: "MessageStatus"; + Statuses: MessageStatus[]; + }; + +export type ClientSubscriptionConfiguration = ( + | MessageStatusSubscriptionConfiguration + | ChannelStatusSubscriptionConfiguration +)[]; diff --git a/scripts/client-subscriptions-management/tsconfig.json b/scripts/client-subscriptions-management/tsconfig.json new file mode 100644 index 0000000..9f3a76b --- /dev/null +++ b/scripts/client-subscriptions-management/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2024" + ], + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist", + "paths": { + "src/*": [ + "src/*" + ] + }, + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index ae7c364..da61efd 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.* -sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/** +sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**, scripts/**/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..0e5f40d --- /dev/null +++ b/scripts/deploy_client_subscriptions.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat < \ + [--terraform-apply] \ + [--environment --group --project --tf-region ] \ + -- + +Examples: + ./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 \ + --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 scripts/client-subscriptions-management run put-message-status -- "${forward_args[@]}" +else + npm --workspace scripts/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