import { createHash, sign } from 'node:crypto'; import { lookup } from 'node:dns'; import { connect } from 'node:http2'; import { request } from 'node:https'; import { isIP } from 'node:net'; import webpush from 'web-push'; import type { PushConfig } from './agent-push-config.js'; import { validatePushEndpoint, type Identity, type Registration } from './agent-push-contract.js'; export type SendResult = { outcome: 'accepted' | 'retry' | 'invalid' | 'failed' | 'ambiguous'; retryAfterMs?: number }; export type ProviderRequest = { endpoint: string; headers: Record; body: Buffer }; export type ProviderResponse = { status: number; body?: string; retryAfter?: string }; export type RequestTransport = (request: ProviderRequest) => Promise; export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number) => Promise; function boundedRetry(value?: string): number | undefined { if (!value || !/^\d+$/.test(value)) return undefined; return Math.min(60_000, Math.max(1_000, Number(value) * 1000)); } function classify(response: ProviderResponse, apns: boolean): SendResult { if (response.status >= 200 && response.status < 300) return { outcome: 'accepted' }; if (response.status === 410 || (!apns && response.status === 404)) return { outcome: 'invalid' }; if (apns && response.status === 400) { try { if (['BadDeviceToken', 'DeviceTokenNotForTopic', 'Unregistered'].includes(JSON.parse(response.body || '{}').reason)) return { outcome: 'invalid' }; } catch { /* Never emit provider text. */ } } if (response.status === 429 || response.status >= 500) return { outcome: 'retry', retryAfterMs: boundedRetry(response.retryAfter) }; return { outcome: 'failed' }; } /** Fixed Apple origin, strict ten-second boundary. Unknown network outcomes are never auto-retried. */ export const createAPNsRequest = (open: typeof connect = connect, timeoutMs = 10_000): RequestTransport => async input => new Promise((resolve, reject) => { const url = new URL(input.endpoint); const session = open(url.origin); let settled = false; const finish = (error?: Error, value?: ProviderResponse) => { if (settled) return; settled = true; clearTimeout(timer); session.destroy(); error ? reject(new Error('APNs transport unavailable')) : resolve(value!); }; const timer = setTimeout(() => finish(new Error()), timeoutMs); session.on('error', () => finish(new Error())); session.on('goaway', () => finish(new Error())); session.on('close', () => finish(new Error())); session.on('connect', () => { if (settled) return; try { const stream = session.request({ ':method': 'POST', ':path': url.pathname, ...input.headers }); let body = '', status = 0, retryAfter: string | undefined; stream.on('response', headers => { status = Number(headers[':status']); retryAfter = String(headers['retry-after'] || ''); }); stream.on('data', data => { body += data.toString(); if (body.length > 4096) finish(new Error()); }); stream.on('error', () => finish(new Error())); stream.on('end', () => finish(undefined, { status, body, retryAfter })); stream.end(input.body); } catch { finish(new Error()); } }); }); export const apnsRequest = createAPNsRequest(); // Only global addresses are permitted even when an allowlisted provider's DNS is compromised. export function isPublicAddress(address: string): boolean { if (isIP(address) === 4) { const [a,b] = address.split('.').map(Number); return !(a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && [0, 168].includes(b)) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && [18,19,51].includes(b)) || (a === 203 && b === 0)); } if (isIP(address) === 6) return /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address) && !/^(2002|3fff):/i.test(address); return false; } export const createWebRequest = (open: typeof request = request, timeoutMs = 10_000): RequestTransport => input => new Promise((resolve, reject) => { const url = validatePushEndpoint(input.endpoint); // https.request never follows redirects. The DNS result passed to the connection is checked here (no resolve/use race). let settled = false; const finish = (error?: Error, response?: ProviderResponse) => { if (settled) return; settled = true; clearTimeout(deadline); if (error) { req?.destroy(); reject(new Error('Web Push transport unavailable')); } else resolve(response!); }; const deadline = setTimeout(() => finish(new Error()), timeoutMs); let req: ReturnType; try { req = open(url, { method: 'POST', headers: input.headers, timeout: 10_000, lookup: (hostname, options, callback) => { lookup(hostname, { all: true, verbatim: true }, (error, addresses) => { if (error || !addresses.length || addresses.some(value => !isPublicAddress(value.address))) { callback(new Error('Unsafe push provider address'), '', 4); return; } const selected = addresses.find(value => !options.family || value.family === options.family) || addresses[0]; // Node's automatic address selection requests all results. if (options.all) (callback as unknown as (error: Error | null, results: { address: string; family: number }[]) => void)(null, addresses); else callback(null, selected.address, selected.family); }); }, }, res => { res.resume(); // Provider bodies can contain endpoint/token values; never retain or log them. res.on('end', () => finish(undefined, { status: res.statusCode || 0, retryAfter: String(res.headers['retry-after'] || '') })); res.on('error', () => finish(new Error())); res.on('aborted', () => finish(new Error())); }); req.on('timeout', () => req.destroy(new Error('Web Push timeout'))); req.on('error', () => finish(new Error())); req.on('close', () => { if (!settled) finish(new Error()); }); req.end(input.body); } catch { finish(new Error()); } }); export const webRequest = createWebRequest(); export function createPushSender(config: PushConfig, adapters: { apns?: RequestTransport; web?: RequestTransport } = {}): PushSend { let jwt: { value: string; issuedAt: number } | undefined; return async (registration, identity, expiresAt, now) => { if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' }; try { if (registration.transport === 'apns') { const credential = config.apns; if (!credential || credential.topic !== registration.topic || credential.environment !== registration.environment) return { outcome: 'failed' }; if (!jwt || now - jwt.issuedAt >= 50 * 60_000 || now < jwt.issuedAt) { const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: credential.keyId })).toString('base64url'); const claims = Buffer.from(JSON.stringify({ iss: credential.teamId, iat: Math.floor(now / 1000) })).toString('base64url'); const value = `${header}.${claims}`; jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now }; } const clear = identity.kind === 'read_state'; const body = Buffer.from(JSON.stringify({ aps: clear ? { 'content-available': 1 } : { alert: { title: 'Sybil Agent', body: 'You have a new message.' }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity })); const response = await (adapters.apns || apnsRequest)({ endpoint: `https://${registration.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'}/3/device/${registration.token}`, headers: { authorization: `bearer ${jwt.value}`, 'apns-topic': credential.topic, 'apns-push-type': clear ? 'background' : 'alert', 'apns-priority': clear ? '5' : '10', 'apns-expiration': String(Math.floor(Math.min(expiresAt, now + 60_000) / 1000)), 'apns-collapse-id': `sybil-${clear ? 'read' : 'message'}-${createHash('sha256').update(identity.sourceId).digest('hex').slice(0,24)}`, 'content-type': 'application/json' }, body, }); return classify(response, true); } // Safari requires visible pushes; there is deliberately no Web Push read-clear transport. if (!config.web || identity.kind !== 'message') return { outcome: 'failed' }; validatePushEndpoint(registration.subscription.endpoint); const payload = JSON.stringify({ ...identity, title: 'Sybil Agent', body: 'You have a new message.', url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` }); const details = webpush.generateRequestDetails(registration.subscription, payload, { vapidDetails: config.web, contentEncoding: 'aes128gcm', TTL: Math.max(1, Math.min(60, Math.floor((expiresAt - now) / 1000))), urgency: 'normal', topic: createHash('sha256').update(identity.sourceId).digest('base64url').slice(0,32) }); const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! }); return classify(response, false); } catch { // Encryption/config and I/O errors are intentionally indistinguishable externally. Never replay an uncertain send. return { outcome: 'ambiguous' }; } }; }