feat(polling-contracts): add generic polling interface with TypeScript types and Zod schemas
- Add @timesafari/polling-contracts package with comprehensive type definitions - Implement GenericPollingRequest, PollingResult, and PollingScheduleConfig interfaces - Add Zod schemas for StarredProjectsRequest/Response and DeepLinkParams validation - Include calculateBackoffDelay utility with unified retry policy (exponential, linear, fixed) - Add OutboxPressureManager for storage pressure controls and back-pressure signals - Implement TelemetryManager with cardinality budgets and PII redaction - Add ClockSyncManager for JWT timestamp validation and skew tolerance - Include comprehensive unit tests with Jest snapshots and race condition testing - Add JWT_ID_PATTERN regex for canonical JWT ID format validation - Support idempotency with X-Idempotency-Key enforcement - Implement watermark CAS (Compare-and-Swap) for race condition prevention This establishes the foundation for the new generic polling system where host apps define request/response schemas and the plugin provides robust polling logic.
This commit is contained in:
14
packages/polling-contracts/jest.config.js
Normal file
14
packages/polling-contracts/jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/__tests__/**'
|
||||||
|
],
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts']
|
||||||
|
};
|
||||||
29
packages/polling-contracts/package.json
Normal file
29
packages/polling-contracts/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@timesafari/polling-contracts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "TypeScript contracts and Zod schemas for TimeSafari polling system",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "jest",
|
||||||
|
"test:snapshots": "jest --updateSnapshot",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"lint-fix": "eslint src --ext .ts --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||||
|
"@typescript-eslint/parser": "^5.57.0",
|
||||||
|
"eslint": "^8.37.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Schema Validation AcknowledgmentRequestSchema should validate acknowledgment request: acknowledgment-request 1`] = `
|
||||||
|
{
|
||||||
|
"acknowledgedAt": "2025-01-01T12:00:00Z",
|
||||||
|
"acknowledgedJwtIds": [
|
||||||
|
"1704067200_abc123_12345678",
|
||||||
|
"1704153600_mno345_87654321",
|
||||||
|
],
|
||||||
|
"clientVersion": "TimeSafari-DailyNotificationPlugin/1.0.0",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation AcknowledgmentResponseSchema should validate acknowledgment response: acknowledgment-response 1`] = `
|
||||||
|
{
|
||||||
|
"acknowledged": 2,
|
||||||
|
"acknowledgmentId": "ack_xyz789",
|
||||||
|
"alreadyAcknowledged": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"timestamp": "2025-01-01T12:00:00Z",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation ClockSyncResponseSchema should validate clock sync response: clock-sync-response 1`] = `
|
||||||
|
{
|
||||||
|
"clientTime": 1704067195000,
|
||||||
|
"ntpServers": [
|
||||||
|
"pool.ntp.org",
|
||||||
|
"time.google.com",
|
||||||
|
],
|
||||||
|
"offset": 5000,
|
||||||
|
"serverTime": 1704067200000,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation DeepLinkParamsSchema should reject invalid params: invalid-params-error 1`] = `
|
||||||
|
[ZodError: [
|
||||||
|
{
|
||||||
|
"validation": "regex",
|
||||||
|
"code": "invalid_string",
|
||||||
|
"message": "Invalid",
|
||||||
|
"path": [
|
||||||
|
"jwtIds",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"validation": "regex",
|
||||||
|
"code": "invalid_string",
|
||||||
|
"message": "Invalid",
|
||||||
|
"path": [
|
||||||
|
"projectId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation DeepLinkParamsSchema should validate multiple JWT IDs params: multiple-jwt-ids-params 1`] = `
|
||||||
|
{
|
||||||
|
"jwtIds": [
|
||||||
|
"1704067200_abc123_12345678",
|
||||||
|
"1704153600_mno345_87654321",
|
||||||
|
"1704240000_new123_abcdef01",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation DeepLinkParamsSchema should validate project ID params: project-id-params 1`] = `
|
||||||
|
{
|
||||||
|
"projectId": "test_project_123",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation DeepLinkParamsSchema should validate shortlink params: shortlink-params 1`] = `
|
||||||
|
{
|
||||||
|
"shortlink": "abc123def456789",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation ErrorResponseSchema should validate generic error: generic-error 1`] = `
|
||||||
|
{
|
||||||
|
"details": {
|
||||||
|
"component": "database",
|
||||||
|
"operation": "query_starred_projects",
|
||||||
|
},
|
||||||
|
"error": "internal_server_error",
|
||||||
|
"message": "Database connection timeout",
|
||||||
|
"requestId": "req_mno345",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation ErrorResponseSchema should validate rate limit error: rate-limit-error 1`] = `
|
||||||
|
{
|
||||||
|
"code": "RATE_LIMIT_EXCEEDED",
|
||||||
|
"details": {
|
||||||
|
"limit": 100,
|
||||||
|
"resetAt": "2025-01-01T12:01:00Z",
|
||||||
|
"window": "1m",
|
||||||
|
},
|
||||||
|
"error": "Rate limit exceeded",
|
||||||
|
"message": "Rate limit exceeded for DID",
|
||||||
|
"requestId": "req_jkl012",
|
||||||
|
"retryAfter": 60,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation StarredProjectsResponseSchema should validate canonical response envelope: canonical-response-envelope 1`] = `
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"planSummary": {
|
||||||
|
"agentDid": "did:key:test_agent_1",
|
||||||
|
"description": "First test project",
|
||||||
|
"endTime": "2025-01-31T23:59:59Z",
|
||||||
|
"handleId": "test_project_1",
|
||||||
|
"issuerDid": "did:key:test_issuer_1",
|
||||||
|
"jwtId": "1704067200_abc123_def45678",
|
||||||
|
"locLat": 40.7128,
|
||||||
|
"locLon": -74.006,
|
||||||
|
"name": "Test Project 1",
|
||||||
|
"startTime": "2025-01-01T00:00:00Z",
|
||||||
|
"url": "https://project-url.com",
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
|
"previousClaim": {
|
||||||
|
"claimData": {
|
||||||
|
"progress": 0.75,
|
||||||
|
"status": "in_progress",
|
||||||
|
},
|
||||||
|
"claimType": "project_update",
|
||||||
|
"jwtId": "1703980800_xyz789_12345678",
|
||||||
|
"metadata": {
|
||||||
|
"createdAt": "2025-01-01T10:00:00Z",
|
||||||
|
"updatedAt": "2025-01-01T12:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"hitLimit": false,
|
||||||
|
"pagination": {
|
||||||
|
"hasMore": false,
|
||||||
|
"nextAfterId": null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation StarredProjectsResponseSchema should validate empty response: empty-response 1`] = `
|
||||||
|
{
|
||||||
|
"data": [],
|
||||||
|
"hitLimit": false,
|
||||||
|
"pagination": {
|
||||||
|
"hasMore": false,
|
||||||
|
"nextAfterId": null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Schema Validation StarredProjectsResponseSchema should validate paginated response: paginated-response 1`] = `
|
||||||
|
{
|
||||||
|
"data": [],
|
||||||
|
"hitLimit": true,
|
||||||
|
"pagination": {
|
||||||
|
"hasMore": true,
|
||||||
|
"nextAfterId": "1704153600_mno345_87654321",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
238
packages/polling-contracts/src/__tests__/backoff.test.ts
Normal file
238
packages/polling-contracts/src/__tests__/backoff.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for backoff policy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateBackoffDelay,
|
||||||
|
createDefaultBackoffPolicy,
|
||||||
|
createRateLimitBackoffPolicy,
|
||||||
|
createNetworkErrorBackoffPolicy,
|
||||||
|
createServerErrorBackoffPolicy
|
||||||
|
} from '../backoff';
|
||||||
|
import { BackoffPolicy } from '../types';
|
||||||
|
|
||||||
|
describe('Backoff Policy', () => {
|
||||||
|
describe('calculateBackoffDelay', () => {
|
||||||
|
const defaultPolicy: BackoffPolicy = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
strategy: 'exponential',
|
||||||
|
jitterEnabled: false, // Disable for predictable tests
|
||||||
|
jitterFactor: 0.25,
|
||||||
|
respectRetryAfter: true,
|
||||||
|
retryAfterMaxMs: 300000
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should calculate exponential backoff correctly', () => {
|
||||||
|
const policy = { ...defaultPolicy, strategy: 'exponential' as const };
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy)).toBe(1000); // 1s
|
||||||
|
expect(calculateBackoffDelay(2, policy)).toBe(2000); // 2s
|
||||||
|
expect(calculateBackoffDelay(3, policy)).toBe(4000); // 4s
|
||||||
|
expect(calculateBackoffDelay(4, policy)).toBe(8000); // 8s
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate linear backoff correctly', () => {
|
||||||
|
const policy = { ...defaultPolicy, strategy: 'linear' as const };
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy)).toBe(1000); // 1s
|
||||||
|
expect(calculateBackoffDelay(2, policy)).toBe(2000); // 2s
|
||||||
|
expect(calculateBackoffDelay(3, policy)).toBe(3000); // 3s
|
||||||
|
expect(calculateBackoffDelay(4, policy)).toBe(4000); // 4s
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate fixed backoff correctly', () => {
|
||||||
|
const policy = { ...defaultPolicy, strategy: 'fixed' as const };
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy)).toBe(1000); // 1s
|
||||||
|
expect(calculateBackoffDelay(2, policy)).toBe(1000); // 1s
|
||||||
|
expect(calculateBackoffDelay(3, policy)).toBe(1000); // 1s
|
||||||
|
expect(calculateBackoffDelay(4, policy)).toBe(1000); // 1s
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect Retry-After header', () => {
|
||||||
|
const policy = { ...defaultPolicy, respectRetryAfter: true };
|
||||||
|
const retryAfterMs = 5000; // 5 seconds
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy, retryAfterMs)).toBe(5000);
|
||||||
|
expect(calculateBackoffDelay(2, policy, retryAfterMs)).toBe(5000);
|
||||||
|
expect(calculateBackoffDelay(3, policy, retryAfterMs)).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap Retry-After at maxDelayMs', () => {
|
||||||
|
const policy = { ...defaultPolicy, maxDelayMs: 10000 };
|
||||||
|
const retryAfterMs = 60000; // 60 seconds (exceeds max)
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy, retryAfterMs)).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap Retry-After at retryAfterMaxMs', () => {
|
||||||
|
const policy = { ...defaultPolicy, retryAfterMaxMs: 15000 };
|
||||||
|
const retryAfterMs = 30000; // 30 seconds (exceeds retryAfterMaxMs)
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy, retryAfterMs)).toBe(15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply jitter when enabled', () => {
|
||||||
|
const policy = { ...defaultPolicy, jitterEnabled: true, jitterFactor: 0.5 };
|
||||||
|
|
||||||
|
// Run multiple times to test jitter range
|
||||||
|
const delays = Array.from({ length: 100 }, () =>
|
||||||
|
calculateBackoffDelay(1, policy)
|
||||||
|
);
|
||||||
|
|
||||||
|
// All delays should be within jitter range (500ms to 1500ms)
|
||||||
|
delays.forEach(delay => {
|
||||||
|
expect(delay).toBeGreaterThanOrEqual(500);
|
||||||
|
expect(delay).toBeLessThanOrEqual(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have some variation due to jitter
|
||||||
|
const uniqueDelays = new Set(delays);
|
||||||
|
expect(uniqueDelays.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap delay at maxDelayMs', () => {
|
||||||
|
const policy = { ...defaultPolicy, maxDelayMs: 5000 };
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(10, policy)).toBe(5000); // Capped at 5s
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero jitter factor', () => {
|
||||||
|
const policy = { ...defaultPolicy, jitterEnabled: true, jitterFactor: 0 };
|
||||||
|
|
||||||
|
expect(calculateBackoffDelay(1, policy)).toBe(1000); // No jitter applied
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Policy Creators', () => {
|
||||||
|
it('should create default backoff policy', () => {
|
||||||
|
const policy = createDefaultBackoffPolicy();
|
||||||
|
|
||||||
|
expect(policy.maxAttempts).toBe(3);
|
||||||
|
expect(policy.baseDelayMs).toBe(1000);
|
||||||
|
expect(policy.maxDelayMs).toBe(30000);
|
||||||
|
expect(policy.strategy).toBe('exponential');
|
||||||
|
expect(policy.jitterEnabled).toBe(true);
|
||||||
|
expect(policy.jitterFactor).toBe(0.25);
|
||||||
|
expect(policy.respectRetryAfter).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create rate limit backoff policy', () => {
|
||||||
|
const retryAfterMs = 60000; // 1 minute
|
||||||
|
const policy = createRateLimitBackoffPolicy(retryAfterMs);
|
||||||
|
|
||||||
|
expect(policy.maxAttempts).toBe(5);
|
||||||
|
expect(policy.baseDelayMs).toBe(60000);
|
||||||
|
expect(policy.strategy).toBe('fixed');
|
||||||
|
expect(policy.jitterEnabled).toBe(true);
|
||||||
|
expect(policy.jitterFactor).toBe(0.1);
|
||||||
|
expect(policy.respectRetryAfter).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create network error backoff policy', () => {
|
||||||
|
const policy = createNetworkErrorBackoffPolicy();
|
||||||
|
|
||||||
|
expect(policy.maxAttempts).toBe(3);
|
||||||
|
expect(policy.baseDelayMs).toBe(1000);
|
||||||
|
expect(policy.strategy).toBe('exponential');
|
||||||
|
expect(policy.jitterEnabled).toBe(true);
|
||||||
|
expect(policy.respectRetryAfter).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create server error backoff policy', () => {
|
||||||
|
const policy = createServerErrorBackoffPolicy();
|
||||||
|
|
||||||
|
expect(policy.maxAttempts).toBe(3);
|
||||||
|
expect(policy.baseDelayMs).toBe(2000);
|
||||||
|
expect(policy.strategy).toBe('exponential');
|
||||||
|
expect(policy.jitterEnabled).toBe(true);
|
||||||
|
expect(policy.jitterFactor).toBe(0.5);
|
||||||
|
expect(policy.respectRetryAfter).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('429 + Retry-After Branch', () => {
|
||||||
|
it('should handle 429 with Retry-After header', () => {
|
||||||
|
const policy = createRateLimitBackoffPolicy(30000); // 30 seconds
|
||||||
|
policy.jitterEnabled = false; // Disable jitter for predictable tests
|
||||||
|
|
||||||
|
// First attempt should use Retry-After
|
||||||
|
expect(calculateBackoffDelay(1, policy, 30000)).toBe(30000);
|
||||||
|
|
||||||
|
// Subsequent attempts should also use Retry-After
|
||||||
|
expect(calculateBackoffDelay(2, policy, 30000)).toBe(30000);
|
||||||
|
expect(calculateBackoffDelay(3, policy, 30000)).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 429 without Retry-After header', () => {
|
||||||
|
const policy = createRateLimitBackoffPolicy(30000);
|
||||||
|
policy.jitterEnabled = false; // Disable jitter for predictable tests
|
||||||
|
|
||||||
|
// Should fall back to fixed strategy
|
||||||
|
expect(calculateBackoffDelay(1, policy)).toBe(30000);
|
||||||
|
expect(calculateBackoffDelay(2, policy)).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap 429 Retry-After at reasonable limits', () => {
|
||||||
|
const policy = createRateLimitBackoffPolicy(300000); // 5 minutes
|
||||||
|
policy.jitterEnabled = false; // Disable jitter for predictable tests
|
||||||
|
|
||||||
|
// Should be capped at retryAfterMaxMs (600000ms = 10 minutes)
|
||||||
|
expect(calculateBackoffDelay(1, policy, 300000)).toBe(300000);
|
||||||
|
expect(calculateBackoffDelay(1, policy, 900000)).toBe(600000); // Capped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Jitter Bounds', () => {
|
||||||
|
it('should respect jitter bounds for exponential backoff', () => {
|
||||||
|
const policy: BackoffPolicy = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
strategy: 'exponential',
|
||||||
|
jitterEnabled: true,
|
||||||
|
jitterFactor: 0.25,
|
||||||
|
respectRetryAfter: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test multiple attempts to ensure jitter is within bounds
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
const baseDelay = 1000 * Math.pow(2, attempt - 1);
|
||||||
|
const minDelay = baseDelay * (1 - 0.25);
|
||||||
|
const maxDelay = baseDelay * (1 + 0.25);
|
||||||
|
|
||||||
|
const delays = Array.from({ length: 50 }, () =>
|
||||||
|
calculateBackoffDelay(attempt, policy)
|
||||||
|
);
|
||||||
|
|
||||||
|
delays.forEach(delay => {
|
||||||
|
expect(delay).toBeGreaterThanOrEqual(minDelay);
|
||||||
|
expect(delay).toBeLessThanOrEqual(maxDelay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply negative jitter', () => {
|
||||||
|
const policy: BackoffPolicy = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
strategy: 'fixed',
|
||||||
|
jitterEnabled: true,
|
||||||
|
jitterFactor: 1.0, // 100% jitter
|
||||||
|
respectRetryAfter: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const delays = Array.from({ length: 100 }, () =>
|
||||||
|
calculateBackoffDelay(1, policy)
|
||||||
|
);
|
||||||
|
|
||||||
|
// All delays should be non-negative
|
||||||
|
delays.forEach(delay => {
|
||||||
|
expect(delay).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
packages/polling-contracts/src/__tests__/clock-sync.test.ts
Normal file
268
packages/polling-contracts/src/__tests__/clock-sync.test.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for clock synchronization and skew handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ClockSyncManager, createDefaultClockSyncManager } from '../clock-sync';
|
||||||
|
|
||||||
|
// Mock fetch for testing
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
describe('Clock Sync Manager', () => {
|
||||||
|
let clockSync: ClockSyncManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clockSync = createDefaultClockSyncManager();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Server Time Sync', () => {
|
||||||
|
it('should sync with server time successfully', async () => {
|
||||||
|
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
|
||||||
|
const mockClientTime = 1704067195000; // 2024-01-01 00:00:00 - 5s
|
||||||
|
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue(mockServerTime.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Date.now to return consistent client time
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
expect(clockSync.getServerOffset()).toBe(5000); // 5 second offset
|
||||||
|
expect(clockSync.getLastSyncTime()).toBe(mockClientTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle server sync failure gracefully', async () => {
|
||||||
|
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const initialOffset = clockSync.getServerOffset();
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
// Offset should remain unchanged on failure
|
||||||
|
expect(clockSync.getServerOffset()).toBe(initialOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid server time response', async () => {
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue('0')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialOffset = clockSync.getServerOffset();
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
// Offset should remain unchanged on invalid response
|
||||||
|
expect(clockSync.getServerOffset()).toBe(initialOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HTTP error responses', async () => {
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialOffset = clockSync.getServerOffset();
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
// Offset should remain unchanged on HTTP error
|
||||||
|
expect(clockSync.getServerOffset()).toBe(initialOffset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Clock Skew Detection', () => {
|
||||||
|
it('should detect excessive clock skew', async () => {
|
||||||
|
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
|
||||||
|
const mockClientTime = 1704067200000 - (35 * 1000); // 35 seconds behind
|
||||||
|
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue(mockServerTime.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
expect(clockSync.isClockSkewExcessive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect excessive clock skew within tolerance', async () => {
|
||||||
|
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
|
||||||
|
const mockClientTime = 1704067200000 - (25 * 1000); // 25 seconds behind
|
||||||
|
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue(mockServerTime.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
expect(clockSync.isClockSkewExcessive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JWT Timestamp Validation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set up clock sync with known offset
|
||||||
|
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
|
||||||
|
const mockClientTime = 1704067195000; // 5 seconds behind
|
||||||
|
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue(mockServerTime.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate JWT within acceptable time window', async () => {
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
const jwt = {
|
||||||
|
iat: Math.floor((1704067200000 - 1000) / 1000), // 1 second ago
|
||||||
|
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(clockSync.validateJwtTimestamp(jwt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject JWT with excessive clock skew', async () => {
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
const jwt = {
|
||||||
|
iat: Math.floor((1704067200000 - 45000) / 1000), // 45 seconds ago (exceeds 30s tolerance)
|
||||||
|
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(clockSync.validateJwtTimestamp(jwt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject expired JWT', async () => {
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
const jwt = {
|
||||||
|
iat: Math.floor((1704067200000 - 3600000) / 1000), // 1 hour ago
|
||||||
|
exp: Math.floor((1704067200000 - 35000) / 1000) // 35 seconds ago (expired, exceeds tolerance)
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(clockSync.validateJwtTimestamp(jwt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject JWT that is too old', async () => {
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
const jwt = {
|
||||||
|
iat: Math.floor((1704067200000 - 7200000) / 1000), // 2 hours ago (exceeds max age)
|
||||||
|
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(clockSync.validateJwtTimestamp(jwt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JWT with clock skew tolerance', async () => {
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
const jwt = {
|
||||||
|
iat: Math.floor((1704067200000 - 25000) / 1000), // 25 seconds ago (within tolerance)
|
||||||
|
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(clockSync.validateJwtTimestamp(jwt)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Periodic Sync', () => {
|
||||||
|
it('should start and stop periodic sync', () => {
|
||||||
|
const syncSpy = jest.spyOn(clockSync, 'syncWithServer');
|
||||||
|
|
||||||
|
clockSync.startPeriodicSync('https://api.example.com');
|
||||||
|
expect(syncSpy).not.toHaveBeenCalled(); // Not called immediately
|
||||||
|
|
||||||
|
clockSync.stopPeriodicSync();
|
||||||
|
// Should not throw error
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect when sync is needed', () => {
|
||||||
|
// Initially needs sync (no previous sync)
|
||||||
|
expect(clockSync.needsSync()).toBe(true);
|
||||||
|
|
||||||
|
// Mock a recent sync
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(1000);
|
||||||
|
clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
// Should not need sync immediately after
|
||||||
|
expect(clockSync.needsSync()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Time Calculations', () => {
|
||||||
|
it('should calculate server time correctly', async () => {
|
||||||
|
const mockServerTime = 1704067200000;
|
||||||
|
const mockClientTime = 1704067195000;
|
||||||
|
|
||||||
|
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: {
|
||||||
|
get: jest.fn().mockReturnValue(mockServerTime.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
|
||||||
|
|
||||||
|
await clockSync.syncWithServer('https://api.example.com');
|
||||||
|
|
||||||
|
// Mock current time to be 1 second later
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime + 1000);
|
||||||
|
|
||||||
|
const serverTime = clockSync.getServerTime();
|
||||||
|
expect(serverTime).toBe(mockServerTime + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return client time correctly', () => {
|
||||||
|
const mockTime = 1704067200000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(mockTime);
|
||||||
|
|
||||||
|
expect(clockSync.getClientTime()).toBe(mockTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration', () => {
|
||||||
|
it('should use default configuration', () => {
|
||||||
|
const config = clockSync.getConfig();
|
||||||
|
|
||||||
|
expect(config.maxClockSkewSeconds).toBe(30);
|
||||||
|
expect(config.skewCheckIntervalMs).toBe(300000);
|
||||||
|
expect(config.jwtClockSkewTolerance).toBe(30);
|
||||||
|
expect(config.jwtMaxAge).toBe(3600000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom configuration', () => {
|
||||||
|
const customClockSync = new ClockSyncManager({
|
||||||
|
maxClockSkewSeconds: 60,
|
||||||
|
skewCheckIntervalMs: 600000
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = customClockSync.getConfig();
|
||||||
|
|
||||||
|
expect(config.maxClockSkewSeconds).toBe(60);
|
||||||
|
expect(config.skewCheckIntervalMs).toBe(600000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
236
packages/polling-contracts/src/__tests__/schemas.test.ts
Normal file
236
packages/polling-contracts/src/__tests__/schemas.test.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Jest tests for schema validation with snapshots
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
StarredProjectsResponseSchema,
|
||||||
|
DeepLinkParamsSchema,
|
||||||
|
ErrorResponseSchema,
|
||||||
|
RateLimitResponseSchema,
|
||||||
|
AcknowledgmentRequestSchema,
|
||||||
|
AcknowledgmentResponseSchema,
|
||||||
|
ClockSyncResponseSchema
|
||||||
|
} from '../schemas';
|
||||||
|
|
||||||
|
describe('Schema Validation', () => {
|
||||||
|
describe('StarredProjectsResponseSchema', () => {
|
||||||
|
it('should validate canonical response envelope', () => {
|
||||||
|
const canonicalResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
planSummary: {
|
||||||
|
jwtId: '1704067200_abc123_def45678',
|
||||||
|
handleId: 'test_project_1',
|
||||||
|
name: 'Test Project 1',
|
||||||
|
description: 'First test project',
|
||||||
|
issuerDid: 'did:key:test_issuer_1',
|
||||||
|
agentDid: 'did:key:test_agent_1',
|
||||||
|
startTime: '2025-01-01T00:00:00Z',
|
||||||
|
endTime: '2025-01-31T23:59:59Z',
|
||||||
|
locLat: 40.7128,
|
||||||
|
locLon: -74.0060,
|
||||||
|
url: 'https://project-url.com',
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
previousClaim: {
|
||||||
|
jwtId: '1703980800_xyz789_12345678',
|
||||||
|
claimType: 'project_update',
|
||||||
|
claimData: {
|
||||||
|
status: 'in_progress',
|
||||||
|
progress: 0.75
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
createdAt: '2025-01-01T10:00:00Z',
|
||||||
|
updatedAt: '2025-01-01T12:00:00Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
hitLimit: false,
|
||||||
|
pagination: {
|
||||||
|
hasMore: false,
|
||||||
|
nextAfterId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = StarredProjectsResponseSchema.safeParse(canonicalResponse);
|
||||||
|
if (!result.success) {
|
||||||
|
console.log('Schema validation errors:', result.error.errors);
|
||||||
|
}
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('canonical-response-envelope');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate empty response', () => {
|
||||||
|
const emptyResponse = {
|
||||||
|
data: [],
|
||||||
|
hitLimit: false,
|
||||||
|
pagination: {
|
||||||
|
hasMore: false,
|
||||||
|
nextAfterId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = StarredProjectsResponseSchema.safeParse(emptyResponse);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('empty-response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate paginated response', () => {
|
||||||
|
const paginatedResponse = {
|
||||||
|
data: [], // Would contain 100 items in real scenario
|
||||||
|
hitLimit: true,
|
||||||
|
pagination: {
|
||||||
|
hasMore: true,
|
||||||
|
nextAfterId: '1704153600_mno345_87654321'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = StarredProjectsResponseSchema.safeParse(paginatedResponse);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('paginated-response');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepLinkParamsSchema', () => {
|
||||||
|
it('should validate single JWT ID params', () => {
|
||||||
|
const params = {
|
||||||
|
jwtId: '1704067200_abc123_def45678'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeepLinkParamsSchema.safeParse(params);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('single-jwt-id-params');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate multiple JWT IDs params', () => {
|
||||||
|
const params = {
|
||||||
|
jwtIds: [
|
||||||
|
'1704067200_abc123_12345678',
|
||||||
|
'1704153600_mno345_87654321',
|
||||||
|
'1704240000_new123_abcdef01'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeepLinkParamsSchema.safeParse(params);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('multiple-jwt-ids-params');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate project ID params', () => {
|
||||||
|
const params = {
|
||||||
|
projectId: 'test_project_123'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeepLinkParamsSchema.safeParse(params);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('project-id-params');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate shortlink params', () => {
|
||||||
|
const params = {
|
||||||
|
shortlink: 'abc123def456789'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeepLinkParamsSchema.safeParse(params);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('shortlink-params');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid params', () => {
|
||||||
|
const invalidParams = {
|
||||||
|
jwtIds: ['invalid_jwt_id'],
|
||||||
|
projectId: 'invalid@project#id'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DeepLinkParamsSchema.safeParse(invalidParams);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.success ? null : result.error).toMatchSnapshot('invalid-params-error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ErrorResponseSchema', () => {
|
||||||
|
it('should validate rate limit error', () => {
|
||||||
|
const rateLimitError = {
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'Rate limit exceeded for DID',
|
||||||
|
details: {
|
||||||
|
limit: 100,
|
||||||
|
window: '1m',
|
||||||
|
resetAt: '2025-01-01T12:01:00Z'
|
||||||
|
},
|
||||||
|
retryAfter: 60,
|
||||||
|
requestId: 'req_jkl012'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = RateLimitResponseSchema.safeParse(rateLimitError);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('rate-limit-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate generic error', () => {
|
||||||
|
const genericError = {
|
||||||
|
error: 'internal_server_error',
|
||||||
|
message: 'Database connection timeout',
|
||||||
|
details: {
|
||||||
|
component: 'database',
|
||||||
|
operation: 'query_starred_projects'
|
||||||
|
},
|
||||||
|
requestId: 'req_mno345'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ErrorResponseSchema.safeParse(genericError);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('generic-error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AcknowledgmentRequestSchema', () => {
|
||||||
|
it('should validate acknowledgment request', () => {
|
||||||
|
const ackRequest = {
|
||||||
|
acknowledgedJwtIds: [
|
||||||
|
'1704067200_abc123_12345678',
|
||||||
|
'1704153600_mno345_87654321'
|
||||||
|
],
|
||||||
|
acknowledgedAt: '2025-01-01T12:00:00Z',
|
||||||
|
clientVersion: 'TimeSafari-DailyNotificationPlugin/1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = AcknowledgmentRequestSchema.safeParse(ackRequest);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('acknowledgment-request');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AcknowledgmentResponseSchema', () => {
|
||||||
|
it('should validate acknowledgment response', () => {
|
||||||
|
const ackResponse = {
|
||||||
|
acknowledged: 2,
|
||||||
|
failed: 0,
|
||||||
|
alreadyAcknowledged: 0,
|
||||||
|
acknowledgmentId: 'ack_xyz789',
|
||||||
|
timestamp: '2025-01-01T12:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = AcknowledgmentResponseSchema.safeParse(ackResponse);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('acknowledgment-response');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ClockSyncResponseSchema', () => {
|
||||||
|
it('should validate clock sync response', () => {
|
||||||
|
const clockSyncResponse = {
|
||||||
|
serverTime: 1704067200000,
|
||||||
|
clientTime: 1704067195000,
|
||||||
|
offset: 5000,
|
||||||
|
ntpServers: ['pool.ntp.org', 'time.google.com']
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ClockSyncResponseSchema.safeParse(clockSyncResponse);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.success ? result.data : null).toMatchSnapshot('clock-sync-response');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
22
packages/polling-contracts/src/__tests__/setup.ts
Normal file
22
packages/polling-contracts/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Jest setup file for polling contracts tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock console methods to reduce noise in tests
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
const originalConsoleWarn = console.warn;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Allow console.log for debugging, but suppress other console methods
|
||||||
|
// console.log = jest.fn();
|
||||||
|
console.warn = jest.fn();
|
||||||
|
console.error = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore console methods
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
265
packages/polling-contracts/src/__tests__/watermark-cas.test.ts
Normal file
265
packages/polling-contracts/src/__tests__/watermark-cas.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Watermark CAS race condition tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { compareJwtIds } from '../validation';
|
||||||
|
|
||||||
|
describe('Watermark CAS Race Conditions', () => {
|
||||||
|
const testJwtIds = [
|
||||||
|
'1704067200_abc123_12345678', // 2024-01-01 00:00:00
|
||||||
|
'1704153600_mno345_87654321', // 2024-01-02 00:00:00
|
||||||
|
'1704240000_new123_abcdef01', // 2024-01-03 00:00:00
|
||||||
|
'1704326400_xyz789_23456789', // 2024-01-04 00:00:00
|
||||||
|
'1704412800_stu901_34567890' // 2024-01-05 00:00:00
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Concurrent Bootstrap Race', () => {
|
||||||
|
it('should handle two clients bootstrapping concurrently', async () => {
|
||||||
|
// Simulate two clients fetching the same data concurrently
|
||||||
|
const client1Bootstrap = testJwtIds[2]; // 2024-01-03
|
||||||
|
const client2Bootstrap = testJwtIds[3]; // 2024-01-04 (newer)
|
||||||
|
|
||||||
|
// Client 1 attempts to set watermark
|
||||||
|
const client1Result = await simulateWatermarkUpdate(null, client1Bootstrap);
|
||||||
|
expect(client1Result.success).toBe(true);
|
||||||
|
expect(client1Result.watermark).toBe(client1Bootstrap);
|
||||||
|
|
||||||
|
// Client 2 attempts to set watermark (should succeed due to CAS)
|
||||||
|
const client2Result = await simulateWatermarkUpdate(null, client2Bootstrap);
|
||||||
|
expect(client2Result.success).toBe(true);
|
||||||
|
expect(client2Result.watermark).toBe(client2Bootstrap);
|
||||||
|
|
||||||
|
// Final watermark should be the maximum JWT ID
|
||||||
|
const finalWatermark = await getCurrentWatermark();
|
||||||
|
expect(finalWatermark).toBe(client2Bootstrap);
|
||||||
|
if (finalWatermark && client1Bootstrap) {
|
||||||
|
expect(compareJwtIds(finalWatermark, client1Bootstrap)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject older watermark updates', async () => {
|
||||||
|
// Set initial watermark
|
||||||
|
await simulateWatermarkUpdate(null, testJwtIds[3]); // 2024-01-04
|
||||||
|
|
||||||
|
// Attempt to set older watermark (should fail)
|
||||||
|
const result = await simulateWatermarkUpdate(testJwtIds[3], testJwtIds[1]); // 2024-01-02
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.watermark).toBe(testJwtIds[3]); // Unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null watermark bootstrap', async () => {
|
||||||
|
// First client sets watermark from null
|
||||||
|
const result1 = await simulateWatermarkUpdate(null, testJwtIds[2]);
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
|
||||||
|
// Second client attempts to set watermark from null (should fail)
|
||||||
|
const result2 = await simulateWatermarkUpdate(null, testJwtIds[1]);
|
||||||
|
expect(result2.success).toBe(false);
|
||||||
|
expect(result2.watermark).toBe(testJwtIds[2]); // First client's watermark
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Overlapping Polls Race', () => {
|
||||||
|
it('should handle overlapping polls with different results', async () => {
|
||||||
|
// Set initial watermark
|
||||||
|
await simulateWatermarkUpdate(null, testJwtIds[1]); // 2024-01-02
|
||||||
|
|
||||||
|
// Client 1 polls and finds changes up to 2024-01-03
|
||||||
|
const client1Changes = [testJwtIds[2]]; // 2024-01-03
|
||||||
|
const client1Result = await simulatePollAndUpdate(testJwtIds[1], client1Changes);
|
||||||
|
expect(client1Result.success).toBe(true);
|
||||||
|
expect(client1Result.newWatermark).toBe(testJwtIds[2]);
|
||||||
|
|
||||||
|
// Client 2 polls concurrently and finds changes up to 2024-01-04
|
||||||
|
const client2Changes = [testJwtIds[2], testJwtIds[3]]; // 2024-01-03, 2024-01-04
|
||||||
|
const client2Result = await simulatePollAndUpdate(testJwtIds[1], client2Changes);
|
||||||
|
expect(client2Result.success).toBe(true);
|
||||||
|
expect(client2Result.newWatermark).toBe(testJwtIds[3]);
|
||||||
|
|
||||||
|
// Final watermark should be the maximum
|
||||||
|
const finalWatermark = await getCurrentWatermark();
|
||||||
|
expect(finalWatermark).toBe(testJwtIds[3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent duplicate notifications from overlapping polls', async () => {
|
||||||
|
// Set initial watermark
|
||||||
|
await simulateWatermarkUpdate(null, testJwtIds[1]);
|
||||||
|
|
||||||
|
// Both clients find the same change
|
||||||
|
const sharedChange = testJwtIds[2];
|
||||||
|
|
||||||
|
// Client 1 processes change
|
||||||
|
const client1Result = await simulatePollAndUpdate(testJwtIds[1], [sharedChange]);
|
||||||
|
expect(client1Result.success).toBe(true);
|
||||||
|
expect(client1Result.notificationsGenerated).toBe(1);
|
||||||
|
|
||||||
|
// Client 2 attempts to process same change (should be no-op)
|
||||||
|
const client2Result = await simulatePollAndUpdate(testJwtIds[1], [sharedChange]);
|
||||||
|
expect(client2Result.success).toBe(false); // No new changes
|
||||||
|
expect(client2Result.notificationsGenerated).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Watermark Monotonicity', () => {
|
||||||
|
it('should maintain monotonic watermark advancement', async () => {
|
||||||
|
let currentWatermark = null;
|
||||||
|
|
||||||
|
// Process changes in order
|
||||||
|
for (let i = 0; i < testJwtIds.length; i++) {
|
||||||
|
const newWatermark = testJwtIds[i];
|
||||||
|
const result = await simulateWatermarkUpdate(currentWatermark, newWatermark);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.watermark).toBe(newWatermark);
|
||||||
|
|
||||||
|
// Verify monotonicity
|
||||||
|
if (currentWatermark) {
|
||||||
|
expect(compareJwtIds(newWatermark, currentWatermark)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWatermark = newWatermark;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-monotonic watermark updates', async () => {
|
||||||
|
// Set watermark to middle value
|
||||||
|
await simulateWatermarkUpdate(null, testJwtIds[2]);
|
||||||
|
|
||||||
|
// Attempt to set older watermark
|
||||||
|
const result = await simulateWatermarkUpdate(testJwtIds[2], testJwtIds[0]);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
|
||||||
|
// Attempt to set same watermark
|
||||||
|
const result2 = await simulateWatermarkUpdate(testJwtIds[2], testJwtIds[2]);
|
||||||
|
expect(result2.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Platform-Specific CAS Implementation', () => {
|
||||||
|
it('should verify SQL CAS returns row update count', async () => {
|
||||||
|
// Simulate SQL UPDATE ... WHERE condition
|
||||||
|
const sqlResult = await simulateSqlWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(sqlResult.rowsAffected).toBe(1);
|
||||||
|
|
||||||
|
// Attempt to update with same condition (should affect 0 rows)
|
||||||
|
const sqlResult2 = await simulateSqlWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(sqlResult2.rowsAffected).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify Room CAS returns update count', async () => {
|
||||||
|
// Simulate Room @Query with return type Int
|
||||||
|
const roomResult = await simulateRoomWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(roomResult).toBe(1);
|
||||||
|
|
||||||
|
// Attempt to update with same condition
|
||||||
|
const roomResult2 = await simulateRoomWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(roomResult2).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify Core Data CAS returns success boolean', async () => {
|
||||||
|
// Simulate Core Data compare-and-swap
|
||||||
|
const coreDataResult = await simulateCoreDataWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(coreDataResult).toBe(true);
|
||||||
|
|
||||||
|
// Attempt to update with same condition
|
||||||
|
const coreDataResult2 = await simulateCoreDataWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(coreDataResult2).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify IndexedDB CAS returns success boolean', async () => {
|
||||||
|
// Simulate IndexedDB transaction
|
||||||
|
const idbResult = await simulateIndexedDBWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(idbResult).toBe(true);
|
||||||
|
|
||||||
|
// Attempt to update with same condition
|
||||||
|
const idbResult2 = await simulateIndexedDBWatermarkUpdate(null, testJwtIds[0]);
|
||||||
|
expect(idbResult2).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock implementations for testing
|
||||||
|
let mockWatermark: string | null = null;
|
||||||
|
|
||||||
|
async function simulateWatermarkUpdate(
|
||||||
|
expectedWatermark: string | null,
|
||||||
|
newWatermark: string
|
||||||
|
): Promise<{ success: boolean; watermark: string | null }> {
|
||||||
|
// Simulate CAS logic
|
||||||
|
if (mockWatermark === expectedWatermark) {
|
||||||
|
mockWatermark = newWatermark;
|
||||||
|
return { success: true, watermark: newWatermark };
|
||||||
|
}
|
||||||
|
return { success: false, watermark: mockWatermark };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulatePollAndUpdate(
|
||||||
|
currentWatermark: string | null,
|
||||||
|
changes: string[]
|
||||||
|
): Promise<{ success: boolean; newWatermark: string | null; notificationsGenerated: number }> {
|
||||||
|
if (changes.length === 0) {
|
||||||
|
return { success: false, newWatermark: currentWatermark, notificationsGenerated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestChange = changes[changes.length - 1];
|
||||||
|
const result = await simulateWatermarkUpdate(currentWatermark, latestChange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
newWatermark: result.watermark,
|
||||||
|
notificationsGenerated: result.success ? changes.length : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentWatermark(): Promise<string | null> {
|
||||||
|
return mockWatermark;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateSqlWatermarkUpdate(
|
||||||
|
expectedWatermark: string | null,
|
||||||
|
newWatermark: string
|
||||||
|
): Promise<{ rowsAffected: number }> {
|
||||||
|
if (mockWatermark === expectedWatermark) {
|
||||||
|
mockWatermark = newWatermark;
|
||||||
|
return { rowsAffected: 1 };
|
||||||
|
}
|
||||||
|
return { rowsAffected: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateRoomWatermarkUpdate(
|
||||||
|
expectedWatermark: string | null,
|
||||||
|
newWatermark: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (mockWatermark === expectedWatermark) {
|
||||||
|
mockWatermark = newWatermark;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateCoreDataWatermarkUpdate(
|
||||||
|
expectedWatermark: string | null,
|
||||||
|
newWatermark: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (mockWatermark === expectedWatermark) {
|
||||||
|
mockWatermark = newWatermark;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateIndexedDBWatermarkUpdate(
|
||||||
|
expectedWatermark: string | null,
|
||||||
|
newWatermark: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (mockWatermark === expectedWatermark) {
|
||||||
|
mockWatermark = newWatermark;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset mock state before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
mockWatermark = null;
|
||||||
|
});
|
||||||
109
packages/polling-contracts/src/backoff.ts
Normal file
109
packages/polling-contracts/src/backoff.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Unified backoff policy implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BackoffPolicy } from './types';
|
||||||
|
import { DEFAULT_CONFIG } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate backoff delay with Retry-After + jittered exponential caps
|
||||||
|
*/
|
||||||
|
export function calculateBackoffDelay(
|
||||||
|
attempt: number,
|
||||||
|
policy: BackoffPolicy,
|
||||||
|
retryAfterMs?: number
|
||||||
|
): number {
|
||||||
|
let delay: number;
|
||||||
|
|
||||||
|
// Respect Retry-After header if present and enabled
|
||||||
|
if (policy.respectRetryAfter && retryAfterMs !== undefined) {
|
||||||
|
delay = Math.min(retryAfterMs, policy.retryAfterMaxMs || policy.maxDelayMs);
|
||||||
|
} else {
|
||||||
|
// Calculate base delay based on strategy
|
||||||
|
switch (policy.strategy) {
|
||||||
|
case 'exponential':
|
||||||
|
delay = policy.baseDelayMs * Math.pow(2, attempt - 1);
|
||||||
|
break;
|
||||||
|
case 'linear':
|
||||||
|
delay = policy.baseDelayMs * attempt;
|
||||||
|
break;
|
||||||
|
case 'fixed':
|
||||||
|
delay = policy.baseDelayMs;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
delay = policy.baseDelayMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply jitter if enabled
|
||||||
|
if (policy.jitterEnabled) {
|
||||||
|
const jitterRange = delay * policy.jitterFactor;
|
||||||
|
const jitter = (Math.random() - 0.5) * 2 * jitterRange;
|
||||||
|
delay = Math.max(0, delay + jitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at maximum delay
|
||||||
|
return Math.min(delay, policy.maxDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default backoff policy
|
||||||
|
*/
|
||||||
|
export function createDefaultBackoffPolicy(): BackoffPolicy {
|
||||||
|
return {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: DEFAULT_CONFIG.baseDelayMs,
|
||||||
|
maxDelayMs: DEFAULT_CONFIG.maxDelayMs,
|
||||||
|
strategy: 'exponential',
|
||||||
|
jitterEnabled: true,
|
||||||
|
jitterFactor: DEFAULT_CONFIG.jitterFactor,
|
||||||
|
respectRetryAfter: DEFAULT_CONFIG.respectRetryAfter,
|
||||||
|
retryAfterMaxMs: DEFAULT_CONFIG.retryAfterMaxMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backoff policy for rate limiting
|
||||||
|
*/
|
||||||
|
export function createRateLimitBackoffPolicy(retryAfterMs: number): BackoffPolicy {
|
||||||
|
return {
|
||||||
|
maxAttempts: 5,
|
||||||
|
baseDelayMs: retryAfterMs,
|
||||||
|
maxDelayMs: Math.max(retryAfterMs * 2, DEFAULT_CONFIG.maxDelayMs),
|
||||||
|
strategy: 'fixed',
|
||||||
|
jitterEnabled: true,
|
||||||
|
jitterFactor: 0.1, // ±10% jitter for rate limits
|
||||||
|
respectRetryAfter: true,
|
||||||
|
retryAfterMaxMs: retryAfterMs * 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backoff policy for network errors
|
||||||
|
*/
|
||||||
|
export function createNetworkErrorBackoffPolicy(): BackoffPolicy {
|
||||||
|
return {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: DEFAULT_CONFIG.baseDelayMs,
|
||||||
|
maxDelayMs: DEFAULT_CONFIG.maxDelayMs,
|
||||||
|
strategy: 'exponential',
|
||||||
|
jitterEnabled: true,
|
||||||
|
jitterFactor: DEFAULT_CONFIG.jitterFactor,
|
||||||
|
respectRetryAfter: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backoff policy for server errors (5xx)
|
||||||
|
*/
|
||||||
|
export function createServerErrorBackoffPolicy(): BackoffPolicy {
|
||||||
|
return {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: 2000, // Start with 2s for server errors
|
||||||
|
maxDelayMs: DEFAULT_CONFIG.maxDelayMs,
|
||||||
|
strategy: 'exponential',
|
||||||
|
jitterEnabled: true,
|
||||||
|
jitterFactor: 0.5, // ±50% jitter for server errors
|
||||||
|
respectRetryAfter: false
|
||||||
|
};
|
||||||
|
}
|
||||||
176
packages/polling-contracts/src/clock-sync.ts
Normal file
176
packages/polling-contracts/src/clock-sync.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Clock synchronization and skew handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ClockSyncConfig } from './types';
|
||||||
|
import { DEFAULT_CONFIG } from './constants';
|
||||||
|
|
||||||
|
export class ClockSyncManager {
|
||||||
|
private config: ClockSyncConfig;
|
||||||
|
private lastSyncTime = 0;
|
||||||
|
private serverOffset = 0; // Server time - client time
|
||||||
|
private syncInterval?: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
constructor(config: Partial<ClockSyncConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
serverTimeSource: config.serverTimeSource ?? 'ntp',
|
||||||
|
ntpServers: config.ntpServers ?? ['pool.ntp.org', 'time.google.com'],
|
||||||
|
maxClockSkewSeconds: config.maxClockSkewSeconds ?? DEFAULT_CONFIG.maxClockSkewSeconds,
|
||||||
|
skewCheckIntervalMs: config.skewCheckIntervalMs ?? DEFAULT_CONFIG.skewCheckIntervalMs,
|
||||||
|
jwtClockSkewTolerance: config.jwtClockSkewTolerance ?? DEFAULT_CONFIG.jwtClockSkewTolerance,
|
||||||
|
jwtMaxAge: config.jwtMaxAge ?? DEFAULT_CONFIG.jwtMaxAge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncWithServer(apiServer: string, jwtToken?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get server time from API
|
||||||
|
const response = await fetch(`${apiServer}/api/v2/time`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: jwtToken ? { 'Authorization': `Bearer ${jwtToken}` } : {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Clock sync failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverTime = parseInt(response.headers.get('X-Server-Time') || '0');
|
||||||
|
const clientTime = Date.now();
|
||||||
|
|
||||||
|
if (serverTime === 0) {
|
||||||
|
throw new Error('Invalid server time response');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverOffset = serverTime - clientTime;
|
||||||
|
this.lastSyncTime = clientTime;
|
||||||
|
|
||||||
|
// Validate skew is within tolerance
|
||||||
|
if (Math.abs(this.serverOffset) > this.config.maxClockSkewSeconds * 1000) {
|
||||||
|
console.warn(`Large clock skew detected: ${this.serverOffset}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Clock sync successful: offset=${this.serverOffset}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clock sync failed:', error);
|
||||||
|
// Continue with client time, but log the issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerTime(): number {
|
||||||
|
return Date.now() + this.serverOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientTime(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerOffset(): number {
|
||||||
|
return this.serverOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSyncTime(): number {
|
||||||
|
return this.lastSyncTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateJwtTimestamp(jwt: any): boolean {
|
||||||
|
const now = this.getServerTime();
|
||||||
|
const iat = jwt.iat * 1000; // Convert to milliseconds
|
||||||
|
const exp = jwt.exp * 1000;
|
||||||
|
|
||||||
|
// Check if JWT is within valid time window
|
||||||
|
const skewTolerance = this.config.jwtClockSkewTolerance * 1000;
|
||||||
|
const maxAge = this.config.jwtMaxAge;
|
||||||
|
|
||||||
|
const isValid = (now >= iat - skewTolerance) &&
|
||||||
|
(now <= exp + skewTolerance) &&
|
||||||
|
(now - iat <= maxAge);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.warn('JWT timestamp validation failed:', {
|
||||||
|
now,
|
||||||
|
iat,
|
||||||
|
exp,
|
||||||
|
skewTolerance,
|
||||||
|
maxAge,
|
||||||
|
serverOffset: this.serverOffset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
isClockSkewExcessive(): boolean {
|
||||||
|
return Math.abs(this.serverOffset) > this.config.maxClockSkewSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
needsSync(): boolean {
|
||||||
|
const timeSinceLastSync = Date.now() - this.lastSyncTime;
|
||||||
|
return timeSinceLastSync > this.config.skewCheckIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic sync
|
||||||
|
startPeriodicSync(apiServer: string, jwtToken?: string): void {
|
||||||
|
if (this.syncInterval) {
|
||||||
|
clearInterval(this.syncInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncInterval = setInterval(() => {
|
||||||
|
this.syncWithServer(apiServer, jwtToken);
|
||||||
|
}, this.config.skewCheckIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPeriodicSync(): void {
|
||||||
|
if (this.syncInterval) {
|
||||||
|
clearInterval(this.syncInterval);
|
||||||
|
this.syncInterval = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): ClockSyncConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default clock sync manager
|
||||||
|
*/
|
||||||
|
export function createDefaultClockSyncManager(): ClockSyncManager {
|
||||||
|
return new ClockSyncManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create clock sync manager with custom config
|
||||||
|
*/
|
||||||
|
export function createClockSyncManager(config: Partial<ClockSyncConfig>): ClockSyncManager {
|
||||||
|
return new ClockSyncManager(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clock sync configuration presets
|
||||||
|
*/
|
||||||
|
export const CLOCK_SYNC_PRESETS = {
|
||||||
|
// Conservative: Frequent sync, strict tolerance
|
||||||
|
conservative: {
|
||||||
|
maxClockSkewSeconds: 15,
|
||||||
|
skewCheckIntervalMs: 180000, // 3 minutes
|
||||||
|
jwtClockSkewTolerance: 15,
|
||||||
|
jwtMaxAge: 1800000 // 30 minutes
|
||||||
|
},
|
||||||
|
|
||||||
|
// Balanced: Good balance of sync frequency and tolerance
|
||||||
|
balanced: {
|
||||||
|
maxClockSkewSeconds: 30,
|
||||||
|
skewCheckIntervalMs: 300000, // 5 minutes
|
||||||
|
jwtClockSkewTolerance: 30,
|
||||||
|
jwtMaxAge: 3600000 // 1 hour
|
||||||
|
},
|
||||||
|
|
||||||
|
// Relaxed: Less frequent sync, more tolerance
|
||||||
|
relaxed: {
|
||||||
|
maxClockSkewSeconds: 60,
|
||||||
|
skewCheckIntervalMs: 600000, // 10 minutes
|
||||||
|
jwtClockSkewTolerance: 60,
|
||||||
|
jwtMaxAge: 7200000 // 2 hours
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
53
packages/polling-contracts/src/constants.ts
Normal file
53
packages/polling-contracts/src/constants.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Canonical constants for polling system
|
||||||
|
*/
|
||||||
|
|
||||||
|
// JWT ID regex pattern with named capture groups
|
||||||
|
export const JWT_ID_PATTERN = /^(?<ts>\d{10})_(?<rnd>[A-Za-z0-9]{6})_(?<hash>[a-f0-9]{8})$/;
|
||||||
|
|
||||||
|
// Default configuration values
|
||||||
|
export const DEFAULT_CONFIG = {
|
||||||
|
// Outbox pressure controls
|
||||||
|
maxUndelivered: 1000,
|
||||||
|
backpressureThreshold: 0.8,
|
||||||
|
maxRetries: 3,
|
||||||
|
cleanupIntervalMs: 3600000, // 1 hour
|
||||||
|
|
||||||
|
// Backoff policy
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitterFactor: 0.25,
|
||||||
|
respectRetryAfter: true,
|
||||||
|
retryAfterMaxMs: 300000, // 5 minutes
|
||||||
|
|
||||||
|
// Clock sync
|
||||||
|
maxClockSkewSeconds: 30,
|
||||||
|
skewCheckIntervalMs: 300000, // 5 minutes
|
||||||
|
jwtClockSkewTolerance: 30,
|
||||||
|
jwtMaxAge: 3600000, // 1 hour
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
metricsPrefix: 'starred_projects',
|
||||||
|
logLevel: 'INFO'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Error codes
|
||||||
|
export const ERROR_CODES = {
|
||||||
|
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||||
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||||
|
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
EXECUTION_ERROR: 'EXECUTION_ERROR',
|
||||||
|
CLOCK_SKEW_ERROR: 'CLOCK_SKEW_ERROR',
|
||||||
|
STORAGE_PRESSURE: 'STORAGE_PRESSURE'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// HTTP status codes
|
||||||
|
export const HTTP_STATUS = {
|
||||||
|
OK: 200,
|
||||||
|
BAD_REQUEST: 400,
|
||||||
|
UNAUTHORIZED: 401,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
TOO_MANY_REQUESTS: 429,
|
||||||
|
INTERNAL_SERVER_ERROR: 500,
|
||||||
|
SERVICE_UNAVAILABLE: 503
|
||||||
|
} as const;
|
||||||
17
packages/polling-contracts/src/index.ts
Normal file
17
packages/polling-contracts/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @timesafari/polling-contracts
|
||||||
|
*
|
||||||
|
* TypeScript contracts and Zod schemas for TimeSafari polling system
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './schemas';
|
||||||
|
export * from './validation';
|
||||||
|
export * from './constants';
|
||||||
|
export * from './backoff';
|
||||||
|
export * from './outbox-pressure';
|
||||||
|
export * from './telemetry';
|
||||||
|
export * from './clock-sync';
|
||||||
144
packages/polling-contracts/src/outbox-pressure.ts
Normal file
144
packages/polling-contracts/src/outbox-pressure.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Outbox pressure management with telemetry
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OutboxPressureConfig, TelemetryMetrics } from './types';
|
||||||
|
import { DEFAULT_CONFIG } from './constants';
|
||||||
|
|
||||||
|
export class OutboxPressureManager {
|
||||||
|
private config: OutboxPressureConfig;
|
||||||
|
private metrics: TelemetryMetrics;
|
||||||
|
|
||||||
|
constructor(config: Partial<OutboxPressureConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
maxUndelivered: config.maxUndelivered ?? DEFAULT_CONFIG.maxUndelivered,
|
||||||
|
cleanupIntervalMs: config.cleanupIntervalMs ?? DEFAULT_CONFIG.cleanupIntervalMs,
|
||||||
|
backpressureThreshold: config.backpressureThreshold ?? DEFAULT_CONFIG.backpressureThreshold,
|
||||||
|
evictionPolicy: config.evictionPolicy ?? 'fifo'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.metrics = {
|
||||||
|
'starred_projects_outbox_size': 0,
|
||||||
|
'starred_projects_outbox_backpressure_active': 0
|
||||||
|
} as TelemetryMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkStoragePressure(undeliveredCount: number): Promise<boolean> {
|
||||||
|
// Update metrics
|
||||||
|
this.metrics['starred_projects_outbox_size'] = undeliveredCount;
|
||||||
|
|
||||||
|
const pressureRatio = undeliveredCount / this.config.maxUndelivered;
|
||||||
|
const backpressureActive = pressureRatio >= this.config.backpressureThreshold;
|
||||||
|
|
||||||
|
// Update backpressure metric
|
||||||
|
this.metrics['starred_projects_outbox_backpressure_active'] = backpressureActive ? 1 : 0;
|
||||||
|
|
||||||
|
if (pressureRatio >= 1.0) {
|
||||||
|
// Critical: Drop oldest notifications to make room
|
||||||
|
const evictCount = undeliveredCount - this.config.maxUndelivered;
|
||||||
|
await this.evictNotifications(evictCount);
|
||||||
|
return true; // Backpressure active
|
||||||
|
}
|
||||||
|
|
||||||
|
return backpressureActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
async evictNotifications(count: number): Promise<void> {
|
||||||
|
if (count <= 0) return;
|
||||||
|
|
||||||
|
// Simulate eviction based on policy
|
||||||
|
switch (this.config.evictionPolicy) {
|
||||||
|
case 'fifo':
|
||||||
|
await this.evictFIFO(count);
|
||||||
|
break;
|
||||||
|
case 'lifo':
|
||||||
|
await this.evictLIFO(count);
|
||||||
|
break;
|
||||||
|
case 'priority':
|
||||||
|
await this.evictByPriority(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evictFIFO(count: number): Promise<void> {
|
||||||
|
// Simulate: DELETE FROM notification_outbox
|
||||||
|
// WHERE delivered_at IS NULL
|
||||||
|
// ORDER BY created_at ASC
|
||||||
|
// LIMIT count
|
||||||
|
console.log(`Evicting ${count} oldest notifications (FIFO)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evictLIFO(count: number): Promise<void> {
|
||||||
|
// Simulate: DELETE FROM notification_outbox
|
||||||
|
// WHERE delivered_at IS NULL
|
||||||
|
// ORDER BY created_at DESC
|
||||||
|
// LIMIT count
|
||||||
|
console.log(`Evicting ${count} newest notifications (LIFO)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evictByPriority(count: number): Promise<void> {
|
||||||
|
// Simulate: DELETE FROM notification_outbox
|
||||||
|
// WHERE delivered_at IS NULL
|
||||||
|
// ORDER BY priority ASC, created_at ASC
|
||||||
|
// LIMIT count
|
||||||
|
console.log(`Evicting ${count} lowest priority notifications`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupDeliveredNotifications(): Promise<void> {
|
||||||
|
// Simulate: DELETE FROM notification_outbox
|
||||||
|
// WHERE delivered_at IS NOT NULL
|
||||||
|
// AND delivered_at < datetime('now', '-${cleanupIntervalMs / 1000} seconds')
|
||||||
|
console.log(`Cleaning up delivered notifications older than ${this.config.cleanupIntervalMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(): TelemetryMetrics {
|
||||||
|
return { ...this.metrics };
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): OutboxPressureConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default outbox pressure manager
|
||||||
|
*/
|
||||||
|
export function createDefaultOutboxPressureManager(): OutboxPressureManager {
|
||||||
|
return new OutboxPressureManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create outbox pressure manager with custom config
|
||||||
|
*/
|
||||||
|
export function createOutboxPressureManager(config: Partial<OutboxPressureConfig>): OutboxPressureManager {
|
||||||
|
return new OutboxPressureManager(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbox pressure configuration presets
|
||||||
|
*/
|
||||||
|
export const OUTBOX_PRESSURE_PRESETS = {
|
||||||
|
// Conservative: Low memory usage, frequent cleanup
|
||||||
|
conservative: {
|
||||||
|
maxUndelivered: 500,
|
||||||
|
backpressureThreshold: 0.7,
|
||||||
|
cleanupIntervalMs: 1800000, // 30 minutes
|
||||||
|
evictionPolicy: 'fifo' as const
|
||||||
|
},
|
||||||
|
|
||||||
|
// Balanced: Good performance, reasonable memory usage
|
||||||
|
balanced: {
|
||||||
|
maxUndelivered: 1000,
|
||||||
|
backpressureThreshold: 0.8,
|
||||||
|
cleanupIntervalMs: 3600000, // 1 hour
|
||||||
|
evictionPolicy: 'fifo' as const
|
||||||
|
},
|
||||||
|
|
||||||
|
// Aggressive: High throughput, more memory usage
|
||||||
|
aggressive: {
|
||||||
|
maxUndelivered: 2000,
|
||||||
|
backpressureThreshold: 0.9,
|
||||||
|
cleanupIntervalMs: 7200000, // 2 hours
|
||||||
|
evictionPolicy: 'priority' as const
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
126
packages/polling-contracts/src/schemas.ts
Normal file
126
packages/polling-contracts/src/schemas.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Zod schemas for strong structural validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JWT_ID_PATTERN } from './constants';
|
||||||
|
|
||||||
|
// Core schemas
|
||||||
|
export const PlanSummarySchema = z.object({
|
||||||
|
jwtId: z.string().regex(JWT_ID_PATTERN, 'Invalid JWT ID format'),
|
||||||
|
handleId: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string(),
|
||||||
|
issuerDid: z.string().startsWith('did:key:'),
|
||||||
|
agentDid: z.string().startsWith('did:key:'),
|
||||||
|
startTime: z.string().datetime(),
|
||||||
|
endTime: z.string().datetime(),
|
||||||
|
locLat: z.number().nullable().optional(),
|
||||||
|
locLon: z.number().nullable().optional(),
|
||||||
|
url: z.string().url().nullable().optional(),
|
||||||
|
version: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PreviousClaimSchema = z.object({
|
||||||
|
jwtId: z.string().regex(JWT_ID_PATTERN),
|
||||||
|
claimType: z.string(),
|
||||||
|
claimData: z.record(z.any()),
|
||||||
|
metadata: z.object({
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PlanSummaryAndPreviousClaimSchema = z.object({
|
||||||
|
planSummary: PlanSummarySchema,
|
||||||
|
previousClaim: PreviousClaimSchema.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StarredProjectsResponseSchema = z.object({
|
||||||
|
data: z.array(PlanSummaryAndPreviousClaimSchema),
|
||||||
|
hitLimit: z.boolean(),
|
||||||
|
pagination: z.object({
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
nextAfterId: z.string().regex(JWT_ID_PATTERN).nullable()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StarredProjectsRequestSchema = z.object({
|
||||||
|
planIds: z.array(z.string()).min(1),
|
||||||
|
afterId: z.string().regex(JWT_ID_PATTERN).optional(),
|
||||||
|
beforeId: z.string().regex(JWT_ID_PATTERN).optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(100)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deep link parameter validation
|
||||||
|
export const DeepLinkParamsSchema = z.object({
|
||||||
|
jwtIds: z.array(z.string().regex(JWT_ID_PATTERN)).max(10).optional(),
|
||||||
|
projectId: z.string().regex(/^[a-zA-Z0-9_-]+$/).optional(),
|
||||||
|
jwtId: z.string().regex(JWT_ID_PATTERN).optional(),
|
||||||
|
shortlink: z.string().min(1).optional()
|
||||||
|
}).refine(
|
||||||
|
(data) => data.jwtIds || data.projectId || data.shortlink,
|
||||||
|
'At least one of jwtIds, projectId, or shortlink must be provided'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error response schema
|
||||||
|
export const ErrorResponseSchema = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
message: z.string(),
|
||||||
|
details: z.record(z.any()).optional(),
|
||||||
|
retryAfter: z.number().optional(),
|
||||||
|
requestId: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit response schema
|
||||||
|
export const RateLimitResponseSchema = z.object({
|
||||||
|
error: z.literal('Rate limit exceeded'),
|
||||||
|
code: z.literal('RATE_LIMIT_EXCEEDED'),
|
||||||
|
message: z.string(),
|
||||||
|
details: z.object({
|
||||||
|
limit: z.number(),
|
||||||
|
window: z.string(),
|
||||||
|
resetAt: z.string().datetime(),
|
||||||
|
remaining: z.number().optional()
|
||||||
|
}),
|
||||||
|
retryAfter: z.number(),
|
||||||
|
requestId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acknowledgment request schema
|
||||||
|
export const AcknowledgmentRequestSchema = z.object({
|
||||||
|
acknowledgedJwtIds: z.array(z.string().regex(JWT_ID_PATTERN)).min(1),
|
||||||
|
acknowledgedAt: z.string().datetime(),
|
||||||
|
clientVersion: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acknowledgment response schema
|
||||||
|
export const AcknowledgmentResponseSchema = z.object({
|
||||||
|
acknowledged: z.number(),
|
||||||
|
failed: z.number(),
|
||||||
|
alreadyAcknowledged: z.number(),
|
||||||
|
acknowledgmentId: z.string(),
|
||||||
|
timestamp: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clock sync response schema
|
||||||
|
export const ClockSyncResponseSchema = z.object({
|
||||||
|
serverTime: z.number(),
|
||||||
|
clientTime: z.number(),
|
||||||
|
offset: z.number(),
|
||||||
|
ntpServers: z.array(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports for use in host apps
|
||||||
|
export type StarredProjectsRequest = z.infer<typeof StarredProjectsRequestSchema>;
|
||||||
|
export type StarredProjectsResponse = z.infer<typeof StarredProjectsResponseSchema>;
|
||||||
|
export type PlanSummary = z.infer<typeof PlanSummarySchema>;
|
||||||
|
export type PreviousClaim = z.infer<typeof PreviousClaimSchema>;
|
||||||
|
export type PlanSummaryAndPreviousClaim = z.infer<typeof PlanSummaryAndPreviousClaimSchema>;
|
||||||
|
export type DeepLinkParams = z.infer<typeof DeepLinkParamsSchema>;
|
||||||
|
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
||||||
|
export type RateLimitResponse = z.infer<typeof RateLimitResponseSchema>;
|
||||||
|
export type AcknowledgmentRequest = z.infer<typeof AcknowledgmentRequestSchema>;
|
||||||
|
export type AcknowledgmentResponse = z.infer<typeof AcknowledgmentResponseSchema>;
|
||||||
|
export type ClockSyncResponse = z.infer<typeof ClockSyncResponseSchema>;
|
||||||
313
packages/polling-contracts/src/telemetry.ts
Normal file
313
packages/polling-contracts/src/telemetry.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry management with cardinality budgets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TelemetryMetrics, TelemetryLogs } from './types';
|
||||||
|
import { hashDid, redactPii } from './validation';
|
||||||
|
|
||||||
|
export class TelemetryManager {
|
||||||
|
private metrics: Map<string, any> = new Map();
|
||||||
|
private logLevel: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||||
|
|
||||||
|
constructor(logLevel: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' = 'INFO') {
|
||||||
|
this.logLevel = logLevel;
|
||||||
|
this.registerMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerMetrics(): void {
|
||||||
|
// Counter metrics (low cardinality)
|
||||||
|
this.metrics.set('starred_projects_poll_attempts_total',
|
||||||
|
this.createCounter('starred_projects_poll_attempts_total', 'Total number of polling attempts'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_poll_success_total',
|
||||||
|
this.createCounter('starred_projects_poll_success_total', 'Total number of successful polls'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_poll_failure_total',
|
||||||
|
this.createCounter('starred_projects_poll_failure_total', 'Total number of failed polls'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_changes_found_total',
|
||||||
|
this.createCounter('starred_projects_changes_found_total', 'Total number of changes found'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_notifications_generated_total',
|
||||||
|
this.createCounter('starred_projects_notifications_generated_total', 'Total notifications generated'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_error_total',
|
||||||
|
this.createCounter('starred_projects_error_total', 'Total number of errors'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_rate_limit_total',
|
||||||
|
this.createCounter('starred_projects_rate_limit_total', 'Total number of rate limit hits'));
|
||||||
|
|
||||||
|
// Histogram metrics (low cardinality)
|
||||||
|
this.metrics.set('starred_projects_poll_duration_seconds',
|
||||||
|
this.createHistogram('starred_projects_poll_duration_seconds', 'Polling duration in seconds',
|
||||||
|
[0.1, 0.5, 1, 2, 5, 10, 30]));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_api_latency_seconds',
|
||||||
|
this.createHistogram('starred_projects_api_latency_seconds', 'API latency in seconds',
|
||||||
|
[0.05, 0.1, 0.25, 0.5, 1, 2, 5]));
|
||||||
|
|
||||||
|
// Gauge metrics (low cardinality)
|
||||||
|
this.metrics.set('starred_projects_outbox_size',
|
||||||
|
this.createGauge('starred_projects_outbox_size', 'Current number of undelivered notifications'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_outbox_backpressure_active',
|
||||||
|
this.createGauge('starred_projects_outbox_backpressure_active', 'Backpressure active (0/1)'));
|
||||||
|
|
||||||
|
this.metrics.set('starred_projects_api_throughput_rps',
|
||||||
|
this.createGauge('starred_projects_api_throughput_rps', 'API throughput in requests per second'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCounter(name: string, help: string): any {
|
||||||
|
// Mock counter implementation
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
help,
|
||||||
|
type: 'counter',
|
||||||
|
value: 0,
|
||||||
|
inc: () => { this.metrics.get(name)!.value++; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createHistogram(name: string, help: string, buckets: number[]): any {
|
||||||
|
// Mock histogram implementation
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
help,
|
||||||
|
type: 'histogram',
|
||||||
|
buckets,
|
||||||
|
values: new Array(buckets.length + 1).fill(0),
|
||||||
|
observe: (value: number) => {
|
||||||
|
const metric = this.metrics.get(name)!;
|
||||||
|
// Find bucket and increment
|
||||||
|
for (let i = 0; i < buckets.length; i++) {
|
||||||
|
if (value <= buckets[i]) {
|
||||||
|
metric.values[i]++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metric.values[buckets.length]++; // +Inf bucket
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createGauge(name: string, help: string): any {
|
||||||
|
// Mock gauge implementation
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
help,
|
||||||
|
type: 'gauge',
|
||||||
|
value: 0,
|
||||||
|
set: (value: number) => { this.metrics.get(name)!.value = value; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-cardinality metric recording
|
||||||
|
recordPollAttempt(): void {
|
||||||
|
this.metrics.get('starred_projects_poll_attempts_total')?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPollSuccess(durationSeconds: number): void {
|
||||||
|
this.metrics.get('starred_projects_poll_success_total')?.inc();
|
||||||
|
this.metrics.get('starred_projects_poll_duration_seconds')?.observe(durationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPollFailure(): void {
|
||||||
|
this.metrics.get('starred_projects_poll_failure_total')?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChangesFound(count: number): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.metrics.get('starred_projects_changes_found_total')?.inc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordNotificationsGenerated(count: number): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.metrics.get('starred_projects_notifications_generated_total')?.inc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordError(): void {
|
||||||
|
this.metrics.get('starred_projects_error_total')?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRateLimit(): void {
|
||||||
|
this.metrics.get('starred_projects_rate_limit_total')?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordApiLatency(latencySeconds: number): void {
|
||||||
|
this.metrics.get('starred_projects_api_latency_seconds')?.observe(latencySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordOutboxSize(size: number): void {
|
||||||
|
this.metrics.get('starred_projects_outbox_size')?.set(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordBackpressureActive(active: boolean): void {
|
||||||
|
this.metrics.get('starred_projects_outbox_backpressure_active')?.set(active ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordApiThroughput(rps: number): void {
|
||||||
|
this.metrics.get('starred_projects_api_throughput_rps')?.set(rps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-cardinality data (logs only, not metrics)
|
||||||
|
logPollingEvent(event: TelemetryLogs): void {
|
||||||
|
if (this.shouldLog('INFO')) {
|
||||||
|
const redactedEvent = redactPii({
|
||||||
|
...event,
|
||||||
|
activeDid: hashDid(event.activeDid) // Hash for privacy
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Polling event:', redactedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error: Error, context?: Record<string, any>): void {
|
||||||
|
if (this.shouldLog('ERROR')) {
|
||||||
|
const redactedContext = context ? redactPii(context) : undefined;
|
||||||
|
console.error('Polling error:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
context: redactedContext
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logWarning(message: string, context?: Record<string, any>): void {
|
||||||
|
if (this.shouldLog('WARN')) {
|
||||||
|
const redactedContext = context ? redactPii(context) : undefined;
|
||||||
|
console.warn('Polling warning:', { message, context: redactedContext });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug(message: string, context?: Record<string, any>): void {
|
||||||
|
if (this.shouldLog('DEBUG')) {
|
||||||
|
const redactedContext = context ? redactPii(context) : undefined;
|
||||||
|
console.debug('Polling debug:', { message, context: redactedContext });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'): boolean {
|
||||||
|
const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
|
||||||
|
return levels[level] >= levels[this.logLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all metrics for export
|
||||||
|
getMetrics(): TelemetryMetrics {
|
||||||
|
const metrics: any = {};
|
||||||
|
for (const [name, metric] of this.metrics) {
|
||||||
|
metrics[name] = metric.value;
|
||||||
|
}
|
||||||
|
return metrics as TelemetryMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metrics in Prometheus format
|
||||||
|
getPrometheusMetrics(): string {
|
||||||
|
let output = '';
|
||||||
|
for (const [name, metric] of this.metrics) {
|
||||||
|
output += `# HELP ${name} ${metric.help}\n`;
|
||||||
|
output += `# TYPE ${name} ${metric.type}\n`;
|
||||||
|
|
||||||
|
if (metric.type === 'histogram') {
|
||||||
|
// Export histogram buckets
|
||||||
|
for (let i = 0; i < metric.buckets.length; i++) {
|
||||||
|
output += `${name}_bucket{le="${metric.buckets[i]}"} ${metric.values[i]}\n`;
|
||||||
|
}
|
||||||
|
output += `${name}_bucket{le="+Inf"} ${metric.values[metric.buckets.length]}\n`;
|
||||||
|
output += `${name}_count ${metric.values.reduce((a: number, b: number) => a + b, 0)}\n`;
|
||||||
|
} else {
|
||||||
|
output += `${name} ${metric.value}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint rule to prevent high-cardinality labels in metrics
|
||||||
|
*/
|
||||||
|
export function validateMetricLabels(labels: Record<string, string>): void {
|
||||||
|
const highCardinalityPatterns = [
|
||||||
|
/requestId/i,
|
||||||
|
/activeDid/i,
|
||||||
|
/jwtId/i,
|
||||||
|
/userId/i,
|
||||||
|
/sessionId/i,
|
||||||
|
/traceId/i,
|
||||||
|
/spanId/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(labels)) {
|
||||||
|
for (const pattern of highCardinalityPatterns) {
|
||||||
|
if (pattern.test(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`High-cardinality label detected: ${key}. ` +
|
||||||
|
`Use logs for request-level data, not metrics. ` +
|
||||||
|
`Consider using a hash or removing the label.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for high-cardinality values
|
||||||
|
if (value.length > 50 || /^[a-f0-9]{32,}$/.test(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`High-cardinality value detected for label ${key}: ${value}. ` +
|
||||||
|
`Consider using a hash or removing the label.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe metric recording with validation
|
||||||
|
*/
|
||||||
|
export function recordMetricWithValidation(
|
||||||
|
telemetry: TelemetryManager,
|
||||||
|
metricName: string,
|
||||||
|
value: number,
|
||||||
|
labels?: Record<string, string>
|
||||||
|
): void {
|
||||||
|
if (labels) {
|
||||||
|
validateMetricLabels(labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record metric based on type
|
||||||
|
switch (metricName) {
|
||||||
|
case 'starred_projects_poll_attempts_total':
|
||||||
|
telemetry.recordPollAttempt();
|
||||||
|
break;
|
||||||
|
case 'starred_projects_poll_success_total':
|
||||||
|
telemetry.recordPollSuccess(value);
|
||||||
|
break;
|
||||||
|
case 'starred_projects_poll_failure_total':
|
||||||
|
telemetry.recordPollFailure();
|
||||||
|
break;
|
||||||
|
case 'starred_projects_changes_found_total':
|
||||||
|
telemetry.recordChangesFound(value);
|
||||||
|
break;
|
||||||
|
case 'starred_projects_notifications_generated_total':
|
||||||
|
telemetry.recordNotificationsGenerated(value);
|
||||||
|
break;
|
||||||
|
case 'starred_projects_error_total':
|
||||||
|
telemetry.recordError();
|
||||||
|
break;
|
||||||
|
case 'starred_projects_rate_limit_total':
|
||||||
|
telemetry.recordRateLimit();
|
||||||
|
break;
|
||||||
|
case 'starred_projects_api_latency_seconds':
|
||||||
|
telemetry.recordApiLatency(value);
|
||||||
|
break;
|
||||||
|
case 'starred_projects_outbox_size':
|
||||||
|
telemetry.recordOutboxSize(value);
|
||||||
|
break;
|
||||||
|
case 'starred_projects_outbox_backpressure_active':
|
||||||
|
telemetry.recordBackpressureActive(value > 0);
|
||||||
|
break;
|
||||||
|
case 'starred_projects_api_throughput_rps':
|
||||||
|
telemetry.recordApiThroughput(value);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown metric: ${metricName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
packages/polling-contracts/src/types.ts
Normal file
234
packages/polling-contracts/src/types.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Core TypeScript interfaces for polling system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Core polling interfaces
|
||||||
|
export interface GenericPollingRequest<TRequest, TResponse> {
|
||||||
|
// Request configuration
|
||||||
|
endpoint: string;
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: TRequest;
|
||||||
|
|
||||||
|
// Idempotency (required for POST requests)
|
||||||
|
idempotencyKey?: string; // Auto-generated if not provided
|
||||||
|
|
||||||
|
// Response handling
|
||||||
|
responseSchema: ResponseSchema<TResponse>;
|
||||||
|
transformResponse?: (rawResponse: any) => TResponse;
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
retryConfig?: RetryConfiguration;
|
||||||
|
timeoutMs?: number;
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
authConfig?: AuthenticationConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseSchema<T> {
|
||||||
|
// Schema validation
|
||||||
|
validate: (data: any) => data is T;
|
||||||
|
// Error transformation
|
||||||
|
transformError?: (error: any) => PollingError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollingResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: PollingError;
|
||||||
|
metadata: {
|
||||||
|
requestId: string;
|
||||||
|
timestamp: string;
|
||||||
|
duration: number;
|
||||||
|
retryCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollingError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
retryable: boolean;
|
||||||
|
retryAfter?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff policy
|
||||||
|
export interface BackoffPolicy {
|
||||||
|
// Base configuration
|
||||||
|
maxAttempts: number;
|
||||||
|
baseDelayMs: number;
|
||||||
|
maxDelayMs: number;
|
||||||
|
|
||||||
|
// Strategy selection
|
||||||
|
strategy: 'exponential' | 'linear' | 'fixed';
|
||||||
|
|
||||||
|
// Jitter configuration
|
||||||
|
jitterEnabled: boolean;
|
||||||
|
jitterFactor: number; // 0.0 to 1.0 (e.g., 0.25 = ±25% jitter)
|
||||||
|
|
||||||
|
// Retry-After integration
|
||||||
|
respectRetryAfter: boolean;
|
||||||
|
retryAfterMaxMs?: number; // Cap Retry-After values
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryConfiguration {
|
||||||
|
maxAttempts: number;
|
||||||
|
backoffStrategy: 'exponential' | 'linear' | 'fixed';
|
||||||
|
baseDelayMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
export interface AuthenticationConfig {
|
||||||
|
type: 'jwt' | 'bearer' | 'api_key';
|
||||||
|
token?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduling
|
||||||
|
export interface PollingScheduleConfig<TRequest, TResponse> {
|
||||||
|
request: GenericPollingRequest<TRequest, TResponse>;
|
||||||
|
schedule: {
|
||||||
|
cronExpression: string;
|
||||||
|
timezone: string;
|
||||||
|
maxConcurrentPolls: number;
|
||||||
|
};
|
||||||
|
notificationConfig?: NotificationConfig;
|
||||||
|
stateConfig: {
|
||||||
|
watermarkKey: string;
|
||||||
|
storageAdapter: StorageAdapter;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
templates: NotificationTemplates;
|
||||||
|
groupingRules: NotificationGroupingRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationTemplates {
|
||||||
|
singleUpdate: string;
|
||||||
|
multipleUpdates: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationGroupingRules {
|
||||||
|
maxGroupSize: number;
|
||||||
|
timeWindowMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
export interface StorageAdapter {
|
||||||
|
get(key: string): Promise<any>;
|
||||||
|
set(key: string, value: any): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
exists(key: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context
|
||||||
|
export interface PollingContext {
|
||||||
|
activeDid: string;
|
||||||
|
apiServer: string;
|
||||||
|
storageAdapter: StorageAdapter;
|
||||||
|
authManager: AuthenticationManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationManager {
|
||||||
|
getCurrentToken(): Promise<string | null>;
|
||||||
|
refreshToken(): Promise<string>;
|
||||||
|
validateToken(token: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starred Projects specific types
|
||||||
|
export interface StarredProjectsRequest {
|
||||||
|
planIds: string[];
|
||||||
|
afterId?: string;
|
||||||
|
beforeId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StarredProjectsResponse {
|
||||||
|
data: PlanSummaryAndPreviousClaim[];
|
||||||
|
hitLimit: boolean;
|
||||||
|
pagination: {
|
||||||
|
hasMore: boolean;
|
||||||
|
nextAfterId: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanSummaryAndPreviousClaim {
|
||||||
|
planSummary: PlanSummary;
|
||||||
|
previousClaim?: PreviousClaim;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanSummary {
|
||||||
|
jwtId: string;
|
||||||
|
handleId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
issuerDid: string;
|
||||||
|
agentDid: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
locLat?: number;
|
||||||
|
locLon?: number;
|
||||||
|
url?: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviousClaim {
|
||||||
|
jwtId: string;
|
||||||
|
claimType: string;
|
||||||
|
claimData: Record<string, any>;
|
||||||
|
metadata: {
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
export interface TelemetryMetrics {
|
||||||
|
// Low-cardinality metrics (Prometheus counters/gauges)
|
||||||
|
'starred_projects_poll_attempts_total': number;
|
||||||
|
'starred_projects_poll_success_total': number;
|
||||||
|
'starred_projects_poll_failure_total': number;
|
||||||
|
'starred_projects_poll_duration_seconds': number;
|
||||||
|
'starred_projects_changes_found_total': number;
|
||||||
|
'starred_projects_notifications_generated_total': number;
|
||||||
|
'starred_projects_error_total': number;
|
||||||
|
'starred_projects_rate_limit_total': number;
|
||||||
|
'starred_projects_api_latency_seconds': number;
|
||||||
|
'starred_projects_api_throughput_rps': number;
|
||||||
|
'starred_projects_outbox_size': number;
|
||||||
|
'starred_projects_outbox_backpressure_active': number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryLogs {
|
||||||
|
// Request-level details (logs only)
|
||||||
|
requestId: string;
|
||||||
|
activeDid: string;
|
||||||
|
projectCount: number;
|
||||||
|
changeCount: number;
|
||||||
|
duration: number;
|
||||||
|
error?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock sync
|
||||||
|
export interface ClockSyncConfig {
|
||||||
|
serverTimeSource: 'ntp' | 'system' | 'atomic';
|
||||||
|
ntpServers: string[];
|
||||||
|
maxClockSkewSeconds: number;
|
||||||
|
skewCheckIntervalMs: number;
|
||||||
|
jwtClockSkewTolerance: number;
|
||||||
|
jwtMaxAge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage pressure
|
||||||
|
export interface OutboxPressureConfig {
|
||||||
|
maxUndelivered: number;
|
||||||
|
cleanupIntervalMs: number;
|
||||||
|
backpressureThreshold: number;
|
||||||
|
evictionPolicy: 'fifo' | 'lifo' | 'priority';
|
||||||
|
}
|
||||||
157
packages/polling-contracts/src/validation.ts
Normal file
157
packages/polling-contracts/src/validation.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Validation utilities and helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JWT_ID_PATTERN, ERROR_CODES } from './constants';
|
||||||
|
import {
|
||||||
|
StarredProjectsResponseSchema,
|
||||||
|
DeepLinkParamsSchema,
|
||||||
|
ErrorResponseSchema,
|
||||||
|
RateLimitResponseSchema
|
||||||
|
} from './schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate JWT ID format
|
||||||
|
*/
|
||||||
|
export function validateJwtId(jwtId: string): boolean {
|
||||||
|
return JWT_ID_PATTERN.test(jwtId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare JWT IDs lexicographically
|
||||||
|
*/
|
||||||
|
export function compareJwtIds(a: string, b: string): number {
|
||||||
|
if (!validateJwtId(a) || !validateJwtId(b)) {
|
||||||
|
throw new Error('Invalid JWT ID format');
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract timestamp from JWT ID
|
||||||
|
*/
|
||||||
|
export function extractJwtTimestamp(jwtId: string): number {
|
||||||
|
const match = jwtId.match(JWT_ID_PATTERN);
|
||||||
|
if (!match || !match.groups?.ts) {
|
||||||
|
throw new Error('Invalid JWT ID format');
|
||||||
|
}
|
||||||
|
return parseInt(match.groups.ts, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate starred projects response
|
||||||
|
*/
|
||||||
|
export function validateStarredProjectsResponse(data: any): boolean {
|
||||||
|
return StarredProjectsResponseSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate deep link parameters
|
||||||
|
*/
|
||||||
|
export function validateDeepLinkParams(params: any): boolean {
|
||||||
|
return DeepLinkParamsSchema.safeParse(params).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate error response
|
||||||
|
*/
|
||||||
|
export function validateErrorResponse(data: any): boolean {
|
||||||
|
return ErrorResponseSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate rate limit response
|
||||||
|
*/
|
||||||
|
export function validateRateLimitResponse(data: any): boolean {
|
||||||
|
return RateLimitResponseSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create response schema validator
|
||||||
|
*/
|
||||||
|
export function createResponseValidator<T>(schema: z.ZodSchema<T>) {
|
||||||
|
return {
|
||||||
|
validate: (data: any): data is T => schema.safeParse(data).success,
|
||||||
|
transformError: (error: any) => ({
|
||||||
|
code: ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
message: error.message || 'Validation failed',
|
||||||
|
retryable: false
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe parse with error transformation
|
||||||
|
*/
|
||||||
|
export function safeParseWithError<T>(
|
||||||
|
schema: z.ZodSchema<T>,
|
||||||
|
data: any
|
||||||
|
): { success: true; data: T } | { success: false; error: string } {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate idempotency key format
|
||||||
|
*/
|
||||||
|
export function validateIdempotencyKey(key: string): boolean {
|
||||||
|
// UUID v4 format
|
||||||
|
const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidV4Pattern.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate idempotency key
|
||||||
|
*/
|
||||||
|
export function generateIdempotencyKey(): string {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash DID for privacy in logs
|
||||||
|
*/
|
||||||
|
export function hashDid(did: string): string {
|
||||||
|
// Simple hash for privacy (use crypto in production)
|
||||||
|
const hash = did.split('').reduce((a, b) => {
|
||||||
|
a = ((a << 5) - a) + b.charCodeAt(0);
|
||||||
|
return a & a;
|
||||||
|
}, 0);
|
||||||
|
return `did:hash:${Math.abs(hash).toString(16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact PII from logs
|
||||||
|
*/
|
||||||
|
export function redactPii(data: any): any {
|
||||||
|
const redacted = JSON.parse(JSON.stringify(data));
|
||||||
|
|
||||||
|
// Redact DID patterns
|
||||||
|
if (typeof redacted === 'string') {
|
||||||
|
return redacted.replace(/did:key:[a-zA-Z0-9]+/g, (match) => hashDid(match));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof redacted === 'object' && redacted !== null) {
|
||||||
|
for (const key in redacted) {
|
||||||
|
if (typeof redacted[key] === 'string') {
|
||||||
|
redacted[key] = redacted[key].replace(/did:key:[a-zA-Z0-9]+/g, (match: string) => hashDid(match));
|
||||||
|
} else if (typeof redacted[key] === 'object') {
|
||||||
|
redacted[key] = redactPii(redacted[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
31
packages/polling-contracts/tsconfig.json
Normal file
31
packages/polling-contracts/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"exactOptionalPropertyTypes": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/__tests__/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user