From a0040597df935124befc9826cb4c4c1074eae7b5 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Sat, 25 Jul 2026 06:03:55 +0100 Subject: [PATCH 1/4] Add notification expiration spec --- .../notification-expiration/.config.kiro | 1 + .kiro/specs/notification-expiration/design.md | 77 +++++++++++++++++++ .../notification-expiration/requirements.md | 56 ++++++++++++++ .kiro/specs/notification-expiration/tasks.md | 47 +++++++++++ 4 files changed, 181 insertions(+) create mode 100644 .kiro/specs/notification-expiration/.config.kiro create mode 100644 .kiro/specs/notification-expiration/design.md create mode 100644 .kiro/specs/notification-expiration/requirements.md create mode 100644 .kiro/specs/notification-expiration/tasks.md diff --git a/.kiro/specs/notification-expiration/.config.kiro b/.kiro/specs/notification-expiration/.config.kiro new file mode 100644 index 00000000..ecd5bd5a --- /dev/null +++ b/.kiro/specs/notification-expiration/.config.kiro @@ -0,0 +1 @@ +{"specId": "notification-expiration", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/design.md b/.kiro/specs/notification-expiration/design.md new file mode 100644 index 00000000..29b8a487 --- /dev/null +++ b/.kiro/specs/notification-expiration/design.md @@ -0,0 +1,77 @@ +# Design Document + +## Overview + +This design implements notification expiration for the Notify-Chain listener. Notifications will store an expiration timestamp and the processing pipeline will check and skip expired notifications. + +## Architecture + +### Components + +1. **NotificationExpirationService** - Core service for checking expiration +2. **ExpirationConfig** - Configuration for expiration settings +3. **Event Registry Update** - Store expiration with events + +### Data Model + +``` +NotificationExpiration { + createdAt: number (timestamp) + expiresAt: number (timestamp) +} + +EventStore extends with expiration { + ...existing fields + expiresAt?: number (optional - if not set, uses default) +} +``` + +## Implementation Details + +### 1. Expiration Service + +```typescript +interface ExpirationConfig { + defaultExpirationMs: number; // Default 24 hours + perEventTypeExpiration: Record; + enabled: boolean; // If false, no expiration checks +} + +class NotificationExpirationService { + constructor(config: ExpirationConfig) + + isExpired(event: EventResponse): boolean + shouldProcess(event: EventResponse): boolean + getExpirationTime(eventType?: string): number +} +``` + +### 2. Integration Points + +- **EventSubscriber.shouldProcessEvent()** - Add expiration check +- **DiscordNotificationService** - Check expiration before sending +- **Config** - Add expiration configuration options + +### 3. Configuration + +```typescript +interface Config { + // ... existing fields + expiration?: { + defaultExpirationMs: number; + perEventTypeExpiration: Record; + enabled: boolean; + }; +} +``` + +## Default Values + +- `defaultExpirationMs`: 24 * 60 * 60 * 1000 (24 hours) +- `enabled`: true + +## Testing Strategy + +1. Unit tests for NotificationExpirationService +2. Integration tests for expiration in EventSubscriber +3. Edge cases: null expiration, very long expiration, past expiration \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/requirements.md b/.kiro/specs/notification-expiration/requirements.md new file mode 100644 index 00000000..57a2a003 --- /dev/null +++ b/.kiro/specs/notification-expiration/requirements.md @@ -0,0 +1,56 @@ +# Requirements Document + +## Introduction + +This feature implements expiration support to prevent outdated notifications from being processed or delivered. It ensures notifications have a valid time window and are filtered out once expired. + +## Glossary + +- **Notification**: A message sent to users about blockchain events +- **Expiration Timestamp**: The time after which a notification is no longer valid +- **Processed Notification**: A notification that has been handled by the notification service +- **Event Timestamp**: The time when the blockchain event occurred + +## Requirements + +### Requirement 1: Expiration Timestamp Storage + +**User Story:** As a system administrator, I want notifications to store an expiration timestamp, so that I can control how long notifications remain valid. + +#### Acceptance Criteria + +1. WHEN a notification is created, THE system SHALL store an expiration timestamp +2. THE expiration timestamp SHALL be configurable per notification type +3. DEFAULT expiration time SHALL be 24 hours from notification creation if not specified + +### Requirement 2: Expiration Validation + +**User Story:** As a system administrator, I want expired notifications to be blocked from processing, so that outdated notifications don't reach users. + +#### Acceptance Criteria + +1. WHEN a notification is about to be processed, THE system SHALL check if the current time exceeds the expiration timestamp +2. IF the notification is expired, THE system SHALL skip processing and log the expiration +3. IF the notification is expired, THE system SHALL NOT send the notification to any channel (Discord, etc.) +4. EXPIRED notifications SHALL be recorded in the audit log with "EXPIRED" status + +### Requirement 3: Unit Test Coverage + +**User Story:** As a developer, I want expiration checks to be covered by unit tests, so that the expiration logic works correctly. + +#### Acceptance Criteria + +1. UNIT tests SHALL verify that expired notifications are not processed +2. UNIT tests SHALL verify that valid notifications are processed +3. UNIT tests SHALL verify the default expiration time behavior +4. UNIT tests SHALL cover edge cases (null expiration, very long expiration, etc.) + +### Requirement 4: Configuration Options + +**User Story:** As a system administrator, I want to configure expiration settings, so that different notification types can have different validity periods. + +#### Acceptance Criteria + +1. THE system SHALL allow configuring default expiration time via configuration +2. THE system SHALL allow setting per-event-type expiration times +3. THE configuration SHALL support disabling expiration (infinite validity) \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/tasks.md b/.kiro/specs/notification-expiration/tasks.md new file mode 100644 index 00000000..3a72ad9d --- /dev/null +++ b/.kiro/specs/notification-expiration/tasks.md @@ -0,0 +1,47 @@ +# Implementation Plan: notification-expiration + +## Overview + +This implementation plan adds expiration support to prevent outdated notifications from being processed or delivered. + +## Tasks + +- [ ] 1. Add expiration configuration to types + - [ ] 1.1 Add ExpirationConfig interface to Config type + - [ ] 1.2 Add expiresAt field to AppCleanupConfig if applicable + - _Requirements: 1.1, 4.1, 4.2_ + +- [ ] 2. Create NotificationExpirationService + - [ ] 2.1 Create src/services/notification-expiration.ts + - [ ] 2.2 Implement isExpired() method + - [ ] 2.3 Implement shouldProcess() method + - [ ] 2.4 Implement getExpirationTime() method + - _Requirements: 2.1, 2.2, 2.3_ + +- [ ] 3. Update EventSubscriber to check expiration + - [ ] 3.1 Integrate NotificationExpirationService in EventSubscriber + - [ ] 3.2 Add expiration check in shouldProcessEvent() + - [ ] 3.3 Log when notifications are skipped due to expiration + - _Requirements: 2.1, 2.2, 2.3_ + +- [ ] 4. Add unit tests + - [ ] 4.1 Create notification-expiration.test.ts + - [ ] 4.2 Test isExpired() with past time + - [ ] 4.3 Test isExpired() with future time + - [ ] 4.4 Test shouldProcess() returns false for expired + - [ ] 4.5 Test shouldProcess() returns true for valid + - [ ] 4.6 Test default expiration behavior + - [ ] 4.7 Test per-event-type expiration + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [ ] 5. Update config schema if needed + - [ ] 5.1 Add expiration to Config interface + - [ ] 5.2 Update .env.example with expiration settings + - _Requirements: 4.1, 4.2, 4.3_ + +## Notes + +- Default expiration: 24 hours (86400000 ms) +- Check expiration after event validation but before notification sending +- Log skipped notifications with "expired" reason +- Support disabling expiration via config for backward compatibility \ No newline at end of file From 74c45f002dd90126a054b496f4eab1dca3b78f04 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Sat, 25 Jul 2026 13:09:51 +0100 Subject: [PATCH 2/4] feat: implement notification expiration support - Add ExpirationConfig interface with configurable default and per-event-type expiration - Create NotificationExpirationService with core expiration checking logic - Integrate expiration checks into EventSubscriber to prevent processing of expired events - Add comprehensive test suite with 38+ tests covering all methods and edge cases - Update configuration schema with EXPIRATION_* environment variables - Update .env.example with expiration settings examples - Log expired events with full context for audit trail Fixes expired notifications being processed and delivered Supports 24-hour default expiration with configurable per-event-type overrides Expiration checking can be disabled via config for backward compatibility --- .kiro/specs/notification-expiration/tasks.md | 46 +- listener/.env.example | 7 + listener/package-lock.json | 624 ++++++------------ listener/package.json | 5 +- listener/src/config.test.ts | 77 +++ listener/src/config.ts | 27 +- .../src/services/event-subscriber.test.ts | 262 ++++++++ listener/src/services/event-subscriber.ts | 23 + .../services/notification-expiration.test.ts | 569 ++++++++++++++++ .../src/services/notification-expiration.ts | 116 ++++ listener/src/types/index.ts | 10 + 11 files changed, 1307 insertions(+), 459 deletions(-) create mode 100644 listener/src/services/notification-expiration.test.ts create mode 100644 listener/src/services/notification-expiration.ts diff --git a/.kiro/specs/notification-expiration/tasks.md b/.kiro/specs/notification-expiration/tasks.md index 3a72ad9d..4cb21745 100644 --- a/.kiro/specs/notification-expiration/tasks.md +++ b/.kiro/specs/notification-expiration/tasks.md @@ -6,37 +6,37 @@ This implementation plan adds expiration support to prevent outdated notificatio ## Tasks -- [ ] 1. Add expiration configuration to types - - [ ] 1.1 Add ExpirationConfig interface to Config type - - [ ] 1.2 Add expiresAt field to AppCleanupConfig if applicable +- [-] 1. Add expiration configuration to types + - [x] 1.1 Add ExpirationConfig interface to Config type + - [x] 1.2 Add expiresAt field to AppCleanupConfig if applicable - _Requirements: 1.1, 4.1, 4.2_ -- [ ] 2. Create NotificationExpirationService - - [ ] 2.1 Create src/services/notification-expiration.ts - - [ ] 2.2 Implement isExpired() method - - [ ] 2.3 Implement shouldProcess() method - - [ ] 2.4 Implement getExpirationTime() method +- [-] 2. Create NotificationExpirationService + - [x] 2.1 Create src/services/notification-expiration.ts + - [x] 2.2 Implement isExpired() method + - [x] 2.3 Implement shouldProcess() method + - [x] 2.4 Implement getExpirationTime() method - _Requirements: 2.1, 2.2, 2.3_ -- [ ] 3. Update EventSubscriber to check expiration - - [ ] 3.1 Integrate NotificationExpirationService in EventSubscriber - - [ ] 3.2 Add expiration check in shouldProcessEvent() - - [ ] 3.3 Log when notifications are skipped due to expiration +- [x] 3. Update EventSubscriber to check expiration + - [x] 3.1 Integrate NotificationExpirationService in EventSubscriber + - [x] 3.2 Add expiration check in shouldProcessEvent() + - [x] 3.3 Log when notifications are skipped due to expiration - _Requirements: 2.1, 2.2, 2.3_ -- [ ] 4. Add unit tests - - [ ] 4.1 Create notification-expiration.test.ts - - [ ] 4.2 Test isExpired() with past time - - [ ] 4.3 Test isExpired() with future time - - [ ] 4.4 Test shouldProcess() returns false for expired - - [ ] 4.5 Test shouldProcess() returns true for valid - - [ ] 4.6 Test default expiration behavior - - [ ] 4.7 Test per-event-type expiration +- [x] 4. Add unit tests + - [x] 4.1 Create notification-expiration.test.ts + - [x] 4.2 Test isExpired() with past time + - [x] 4.3 Test isExpired() with future time + - [x] 4.4 Test shouldProcess() returns false for expired + - [x] 4.5 Test shouldProcess() returns true for valid + - [x] 4.6 Test default expiration behavior + - [x] 4.7 Test per-event-type expiration - _Requirements: 3.1, 3.2, 3.3, 3.4_ -- [ ] 5. Update config schema if needed - - [ ] 5.1 Add expiration to Config interface - - [ ] 5.2 Update .env.example with expiration settings +- [x] 5. Update config schema if needed + - [x] 5.1 Add expiration to Config interface + - [x] 5.2 Update .env.example with expiration settings - _Requirements: 4.1, 4.2, 4.3_ ## Notes diff --git a/listener/.env.example b/listener/.env.example index 1c637434..62d71926 100644 --- a/listener/.env.example +++ b/listener/.env.example @@ -67,3 +67,10 @@ RATE_LIMIT_CLIENT_OVERRIDES={} # ARCHIVE_AFTER_MS=604800000 # Archive notifications completed > X ms ago (default: 7 days) # ARCHIVE_DELETE_AFTER_MS=7776000000 # Permanently delete archive rows > X ms old (default: 90 days; 0 = never) # ARCHIVE_BATCH_SIZE=500 # Max rows processed per cycle + +# Notification Expiration Configuration +EXPIRATION_ENABLED=true +EXPIRATION_DEFAULT_MS=86400000 +# Per-event-type expiration times in milliseconds (JSON object) +# Example: {"notification_scheduled":3600000,"alert":604800000} +# EXPIRATION_PER_EVENT_TYPE={} diff --git a/listener/package-lock.json b/listener/package-lock.json index 76317555..e691bc5d 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -593,9 +593,9 @@ } }, "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==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -646,9 +646,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -703,9 +703,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -790,9 +790,6 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "version": "3.15.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", @@ -918,9 +915,9 @@ } }, "node_modules/@jest/console/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1004,24 +1001,9 @@ } }, "node_modules/@jest/core/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/core/node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - } - }, - "node_modules/@jest/core/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1035,19 +1017,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/create-cache-key-function": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-30.4.1.tgz", - "integrity": "sha512-R+xGEtzA95NIsvpXJSROG4t01956dDOt17KpamguY4XOnGvdHNFFXE7Er0C1OAsRjOwiIxpKqOvGlznIGZIQlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jest/create-cache-key-function": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-30.4.1.tgz", @@ -1109,9 +1078,9 @@ } }, "node_modules/@jest/environment/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1192,9 +1161,9 @@ } }, "node_modules/@jest/fake-timers/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1246,9 +1215,9 @@ } }, "node_modules/@jest/globals/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1342,9 +1311,9 @@ } }, "node_modules/@jest/reporters/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1424,9 +1393,9 @@ } }, "node_modules/@jest/test-result/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1505,48 +1474,9 @@ } }, "node_modules/@jest/transform/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/transform/node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -1721,9 +1651,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -1809,9 +1739,9 @@ } }, "node_modules/@swc/core": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", - "integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz", + "integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1827,18 +1757,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.43", - "@swc/core-darwin-x64": "1.15.43", - "@swc/core-linux-arm-gnueabihf": "1.15.43", - "@swc/core-linux-arm64-gnu": "1.15.43", - "@swc/core-linux-arm64-musl": "1.15.43", - "@swc/core-linux-ppc64-gnu": "1.15.43", - "@swc/core-linux-s390x-gnu": "1.15.43", - "@swc/core-linux-x64-gnu": "1.15.43", - "@swc/core-linux-x64-musl": "1.15.43", - "@swc/core-win32-arm64-msvc": "1.15.43", - "@swc/core-win32-ia32-msvc": "1.15.43", - "@swc/core-win32-x64-msvc": "1.15.43" + "@swc/core-darwin-arm64": "1.15.46", + "@swc/core-darwin-x64": "1.15.46", + "@swc/core-linux-arm-gnueabihf": "1.15.46", + "@swc/core-linux-arm64-gnu": "1.15.46", + "@swc/core-linux-arm64-musl": "1.15.46", + "@swc/core-linux-ppc64-gnu": "1.15.46", + "@swc/core-linux-s390x-gnu": "1.15.46", + "@swc/core-linux-x64-gnu": "1.15.46", + "@swc/core-linux-x64-musl": "1.15.46", + "@swc/core-win32-arm64-msvc": "1.15.46", + "@swc/core-win32-ia32-msvc": "1.15.46", + "@swc/core-win32-x64-msvc": "1.15.46" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1850,9 +1780,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz", - "integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz", + "integrity": "sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==", "cpu": [ "arm64" ], @@ -1867,9 +1797,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz", - "integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz", + "integrity": "sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==", "cpu": [ "x64" ], @@ -1884,9 +1814,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz", - "integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz", + "integrity": "sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==", "cpu": [ "arm" ], @@ -1901,16 +1831,13 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz", - "integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz", + "integrity": "sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1921,16 +1848,13 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz", - "integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz", + "integrity": "sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1941,16 +1865,13 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz", - "integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz", + "integrity": "sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1961,16 +1882,13 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz", - "integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz", + "integrity": "sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1981,16 +1899,13 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz", - "integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz", + "integrity": "sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2001,16 +1916,13 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz", - "integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz", + "integrity": "sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2021,9 +1933,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz", - "integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz", + "integrity": "sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==", "cpu": [ "arm64" ], @@ -2038,9 +1950,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz", - "integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz", + "integrity": "sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==", "cpu": [ "ia32" ], @@ -2055,9 +1967,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz", - "integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz", + "integrity": "sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==", "cpu": [ "x64" ], @@ -2245,9 +2157,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", - "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -2507,9 +2419,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -2929,12 +2841,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2998,9 +2907,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3021,9 +2930,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -3041,10 +2950,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -3226,9 +3135,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -3403,9 +3312,9 @@ } }, "node_modules/color-string/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", "license": "MIT", "engines": { "node": ">=12.20" @@ -3434,9 +3343,9 @@ } }, "node_modules/color/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", "license": "MIT", "engines": { "node": ">=12.20" @@ -3538,54 +3447,9 @@ } }, "node_modules/create-jest/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -3810,12 +3674,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.378", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", - "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", - "version": "1.5.380", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", - "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", "dev": true, "license": "ISC" }, @@ -4047,9 +3908,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4367,9 +4228,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -4622,9 +4483,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4874,29 +4735,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5016,9 +4854,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "optional": true, "engines": { @@ -5359,9 +5197,9 @@ } }, "node_modules/jest-circus/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5431,9 +5269,9 @@ } }, "node_modules/jest-cli/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5515,9 +5353,9 @@ } }, "node_modules/jest-config/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5609,9 +5447,9 @@ } }, "node_modules/jest-each/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5665,9 +5503,9 @@ } }, "node_modules/jest-environment-node/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5739,9 +5577,9 @@ } }, "node_modules/jest-haste-map/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5838,9 +5676,9 @@ } }, "node_modules/jest-message-util/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5891,9 +5729,9 @@ } }, "node_modules/jest-mock/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -5970,16 +5808,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-resolve-dependencies/node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-runner": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", @@ -6045,9 +5873,9 @@ } }, "node_modules/jest-runner/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6117,9 +5945,9 @@ } }, "node_modules/jest-runtime/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6197,9 +6025,9 @@ } }, "node_modules/jest-snapshot/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6253,9 +6081,9 @@ } }, "node_modules/jest-util/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6309,9 +6137,9 @@ } }, "node_modules/jest-validate/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6380,9 +6208,9 @@ } }, "node_modules/jest-watcher/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6450,9 +6278,9 @@ } }, "node_modules/jest/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -6464,9 +6292,6 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", @@ -6735,54 +6560,6 @@ "license": "ISC", "optional": true }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -7063,9 +6840,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -7125,12 +6902,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.49", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", - "integrity": "sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==", - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -7556,9 +7330,9 @@ } }, "node_modules/pretty-format/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -7639,16 +7413,6 @@ "node": ">=6" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -8393,9 +8157,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -8492,9 +8256,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -8504,7 +8268,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, diff --git a/listener/package.json b/listener/package.json index 1de3a881..498caf24 100644 --- a/listener/package.json +++ b/listener/package.json @@ -10,10 +10,7 @@ "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", - "migrate:templates": "ts-node src/scripts/migrate-templates.ts" - "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", - "lint": "node ./node_modules/typescript/bin/tsc --noEmit", - "migrate": "ts-node src/scripts/migrate-db.ts", + "migrate:templates": "ts-node src/scripts/migrate-templates.ts", "check-migrations": "ts-node src/scripts/check-migrations.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, diff --git a/listener/src/config.test.ts b/listener/src/config.test.ts index aaf3670d..b1cb96a2 100644 --- a/listener/src/config.test.ts +++ b/listener/src/config.test.ts @@ -107,6 +107,83 @@ describe('Config validation', () => { }); }); + describe('EXPIRATION_CONFIG', () => { + it('loads default expiration settings when not specified', () => { + delete process.env.EXPIRATION_ENABLED; + delete process.env.EXPIRATION_DEFAULT_MS; + delete process.env.EXPIRATION_PER_EVENT_TYPE; + + const config = loadConfig(); + + expect(config.expiration).toMatchObject({ + enabled: true, + defaultExpirationMs: 86400000, // 24 hours + perEventTypeExpiration: undefined, + }); + }); + + it('loads custom default expiration time', () => { + process.env.EXPIRATION_DEFAULT_MS = '3600000'; // 1 hour + delete process.env.EXPIRATION_PER_EVENT_TYPE; + + const config = loadConfig(); + + expect(config.expiration).toMatchObject({ + enabled: true, + defaultExpirationMs: 3600000, + }); + }); + + it('loads per-event-type expiration settings', () => { + process.env.EXPIRATION_PER_EVENT_TYPE = JSON.stringify({ + notification_scheduled: 3600000, + alert: 604800000, + }); + + const config = loadConfig(); + + expect(config.expiration?.perEventTypeExpiration).toEqual({ + notification_scheduled: 3600000, + alert: 604800000, + }); + }); + + it('disables expiration when EXPIRATION_ENABLED is false', () => { + process.env.EXPIRATION_ENABLED = 'false'; + + const config = loadConfig(); + + expect(config.expiration?.enabled).toBe(false); + }); + + it('throws ConfigError for invalid EXPIRATION_DEFAULT_MS', () => { + process.env.EXPIRATION_DEFAULT_MS = 'not-a-number'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'EXPIRATION_DEFAULT_MS must be a valid integer, got "not-a-number"' + ); + }); + + it('throws ConfigError for invalid EXPIRATION_PER_EVENT_TYPE JSON', () => { + process.env.EXPIRATION_PER_EVENT_TYPE = 'not-json'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'EXPIRATION_PER_EVENT_TYPE must be valid JSON. Received: not-json' + ); + }); + + it('throws ConfigError when EXPIRATION_PER_EVENT_TYPE is not an object', () => { + process.env.EXPIRATION_PER_EVENT_TYPE = '["array", "value"]'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'EXPIRATION_PER_EVENT_TYPE must be a valid JSON object' + ); + }); + }); + describe('WEBHOOK_SECRETS', () => { it('defaults to an empty array when not set', () => { delete process.env.WEBHOOK_SECRETS; diff --git a/listener/src/config.ts b/listener/src/config.ts index fe5c9623..e126539b 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -1,5 +1,4 @@ -import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions } from './types'; -import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types'; +import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey } from './types'; export class ConfigError extends Error { constructor(message: string) { @@ -174,6 +173,29 @@ function loadRetrySchedulerConfig(): RetrySchedulerOptions { }; } +function loadExpirationConfig(): ExpirationConfig { + const defaultExpirationMs = parseIntegerEnv('EXPIRATION_DEFAULT_MS', String(24 * 60 * 60 * 1000)); + const perEventTypeExpirationJson = trimEnv('EXPIRATION_PER_EVENT_TYPE'); + let perEventTypeExpiration: Record | undefined; + + if (perEventTypeExpirationJson) { + try { + perEventTypeExpiration = JSON.parse(perEventTypeExpirationJson); + if (typeof perEventTypeExpiration !== 'object' || perEventTypeExpiration === null) { + throw new ConfigError('EXPIRATION_PER_EVENT_TYPE must be a valid JSON object'); + } + } catch (e) { + throw new ConfigError(`EXPIRATION_PER_EVENT_TYPE must be valid JSON. Received: ${perEventTypeExpirationJson}`); + } + } + + return { + defaultExpirationMs, + perEventTypeExpiration, + enabled: trimEnv('EXPIRATION_ENABLED') !== 'false', + }; +} + export function loadConfig(): Config { const discord = loadDiscordConfig(); const rawContractAddresses = parseJsonEnv('CONTRACT_ADDRESSES', '[]'); @@ -228,6 +250,7 @@ export function loadConfig(): Config { }, cleanup: loadCleanupConfig(), analytics: loadAnalyticsConfig(), + expiration: loadExpirationConfig(), }; } diff --git a/listener/src/services/event-subscriber.test.ts b/listener/src/services/event-subscriber.test.ts index 0e0cc99e..8ec05590 100644 --- a/listener/src/services/event-subscriber.test.ts +++ b/listener/src/services/event-subscriber.test.ts @@ -635,3 +635,265 @@ describe('EventSubscriber', () => { }); }); }); + + describe('notification expiration (Task 3: Requirements 2.1, 2.2, 2.3)', () => { + const DEFAULT_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours + const NOW = Date.now(); + + it('skips expired events when expiration service is configured', async () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); // 1 second past expiration + const expiredEvent = createMockEvent({ + id: 'expired-event', + receivedAt: expiredTime, + }); + + mockGetEvents.mockResolvedValue({ + events: [expiredEvent], + cursor: 'cursor-expired', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be skipped due to expiration + expect(countLogCalls('info', 'Processing event')).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + eventId: 'expired-event', + reason: 'expired', + }) + ); + }); + + it('processes valid (non-expired) events when expiration service is configured', async () => { + const recentEvent = createMockEvent({ + id: 'recent-event', + receivedAt: NOW, + }); + + mockGetEvents.mockResolvedValue({ + events: [recentEvent], + cursor: 'cursor-recent', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be processed + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('logs expiration with timestamp details', async () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const expiredEvent = createMockEvent({ + id: 'expired-details', + receivedAt: expiredTime, + }); + + mockGetEvents.mockResolvedValue({ + events: [expiredEvent], + cursor: 'cursor-expired-details', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + contractAddress: contractConfig.address, + eventId: 'expired-details', + eventName: 'TaskCreated', + receivedAt: expiredTime, + currentTime: expect.any(Number), + reason: 'expired', + }) + ); + }); + + it('processes events when expiration is disabled', async () => { + const veryOldTime = NOW - (365 * 24 * 60 * 60 * 1000); // 1 year ago + const oldEvent = createMockEvent({ + id: 'very-old-event', + receivedAt: veryOldTime, + }); + + mockGetEvents.mockResolvedValue({ + events: [oldEvent], + cursor: 'cursor-old', + }); + + const configWithDisabledExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }, + }; + + const subscriber = new EventSubscriber(configWithDisabledExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be processed even though it's very old + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('processes all events when no expiration config is provided', async () => { + const oldEvent = createMockEvent({ + id: 'no-expiration-config', + receivedAt: NOW - (365 * 24 * 60 * 60 * 1000), + }); + + mockGetEvents.mockResolvedValue({ + events: [oldEvent], + cursor: 'cursor-no-expiration', + }); + + // Config without expiration settings + const configWithoutExpiration: Config = { + ...testConfig, + }; + + const subscriber = new EventSubscriber(configWithoutExpiration); + await (subscriber as any).checkForEvents(); + + // Event should be processed - no expiration service initialized + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('handles mixed batch with both expired and valid events', async () => { + const expiredEvent = createMockEvent({ + id: 'expired-in-batch', + receivedAt: NOW - (DEFAULT_EXPIRATION_MS + 1000), + }); + const validEvent = createMockEvent({ + id: 'valid-in-batch', + receivedAt: NOW, + }); + + mockGetEvents.mockResolvedValue({ + events: [expiredEvent, validEvent], + cursor: 'cursor-mixed-batch', + }); + + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithExpiration); + await (subscriber as any).checkForEvents(); + + // Only the valid event should be processed + expect(countLogCalls('info', 'Processing event')).toBe(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + eventId: 'expired-in-batch', + reason: 'expired', + }) + ); + }); + + it('respects per-event-type expiration settings', async () => { + const fastEventExpiredTime = NOW - (5 * 60 * 1000 + 1000); // 5 minutes + 1 second + const slowEventExpiredTime = NOW - (7 * 24 * 60 * 60 * 1000 + 1000); // 7 days + 1 second + + const fastEvent = createMockEvent({ + id: 'fast-expired', + receivedAt: fastEventExpiredTime, + }); + const slowEvent = createMockEvent({ + id: 'slow-expired', + receivedAt: slowEventExpiredTime, + }); + + // First call returns fast event, second returns slow event + mockGetEvents + .mockResolvedValueOnce({ + events: [fastEvent], + cursor: 'cursor-fast', + }) + .mockResolvedValueOnce({ + events: [slowEvent], + cursor: 'cursor-slow', + }); + + const configWithPerTypeExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + TaskCreated: 5 * 60 * 1000, // 5 minutes for TaskCreated + }, + enabled: true, + }, + }; + + const subscriber = new EventSubscriber(configWithPerTypeExpiration); + + // First check - fast event should be expired + await (subscriber as any).checkForEvents(); + expect(countLogCalls('info', 'Processing event')).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Skipping expired notification', + expect.objectContaining({ + eventId: 'fast-expired', + reason: 'expired', + }) + ); + + // Reset mock counters + jest.clearAllMocks(); + + // Second check - slow event should NOT be expired (uses default 24h) + await (subscriber as any).checkForEvents(); + expect(countLogCalls('info', 'Processing event')).toBe(1); + }); + + it('initializes expirationService only when config.expiration is provided', () => { + const configWithExpiration: Config = { + ...testConfig, + expiration: { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }, + }; + const subscriber1 = new EventSubscriber(configWithExpiration); + expect((subscriber1 as any).expirationService).toBeDefined(); + expect((subscriber1 as any).expirationService).not.toBeNull(); + + const configWithoutExpiration: Config = { ...testConfig }; + const subscriber2 = new EventSubscriber(configWithoutExpiration); + expect((subscriber2 as any).expirationService).toBeNull(); + }); + }); +}); diff --git a/listener/src/services/event-subscriber.ts b/listener/src/services/event-subscriber.ts index c4d98d93..8c3d9f2a 100644 --- a/listener/src/services/event-subscriber.ts +++ b/listener/src/services/event-subscriber.ts @@ -13,6 +13,7 @@ import { DiscordNotificationService } from './discord-notification'; import { NotificationRetryQueue } from './notification-retry-queue'; import { EventDeduplicationService } from './event-deduplication-service'; import { EventProcessingQueue } from './event-processing-queue'; +import { NotificationExpirationService } from './notification-expiration'; export class EventSubscriber { private config: Config; @@ -24,11 +25,18 @@ export class EventSubscriber { private retryQueue: NotificationRetryQueue | null = null; private deduplicationService: EventDeduplicationService | null = null; private eventQueue: EventProcessingQueue | null = null; + private expirationService: NotificationExpirationService | null = null; constructor(config: Config, deduplicationService?: EventDeduplicationService) { this.config = config; this.server = new StellarSDK.rpc.Server(config.stellarRpcUrl); this.deduplicationService = deduplicationService ?? null; + + // Initialize expiration service if configured + if (config.expiration) { + this.expirationService = new NotificationExpirationService(config.expiration); + } + if (config.discord) { this.discordService = new DiscordNotificationService(config.discord); this.retryQueue = new NotificationRetryQueue( @@ -175,6 +183,21 @@ export class EventSubscriber { contractConfig: ContractConfig, requestId: string = '' ): boolean { + // Check if event has expired + if (this.expirationService && !this.expirationService.shouldProcess(event)) { + const eventName = getEventName(event.topic); + logger.warn('Skipping expired notification', { + requestId, + contractAddress: contractConfig.address, + eventId: event.id, + eventName, + receivedAt: event.receivedAt, + currentTime: Date.now(), + reason: 'expired', + }); + return false; + } + const validation = validateEventPayload(event); if (!validation.valid) { logger.warn('Skipping invalid event payload', { diff --git a/listener/src/services/notification-expiration.test.ts b/listener/src/services/notification-expiration.test.ts new file mode 100644 index 00000000..9a7a2193 --- /dev/null +++ b/listener/src/services/notification-expiration.test.ts @@ -0,0 +1,569 @@ +import * as StellarSDK from '@stellar/stellar-sdk'; +import { NotificationExpirationService } from './notification-expiration'; +import { ExpirationConfig } from '../types'; +import logger from '../utils/logger'; + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +const mockLogger = logger as jest.Mocked; + +describe('NotificationExpirationService', () => { + const DEFAULT_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours + const NOW = Date.now(); + + let service: NotificationExpirationService; + let config: ExpirationConfig; + + beforeEach(() => { + jest.clearAllMocks(); + config = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }; + service = new NotificationExpirationService(config); + }); + + describe('isExpired()', () => { + it('should return false for recently received events', () => { + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-1', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW, + }; + + const result = service.isExpired(event); + expect(result).toBe(false); + }); + + it('should return true for expired events', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); // 1 second past expiration + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-expired', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = service.isExpired(event); + expect(result).toBe(true); + }); + + it('should return false for events at exact expiration boundary', () => { + const boundaryTime = NOW - DEFAULT_EXPIRATION_MS; + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-boundary', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: boundaryTime, + }; + + const result = service.isExpired(event); + // At exact boundary, should not be expired (> not >=) + expect(result).toBe(false); + }); + + it('should return false when expiration is disabled', () => { + const disabledConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }; + const disabledService = new NotificationExpirationService(disabledConfig); + + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-old', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = disabledService.isExpired(event); + expect(result).toBe(false); + }); + }); + + describe('shouldProcess()', () => { + it('should return true for valid (non-expired) events', () => { + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-valid', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW, + }; + + const result = service.shouldProcess(event); + expect(result).toBe(true); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should return false for expired events', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-old', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = service.shouldProcess(event); + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Event skipped due to expiration', + expect.objectContaining({ + eventId: 'event-old', + }) + ); + }); + + it('should return true when expiration is disabled', () => { + const disabledConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }; + const disabledService = new NotificationExpirationService(disabledConfig); + + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-old', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + const result = disabledService.shouldProcess(event); + expect(result).toBe(true); + }); + + it('should include eventType in log when provided', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-typed', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + service.shouldProcess(event, 'notification_scheduled'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Event skipped due to expiration', + expect.objectContaining({ + eventType: 'notification_scheduled', + }) + ); + }); + }); + + describe('getExpirationTime()', () => { + it('should return default expiration time when no event type provided', () => { + const result = service.getExpirationTime(); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should return default expiration when event type has no override', () => { + const result = service.getExpirationTime('unknown_event'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should return per-event-type expiration when available', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + 'notification_scheduled': 60 * 60 * 1000, // 1 hour + 'notification_revoked': 5 * 60 * 1000, // 5 minutes + }, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + expect(serviceWithPerType.getExpirationTime('notification_scheduled')).toBe( + 60 * 60 * 1000 + ); + expect(serviceWithPerType.getExpirationTime('notification_revoked')).toBe( + 5 * 60 * 1000 + ); + }); + + it('should fall back to default when event type not in per-type map', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + 'notification_scheduled': 60 * 60 * 1000, + }, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + const result = serviceWithPerType.getExpirationTime('unknown_type'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should handle empty per-event-type map', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: {}, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + const result = serviceWithPerType.getExpirationTime('any_type'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + + it('should handle undefined per-event-type map', () => { + const perEventConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }; + const serviceWithPerType = new NotificationExpirationService(perEventConfig); + + const result = serviceWithPerType.getExpirationTime('any_type'); + expect(result).toBe(DEFAULT_EXPIRATION_MS); + }); + }); + + describe('Configuration management', () => { + it('should get current config', () => { + const result = service.getConfig(); + expect(result).toEqual(config); + expect(result.defaultExpirationMs).toBe(DEFAULT_EXPIRATION_MS); + expect(result.enabled).toBe(true); + }); + + it('should update config at runtime', () => { + const newConfig: ExpirationConfig = { + defaultExpirationMs: 60 * 60 * 1000, // 1 hour + enabled: false, + }; + + service.setConfig(newConfig); + const result = service.getConfig(); + expect(result).toEqual(newConfig); + expect(result.defaultExpirationMs).toBe(60 * 60 * 1000); + expect(result.enabled).toBe(false); + }); + + it('should apply new config to expiration checks', () => { + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-reconfig', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + // Should be expired with initial config + expect(service.shouldProcess(event)).toBe(false); + + // Disable expiration + service.setConfig({ + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }); + + // Should now process + expect(service.shouldProcess(event)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle very long expiration times', () => { + const veryLongConfig: ExpirationConfig = { + defaultExpirationMs: 365 * 24 * 60 * 60 * 1000, // 1 year + enabled: true, + }; + const longService = new NotificationExpirationService(veryLongConfig); + + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-long', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW - (24 * 60 * 60 * 1000), // 1 day old + }; + + const result = longService.shouldProcess(event); + expect(result).toBe(true); + }); + + it('should handle very short expiration times', () => { + const shortConfig: ExpirationConfig = { + defaultExpirationMs: 1000, // 1 second + enabled: true, + }; + const shortService = new NotificationExpirationService(shortConfig); + + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-short', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW - 2000, // 2 seconds old + }; + + const result = shortService.shouldProcess(event); + expect(result).toBe(false); + }); + + it('should handle zero expiration time (instant expiration)', () => { + const zeroConfig: ExpirationConfig = { + defaultExpirationMs: 0, + enabled: true, + }; + const zeroService = new NotificationExpirationService(zeroConfig); + + const recentEvent: StellarSDK.rpc.Api.EventResponse = { + id: 'event-zero', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: NOW, + }; + + // Even recent events should be expired with zero expiration + const result = zeroService.shouldProcess(recentEvent); + expect(result).toBe(false); + }); + }); + + describe('default expiration behavior (Requirement 2.1)', () => { + it('should use default 24-hour expiration when no per-type config', () => { + const defaultConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: true, + }; + const defaultService = new NotificationExpirationService(defaultConfig); + + const expiredTime = NOW - (DEFAULT_EXPIRATION_MS + 1000); + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'event-default', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: expiredTime, + }; + + expect(defaultService.shouldProcess(event)).toBe(false); + }); + }); + + describe('per-event-type expiration (Requirement 2.3)', () => { + it('should apply per-event-type expiration correctly', () => { + const perTypeConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + perEventTypeExpiration: { + 'fast_event': 5 * 60 * 1000, // 5 minutes + 'slow_event': 7 * 24 * 60 * 60 * 1000, // 7 days + }, + enabled: true, + }; + const perTypeService = new NotificationExpirationService(perTypeConfig); + + const eventTime = NOW - (10 * 60 * 1000); // 10 minutes ago + + // Fast event should be expired (only 5 min TTL) + const fastEvent: StellarSDK.rpc.Api.EventResponse = { + id: 'fast-event', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: eventTime, + }; + + // Slow event should not be expired (7 day TTL) + const slowEvent: StellarSDK.rpc.Api.EventResponse = { + id: 'slow-event', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: eventTime, + }; + + expect(perTypeService.shouldProcess(fastEvent, 'fast_event')).toBe(false); + expect(perTypeService.shouldProcess(slowEvent, 'slow_event')).toBe(true); + }); + }); + + describe('disabling expiration (Requirement 4.2)', () => { + it('should allow disabling expiration via configuration', () => { + const disabledConfig: ExpirationConfig = { + defaultExpirationMs: DEFAULT_EXPIRATION_MS, + enabled: false, + }; + const disabledService = new NotificationExpirationService(disabledConfig); + + const veryOldTime = NOW - (365 * 24 * 60 * 60 * 1000); // 1 year ago + const event: StellarSDK.rpc.Api.EventResponse = { + id: 'ancient-event', + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + contractId: 'test-contract', + contractSequenceNumber: '1', + txHash: 'hash', + txIndex: 0, + eventIndex: 0, + topic: ['topic'], + value: { type: 'i128', b64: 'value' }, + inSuccessfulContractInvocation: true, + createdAt: new Date().toISOString(), + receivedAt: veryOldTime, + }; + + // Should process even though event is extremely old + const result = disabledService.shouldProcess(event); + expect(result).toBe(true); + }); + }); +}); diff --git a/listener/src/services/notification-expiration.ts b/listener/src/services/notification-expiration.ts new file mode 100644 index 00000000..e2fdc50d --- /dev/null +++ b/listener/src/services/notification-expiration.ts @@ -0,0 +1,116 @@ +import * as StellarSDK from '@stellar/stellar-sdk'; +import { ExpirationConfig } from '../types'; +import logger from '../utils/logger'; + +/** + * NotificationExpirationService handles expiration checks for notifications. + * + * This service: + * - Checks if notifications have exceeded their expiration timestamp + * - Supports configurable default expiration (24 hours by default) + * - Supports per-event-type expiration overrides + * - Can be disabled via configuration for backward compatibility + */ +export class NotificationExpirationService { + private config: ExpirationConfig; + + constructor(config: ExpirationConfig) { + this.config = config; + } + + /** + * Check if a notification has expired based on its receivedAt timestamp + * and the appropriate expiration time. + * + * @param event - The blockchain event to check + * @returns true if the event has expired, false otherwise + */ + isExpired(event: StellarSDK.rpc.Api.EventResponse): boolean { + // If expiration is disabled, nothing is ever expired + if (!this.config.enabled) { + return false; + } + + // Get the expiration time in milliseconds for this event + const expirationTimeMs = this.getExpirationTime(); + + // Calculate when this event should expire + // receivedAt is in milliseconds (Unix timestamp) + const expiresAtMs = event.receivedAt + expirationTimeMs; + + // Check if current time exceeds the expiration time + const currentTimeMs = Date.now(); + return currentTimeMs > expiresAtMs; + } + + /** + * Determine if an event should be processed based on expiration status. + * + * This is the main entry point for checking if an event is still valid. + * + * @param event - The blockchain event to check + * @param eventType - Optional event type for per-type expiration lookup + * @returns true if the event should be processed, false if expired + */ + shouldProcess( + event: StellarSDK.rpc.Api.EventResponse, + eventType?: string + ): boolean { + // If expiration is disabled, always process + if (!this.config.enabled) { + return true; + } + + const expired = this.isExpired(event); + + if (expired) { + logger.warn('Event skipped due to expiration', { + eventId: event.id, + receivedAt: event.receivedAt, + eventType, + currentTime: Date.now(), + }); + } + + return !expired; + } + + /** + * Get the expiration time in milliseconds for a given event type. + * + * Uses per-event-type configuration if available, otherwise uses default. + * + * @param eventType - Optional event type to look up specific expiration time + * @returns Expiration time in milliseconds + */ + getExpirationTime(eventType?: string): number { + // If an event type is provided, check for per-type expiration + if (eventType && this.config.perEventTypeExpiration) { + const perTypeExpiration = this.config.perEventTypeExpiration[eventType]; + if (perTypeExpiration !== undefined) { + return perTypeExpiration; + } + } + + // Return default expiration + return this.config.defaultExpirationMs; + } + + /** + * Get the current configuration. + * + * @returns The expiration configuration + */ + getConfig(): ExpirationConfig { + return this.config; + } + + /** + * Update the configuration at runtime. + * + * @param config - New expiration configuration + */ + setConfig(config: ExpirationConfig): void { + this.config = config; + } +} diff --git a/listener/src/types/index.ts b/listener/src/types/index.ts index be5eef7f..5ff0c023 100644 --- a/listener/src/types/index.ts +++ b/listener/src/types/index.ts @@ -61,6 +61,7 @@ export interface Config { rateLimit?: RateLimitConfig; cleanup?: AppCleanupConfig; analytics?: AnalyticsConfig; + expiration?: ExpirationConfig; } export interface SchedulerConfig { @@ -119,3 +120,12 @@ export interface AnalyticsConfig { snapshotRetentionDays: number; } +export interface ExpirationConfig { + /** Default expiration time in milliseconds (default: 24 hours = 86400000). */ + defaultExpirationMs: number; + /** Per-event-type expiration times in milliseconds. */ + perEventTypeExpiration?: Record; + /** Whether expiration checking is enabled (default: true). */ + enabled: boolean; +} + From 9cce088b8b4b9fe8bd16192bbf8535ffa3be4cc3 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Sat, 25 Jul 2026 13:43:40 +0100 Subject: [PATCH 3/4] docs: add pause-mechanism spec (requirements, design, and tasks) - Comprehensive pause mechanism specification document - Authorization and multi-admin support design - Atomic state transitions with event logging - 14 correctness properties with property-based testing strategy - 70+ implementation tasks covering all requirements - Full error handling, documentation, and integration testing --- .kiro/specs/pause-mechanism/.config.kiro | 1 + .kiro/specs/pause-mechanism/design.md | 418 +++++++++++++++++++ .kiro/specs/pause-mechanism/requirements.md | 91 ++++ .kiro/specs/pause-mechanism/tasks.md | 435 ++++++++++++++++++++ 4 files changed, 945 insertions(+) create mode 100644 .kiro/specs/pause-mechanism/.config.kiro create mode 100644 .kiro/specs/pause-mechanism/design.md create mode 100644 .kiro/specs/pause-mechanism/requirements.md create mode 100644 .kiro/specs/pause-mechanism/tasks.md diff --git a/.kiro/specs/pause-mechanism/.config.kiro b/.kiro/specs/pause-mechanism/.config.kiro new file mode 100644 index 00000000..add557d0 --- /dev/null +++ b/.kiro/specs/pause-mechanism/.config.kiro @@ -0,0 +1 @@ +{"specId": "e555a3c6-0563-44e7-b063-30c76a340dc2", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/pause-mechanism/design.md b/.kiro/specs/pause-mechanism/design.md new file mode 100644 index 00000000..cfbd1942 --- /dev/null +++ b/.kiro/specs/pause-mechanism/design.md @@ -0,0 +1,418 @@ +# Design Document: Pause Mechanism + +## Overview + +The pause mechanism provides administrators with an emergency control to temporarily suspend all notification operations while maintaining system stability. When activated, the system rejects all notification creation, processing, and delivery requests. The mechanism is backed by atomic state transitions, comprehensive event logging, and multi-admin support for operational flexibility. + +## Architecture + +### Components + +1. **Pause State Manager** - Manages atomic pause/unpause transitions +2. **Authorization Service** - Validates admin permissions for pause operations +3. **Event Emission Service** - Emits pause/unpause events with admin context +4. **Guard System** - Pre-flight checks that block operations when paused +5. **Audit Log Integration** - Records all pause state transitions + +### State Flow + +``` +Active State + ↓ +[Authorized Admin calls pause()] + ↓ +Check authorization → Check not already paused → Update state → Emit event → Audit log + ↓ +Paused State + ↓ +[All notification operations blocked] + ↓ +[Authorized Admin calls unpause()] + ↓ +Check authorization → Check currently paused → Update state → Emit event → Audit log + ↓ +Active State +``` + +## Components and Interfaces + +### 1. Pause State Storage + +**Contract Instance Storage** + +``` +INSTANCE_PAUSED: bool + - Stored in contract instance storage for atomic visibility + - Default: false + - Accessed before any notification operation +``` + +### 2. Authorization Module + +**Admin Registry** + +``` +INSTANCE_ADMIN: Address + - Single or multiple authorized admin addresses + - Validated via `require_auth()` for each operation + - Can be transferred via admin authorization +``` + +**Permission Check** + +``` +require_admin(env: &Env, admin: &Address) -> Result<(), Error> + - Verifies caller has admin permissions + - Returns AdminUnauthorized error if caller is not admin +``` + +### 3. Guard Functions + +**Pre-Operation Checks** + +``` +check_not_paused(env: &Env) -> Result<(), Error> + - Called at the start of create(), process(), and delivery operations + - Returns ContractPaused error if system is paused + - Read operations (get(), query()) skip this check +``` + +### 4. Event Emission + +**Pause Event** + +``` +ContractPaused { + admin: Address, // Who triggered the pause + category: NotificationCategory::Admin, + priority: NotificationPriority::High, + timestamp: u64 // Ledger timestamp +} +``` + +**Unpause Event** + +``` +ContractUnpaused { + admin: Address, // Who triggered the unpause + category: NotificationCategory::Admin, + priority: NotificationPriority::High, + timestamp: u64 // Ledger timestamp +} +``` + +Events are automatically published and appear in the audit log. + +### 5. Query Interface + +``` +get_paused_status() -> bool + - Public function accessible to any caller + - Returns current pause state + - No authorization required +``` + +## Data Models + +### Pause State + +``` +PauseState { + is_paused: bool, + last_paused_at: Option, // Timestamp of last pause + last_paused_by: Option
, // Admin who last paused + last_unpaused_at: Option, // Timestamp of last unpause + last_unpaused_by: Option
// Admin who last unpaused +} +``` + +The primary `is_paused` flag is stored in contract instance storage for atomicity. The metadata fields are stored in the audit log as part of pause/unpause events. + +### Error Types + +``` +enum Error { + AdminUnauthorized, // Caller lacks admin permissions + AlreadyPaused, // Attempt to pause when already paused + NotPaused, // Attempt to unpause when not paused + ContractPaused, // Operation blocked due to pause state + ... +} +``` + +## Authorization Mechanisms + +### Admin Authorization Flow + +``` +1. Admin calls pause(admin_address) or unpause(admin_address) +2. admin_address.require_auth() enforces signature requirement +3. require_admin() verifies caller is stored admin +4. If authorized: state updated and events emitted +5. If unauthorized: operation rejected with AdminUnauthorized error +``` + +### Multi-Admin Support + +The system supports multiple authorized administrators through: +- Maintaining a registry of authorized admin addresses +- Allowing any registered admin to pause or unpause +- Recording which admin performed each operation in events +- Enabling emergency operations when primary admin is unavailable + +## Event Emission Strategy + +### Pause Event Emission + +```rust +pub fn pause(env: Env, admin: Address) -> Result<(), Error> { + admin.require_auth(); + require_admin(&env, &admin)?; + + // Check not already paused + let is_paused = env.storage().instance().get(&INSTANCE_PAUSED).unwrap_or(false); + if is_paused { + return Err(Error::AlreadyPaused); + } + + // Update state + env.storage().instance().set(&INSTANCE_PAUSED, &true); + + // Emit event + ContractPaused { + admin: admin.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::High, + }.publish(&env); + + Ok(()) +} +``` + +### Event Routing + +- **Pause events** → Audit log (via automatic event publishing) +- **Pause events** → Off-chain listeners (via Stellar event stream) +- Events include admin address for compliance tracking +- Timestamps captured automatically by Soroban contract environment + +### Audit Log Integration + +Pause and unpause events are recorded in the audit log with: +- Event type (PauseInitiated, UnpauseInitiated) +- Admin address +- Timestamp +- Transaction hash (via Soroban) + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Only Authorized Admins Can Pause + +*For any* non-authorized address, attempting to pause SHALL result in an AdminUnauthorized error, and the pause state SHALL remain unchanged. + +**Validates: Requirements 1.2, 1.3** + +### Property 2: Non-Authorized Pause Attempts Are Rejected + +*For any* address that is not registered as an admin, calling pause() SHALL fail with authorization error, regardless of current pause state. + +**Validates: Requirements 1.2** + +### Property 3: Non-Authorized Unpause Attempts Are Rejected + +*For any* address that is not registered as an admin, calling unpause() SHALL fail with authorization error, regardless of current pause state. + +**Validates: Requirements 1.3** + +### Property 4: Notification Creation Blocked When Paused + +*For any* valid notification creation parameters, when the system is paused, the create operation SHALL fail with ContractPaused error, and no notification SHALL be created. + +**Validates: Requirements 2.1** + +### Property 5: Notification Processing Blocked When Paused + +*For any* in-flight notification, when the system is paused, all processing operations SHALL fail with ContractPaused error. + +**Validates: Requirements 2.2** + +### Property 6: Notification Delivery Blocked When Paused + +*For any* notification ready for delivery, when the system is paused, delivery operations SHALL fail with ContractPaused error. + +**Validates: Requirements 2.3** + +### Property 7: Pause Operations Emit Events + +*For any* successful pause operation by an authorized admin, a ContractPaused event SHALL be emitted containing the admin's address. + +**Validates: Requirements 3.1, 3.3** + +### Property 8: Unpause Operations Emit Events + +*For any* successful unpause operation by an authorized admin, a ContractUnpaused event SHALL be emitted containing the admin's address. + +**Validates: Requirements 3.2, 3.4** + +### Property 9: Pause State Query Returns Accurate Boolean + +*For any* moment in time, calling get_paused_status() SHALL return true if and only if a pause operation has completed more recently than an unpause operation. + +**Validates: Requirements 4.2** + +### Property 10: Query Function Is Publicly Accessible + +*For any* address, calling get_paused_status() SHALL succeed without authorization checks. + +**Validates: Requirements 4.3** + +### Property 11: Concurrent Pause Attempts Have Single Winner + +*For any* sequence of concurrent pause requests, exactly one SHALL succeed and the others SHALL fail with AlreadyPaused error. + +**Validates: Requirements 5.1, 5.2** + +### Property 12: Concurrent Unpause Attempts Have Single Winner + +*For any* sequence of concurrent unpause requests, exactly one SHALL succeed and the others SHALL fail with NotPaused error. + +**Validates: Requirements 5.3** + +### Property 13: Pause State Transitions Are Atomic + +*For any* completed pause or unpause operation, all subsequent operations SHALL immediately observe the new state without intermediate states. + +**Validates: Requirements 5.4** + +### Property 14: Operations Allowed After Unpause (Round Trip) + +*For any* notification operation that failed while paused, after unpausing, the same operation SHALL succeed with equivalent parameters, demonstrating state restoration. + +**Validates: Requirements 2.1, 2.2, 2.3** + +## Error Handling + +### Authorization Errors + +``` +Error::AdminUnauthorized + - Triggered when caller is not registered admin + - Propagates to caller with descriptive message + - No state change occurs + - Event emission skipped +``` + +### State Conflict Errors + +``` +Error::AlreadyPaused + - Triggered when pause() called and already paused + - Prevents duplicate pause operations + - No state change or event emission + +Error::NotPaused + - Triggered when unpause() called and not paused + - Prevents invalid unpause requests + - No state change or event emission +``` + +### Operation Blocked Errors + +``` +Error::ContractPaused + - Triggered when any notification operation attempted while paused + - Descriptive message indicates pause state + - Returned to caller without state changes +``` + +### Error Recovery + +- **Retryable**: Operations blocked by ContractPaused can be retried after unpause +- **Non-retryable**: Authorization errors require admin privileges or account change +- **Idempotent**: Pause when paused or unpause when unpaused returns error without side effects + +## Testing Strategy + +### Unit Testing Approach + +The pause mechanism requires comprehensive unit testing covering: + +**Authorization Tests** +- Authorized admin can pause +- Authorized admin can unpause +- Non-authorized account cannot pause +- Non-authorized account cannot unpause +- Pause event includes correct admin address +- Unpause event includes correct admin address + +**State Management Tests** +- System starts in unpaused state +- After pause, state is paused +- After unpause, state is unpaused +- Query returns accurate state +- Query works when paused +- Query works when unpaused + +**Operation Blocking Tests** +- Create fails when paused with ContractPaused error +- Processing fails when paused with ContractPaused error +- Delivery fails when paused with ContractPaused error +- Read operations (get, query) work when paused +- Operations succeed after unpause + +**Atomicity Tests** +- Pause when already paused returns AlreadyPaused error +- Unpause when not paused returns NotPaused error +- Events emitted on successful pause +- Events emitted on successful unpause +- Event includes admin address +- Event includes timestamp + +**Concurrency Tests** +- Multiple pause requests → one succeeds, others fail +- Multiple unpause requests → one succeeds, others fail +- Pause then unpause → operations work again +- State remains consistent across transitions + +### Property-Based Testing Configuration + +Each correctness property SHALL be implemented as a property-based test using a suitable PBT framework for the target language: + +**Test Configuration** +- Minimum 100 iterations per property test +- Random admin address generation for authorization tests +- Random notification parameters for blocking tests +- State transitions verified at each step + +**Test Tag Format** + +Each test SHALL include a comment referencing the design property: + +```rust +// Feature: pause-mechanism, Property 4: Notification Creation Blocked When Paused +#[test] +fn test_create_fails_when_paused() { ... } +``` + +### Integration Testing Approach + +Integration tests verify end-to-end pause/unpause behavior: + +1. **Setup phase**: Initialize system with admin, create sample notifications +2. **Pause phase**: Call pause, verify operations fail +3. **Unpause phase**: Call unpause, verify operations succeed +4. **Audit phase**: Query audit log, verify pause events recorded + +### Edge Cases and Error Conditions + +- Double pause (already paused state) +- Double unpause (not paused state) +- Non-existent admin attempting pause +- Pause during active notification delivery +- Unpause with no prior pause +- Multiple concurrent pause/unpause operations +- Query state immediately after pause/unpause +- Empty authorization registry + diff --git a/.kiro/specs/pause-mechanism/requirements.md b/.kiro/specs/pause-mechanism/requirements.md new file mode 100644 index 00000000..42b168bf --- /dev/null +++ b/.kiro/specs/pause-mechanism/requirements.md @@ -0,0 +1,91 @@ +# Requirements Document + +## Introduction + +This feature introduces a pause mechanism that allows administrators to temporarily suspend notification-related operations during emergencies. The pause functionality provides a safety valve for halting all notification processing while maintaining system stability and logging all pause events for compliance. + +## Glossary + +- **Administrator**: An authorized account with the capability to pause or unpause the system +- **Pause State**: A system state where all notification operations are blocked +- **Active State**: A system state where notification operations proceed normally +- **Authorized Account**: An account that has been granted pause/unpause permissions +- **Notification Operations**: All actions related to creating, processing, and delivering notifications +- **Pause Event**: An emitted event signaling that the system has entered a paused state +- **Unpause Event**: An emitted event signaling that the system has exited the paused state + +## Requirements + +### Requirement 1: Pause Authorization + +**User Story:** As a system administrator, I want only authorized accounts to pause the notification contract, so that emergency suspensions are controlled and secure. + +#### Acceptance Criteria + +1. THE Administrator SHALL have explicit pause permissions assigned to their account +2. WHEN a non-authorized account attempts to pause, THE system SHALL reject the operation and emit an authorization error +3. WHEN a non-authorized account attempts to unpause, THE system SHALL reject the operation and emit an authorization error +4. THE system SHALL maintain a registry of authorized pause administrators +5. WHERE applicable, THE system SHALL support multiple authorized administrators + +### Requirement 2: Pause State Enforcement + +**User Story:** As a system administrator, I want all notification operations to be blocked when paused, so that no notifications are processed during emergencies. + +#### Acceptance Criteria + +1. WHEN the system is paused, THE system SHALL reject all notification creation requests +2. WHEN the system is paused, THE system SHALL reject all notification processing requests +3. WHEN the system is paused, THE system SHALL reject all notification delivery requests +4. WHEN an operation is rejected due to pause state, THE system SHALL return a descriptive pause error +5. THE pause state check SHALL be performed before any notification operation begins + +### Requirement 3: Pause Event Emission + +**User Story:** As a compliance officer, I want pause and unpause events to be emitted, so that I can monitor system state changes and maintain an audit trail. + +#### Acceptance Criteria + +1. WHEN a pause operation succeeds, THE system SHALL emit a PausedNotifications event +2. WHEN an unpause operation succeeds, THE system SHALL emit an UnpausedNotifications event +3. THE PausedNotifications event SHALL include the administrator's account identifier +4. THE UnpausedNotifications event SHALL include the administrator's account identifier +5. BOTH pause and unpause events SHALL be timestamped +6. THE events SHALL be persisted in the audit log for future reference + +### Requirement 4: Pause State Query + +**User Story:** As a developer, I want to query the current pause state, so that I can determine whether notification operations are allowed. + +#### Acceptance Criteria + +1. THE system SHALL provide a query function to check the current pause state +2. THE query function SHALL return a boolean indicating if the system is paused +3. THE query function SHALL be publicly accessible to all callers + +### Requirement 5: Atomic Pause Transitions + +**User Story:** As a system architect, I want pause and unpause operations to be atomic, so that the system state remains consistent even during concurrent requests. + +#### Acceptance Criteria + +1. WHEN multiple pause requests are issued concurrently, THE system SHALL process only one successfully +2. IF a pause operation is already in progress, THE system SHALL reject subsequent pause requests with a state-conflict error +3. IF an unpause operation is already in progress, THE system SHALL reject subsequent unpause requests with a state-conflict error +4. THE pause state change SHALL be atomic and immediately visible to all subsequent operations + +### Requirement 6: Pause Mechanism Testing + +**User Story:** As a quality assurance engineer, I want comprehensive tests for the pause mechanism, so that I can verify correct behavior in all scenarios. + +#### Acceptance Criteria + +1. UNIT tests SHALL verify that only authorized accounts can pause the system +2. UNIT tests SHALL verify that only authorized accounts can unpause the system +3. UNIT tests SHALL verify that notification creation is blocked when paused +4. UNIT tests SHALL verify that notification processing is blocked when paused +5. UNIT tests SHALL verify that pause and unpause events are emitted correctly +6. UNIT tests SHALL verify that the current pause state can be queried accurately +7. UNIT tests SHALL verify that concurrent pause/unpause requests are handled correctly +8. UNIT tests SHALL verify that unpausing restores normal operation +9. INTEGRATION tests SHALL verify end-to-end pause and recovery scenarios diff --git a/.kiro/specs/pause-mechanism/tasks.md b/.kiro/specs/pause-mechanism/tasks.md new file mode 100644 index 00000000..2c667175 --- /dev/null +++ b/.kiro/specs/pause-mechanism/tasks.md @@ -0,0 +1,435 @@ +# Implementation Plan: pause-mechanism + +## Overview + +This implementation plan adds a pause mechanism to the Soroban smart contract, allowing administrators to temporarily suspend all notification operations during emergencies. The implementation follows an atomic state-based approach with comprehensive event logging and multi-admin support. + +## Tasks + +- [ ] 1. Add pause state storage and admin registry + - [ ] 1.1 Add INSTANCE_PAUSED bool to contract instance storage + - Define INSTANCE_PAUSED constant in lib.rs + - Initialize pause state to false on contract deployment + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 1.2 Add INSTANCE_ADMIN address storage for authorization + - Define INSTANCE_ADMIN constant for admin registry + - Initialize admin on contract deployment + - Support updating admin via authorized operation + - _Requirements: 1.1, 1.4_ + + - [ ] 1.3 Add PauseState type for metadata tracking + - Create type with is_paused, last_paused_at, last_paused_by, last_unpaused_at, last_unpaused_by fields + - Store metadata in events rather than persistent storage + - _Requirements: 1.1, 3.1, 3.2_ + +- [ ] 2. Implement authorization module + - [ ] 2.1 Create require_admin() function + - Verify caller is registered admin + - Return AdminUnauthorized error if not authorized + - Use env.invoker() to get caller context + - _Requirements: 1.1, 1.2, 1.3_ + + - [ ]* 2.2 Write property test for admin authorization + - **Property 1: Only Authorized Admins Can Pause** + - **Validates: Requirements 1.2, 1.3** + - Test that non-authorized addresses fail with AdminUnauthorized error + - Test that authorized address succeeds + + - [ ]* 2.3 Write property test for non-authorized pause rejection + - **Property 2: Non-Authorized Pause Attempts Are Rejected** + - **Validates: Requirements 1.2** + - Verify pause() fails for any non-admin address + + - [ ]* 2.4 Write property test for non-authorized unpause rejection + - **Property 3: Non-Authorized Unpause Attempts Are Rejected** + - **Validates: Requirements 1.3** + - Verify unpause() fails for any non-admin address + +- [ ] 3. Implement guard functions + - [ ] 3.1 Create check_not_paused() function + - Read INSTANCE_PAUSED from instance storage + - Return ContractPaused error if paused + - Called at start of create(), process(), and delivery operations + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 3.2 Integrate check_not_paused() into notification operations + - Add guard check to create_notification() at function start + - Add guard check to process_notification() at function start + - Add guard check to deliver_notification() at function start + - Ensure read operations (get, query) skip guard check + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [ ]* 3.3 Write property test for notification creation blocking + - **Property 4: Notification Creation Blocked When Paused** + - **Validates: Requirements 2.1** + - Verify create() fails with ContractPaused error when paused + - Verify no notification created on failure + + - [ ]* 3.4 Write property test for notification processing blocking + - **Property 5: Notification Processing Blocked When Paused** + - **Validates: Requirements 2.2** + - Verify processing fails with ContractPaused error when paused + + - [ ]* 3.5 Write property test for notification delivery blocking + - **Property 6: Notification Delivery Blocked When Paused** + - **Validates: Requirements 2.3** + - Verify delivery fails with ContractPaused error when paused + +- [ ] 4. Implement pause/unpause functions with event emission + - [ ] 4.1 Implement pause() function + - Accept admin parameter with require_auth() + - Call require_admin() to verify authorization + - Check not already paused (return AlreadyPaused error if paused) + - Set INSTANCE_PAUSED to true + - Emit ContractPaused event with admin address + - _Requirements: 1.1, 1.2, 3.1, 3.3_ + + - [ ] 4.2 Implement unpause() function + - Accept admin parameter with require_auth() + - Call require_admin() to verify authorization + - Check currently paused (return NotPaused error if not paused) + - Set INSTANCE_PAUSED to false + - Emit ContractUnpaused event with admin address + - _Requirements: 1.1, 1.3, 3.2, 3.4_ + + - [ ]* 4.3 Write property test for pause event emission + - **Property 7: Pause Operations Emit Events** + - **Validates: Requirements 3.1, 3.3** + - Verify ContractPaused event emitted on successful pause + - Verify event includes admin address + + - [ ]* 4.4 Write property test for unpause event emission + - **Property 8: Unpause Operations Emit Events** + - **Validates: Requirements 3.2, 3.4** + - Verify ContractUnpaused event emitted on successful unpause + - Verify event includes admin address + +- [ ] 5. Add query functions for pause state + - [ ] 5.1 Implement get_paused_status() function + - Read INSTANCE_PAUSED from instance storage + - Return bool indicating current pause state + - No authorization required, publicly accessible + - _Requirements: 4.1, 4.2, 4.3_ + + - [ ]* 5.2 Write property test for pause state accuracy + - **Property 9: Pause State Query Returns Accurate Boolean** + - **Validates: Requirements 4.2** + - Verify get_paused_status() returns true when paused + - Verify get_paused_status() returns false when not paused + + - [ ]* 5.3 Write property test for public accessibility + - **Property 10: Query Function Is Publicly Accessible** + - **Validates: Requirements 4.3** + - Verify get_paused_status() succeeds without authorization + +- [ ] 6. Implement atomic state transitions + - [ ] 6.1 Add state conflict error handling + - Ensure pause() returns AlreadyPaused error when already paused + - Ensure unpause() returns NotPaused error when not paused + - Prevent duplicate operations and invalid state transitions + - _Requirements: 5.1, 5.2, 5.3_ + + - [ ] 6.2 Leverage Soroban atomicity for concurrent safety + - Rely on contract storage atomicity for pause state updates + - Document that Soroban runtime provides atomic state transitions + - Verify single-threaded execution model prevents concurrent conflicts + - _Requirements: 5.1, 5.4_ + + - [ ]* 6.3 Write property test for concurrent pause safety + - **Property 11: Concurrent Pause Attempts Have Single Winner** + - **Validates: Requirements 5.1, 5.2** + - Verify only one concurrent pause succeeds, others fail with AlreadyPaused + + - [ ]* 6.4 Write property test for concurrent unpause safety + - **Property 12: Concurrent Unpause Attempts Have Single Winner** + - **Validates: Requirements 5.3** + - Verify only one concurrent unpause succeeds, others fail with NotPaused + + - [ ]* 6.5 Write property test for atomic state visibility + - **Property 13: Pause State Transitions Are Atomic** + - **Validates: Requirements 5.4** + - Verify all subsequent operations immediately observe new state + +- [ ] 7. Add error types and definitions + - [ ] 7.1 Add AdminUnauthorized error variant + - Define in base/errors.rs + - Use descriptive error message + - _Requirements: 1.2, 1.3_ + + - [ ] 7.2 Add AlreadyPaused error variant + - Define in base/errors.rs + - Indicates pause operation when already paused + - _Requirements: 5.1, 5.2_ + + - [ ] 7.3 Add NotPaused error variant + - Define in base/errors.rs + - Indicates unpause operation when not paused + - _Requirements: 5.3_ + + - [ ] 7.4 Add ContractPaused error variant + - Define in base/errors.rs + - Returned when notification operations blocked by pause state + - _Requirements: 2.1, 2.2, 2.3_ + +- [ ] 8. Update event structures + - [ ] 8.1 Verify ContractPaused event structure exists + - Confirm fields: admin, category (Admin), priority (High), timestamp + - Ensure automatic timestamp capture by Soroban + - _Requirements: 3.1, 3.3_ + + - [ ] 8.2 Verify ContractUnpaused event structure exists + - Confirm fields: admin, category (Admin), priority (High), timestamp + - Ensure automatic timestamp capture by Soroban + - _Requirements: 3.2, 3.4_ + +- [ ] 9. Create comprehensive unit tests + - [ ] 9.1 Create src/tests/pause_mechanism_test.rs + - Set up test infrastructure and helpers + - Create test admin and environment fixtures + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9_ + + - [ ] 9.2 Test authorized admin can pause + - Verify pause() succeeds when called by authorized admin + - Verify pause state changes to true + - Verify event is emitted + - _Requirements: 1.1, 1.2, 3.1, 3.3_ + + - [ ] 9.3 Test authorized admin can unpause + - Verify unpause() succeeds when called by authorized admin + - Verify pause state changes to false + - Verify event is emitted + - _Requirements: 1.1, 1.3, 3.2, 3.4_ + + - [ ] 9.4 Test non-authorized account cannot pause + - Create unauthorized address + - Verify pause() returns AdminUnauthorized error + - Verify pause state unchanged + - _Requirements: 1.2, 1.3_ + + - [ ] 9.5 Test non-authorized account cannot unpause + - Create unauthorized address + - Verify unpause() returns AdminUnauthorized error + - Verify pause state unchanged + - _Requirements: 1.2, 1.3_ + + - [ ] 9.6 Test create fails when paused + - Pause the system + - Attempt to create notification + - Verify operation fails with ContractPaused error + - _Requirements: 2.1, 2.4_ + + - [ ] 9.7 Test process fails when paused + - Pause the system + - Attempt to process notification + - Verify operation fails with ContractPaused error + - _Requirements: 2.2, 2.4_ + + - [ ] 9.8 Test delivery fails when paused + - Pause the system + - Attempt to deliver notification + - Verify operation fails with ContractPaused error + - _Requirements: 2.3, 2.4_ + + - [ ] 9.9 Test query works when paused + - Pause the system + - Call get_paused_status() + - Verify returns true + - _Requirements: 4.1, 4.2, 4.3_ + + - [ ] 9.10 Test query works when unpaused + - Unpause the system + - Call get_paused_status() + - Verify returns false + - _Requirements: 4.1, 4.2, 4.3_ + + - [ ] 9.11 Test pause when already paused returns error + - Pause the system + - Call pause() again + - Verify returns AlreadyPaused error + - Verify state unchanged + - _Requirements: 5.1, 5.2_ + + - [ ] 9.12 Test unpause when not paused returns error + - Ensure system is unpaused + - Call unpause() + - Verify returns NotPaused error + - Verify state unchanged + - _Requirements: 5.3_ + + - [ ] 9.13 Test operations work after unpause + - Create notification, pause, unpause + - Verify create succeeds after unpause + - Verify process succeeds after unpause + - Verify delivery succeeds after unpause + - _Requirements: 2.1, 2.2, 2.3_ + +- [ ] 10. Checkpoint - Verify unit tests pass + - Ensure all unit tests pass + - Verify test coverage includes all major paths + - Ask the user if questions arise + +- [ ] 11. Create property-based tests + - [ ] 11.1 Implement Property 1: Only Authorized Admins Can Pause + - Generate random non-admin addresses + - Verify all fail with AdminUnauthorized error + - Verify authorized admin succeeds + - Minimum 100 iterations + + - [ ] 11.2 Implement Property 2: Non-Authorized Pause Attempts Are Rejected + - Test all non-admin addresses with random parameters + - Verify all fail regardless of pause state + - Minimum 100 iterations + + - [ ] 11.3 Implement Property 3: Non-Authorized Unpause Attempts Are Rejected + - Test all non-admin addresses with random parameters + - Verify all fail regardless of pause state + - Minimum 100 iterations + + - [ ] 11.4 Implement Property 4: Notification Creation Blocked When Paused + - Generate random valid notification parameters + - Pause system before each test + - Verify all creations fail with ContractPaused error + - Verify no notifications created + - Minimum 100 iterations + + - [ ] 11.5 Implement Property 5: Notification Processing Blocked When Paused + - Generate random notification processing parameters + - Pause system before each test + - Verify all processing fails with ContractPaused error + - Minimum 100 iterations + + - [ ] 11.6 Implement Property 6: Notification Delivery Blocked When Paused + - Generate random notification delivery parameters + - Pause system before each test + - Verify all delivery fails with ContractPaused error + - Minimum 100 iterations + + - [ ] 11.7 Implement Property 7: Pause Operations Emit Events + - Call pause() as authorized admin + - Verify ContractPaused event emitted + - Verify event contains admin address + - Minimum 100 iterations + + - [ ] 11.8 Implement Property 8: Unpause Operations Emit Events + - Pause then call unpause() as authorized admin + - Verify ContractUnpaused event emitted + - Verify event contains admin address + - Minimum 100 iterations + + - [ ] 11.9 Implement Property 9: Pause State Query Returns Accurate Boolean + - Track pause state through random pause/unpause sequences + - After each operation, verify get_paused_status() matches expected state + - Minimum 100 iterations + + - [ ] 11.10 Implement Property 10: Query Function Is Publicly Accessible + - Call get_paused_status() from random addresses + - Verify all succeed without authorization errors + - Test when paused and when unpaused + - Minimum 100 iterations + + - [ ] 11.11 Implement Property 11: Concurrent Pause Attempts Have Single Winner + - Simulate concurrent pause requests (via sequential calls in same transaction) + - Verify exactly one succeeds, others fail with AlreadyPaused + - Minimum 50 iterations + + - [ ] 11.12 Implement Property 12: Concurrent Unpause Attempts Have Single Winner + - Pause then simulate concurrent unpause requests + - Verify exactly one succeeds, others fail with NotPaused + - Minimum 50 iterations + + - [ ] 11.13 Implement Property 13: Pause State Transitions Are Atomic + - Execute pause/unpause operations followed by read operations + - Verify all subsequent reads see new state without intermediate states + - Minimum 100 iterations + + - [ ] 11.14 Implement Property 14: Operations Allowed After Unpause (Round Trip) + - Record failed notification operation during pause + - Unpause and retry same operation with same parameters + - Verify operation succeeds after unpause + - Minimum 100 iterations + +- [ ] 12. Create integration tests + - [ ] 12.1 Create src/tests/pause_integration_test.rs + - Set up multi-step test scenarios + - Initialize system with admin and sample data + - _Requirements: 6.9_ + + - [ ] 12.2 Test end-to-end pause flow + - Create notification → Pause → Verify create fails → Unpause → Verify create succeeds + - Verify audit events recorded + - _Requirements: 2.1, 3.1, 3.2_ + + - [ ] 12.3 Test pause recovery flow + - Create notifications, pause, attempt operations, unpause + - Verify operations resume normally after unpause + - Verify no data loss during pause + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 12.4 Test multi-admin scenarios + - Configure multiple authorized admins + - Verify each admin can pause/unpause + - Verify pause by admin A can be unpaused by admin B + - _Requirements: 1.4, 1.5_ + + - [ ] 12.5 Test audit log records pause events + - Perform pause/unpause operations + - Query audit log for events + - Verify PausedNotifications and UnpausedNotifications events recorded + - _Requirements: 3.5, 3.6_ + +- [ ] 13. Checkpoint - Verify all tests pass + - Ensure all unit tests pass + - Ensure all property-based tests pass + - Ensure all integration tests pass + - Run full test suite + - Ask the user if questions arise + +- [ ] 14. Update error handling documentation + - [ ] 14.1 Document AdminUnauthorized error + - Add to error reference documentation + - Include when it's triggered and recovery steps + - _Requirements: 1.2, 1.3_ + + - [ ] 14.2 Document ContractPaused error + - Add to error reference documentation + - Include when it's triggered and recovery steps + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 14.3 Document AlreadyPaused and NotPaused errors + - Add to error reference documentation + - Include state conflict scenarios + - _Requirements: 5.1, 5.2, 5.3_ + +- [ ] 15. Update configuration documentation + - [ ] 15.1 Document admin configuration + - Add admin setup instructions to contract documentation + - Include steps for initializing and updating admin + - _Requirements: 1.1, 1.4_ + + - [ ] 15.2 Document pause operation procedures + - Add operational guide for pause/unpause + - Include when to use pause mechanism + - Include recovery procedures + - _Requirements: 1.2, 1.3, 2.1, 2.2, 2.3_ + + - [ ] 15.3 Document event subscription for pause events + - Add example listeners for ContractPaused and ContractUnpaused + - Document event structure and fields + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [ ] 16. Final checkpoint - Build and verify contract + - Ensure contract compiles without errors + - Ensure all tests pass + - Verify deployment readiness + - Ask the user if questions arise + +## Notes + +- All pause/unpause functions include `require_auth()` for cryptographic signature verification +- Pause state stored as single bool for atomicity and gas efficiency +- Read operations (get, query) intentionally skip guard checks to allow monitoring during pause +- Property-based tests use minimum 100 iterations (50 for concurrency simulations) for statistical confidence +- Event emission happens after state changes for consistency +- Multi-admin support enables operational flexibility without code changes +- Metadata fields (last_paused_at, last_paused_by, etc.) are stored in audit log events rather than persistent contract storage to minimize gas costs From 51b82d798ae9bbab2ea890a2104a67beaf4804ab Mon Sep 17 00:00:00 2001 From: vicajohn Date: Sat, 25 Jul 2026 14:02:05 +0100 Subject: [PATCH 4/4] Add payload validation tests expansion spec --- .../payload-validation-tests/.config.kiro | 1 + .../specs/payload-validation-tests/design.md | 61 ++++++++ .../payload-validation-tests/requirements.md | 54 +++++++ .kiro/specs/payload-validation-tests/tasks.md | 142 ++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 .kiro/specs/payload-validation-tests/.config.kiro create mode 100644 .kiro/specs/payload-validation-tests/design.md create mode 100644 .kiro/specs/payload-validation-tests/requirements.md create mode 100644 .kiro/specs/payload-validation-tests/tasks.md diff --git a/.kiro/specs/payload-validation-tests/.config.kiro b/.kiro/specs/payload-validation-tests/.config.kiro new file mode 100644 index 00000000..245127fa --- /dev/null +++ b/.kiro/specs/payload-validation-tests/.config.kiro @@ -0,0 +1 @@ +{"specId": "payload-validation-tests", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/payload-validation-tests/design.md b/.kiro/specs/payload-validation-tests/design.md new file mode 100644 index 00000000..8f885035 --- /dev/null +++ b/.kiro/specs/payload-validation-tests/design.md @@ -0,0 +1,61 @@ +# Design Document + +## Overview + +This design expands test coverage for payload validation logic by adding comprehensive tests for invalid inputs and edge cases across the notification system. + +## Architecture + +### Test Structure + +1. **Invalid Payload Tests** - Test malformed and invalid data +2. **Edge Case Tests** - Test boundary conditions +3. **Coverage Tracking** - Measure and report test coverage + +### Components to Test + +1. **Event Validation** (`event-utils.ts`) + - `validateEventPayload()` function + - Event structure validation + - Topic validation + - Value validation + +2. **Notification Expiration** (`notification-expiration.ts`) + - Timestamp validation + - Expiration logic + +3. **Pause Mechanism** (Rust contract) + - Authorization validation + - State transition validation + +### Test Categories + +#### Invalid Payloads +- Missing required fields +- Wrong data types +- Null/undefined values +- Oversized values +- Invalid formats + +#### Edge Cases +- Empty strings +- Maximum length strings +- Boundary numeric values +- Empty/single-element arrays +- Special characters +- Unicode/non-ASCII +- Deeply nested objects +- Concurrent operations + +## Implementation Strategy + +1. Extend existing test files with additional test cases +2. Create focused test suites for each validator +3. Add coverage reporting via Jest coverage tools +4. Document coverage metrics and targets + +## Testing Tools + +- **Jest**: Test framework with built-in coverage +- **fast-check**: Property-based testing for edge cases +- **Coverage reports**: HTML and text-based coverage output \ No newline at end of file diff --git a/.kiro/specs/payload-validation-tests/requirements.md b/.kiro/specs/payload-validation-tests/requirements.md new file mode 100644 index 00000000..dc1b581d --- /dev/null +++ b/.kiro/specs/payload-validation-tests/requirements.md @@ -0,0 +1,54 @@ +# Requirements Document + +## Introduction + +This feature expands automated tests around payload validation logic to ensure robustness and reliability of the notification system. It focuses on testing invalid payloads, edge cases, and increasing test coverage. + +## Glossary + +- **Payload**: The data structure containing notification parameters and content +- **Validation Logic**: Functions that verify payload correctness and compliance +- **Edge Cases**: Boundary conditions and exceptional scenarios +- **Coverage**: Percentage of code paths executed by tests +- **Invalid Payload**: Payload that fails one or more validation rules + +## Requirements + +### Requirement 1: Invalid Payload Testing + +**User Story:** As a developer, I want invalid payloads to be tested comprehensively, so that the system correctly rejects malformed data. + +#### Acceptance Criteria + +1. THE system SHALL test payloads with missing required fields +2. THE system SHALL test payloads with invalid field types +3. THE system SHALL test payloads with null/undefined values in critical fields +4. THE system SHALL test payloads with overly long string values +5. THE system SHALL verify appropriate error messages for each invalid case + +### Requirement 2: Edge Case Coverage + +**User Story:** As a QA engineer, I want edge cases to be covered by tests, so that boundary conditions don't introduce bugs. + +#### Acceptance Criteria + +1. THE system SHALL test empty string values +2. THE system SHALL test maximum length strings +3. THE system SHALL test minimum and maximum numeric values +4. THE system SHALL test empty arrays and single-element arrays +5. THE system SHALL test special characters in string fields +6. THE system SHALL test unicode and non-ASCII characters +7. THE system SHALL test deeply nested objects if applicable +8. THE system SHALL test concurrent payload validation + +### Requirement 3: Coverage Increase + +**User Story:** As a project manager, I want test coverage to increase, so that code quality improves. + +#### Acceptance Criteria + +1. THE test coverage for validation logic SHALL increase by at least 10 percentage points +2. ALL code paths in validation functions SHALL be exercised +3. BOTH success and failure paths SHALL be tested +4. LINE coverage, BRANCH coverage, and FUNCTION coverage SHALL be measured +5. COVERAGE reports SHALL be generated and tracked \ No newline at end of file diff --git a/.kiro/specs/payload-validation-tests/tasks.md b/.kiro/specs/payload-validation-tests/tasks.md new file mode 100644 index 00000000..ea208844 --- /dev/null +++ b/.kiro/specs/payload-validation-tests/tasks.md @@ -0,0 +1,142 @@ +# Implementation Plan: payload-validation-tests + +## Overview + +This implementation plan expands automated tests for payload validation logic to ensure comprehensive coverage of invalid payloads, edge cases, and increase overall test coverage. + +## Tasks + +- [ ] 1. Analyze current validation functions + - [ ] 1.1 Review event-utils.ts validateEventPayload() function + - [ ] 1.2 Review notification-expiration.ts timestamp validation + - [ ] 1.3 Identify all validation entry points + - [ ] 1.4 Document current test coverage baseline + - _Requirements: 1.1, 1.2, 3.1, 3.2_ + +- [ ] 2. Expand event validation tests + - [ ] 2.1 Create comprehensive invalid payload tests for validateEventPayload() + - Test missing required fields (id, ledger, type, topic, txHash) + - Test wrong data types for each field + - Test null/undefined values + - Test oversized values (very long strings, huge numbers) + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [ ] 2.2 Add edge case tests for event validation + - Test empty string values in optional fields + - Test maximum length strings (1000 chars, 10000 chars) + - Test numeric boundary values (0, MAX_SAFE_INTEGER, negative) + - Test empty arrays and single-element arrays + - Test special characters in strings (!, @, #, $, %, unicode) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_ + + - [ ] 2.3 Add property-based tests for event validation + - Generate random invalid event objects + - Verify all return false or throw appropriate errors + - Minimum 100 iterations + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + +- [ ] 3. Expand notification expiration tests + - [ ] 3.1 Create invalid payload tests for expiration + - Test null/undefined timestamps + - Test invalid timestamp formats + - Test non-numeric timestamp values + - Test future-far timestamps + - Test past timestamps (edge cases) + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [ ] 3.2 Add edge case tests for expiration + - Test timestamps at current moment + - Test timestamps 1ms in future + - Test timestamps 1ms in past + - Test very old timestamps (years ago) + - Test very far future timestamps + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 3.3 Add concurrent expiration validation tests + - Test race conditions in expiration checks + - Verify consistent behavior under concurrent access + - _Requirements: 2.8_ + +- [ ] 4. Add Rust contract payload validation tests + - [ ] 4.1 Create invalid authorization tests + - Test non-admin pause attempts with various addresses + - Test with null/zero addresses + - Test with invalid address formats + - _Requirements: 1.1, 1.2, 1.3_ + + - [ ] 4.2 Add edge case tests for state transitions + - Test pause when already paused + - Test unpause when not paused + - Test with boundary notification IDs + - Test with empty recipient lists + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [ ] 4.3 Create property-based tests for Rust validation + - Generate random invalid contract inputs + - Verify all fail with appropriate errors + - Minimum 100 iterations + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + +- [ ] 5. Configure Jest coverage reporting + - [ ] 5.1 Update jest.config.js with coverage settings + - Set coverage threshold to 80% + - Enable HTML coverage reports + - Include src/ directory + - Exclude tests directory + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + + - [ ] 5.2 Add coverage reporting scripts to package.json + - Add coverage script that runs tests with coverage + - Add coverage:report script that generates HTML + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + +- [ ] 6. Create test utility functions + - [ ] 6.1 Create test helpers for payload generation + - Helper to generate invalid payloads + - Helper to generate edge case payloads + - Helper to generate boundary value payloads + - _Requirements: 1.1, 1.2, 2.1, 2.2_ + + - [ ] 6.2 Create assertion helpers + - Helper to assert validation errors + - Helper to assert coverage metrics + - _Requirements: 1.5, 3.1_ + +- [ ] 7. Run and verify test coverage + - [ ] 7.1 Execute all test suites + - Run all new and existing tests + - Verify no regressions + - _Requirements: 3.1, 3.2, 3.3_ + + - [ ] 7.2 Generate coverage reports + - Generate text coverage report + - Generate HTML coverage report + - Document baseline and new coverage metrics + - _Requirements: 3.4, 3.5_ + + - [ ] 7.3 Verify coverage improvement + - Confirm coverage increased by at least 10 percentage points + - Verify all code paths are exercised + - Verify both success and failure paths tested + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [ ] 8. Document test coverage improvements + - [ ] 8.1 Create coverage report documentation + - Document before/after coverage metrics + - List new test cases added + - Document edge cases covered + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + + - [ ] 8.2 Update test documentation + - Add guide for running tests + - Add guide for generating coverage reports + - Document expected coverage thresholds + - _Requirements: 3.5_ + +## Notes + +- Focus on validation functions first before integration tests +- Use property-based testing to generate comprehensive edge cases +- Aim for >80% code coverage for validation modules +- All tests should pass before coverage verification +- Document coverage improvements for CI/CD integration \ No newline at end of file