feat(agent): add opt-in push notifications
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { createHash, createPublicKey, ECDH } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 30_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<typeof PresenceBody>;
|
||||
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<typeof InstallationBody>;
|
||||
export type Subscription = z.infer<typeof WebSubscription>;
|
||||
export const NotificationContext = z.object({ version: z.literal(1), 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<typeof NotificationContext>;
|
||||
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<T>(schema: z.ZodType<T>, 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');
|
||||
}
|
||||
Reference in New Issue
Block a user