feat(agent): add opt-in push notifications
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/** 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<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
|
||||
throw new Error("Notification configuration is unavailable.");
|
||||
}
|
||||
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<string | null> {
|
||||
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<unknown> {
|
||||
const token = this.options.token();
|
||||
if (!token) throw new Error("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 Error("Notification authority changed.");
|
||||
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 new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
|
||||
return response.status === 204 ? undefined : await response.json();
|
||||
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
|
||||
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 Error("Notification registration was not acknowledged.");
|
||||
}
|
||||
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<PushSubscription, "toJSON" | "unsubscribe">;
|
||||
type PushPlatform = {
|
||||
support: () => "supported" | "unsupported" | "install";
|
||||
permission: () => NotificationPermission;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
subscription: () => Promise<PushSubscriptionHandle | null>;
|
||||
subscribe: (publicKey: string) => Promise<PushSubscriptionHandle>;
|
||||
clearNotifications: () => Promise<void>;
|
||||
};
|
||||
/** Explicit consent survives restarts; a revoke tombstone always wins over an older enable. */
|
||||
export function createAgentPushController(options: {
|
||||
api: Pick<AgentPushClient, "config" | "register" | "revoke">;
|
||||
platform: PushPlatform; read: () => AgentPushConsent | null; write: (consent: AgentPushConsent) => void; remove?: () => void;
|
||||
scope: () => Promise<string | null>;
|
||||
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
|
||||
}) {
|
||||
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | 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.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 Error("Notification authority changed."); }
|
||||
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 === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
|
||||
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
|
||||
: 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."
|
||||
: !available ? "Notifications are not configured on this server yet."
|
||||
: enabled ? "Notifications are on for this browser. Alerts use a private, generic preview."
|
||||
: "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 };
|
||||
};
|
||||
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<AgentPushConsent, "installationId" | "scope">) => {
|
||||
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;
|
||||
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 () => {
|
||||
if (busy) return;
|
||||
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 { await invalidate(record); throw new Error("Notification service is unavailable."); }
|
||||
registeredSubscription = serialized;
|
||||
}
|
||||
}
|
||||
});
|
||||
error = null;
|
||||
} catch { error = "Notification service is unavailable. Try again when connected."; }
|
||||
publish();
|
||||
};
|
||||
const enable = async () => {
|
||||
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
|
||||
error = 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<NotificationPermission>;
|
||||
try { permission = options.platform.requestPermission(); }
|
||||
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
|
||||
busy = true; publish();
|
||||
try {
|
||||
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
|
||||
await options.lock(async () => {
|
||||
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; }
|
||||
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 }; write(pending);
|
||||
try {
|
||||
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; }
|
||||
write({ ...record, enabled: true, revocationPending: false });
|
||||
registeredSubscription = JSON.stringify(subscription.toJSON());
|
||||
} catch {
|
||||
await revoke(pending);
|
||||
throw new Error("Notifications could not be enabled. Try again when connected.");
|
||||
}
|
||||
});
|
||||
} catch { error = "Notifications could not be enabled. Try again when connected."; }
|
||||
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<void>;
|
||||
}) {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user