import { AgentPushError, agentPushFailure, agentPushHttpFailure, type AgentPushStage } from "./agent-push-errors.ts"; /** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */ export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1"; export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed"; export const agentPushCursor = (value: unknown): value is string => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value)); export const agentPushUuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); export const agentPushSourceId = agentPushUuid; const object = (value: unknown): Record | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; export type AgentPushConfig = { version: 1; enabled: boolean; webPushAvailable: boolean; apnsAvailable: boolean; sourceId: string | null; quarantined: boolean; vapidPublicKey?: string; policy: { presenceHeartbeatMs: number; presenceLeaseMs: number }; }; export function parseAgentPushConfig(value: unknown): AgentPushConfig { const config = object(value), policy = object(config?.policy); if (!config || config.version !== 1 || typeof config.enabled !== "boolean" || typeof config.webPushAvailable !== "boolean" || typeof config.apnsAvailable !== "boolean" || (config.sourceId !== null && !agentPushSourceId(config.sourceId)) || (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy || typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000 || typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000 || (config.webPushAvailable && config.vapidPublicKey === undefined) || (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) { throw new AgentPushError("The server returned invalid notification configuration. Refresh Sybil; if it continues, ask the server administrator to check Web Push configuration."); } return { ...config, quarantined: config.quarantined === true } as AgentPushConfig; } export type AgentPresence = { sequence: number; visibility: "visible" | "hidden"; focused: boolean; interacted: boolean }; /** Nonsecret identity only; the bearer is never retained with the digest. */ export async function agentPushAuthorityScope(baseUrl: string, token: string | null, origin = globalThis.location?.href): Promise { if (!token || !globalThis.crypto?.subtle) return null; const base = new URL(baseUrl.replace(/\/+$/, ""), origin).href; const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([base, token]))); return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join(""); } export class AgentPushClient { private options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }; constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; } private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise { const token = this.options.token(); if (!token) throw new AgentPushError("Sign in to manage notifications."); const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000); try { // Capture the bearer before hashing, then use that exact bearer for the // request. A concurrent sign-in must not turn old-authority cleanup into // a new-authority DELETE whose204 falsely clears a durable tombstone. if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again."); const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive, }); // Never echo server/provider bodies: they may contain endpoint or key material. if (!response.ok) throw agentPushHttpFailure(response.status); return response.status === 204 ? undefined : await response.json(); } catch (cause) { throw agentPushFailure(cause, "service"); } finally { clearTimeout(timeout); } } async config() { return parseAgentPushConfig(await this.request("/push/config")); } async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) { if (!agentPushUuid(id)) throw new Error("Invalid notification installation."); const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope)); if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new AgentPushError("The Sybil server did not acknowledge the notification registration. Refresh and try again."); } async revoke(id: string, expectedScope?: string) { if (!agentPushUuid(id)) throw new Error("Invalid notification installation."); await this.request(`/push/installations/${id}`, "DELETE", undefined, false, expectedScope); } async presence(id: string, presence: AgentPresence, keepalive = false) { if (!agentPushUuid(id)) throw new Error("Invalid attention session."); await this.request(`/presence/${id}`, "PUT", presence, keepalive); } async retirePresence(id: string) { if (agentPushUuid(id)) await this.request(`/presence/${id}`, "DELETE", undefined, true); } async readState() { const value = object(await this.request("/read-state")); if (agentPushSourceId(value?.sourceId) && agentPushCursor(value?.cursor)) return { sourceId: value.sourceId, cursor: BigInt(value.cursor).toString() }; return null; } } export const MAX_AGENT_PUSH_REVOCATIONS = 16; export type AgentPushRevocation = { installationId: string; scope: string }; export type AgentPushConsent = { version: 1; installationId: string; revision: string; scope?: string; sourceId?: string; enabled: boolean; revocationPending: boolean; pendingRevocations?: AgentPushRevocation[] }; export function parseAgentPushConsent(raw: string | null): AgentPushConsent | null { try { const value = object(JSON.parse(raw || "null")); if (!(value?.version === 1 && agentPushUuid(value.installationId) && agentPushUuid(value.revision) && (value.scope === undefined || (typeof value.scope === "string" && /^[a-f0-9]{64}$/.test(value.scope))) && (value.pendingRevocations === undefined || (Array.isArray(value.pendingRevocations) && value.pendingRevocations.length <= MAX_AGENT_PUSH_REVOCATIONS && value.pendingRevocations.every(item => agentPushUuid(object(item)?.installationId) && typeof object(item)?.scope === "string" && /^[a-f0-9]{64}$/.test(String(object(item)?.scope))))) && typeof value.enabled === "boolean" && typeof value.revocationPending === "boolean")) return null; // Keep the installation identity for cleanup when migrating old/unbound // consent. An absent or malformed source can never authorize registration. return { ...value, sourceId: agentPushSourceId(value.sourceId) ? value.sourceId : undefined } as AgentPushConsent; } catch { return null; } } export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null }; export type PushSubscriptionHandle = Pick; type PushPlatform = { support: () => "supported" | "unsupported" | "install" | "insecure"; permission: () => NotificationPermission; requestPermission: () => Promise; subscription: () => Promise; subscribe: (publicKey: string) => Promise; clearNotifications: () => Promise; }; /** Explicit consent survives restarts; a revoke tombstone always wins over an older enable. */ export function createAgentPushController(options: { api: Pick; platform: PushPlatform; read: () => AgentPushConsent | null; write: (consent: AgentPushConsent) => void; remove?: () => void; scope: () => Promise; uuid: () => string; lock: (operation: () => Promise) => Promise; changed: (view: AgentPushView) => void; }) { let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null; let refreshError: string | null = null; let scope: string | null = null; let registeredSubscription: string | null = null; const currentConsent = () => localRevocation || options.read(); const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope && agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId; const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !!config.vapidPublicKey && !config.quarantined && agentPushSourceId(config.sourceId); const refreshContext = async () => { const nextScope = await options.scope(), nextConfig = await options.api.config(); if (nextScope !== await options.scope()) { scope = null; config = null; throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again."); } scope = nextScope; config = nextConfig; }; const recheckContext = async (record: AgentPushConsent) => { await refreshContext(); return matchesContext(record) && transportAvailable(); }; const state = (): AgentPushView => { const consent = currentConsent(), support = options.platform.support(), permission = options.platform.permission(); const available = !!scope && transportAvailable(); const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS; const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted"; const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there." : support === "insecure" ? "Notifications require a secure connection. Open Sybil over HTTPS, then try again." : support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS." : permission === "denied" ? "Notifications are blocked. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh." : cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more." : consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected." : config?.quarantined ? "Notification delivery is paused after an Agent source change. Ask the server administrator to reconcile the source before enabling notifications." : config?.enabled && config.webPushAvailable && !agentPushSourceId(config.sourceId) ? "The Agent notification source is not ready yet. Refresh in a moment; if it continues, ask the server administrator to check the Agent connection." : !available ? "Notifications are not configured on this server yet." : enabled ? "Notifications are on for this browser." : "Get an alert for unread Agent replies when all your web sessions are inactive."; return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error: error || refreshError }; }; const publish = () => options.changed(state()); const write = (consent: AgentPushConsent) => { options.write(consent); publish(); }; const consent = (): AgentPushConsent => currentConsent() || { version: 1, installationId: options.uuid(), revision: options.uuid(), ...(scope ? { scope } : {}), enabled: false, revocationPending: false }; const retireAtOriginalAuthority = async (record: Pick) => { if (!record.scope || record.scope !== await options.scope()) return false; await options.api.revoke(record.installationId, record.scope); return true; }; const retryRevocations = async () => { const original = currentConsent(); if (!original) return; for (const pending of original.pendingRevocations || []) { if (currentConsent()?.revision !== original.revision) return; const removed = await retireAtOriginalAuthority(pending).catch(() => false); const latest = currentConsent(); if (removed && latest?.revision === original.revision) { write({ ...latest, pendingRevocations: (latest.pendingRevocations || []).filter(item => item.installationId !== pending.installationId || item.scope !== pending.scope) }); } } }; const revoke = async (record: AgentPushConsent) => { let serverRemoved = false, subscriptionRemoved = false; await Promise.all([ retireAtOriginalAuthority(record).then(removed => { serverRemoved = removed; }).catch(() => undefined), options.platform.subscription().then(async subscription => { subscriptionRemoved = !subscription || await subscription.unsubscribe(); }).catch(() => undefined), options.platform.clearNotifications().catch(() => undefined), ]); const authority = await options.scope().catch(() => null); const latest = currentConsent(); if (latest?.revision === record.revision && subscriptionRemoved) { const queue = latest.pendingRevocations || []; const archived = !!record.scope && queue.some(item => item.installationId === record.installationId && item.scope === record.scope); const differentAuthority = record.scope && record.scope !== authority; if (serverRemoved || archived) { const removed = { ...latest, revocationPending: false }; try { write(removed); localRevocation = null; } catch { localRevocation = removed; } } else if (differentAuthority && queue.length < MAX_AGENT_PUSH_REVOCATIONS) { // Keep only the old nonsecret authority digest and ID. A current-server // DELETE/204 is never evidence that a different server retired this ID. const archivedRecord = { ...latest, revocationPending: false, pendingRevocations: [...queue, { installationId: record.installationId, scope: record.scope! }] }; try { write(archivedRecord); localRevocation = null; } catch { localRevocation = latest; } } } }; const invalidate = async (record: AgentPushConsent) => { if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record).catch(() => undefined); return; } const pending = { ...record, enabled: false, revocationPending: true, revision: options.uuid() }; registeredSubscription = null; try { write(pending); } catch { localRevocation = pending; } await revoke(pending); }; const disable = async () => { error = null; refreshError = null; const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() }; // Synchronous durable intent is necessary before logout clears page credentials. registeredSubscription = null; try { write(record); } catch { localRevocation = record; // Some browsers reject setItem when full but still permit removal. try { options.remove?.(); } catch { /* Unsubscribe/revoke below still run. */ } error = "Browser storage is unavailable. Check that notifications are off in browser settings."; } busy = true; publish(); try { await options.lock(() => revoke(record)); } catch { // A stuck tab must not prevent logout cleanup. The server permanently // retires this UUID, so cleanup remains safe without acquiring that lock. await revoke(record).catch(() => undefined); error = "Notifications are off here. Removal will retry when connected."; } finally { busy = false; publish(); } }; const refresh = async (clearError = false) => { if (busy) return; if (clearError) error = null; try { await refreshContext(); await options.lock(async () => { await retryRevocations(); let record = currentConsent(); if (!record) return; if (record.revocationPending) { await revoke(record); return; } if (!record.enabled) return; if (!matchesContext(record)) { await invalidate(record); return; } const subscription = await options.platform.subscription(); if (currentConsent()?.revision !== record.revision) return; if (options.platform.permission() !== "granted" || !subscription) { record = { ...record, enabled: false, revocationPending: true, revision: options.uuid() }; write(record); await revoke(record); return; } if (transportAvailable()) { const serialized = JSON.stringify(subscription.toJSON()); if (registeredSubscription !== serialized) { const matching = await recheckContext(record); if (currentConsent()?.revision !== record.revision) return; if (!matching) { await invalidate(record); return; } await options.api.register(record.installationId, subscription.toJSON(), record.scope); if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; } // A source replacement may complete while the PUT is in flight. // Never retain that registration without a fresh source check. try { const stillMatching = await recheckContext(record); if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; } if (!stillMatching) { await invalidate(record); return; } } catch (cause) { await invalidate(record); throw cause; } registeredSubscription = serialized; } } }); refreshError = null; } catch (cause) { refreshError = agentPushFailure(cause, "service").message; } publish(); }; const enable = async () => { if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return; error = null; refreshError = null; // Explicit revocation permanently retires the old UUID on the server. Each // new opt-in has a new identity, so a delayed old PUT cannot resurrect it. const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId, enabled: false, revocationPending: false, pendingRevocations: currentConsent()?.pendingRevocations || [] }; const publicKey = config.vapidPublicKey; try { options.write(record); localRevocation = null; } catch { error = "Allow browser storage before enabling notifications."; publish(); return; } // Must execute in the original click stack, before locks, worker startup or fetches. let permission: Promise; try { permission = options.platform.requestPermission(); } catch (cause) { error = agentPushFailure(cause, "permission").message; publish(); return; } busy = true; publish(); let stage: AgentPushStage = "permission"; try { const decision = await permission; if (decision !== "granted") { error = decision === "denied" ? "Notifications were not allowed. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh." : "The notification prompt was dismissed. Tap Enable notifications again and choose Allow."; return; } stage = "lock"; await options.lock(async () => { stage = "service"; const unchanged = () => options.read()?.revision === record.revision; if (!unchanged()) return; const matching = await recheckContext(record); if (!unchanged()) return; if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; } stage = "subscription"; const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey); if (!unchanged()) { await subscription.unsubscribe(); return; } // A tombstone also covers a lost registration response or failed local storage write. const pending = { ...record, enabled: false, revocationPending: true }; try { stage = "storage"; write(pending); stage = "service"; const matchingBeforeRegister = await recheckContext(record); if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; } await options.api.register(record.installationId, subscription.toJSON(), record.scope); if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; } const matchingAfterRegister = await recheckContext(record); if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; } stage = "storage"; write({ ...record, enabled: true, revocationPending: false }); registeredSubscription = JSON.stringify(subscription.toJSON()); } catch (cause) { // Retain cleanup intent even when storage fails after subscribing. if (options.read()?.revision === record.revision) localRevocation = pending; await revoke(pending); throw agentPushFailure(cause, stage); } }); } catch (cause) { error = agentPushFailure(cause, stage).message; } finally { busy = false; publish(); } }; return { state, refresh, enable, disable }; } /** Independent ordered lease per document, including focused-but-idle and recent input. */ export function createAgentPresenceReporter(options: { snapshot: () => { visibility: "visible" | "hidden"; focused: boolean }; send: (presence: AgentPresence, keepalive: boolean) => Promise; }) { let sequence = 0, interaction = 0, acknowledgedInteraction = 0; return { interact() { interaction++; }, async report(keepalive = false, hidden = false) { const snapshot = hidden ? { visibility: "hidden" as const, focused: false } : options.snapshot(); const capturedInteraction = interaction; const presence = { ...snapshot, focused: snapshot.visibility === "visible" && snapshot.focused, sequence: ++sequence, interacted: capturedInteraction > acknowledgedInteraction }; await options.send(presence, keepalive); acknowledgedInteraction = Math.max(acknowledgedInteraction, capturedInteraction); }, }; }