Files
Sybil-2/server/src/agent-push-contract.ts
T

68 lines
4.7 KiB
TypeScript
Raw Normal View History

2026-10-14 22:03:21 -07:00
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 });
2026-10-14 22:03:21 -07:00
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');
}