diff --git a/.kiro/specs/audit-logging-system/.config.kiro b/.kiro/specs/audit-logging-system/.config.kiro new file mode 100644 index 0000000..66da147 --- /dev/null +++ b/.kiro/specs/audit-logging-system/.config.kiro @@ -0,0 +1 @@ +{"specId": "audit-logging-system", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/audit-logging-system/design.md b/.kiro/specs/audit-logging-system/design.md new file mode 100644 index 0000000..8721d2c --- /dev/null +++ b/.kiro/specs/audit-logging-system/design.md @@ -0,0 +1,95 @@ +# Design Document + +## Overview + +This design implements a comprehensive audit logging system for recording notification lifecycle events with immutability and queryability guarantees. + +## Architecture + +### Data Model + +```typescript +interface AuditLogEntry { + entryId: string; // Unique identifier + notificationId: string; // Reference to notification + timestamp: number; // Unix timestamp + eventType: EventType; // CREATION, DELIVERY_ATTEMPT, etc. + actor: string; // Address or identifier + status: 'success' | 'failure'; // Operation status + metadata: { + recipient?: string; + channel?: string; + errorReason?: string; + retryCount?: number; + [key: string]: any; // Flexible for event-specific data + }; + createdAt: number; // When log was created + immutable: true; // Flag indicating immutability +} + +enum EventType { + CREATION = 'CREATION', + DELIVERY_ATTEMPT = 'DELIVERY_ATTEMPT', + DELIVERY_SUCCESS = 'DELIVERY_SUCCESS', + DELIVERY_FAILURE = 'DELIVERY_FAILURE', + ACKNOWLEDGMENT = 'ACKNOWLEDGMENT' +} +``` + +### Storage Strategy + +1. **Primary Storage**: Database/persistent store for all audit logs +2. **Indexing**: Create indexes on notificationId, recipient, eventType, timestamp +3. **Archival**: Move logs older than retention period to archive +4. **Performance**: Use read-optimized queries for audit log retrieval + +### Query Interface + +```typescript +interface AuditLogQuery { + notificationId?: string; + recipient?: string; + eventType?: EventType | EventType[]; + dateRange?: { + startTime: number; + endTime: number; + }; + actor?: string; + limit?: number; // Default 100, max 1000 + offset?: number; // For pagination +} + +interface QueryResult { + entries: AuditLogEntry[]; + total: number; + hasMore: boolean; +} +``` + +### Integration Points + +1. **Event Subscriber**: Log CREATION events +2. **Discord Service**: Log DELIVERY_ATTEMPT, DELIVERY_SUCCESS, DELIVERY_FAILURE +3. **Notification Expiration**: Log EXPIRATION events +4. **API Endpoints**: Provide query interface for clients + +### Immutability Enforcement + +- No UPDATE operations on audit logs +- DELETE only through archive/purge administrative functions +- Logs stored in append-only structure +- Database constraints to prevent modification + +## Performance Considerations + +- Indexes on query fields (notificationId, recipient, eventType, timestamp) +- Pagination for large queries +- Cache frequently accessed audit logs +- Archive old logs to separate storage + +## Compliance and Retention + +- Default retention: 365 days +- Configurable retention policy +- Audit trails for who accessed what logs +- Compliance reporting endpoints \ No newline at end of file diff --git a/.kiro/specs/audit-logging-system/requirements.md b/.kiro/specs/audit-logging-system/requirements.md new file mode 100644 index 0000000..7e75e1f --- /dev/null +++ b/.kiro/specs/audit-logging-system/requirements.md @@ -0,0 +1,97 @@ +# Requirements Document + +## Introduction + +This feature creates an audit logging system that records notification lifecycle events for compliance and operational monitoring, enabling complete visibility into delivery attempts and outcomes. + +## Glossary + +- **Audit Log**: Immutable record of notification lifecycle events +- **Lifecycle Event**: Creation, delivery attempt, delivery failure, acknowledgment +- **Audit Record**: Single entry in the audit log with timestamp and event details +- **Query Endpoint**: API or function to retrieve and filter audit logs +- **Immutable**: Cannot be modified or deleted after creation +- **Compliance**: Meeting regulatory and operational requirements + +## Requirements + +### Requirement 1: Audit Event Schema + +**User Story:** As a compliance officer, I want a well-defined audit event schema, so that all events are recorded consistently. + +#### Acceptance Criteria + +1. THE audit event schema SHALL include: eventId, notificationId, timestamp, eventType, actor, status, metadata +2. THE eventType SHALL be one of: CREATION, DELIVERY_ATTEMPT, DELIVERY_SUCCESS, DELIVERY_FAILURE, ACKNOWLEDGMENT +3. THE actor field SHALL identify who/what triggered the event +4. THE metadata field SHALL be flexible JSON object for event-specific data +5. ALL fields SHALL be optional except eventId, timestamp, and eventType + +### Requirement 2: Creation Event Logging + +**User Story:** As an auditor, I want to see when notifications are created, so that I can track notification lifecycle start. + +#### Acceptance Criteria + +1. WHEN a notification is created, THE system SHALL log a CREATION event +2. THE CREATION event SHALL include: notificationId, creator address, recipient, title, content +3. THE CREATION event SHALL be logged before notification is marked active +4. THE event SHALL not be lost even if creation partially fails + +### Requirement 3: Delivery Attempt Logging + +**User Story:** As an operations manager, I want to see all delivery attempts, so that I can troubleshoot delivery issues. + +#### Acceptance Criteria + +1. WHEN delivery is attempted, THE system SHALL log a DELIVERY_ATTEMPT event +2. THE DELIVERY_ATTEMPT event SHALL include: notificationId, recipient, channel (Discord, etc.), timestamp +3. IF delivery succeeds, THE system SHALL log DELIVERY_SUCCESS event +4. IF delivery fails, THE system SHALL log DELIVERY_FAILURE event with error reason +5. EACH delivery attempt SHALL be logged independently + +### Requirement 4: Failure Logging + +**User Story:** As a support engineer, I want detailed failure logs, so that I can diagnose delivery problems. + +#### Acceptance Criteria + +1. WHEN delivery fails, THE system SHALL log failure reason/error message +2. THE failure log SHALL include: error type, error message, retry count, next retry time (if applicable) +3. RETRIES SHALL be logged as separate events +4. FAILURE logs SHALL be retained for troubleshooting + +### Requirement 5: Acknowledgment Logging + +**User Story:** As a product manager, I want to see when notifications are acknowledged, so that I can track user engagement. + +#### Acceptance Criteria + +1. WHEN a notification is acknowledged, THE system SHALL log an ACKNOWLEDGMENT event +2. THE ACKNOWLEDGMENT event SHALL include: notificationId, recipient, timestamp +3. MULTIPLE acknowledgments for same notification SHALL be supported +4. ACKNOWLEDGMENT timing relative to delivery SHALL be trackable + +### Requirement 6: Query Endpoints + +**User Story:** As a system user, I want to query audit logs, so that I can analyze notification history. + +#### Acceptance Criteria + +1. THE system SHALL provide query endpoint for retrieving audit logs +2. QUERIES SHALL support filtering by: notificationId, recipient, eventType, dateRange, actor +3. QUERIES SHALL return results in chronological order +4. QUERIES SHALL support pagination for large result sets +5. QUERIES SHALL be fast (sub-second response times) + +### Requirement 7: Immutability and Retention + +**User Story:** As a compliance manager, I want audit logs to be immutable, so that historical records cannot be tampered with. + +#### Acceptance Criteria + +1. AUDIT logs SHALL NOT be modifiable or deletable after creation +2. AUDIT logs SHALL be retained for minimum 365 days +3. ARCHIVED logs older than retention period MAY be removed +4. RETENTION policy SHALL be configurable +5. DELETION of logs SHALL only be allowed through privileged administrative function \ No newline at end of file diff --git a/.kiro/specs/audit-logging-system/tasks.md b/.kiro/specs/audit-logging-system/tasks.md new file mode 100644 index 0000000..2ada855 --- /dev/null +++ b/.kiro/specs/audit-logging-system/tasks.md @@ -0,0 +1,291 @@ +# Implementation Plan: audit-logging-system + +## Overview + +This implementation plan creates a comprehensive audit logging system for recording notification lifecycle events with queryability and immutability guarantees. + +## Tasks + +- [ ] 1. Define audit log data structures + - [ ] 1.1 Create AuditLogEntry interface + - Fields: entryId, notificationId, timestamp, eventType, actor, status, metadata + - _Requirements: 1.1, 1.2_ + + - [ ] 1.2 Create EventType enum + - Values: CREATION, DELIVERY_ATTEMPT, DELIVERY_SUCCESS, DELIVERY_FAILURE, ACKNOWLEDGMENT + - _Requirements: 1.2_ + + - [ ] 1.3 Create AuditLogQuery interface for filtering + - Fields: notificationId, recipient, eventType, dateRange, actor, limit, offset + - _Requirements: 6.2_ + + - [ ] 1.4 Create QueryResult interface + - Fields: entries, total, hasMore + - _Requirements: 6.2, 6.4_ + +- [ ] 2. Implement audit log storage + - [ ] 2.1 Create database schema for audit logs + - Table/collection structure + - Indexes on query fields + - _Requirements: 6.1, 6.5_ + + - [ ] 2.2 Implement append-only storage pattern + - Ensure logs cannot be modified + - Enforce immutability at storage level + - _Requirements: 7.1, 7.2_ + + - [ ] 2.3 Configure retention policy + - Default 365 days + - Configurable per deployment + - _Requirements: 7.2, 7.4_ + + - [ ] 2.4 Implement archival mechanism + - Move old logs to archive storage + - Maintain query access to archived logs + - _Requirements: 7.3_ + +- [ ] 3. Implement AuditLogger service + - [ ] 3.1 Create AuditLogger class + - Methods: logCreation(), logDeliveryAttempt(), logDeliverySuccess(), logDeliveryFailure(), logAcknowledgment() + - Persist all events to database + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + + - [ ] 3.2 Implement logCreation() method + - Accept: notificationId, creator, recipient, title, content + - Log CREATION event with metadata + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [ ] 3.3 Implement logDeliveryAttempt() method + - Accept: notificationId, recipient, channel + - Log DELIVERY_ATTEMPT event + - _Requirements: 3.1, 3.2, 3.3_ + + - [ ] 3.4 Implement logDeliverySuccess() method + - Accept: notificationId, recipient, channel + - Log DELIVERY_SUCCESS event + - _Requirements: 3.3, 3.4_ + + - [ ] 3.5 Implement logDeliveryFailure() method + - Accept: notificationId, recipient, channel, errorReason, retryCount + - Log DELIVERY_FAILURE event + - _Requirements: 3.4, 4.1, 4.2, 4.3, 4.4_ + + - [ ] 3.6 Implement logAcknowledgment() method + - Accept: notificationId, recipient + - Log ACKNOWLEDGMENT event + - _Requirements: 5.1, 5.2, 5.3_ + +- [ ] 4. Implement query interface + - [ ] 4.1 Create queryAuditLogs() function + - Accept AuditLogQuery parameters + - Return QueryResult with entries and pagination + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 4.2 Implement filtering by notificationId + - Query all events for a specific notification + - _Requirements: 6.2_ + + - [ ] 4.3 Implement filtering by recipient + - Query all events for a specific recipient + - _Requirements: 6.2_ + + - [ ] 4.4 Implement filtering by eventType + - Query events of specific type(s) + - Support multiple types in single query + - _Requirements: 6.2_ + + - [ ] 4.5 Implement filtering by dateRange + - Query events within time period + - _Requirements: 6.2_ + + - [ ] 4.6 Implement filtering by actor + - Query events by who triggered them + - _Requirements: 6.2_ + + - [ ] 4.7 Implement pagination support + - Support limit and offset parameters + - Return hasMore flag for client + - _Requirements: 6.4_ + +- [ ] 5. Integrate logging into event processing + - [ ] 5.1 Update EventSubscriber to log CREATION + - Call auditLogger.logCreation() when processing new notification + - _Requirements: 2.1, 2.2_ + + - [ ] 5.2 Update EventSubscriber to log failures + - Log when event processing fails + - Include error details + - _Requirements: 4.1, 4.2_ + + - [ ] 5.3 Update DiscordNotificationService to log delivery attempts + - Log DELIVERY_ATTEMPT before sending + - Log DELIVERY_SUCCESS if successful + - Log DELIVERY_FAILURE if unsuccessful + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [ ] 5.4 Add notification expiration logging + - Log when notifications expire + - Include expiration details in metadata + - _Requirements: 2.1_ + +- [ ] 6. Create API endpoints for audit queries + - [ ] 6.1 Create GET /audit-logs endpoint + - Query audit logs with filters + - Support query parameters: notificationId, recipient, eventType, startTime, endTime, limit, offset + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 6.2 Create GET /audit-logs/:notificationId endpoint + - Get all audit logs for a specific notification + - Return full lifecycle + - _Requirements: 6.1, 6.2, 6.3_ + + - [ ] 6.3 Create GET /audit-logs/recipient/:recipient endpoint + - Get all audit logs for a specific recipient + - _Requirements: 6.1, 6.2_ + + - [ ] 6.4 Create GET /audit-logs/stats endpoint + - Return audit log statistics + - Include: total events, events by type, date range, etc. + - _Requirements: 6.1_ + +- [ ] 7. Implement immutability guarantees + - [ ] 7.1 Add database constraints to prevent modification + - Use NOT NULL constraints on immutable field + - Add trigger to prevent UPDATE operations + - _Requirements: 7.1_ + + - [ ] 7.2 Implement access control for deletion + - Only administrators can purge old logs + - Require authorization for deletion + - _Requirements: 7.5_ + + - [ ] 7.3 Add audit trail for admin actions + - Log who accessed audit logs and when + - _Requirements: 7.1_ + +- [ ] 8. Create unit tests + - [ ] 8.1 Create audit-logger.test.ts + - Test each logging method + - Test event creation with correct metadata + - _Requirements: All_ + + - [ ] 8.2 Test logCreation() + - Verify event is logged with correct details + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [ ] 8.3 Test logDeliveryAttempt() + - Verify event is logged before delivery + - _Requirements: 3.1, 3.2_ + + - [ ] 8.4 Test logDeliverySuccess() + - Verify success event is logged + - _Requirements: 3.3_ + + - [ ] 8.5 Test logDeliveryFailure() + - Verify failure event with error details + - _Requirements: 4.1, 4.2, 4.3_ + + - [ ] 8.6 Test logAcknowledgment() + - Verify acknowledgment event is logged + - _Requirements: 5.1, 5.2_ + +- [ ] 9. Create query tests + - [ ] 9.1 Create audit-query.test.ts + - Test query interface + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 9.2 Test query by notificationId + - Verify correct logs returned + - _Requirements: 6.2_ + + - [ ] 9.3 Test query by recipient + - Verify correct logs returned + - _Requirements: 6.2_ + + - [ ] 9.4 Test query by eventType + - Test single and multiple types + - _Requirements: 6.2_ + + - [ ] 9.5 Test query by dateRange + - Verify only logs in range returned + - _Requirements: 6.2_ + + - [ ] 9.6 Test pagination + - Verify limit and offset work correctly + - Verify hasMore flag accurate + - _Requirements: 6.4_ + + - [ ] 9.7 Test query performance + - Verify sub-second response times + - _Requirements: 6.5_ + +- [ ] 10. Create integration tests + - [ ] 10.1 Create audit-integration.test.ts + - End-to-end test of notification lifecycle logging + - _Requirements: All_ + + - [ ] 10.2 Test complete notification lifecycle + - Create → Deliver → Acknowledge + - Verify all events logged in order + - _Requirements: 2.1, 3.1, 4.1, 5.1_ + + - [ ] 10.3 Test failure and retry scenario + - Delivery attempt → Failure → Retry → Success + - Verify all events logged with correct context + - _Requirements: 3.1, 4.1, 4.3_ + + - [ ] 10.4 Test immutability + - Attempt to modify audit log entry + - Verify error is returned + - _Requirements: 7.1_ + + - [ ] 10.5 Test retention policy + - Create old entries + - Verify they are archived/removed per policy + - _Requirements: 7.2, 7.3, 7.4_ + +- [ ] 11. Create documentation + - [ ] 11.1 Document audit log schema + - List all fields and types + - Explain each event type + - _Requirements: 1.1, 1.2_ + + - [ ] 11.2 Document query API + - Provide examples for each query type + - Document filter syntax + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 11.3 Document retention policy + - Explain default and custom retention + - Document archival process + - _Requirements: 7.2, 7.4_ + + - [ ] 11.4 Document compliance features + - Explain immutability guarantees + - Document audit trail for admin access + - _Requirements: 7.1, 7.5_ + +- [ ] 12. Final testing checkpoint + - [ ] 12.1 Run all tests + - Ensure no regressions + - Verify all requirements met + - _Requirements: All_ + + - [ ] 12.2 Performance testing + - Measure query times + - Verify performance meets requirements + - _Requirements: 6.5_ + + - [ ] 12.3 Compliance verification + - Verify immutability enforcement + - Test retention policy + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ + +## Notes + +- Audit logs should be append-only to ensure immutability +- All lifecycle events should be logged regardless of success/failure +- Query performance is critical - proper indexing is essential +- Retention policy must be configurable and enforced +- Consider separate storage/archival for old logs +- Compliance and regulatory requirements may require additional fields \ No newline at end of file diff --git a/.kiro/specs/notification-expiration/.config.kiro b/.kiro/specs/notification-expiration/.config.kiro new file mode 100644 index 0000000..ecd5bd5 --- /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 0000000..29b8a48 --- /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 0000000..57a2a00 --- /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 0000000..4cb2174 --- /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 + - [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 + - [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_ + +- [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_ + +- [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_ + +- [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 + +- 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 diff --git a/listener/.env.example b/listener/.env.example index 1c63743..62d7192 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 7631755..e691bc5 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 1de3a88..498caf2 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 aaf3670..b1cb96a 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 fe5c962..e126539 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 0e0cc99..8ec0559 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 c4d98d9..8c3d9f2 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 0000000..9a7a219 --- /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 0000000..e2fdc50 --- /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 be5eef7..5ff0c02 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; +} +