import { createHash, createPublicKey, ECDH } from 'node:crypto'; import { z } from 'zod'; export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 5_000, inactiveGraceMs: 5_000, unknownGraceMs: 5_000, presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 }); export const MAX_AGE_MS = 5 * 60_000; export const MAX_ATTEMPTS = 4; export const ROOT_OWNER = 'root'; export const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value))) .transform(value => String(Number(value))); export const InstallationId = z.string().uuid(); export const PresenceBody = z.object({ sequence: z.number().int().min(0).max(2_147_483_647), visibility: z.enum(['visible', 'hidden']), focused: z.boolean(), interacted: z.boolean() }).strict(); export type Presence = z.infer; const Base64url = z.string().regex(/^[A-Za-z0-9_-]+={0,2}$/); export const WebSubscription = z.object({ endpoint: z.string().max(2048), expirationTime: z.number().nonnegative().nullable().optional(), keys: z.object({ p256dh: Base64url.max(90), auth: Base64url.max(24) }).strict() }).strict(); export const InstallationBody = z.discriminatedUnion('transport', [ z.object({ transport: z.literal('apns'), enabled: z.literal(true), token: z.string().regex(/^[a-fA-F0-9]{32,512}$/).refine(v => v.length % 2 === 0), environment: z.enum(['sandbox', 'production']), topic: z.string().min(1).max(128) }).strict(), z.object({ transport: z.literal('web'), enabled: z.literal(true), subscription: WebSubscription }).strict(), ]); export type Registration = z.infer; export type Subscription = z.infer; export const NotificationContext = z.object({ version: z.literal(1), nextChangeAt: z.string().datetime().nullable().optional(), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128), cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor, uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }), candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor, createdAt: z.string().datetime(), trigger: z.enum(['user', 'external_event', 'timer', 'worker', 'unknown']) })).max(200), }); export type Context = z.infer; export type Identity = { version: 1; sourceId: string; kind: 'message'; messageId: string; cursor: string } | { version: 1; sourceId: string; kind: 'read_state'; readCursor: string }; export const digest = (value: string) => createHash('sha256').update(value).digest('hex'); export const ownerEpoch = (token: string) => digest(`sybil-agent-push-owner-v1\0${token}`); export const fail = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode }); export function parseSafe(schema: z.ZodType, value: unknown): T { const result = schema.safeParse(value); // Do not reflect supplied endpoints/tokens or validation objects into logs/errors. if (!result.success) throw fail(400, 'Invalid Agent push request'); return result.data; } /** Exact provider origins only: no arbitrary-host/redirect/private-network egress. */ export function validatePushEndpoint(endpoint: string): URL { let url: URL; try { url = new URL(endpoint); } catch { throw fail(400, 'Invalid Web Push subscription'); } const allowed = url.hostname === 'fcm.googleapis.com' || url.hostname === 'updates.push.services.mozilla.com' || url.hostname === 'web.push.apple.com' || /^[a-z0-9-]+\.push\.apple\.com$/.test(url.hostname); if (!allowed || url.protocol !== 'https:' || url.username || url.password || url.hash || (url.port && url.port !== '443') || endpoint.length > 2048 || !url.pathname || url.pathname === '/') throw fail(400, 'Unsupported Web Push provider'); return url; } export function validateSubscription(subscription: Subscription, now: number) { validatePushEndpoint(subscription.endpoint); if (subscription.expirationTime != null && subscription.expirationTime <= now) throw fail(400, 'Expired Web Push subscription'); try { const key = Buffer.from(subscription.keys.p256dh, 'base64url'); if (key.length !== 65 || key[0] !== 4 || Buffer.from(subscription.keys.auth, 'base64url').length !== 16) throw new Error(); // Validate the curve point as well as its length before persistence. ECDH.convertKey(key, 'prime256v1', undefined, undefined, 'uncompressed'); } catch { throw fail(400, 'Invalid Web Push encryption keys'); } } export function validateAPNsKey(key: string) { const publicKey = createPublicKey(key); if (publicKey.asymmetricKeyType !== 'ec' || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new Error('Invalid APNs key'); }