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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user