feat(agent): add opt-in push notifications
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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<string, string | number>; body: Buffer };
|
||||
export type ProviderResponse = { status: number; body?: string; retryAfter?: string };
|
||||
export type RequestTransport = (request: ProviderRequest) => Promise<ProviderResponse>;
|
||||
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number) => Promise<SendResult>;
|
||||
|
||||
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<typeof request>;
|
||||
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' };
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user