feat(agent): add opt-in push notifications
This commit is contained in:
@@ -12,3 +12,119 @@ self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||
});
|
||||
|
||||
// This worker never receives credentials and never performs authenticated fetches.
|
||||
// Safari requires a visible notification for each valid message push. Reads are
|
||||
// reconciled only by foreground page messages, never an invisible Web Push.
|
||||
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
|
||||
const sourceId = value => 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);
|
||||
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
|
||||
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
|
||||
const owned = value => value?.version === 1 && value.kind === "message" && sourceId(value.sourceId) && cursor(value.cursor) && messageId(value.messageId);
|
||||
let queue = Promise.resolve();
|
||||
const serial = operation => { const result = queue.then(operation); queue = result.catch(() => undefined); return result; };
|
||||
function database() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("sybil-agent-notifications-v1", 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("sources", { keyPath: "sourceId" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
request.onblocked = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
}
|
||||
async function stored(id) {
|
||||
const db = await database();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = db.transaction("sources").objectStore("sources").get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function save(value) {
|
||||
const db = await database();
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction("sources", "readwrite"), store = tx.objectStore("sources");
|
||||
store.put({ ...value, touched: Date.now() });
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result.sort((a, b) => b.touched - a.touched);
|
||||
for (const stale of rows.slice(16)) store.delete(stale.sourceId);
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
tx.onabort = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function announce(type) {
|
||||
for (const client of await self.clients.matchAll({ type: "window", includeUncontrolled: true })) {
|
||||
if (new URL(client.url).origin === self.location.origin) client.postMessage({ type });
|
||||
}
|
||||
}
|
||||
self.addEventListener("push", event => {
|
||||
event.waitUntil(serial(async () => {
|
||||
let value;
|
||||
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) return; value = JSON.parse(raw); } catch { return; }
|
||||
if (!owned(value) || value.url !== PUSH_URL || value.tag !== `sybil-agent:${value.sourceId}`
|
||||
|| value.title !== PUSH_TITLE || value.body !== PUSH_BODY) return;
|
||||
const previous = await stored(value.sourceId).catch(() => null);
|
||||
// Re-delivery replaces the same owned tag without regressing its cursor.
|
||||
// Still show it visibly: local read state is not a reason for an invisible push.
|
||||
const identity = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor) ? previous.latest : {
|
||||
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
|
||||
};
|
||||
await self.registration.showNotification(PUSH_TITLE, {
|
||||
body: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
|
||||
renotify: false, data: identity,
|
||||
});
|
||||
await save({ ...previous, sourceId: identity.sourceId, latest: identity }).catch(() => undefined);
|
||||
await announce("sybil-push-shown");
|
||||
}));
|
||||
});
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close();
|
||||
if (!owned(event.notification.data) || event.notification.tag !== `sybil-agent:${event.notification.data.sourceId}`) return;
|
||||
event.waitUntil((async () => {
|
||||
const destination = new URL(PUSH_URL, self.location.origin).href;
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
for (const client of windows) {
|
||||
const url = new URL(client.url);
|
||||
if (url.origin !== self.location.origin || !["/", "/search"].includes(url.pathname)) continue;
|
||||
try { const routed = await client.navigate(destination); if (routed) { await routed.focus(); return; } } catch { /* Try another client. */ }
|
||||
}
|
||||
await self.clients.openWindow(destination);
|
||||
// A tap routes only. The rendered transcript owns the read acknowledgment.
|
||||
})());
|
||||
});
|
||||
self.addEventListener("message", event => {
|
||||
const value = event.data;
|
||||
if (value?.version !== 1 || !["sybil-agent-read", "sybil-agent-logout"].includes(value.type)) return;
|
||||
event.waitUntil(serial(async () => {
|
||||
// A same-origin WindowClient is required; no remote/message-channel authority.
|
||||
const client = event.source?.id ? await self.clients.get(event.source.id) : null;
|
||||
if (!client || client.type !== "window" || new URL(client.url).origin !== self.location.origin) return;
|
||||
if (value.type === "sybil-agent-read") {
|
||||
if (!sourceId(value.sourceId) || !cursor(value.cursor)) return;
|
||||
const previous = await stored(value.sourceId).catch(() => null);
|
||||
const readCursor = cursor(previous?.readCursor) && BigInt(previous.readCursor) > BigInt(value.cursor) ? previous.readCursor : BigInt(value.cursor).toString();
|
||||
await save({ ...previous, sourceId: value.sourceId, readCursor }).catch(() => undefined);
|
||||
const notifications = await self.registration.getNotifications({ tag: `sybil-agent:${value.sourceId}` });
|
||||
for (const notification of notifications) {
|
||||
const data = notification.data;
|
||||
if (owned(data) && data.sourceId === value.sourceId && notification.tag === `sybil-agent:${value.sourceId}` && BigInt(data.cursor) <= BigInt(readCursor)) notification.close();
|
||||
}
|
||||
} else {
|
||||
for (const notification of await self.registration.getNotifications()) {
|
||||
if (owned(notification.data) && notification.tag === `sybil-agent:${notification.data.sourceId}`) notification.close();
|
||||
}
|
||||
}
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
}));
|
||||
});
|
||||
self.addEventListener("pushsubscriptionchange", event => {
|
||||
// Re-registration needs a currently authenticated page. Never cache its token here.
|
||||
event.waitUntil(announce("sybil-push-subscription-changed"));
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { AgentConversation } from "@/components/agent/agent-conversation";
|
||||
import { AgentNotifications } from "@/components/agent/agent-notifications";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
@@ -965,6 +966,7 @@ export default function App() {
|
||||
isCheckingSession,
|
||||
isSigningIn,
|
||||
isAuthenticated,
|
||||
authMode,
|
||||
authError,
|
||||
handleAuthFailure: baseHandleAuthFailure,
|
||||
handleSignIn,
|
||||
@@ -3721,6 +3723,7 @@ export default function App() {
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
|
||||
) : null}
|
||||
</div>
|
||||
{authMode === "token" ? <AgentNotifications onLogout={logout} /> : null}
|
||||
</aside>
|
||||
|
||||
<main
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Bell } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, observeAgentPush, refreshAgentBrowserPush } from "@/lib/agent-push-browser";
|
||||
|
||||
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
|
||||
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
|
||||
useEffect(() => observeAgentPush(setState), []);
|
||||
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
|
||||
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
|
||||
<div className="space-y-2 pb-1 pt-2">
|
||||
<p role="status">{state.message}</p>
|
||||
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.enabled ? <Button className="h-8 text-xs" variant="secondary" disabled={state.busy} onClick={() => void disableAgentBrowserPush()}>Turn off notifications</Button>
|
||||
: <Button className="h-8 text-xs" variant="secondary" disabled={!state.canEnable} onClick={() => void enableAgentBrowserPush()}>Enable notifications</Button>}
|
||||
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
|
||||
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||
import {
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentReadState,
|
||||
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
type AgentActivity, type AgentRoomState,
|
||||
} from "@/lib/agent-room";
|
||||
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
|
||||
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
|
||||
import { getAgentPresenceSessionId, syncAgentNotificationRead } from "@/lib/agent-push-browser";
|
||||
|
||||
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
|
||||
|
||||
@@ -47,7 +48,8 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
},
|
||||
});
|
||||
useEffect(() => () => uploadQueue.current?.dispose(), []);
|
||||
const mark = useRef<((cursor: string) => void) | null>(null);
|
||||
const mark = useRef<((cursor: string, sourceId: string | null | undefined) => void) | null>(null);
|
||||
const sourceMismatch = useRef(false);
|
||||
const [outboxRevision, setOutboxRevision] = useState(0);
|
||||
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
|
||||
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
|
||||
@@ -80,11 +82,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
sourceMismatch.current = false;
|
||||
client.current = null; mark.current = null; setAttachmentCapability(undefined);
|
||||
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let sourceChanged = false;
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -100,7 +104,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
client.current = api;
|
||||
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
|
||||
publish: (next, isTyping) => {
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
outbox.current?.reconcile(next);
|
||||
current.current = next;
|
||||
setView({ room: next, isTyping });
|
||||
@@ -108,35 +112,36 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
});
|
||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
||||
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
|
||||
// writer must observe that acknowledged cursor immediately to avoid a
|
||||
// repeated PUT loop while the presenter waits to publish its batch.
|
||||
let acknowledgedReadCursor = current.current.readCursor;
|
||||
const readWriter = createAgentReadWriter({
|
||||
current: () => acknowledgedReadCursor,
|
||||
save: cursor => api.markRead(cursor, controller.signal),
|
||||
const readSession = createAgentReadSession({
|
||||
initial: current.current,
|
||||
save: (cursor, sourceId) => api.markRead(cursor, controller.signal, sourceId),
|
||||
invalidated: failure => {
|
||||
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
||||
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
presenter.dispose(); controller.abort(); socket?.close();
|
||||
setError(failure.message); setConnection("offline"); setLoading(false);
|
||||
},
|
||||
apply: read => {
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
void syncAgentNotificationRead(read);
|
||||
if (active && !sourceChanged) presenter.update(state => applyAgentReadState(state, read));
|
||||
},
|
||||
});
|
||||
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
|
||||
mark.current = (cursor, sourceId) => { void readSession.request(cursor, sourceId).catch(reportError); };
|
||||
|
||||
const refreshRead = async () => {
|
||||
const read = await api.readState(controller.signal);
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
if (active && !sourceChanged) readSession.receive(read);
|
||||
};
|
||||
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
|
||||
if (active) presenter.push({ type: "messages", ...page }, { replay });
|
||||
if (active && !sourceChanged) presenter.push({ type: "messages", ...page }, { replay });
|
||||
}, controller.signal);
|
||||
const refreshActivity = async () => {
|
||||
const descriptor = await api.descriptor(controller.signal);
|
||||
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (!active || authFailed) return;
|
||||
if (!active || authFailed || sourceChanged) return;
|
||||
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
|
||||
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
|
||||
socketAuthenticated = false;
|
||||
@@ -144,12 +149,12 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
socket = next;
|
||||
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
|
||||
next.onopen = () => {
|
||||
if (!active || socket !== next) { next.close(); return; }
|
||||
if (!active || sourceChanged || socket !== next) { next.close(); return; }
|
||||
lastPacket = Date.now();
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor, getAgentPresenceSessionId()));
|
||||
};
|
||||
next.onmessage = ({ data }) => {
|
||||
if (!active || socket !== next) return;
|
||||
if (!active || sourceChanged || socket !== next) return;
|
||||
lastPacket = Date.now();
|
||||
try {
|
||||
const event = parseAgentRoomEvent(String(data));
|
||||
@@ -159,6 +164,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
|
||||
setConnection("connected"); setError(null); return;
|
||||
}
|
||||
if (event.type === "read_state") {
|
||||
readSession.receive(event); return;
|
||||
}
|
||||
presenter.push(event);
|
||||
} catch (failure) {
|
||||
reportError(failure);
|
||||
@@ -167,9 +175,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
next.close();
|
||||
}
|
||||
};
|
||||
next.onerror = () => { if (active) setConnection("reconnecting"); };
|
||||
next.onerror = () => { if (active && !sourceChanged) setConnection("reconnecting"); };
|
||||
next.onclose = event => {
|
||||
if (!active || socket !== next) return;
|
||||
if (!active || sourceChanged || socket !== next) return;
|
||||
clearTimeout(socketTimeout); socketAuthenticated = false;
|
||||
if (event.code === 4401) {
|
||||
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
|
||||
@@ -186,21 +194,21 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
// The first start begins at zero, not the latest page. Unread counts
|
||||
// therefore include older public replies even after a long absence.
|
||||
await catchUp(!started);
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
presenter.flush();
|
||||
started = true; setLoading(false); connect();
|
||||
} catch (failure) {
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
reportError(failure); setLoading(false); setConnection("offline");
|
||||
startupTimer = setTimeout(() => { void start(); }, 10_000);
|
||||
}
|
||||
};
|
||||
const maintain = async (foreground = false) => {
|
||||
if (!active || !started || maintaining || authFailed) return;
|
||||
if (!active || sourceChanged || !started || maintaining || authFailed) return;
|
||||
maintaining = true;
|
||||
try {
|
||||
await refreshRead();
|
||||
await readWriter.flush();
|
||||
await readSession.flush();
|
||||
if (!socketAuthenticated) {
|
||||
await Promise.all([catchUp(), refreshActivity()]);
|
||||
} else if (foreground) await catchUp();
|
||||
@@ -217,7 +225,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
document.addEventListener("visibilitychange", focus);
|
||||
void start();
|
||||
return () => {
|
||||
active = false; controller.abort();
|
||||
active = false; readSession.cancel(); controller.abort();
|
||||
presenter.dispose();
|
||||
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||
@@ -228,12 +236,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
}, [enabled, revision, update]);
|
||||
|
||||
const renderedCursor = room.cursor;
|
||||
const renderedSourceId = room.sourceId;
|
||||
const markRead = useCallback(() => {
|
||||
if (!enabled || isLoading) return;
|
||||
// This callback belongs to this rendered snapshot. Reading current.current
|
||||
// here would incorrectly mark a socket message that has not rendered yet.
|
||||
mark.current?.(renderedCursor);
|
||||
}, [enabled, isLoading, renderedCursor]);
|
||||
mark.current?.(renderedCursor, renderedSourceId);
|
||||
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
|
||||
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
||||
@@ -250,7 +259,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.retry(messageId);
|
||||
}, [enabled]);
|
||||
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
|
||||
const retry = useCallback(() => {
|
||||
if (sourceMismatch.current) { window.location.reload(); return; }
|
||||
setRevision(value => value + 1);
|
||||
}, []);
|
||||
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
|
||||
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
|
||||
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
|
||||
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
|
||||
@@ -47,6 +48,7 @@ export function useSessionAuth() {
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string | null) => {
|
||||
void revokeAgentBrowserPush();
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(message ? normalizeAuthError(message) : null);
|
||||
@@ -69,7 +71,8 @@ export function useSessionAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
await revokeAgentBrowserPush();
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -77,6 +80,18 @@ export function useSessionAuth() {
|
||||
setAuthError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || authMode !== "token") return;
|
||||
return startAgentBrowserSession();
|
||||
}, [isAuthenticated, authMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Signing out in one tab retires the others instead of using a stale token.
|
||||
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
|
||||
window.addEventListener("storage", signedOut);
|
||||
return () => window.removeEventListener("storage", signedOut);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredToken() ?? getConfiguredToken();
|
||||
void (async () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||
import { ensureServiceWorker } from "../pwa";
|
||||
import {
|
||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||
} from "./agent-push.ts";
|
||||
|
||||
let sessionId: string | undefined;
|
||||
function browserUuid() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
|
||||
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
|
||||
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
|
||||
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
|
||||
async function consentScope() {
|
||||
const token = getAuthToken(), base = new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href;
|
||||
const digest = await agentPushAuthorityScope(base, token);
|
||||
if (token !== getAuthToken() || base !== new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href) return null;
|
||||
return digest;
|
||||
}
|
||||
const listeners = new Set<(state: AgentPushView) => void>();
|
||||
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
|
||||
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
|
||||
});
|
||||
}
|
||||
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
|
||||
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
|
||||
});
|
||||
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
||||
const support = () => {
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|
||||
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
if (ios && !standalone) return "install" as const;
|
||||
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !!navigator.locks
|
||||
? "supported" as const : "unsupported" as const;
|
||||
};
|
||||
async function postWorker(message: unknown) {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// No registration from background sync. A page worker can safely receive only sanitized IDs.
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
const worker = registration?.active;
|
||||
if (!worker) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel(), timeout = setTimeout(() => { channel.port1.close(); resolve(); }, 1500);
|
||||
channel.port1.onmessage = () => { clearTimeout(timeout); channel.port1.close(); resolve(); };
|
||||
worker.postMessage(message, [channel.port2]);
|
||||
});
|
||||
}
|
||||
export async function syncAgentNotificationRead(read: { sourceId?: string | null; cursor: string }) {
|
||||
if (!getAuthToken() || !agentPushSourceId(read.sourceId) || !agentPushCursor(read.cursor)) return;
|
||||
await postWorker({ type: "sybil-agent-read", version: 1, sourceId: read.sourceId, cursor: BigInt(read.cursor).toString() }).catch(() => undefined);
|
||||
}
|
||||
function decodePublicKey(value: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
const controller = createAgentPushController({
|
||||
api, read: readConsent, scope: consentScope,
|
||||
write: value => { localStorage.setItem(AGENT_PUSH_STORAGE_KEY, JSON.stringify(value)); },
|
||||
remove: () => localStorage.removeItem(AGENT_PUSH_STORAGE_KEY),
|
||||
uuid: browserUuid,
|
||||
lock: async operation => {
|
||||
if (!navigator.locks) return await operation(); // Cleanup only; support() disables opt-in.
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try { return await navigator.locks.request("sybil-agent-push-installation-v1", { signal: controller.signal }, operation); }
|
||||
finally { clearTimeout(timeout); }
|
||||
},
|
||||
changed: state => { for (const listener of listeners) listener(state); },
|
||||
platform: {
|
||||
support, permission: () => "Notification" in window ? Notification.permission : "denied",
|
||||
requestPermission: () => Notification.requestPermission(),
|
||||
subscription: async () => {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
|
||||
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
|
||||
return subscription ? subscriptionHandle(subscription) : null;
|
||||
},
|
||||
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
|
||||
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
||||
},
|
||||
});
|
||||
export const getAgentPushState = () => controller.state();
|
||||
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
||||
export const enableAgentBrowserPush = () => controller.enable();
|
||||
export const disableAgentBrowserPush = () => controller.disable();
|
||||
export const refreshAgentBrowserPush = () => controller.refresh();
|
||||
|
||||
const presence = createAgentPresenceReporter({
|
||||
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
||||
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
|
||||
});
|
||||
let stopSession: (() => void) | undefined;
|
||||
export async function revokeAgentBrowserPush() {
|
||||
stopSession?.();
|
||||
// Revoke/mark the durable tombstone before the caller clears the bearer token.
|
||||
const id = getAgentPresenceSessionId();
|
||||
await Promise.allSettled([readConsent() ? controller.disable() : postWorker({ type: "sybil-agent-logout", version: 1 }), ...(id ? [api.retirePresence(id)] : [])]);
|
||||
}
|
||||
/** Mounted from shared authentication so /search, every chat and every tab participate. */
|
||||
export function startAgentBrowserSession() {
|
||||
stopSession?.();
|
||||
let active = true, refreshing = false, interactionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const report = (keepalive = false, hidden = false) => { if (active) void presence.report(keepalive, hidden).catch(() => undefined); };
|
||||
const refresh = async () => {
|
||||
if (!active || refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
await controller.refresh();
|
||||
if (active && document.visibilityState === "visible") {
|
||||
const read = await api.readState();
|
||||
if (active && read) await syncAgentNotificationRead(read);
|
||||
}
|
||||
} catch { /* Absence of an acknowledged source/read never closes notifications. */ }
|
||||
finally { refreshing = false; }
|
||||
};
|
||||
const attention = () => { report(); if (document.visibilityState === "visible") void refresh(); };
|
||||
const interaction = (event: Event) => {
|
||||
if (!event.isTrusted) return;
|
||||
presence.interact();
|
||||
if (!interactionTimer) {
|
||||
report();
|
||||
interactionTimer = setTimeout(() => { interactionTimer = undefined; report(); }, 1000);
|
||||
}
|
||||
};
|
||||
const hide = () => report(true, true);
|
||||
const storage = (event: StorageEvent) => { if (event.key === AGENT_PUSH_STORAGE_KEY) void refresh(); };
|
||||
const workerMessage = (event: MessageEvent) => { if (["sybil-push-subscription-changed", "sybil-push-shown"].includes(event.data?.type)) void refresh(); };
|
||||
const heartbeat = setInterval(() => { report(); void refresh(); }, 15000);
|
||||
window.addEventListener("focus", attention); window.addEventListener("blur", attention);
|
||||
window.addEventListener("pageshow", attention); window.addEventListener("online", attention);
|
||||
window.addEventListener("pagehide", hide); window.addEventListener("storage", storage);
|
||||
document.addEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.addEventListener(type, interaction, { passive: true, capture: true });
|
||||
navigator.serviceWorker?.addEventListener("message", workerMessage);
|
||||
report(); void refresh();
|
||||
const stop = () => {
|
||||
if (!active) return;
|
||||
active = false; clearInterval(heartbeat); clearTimeout(interactionTimer);
|
||||
window.removeEventListener("focus", attention); window.removeEventListener("blur", attention);
|
||||
window.removeEventListener("pageshow", attention); window.removeEventListener("online", attention);
|
||||
window.removeEventListener("pagehide", hide); window.removeEventListener("storage", storage);
|
||||
document.removeEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.removeEventListener(type, interaction, true);
|
||||
navigator.serviceWorker?.removeEventListener("message", workerMessage);
|
||||
if (stopSession === stop) stopSession = undefined;
|
||||
// On unmount preserve the final lease until expiry; a late PUT cannot undo a DELETE.
|
||||
};
|
||||
stopSession = stop;
|
||||
return stop;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+91
-13
@@ -1,5 +1,6 @@
|
||||
import type { Message } from "./api";
|
||||
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
|
||||
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
|
||||
|
||||
export type AgentActivity = {
|
||||
state: "idle" | "thinking" | "tool" | "waiting" | "error";
|
||||
@@ -19,15 +20,18 @@ export type AgentDescriptor = {
|
||||
id: string; title: string; model: string; activity: AgentActivity;
|
||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
|
||||
};
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null };
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
|
||||
export type AgentRoomState = {
|
||||
conversationId: string | null; messages: Message[]; cursor: string;
|
||||
readCursor: string; activity: AgentActivity | null;
|
||||
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
||||
sourceId?: string | null;
|
||||
};
|
||||
export type AgentRoomEvent =
|
||||
| ({ type: "messages" } & AgentMessagesPage)
|
||||
| { type: "activity"; conversationId: string; activity: AgentActivity }
|
||||
| { type: "ready"; conversationId?: string }
|
||||
| ({ type: "read_state" } & AgentReadState)
|
||||
| { type: "pong" | "heartbeat" }
|
||||
| { type: "error"; error?: string; message?: string };
|
||||
|
||||
@@ -81,6 +85,17 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
||||
export const createAgentRoomState = (): AgentRoomState => ({
|
||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||
});
|
||||
export class AgentReadSourceChangedError extends Error {
|
||||
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
||||
}
|
||||
function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCursor" | "messages">, read: AgentReadState): string | null {
|
||||
if (read.sourceId != null && !agentPushSourceId(read.sourceId)) throw new AgentReadSourceChangedError();
|
||||
const incoming = read.sourceId ?? null;
|
||||
if (state.sourceId !== undefined && state.sourceId !== incoming) throw new AgentReadSourceChangedError();
|
||||
// A source cannot be attached retroactively to already rendered unknown history.
|
||||
if (state.sourceId === undefined && incoming !== null && (state.cursor !== "0" || state.readCursor !== "0" || state.messages.length > 0)) throw new AgentReadSourceChangedError();
|
||||
return incoming;
|
||||
}
|
||||
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
|
||||
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
|
||||
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
|
||||
@@ -88,10 +103,12 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
|
||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||
}
|
||||
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
const sourceId = readSource(state, read);
|
||||
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
}
|
||||
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
|
||||
if (event.type === "messages") return applyAgentPage(state, event);
|
||||
if (event.type === "read_state") return applyAgentReadState(state, event);
|
||||
if (event.type !== "activity") return state;
|
||||
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
|
||||
return { ...state, conversationId: event.conversationId, activity: event.activity };
|
||||
@@ -110,7 +127,10 @@ export function agentDisplayMessages(state: AgentRoomState): Message[] {
|
||||
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
const event = object(JSON.parse(raw));
|
||||
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error", "read_state"].includes(event.type)) return null;
|
||||
if (event.type === "read_state" && (!agentPushCursor(event.cursor)
|
||||
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|
||||
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
|
||||
if (event.type === "messages") {
|
||||
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
|
||||
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
|
||||
@@ -123,8 +143,9 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
|
||||
return event as AgentRoomEvent;
|
||||
}
|
||||
export function agentAuthFrame(token: string | null, cursor: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
|
||||
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
|
||||
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
|
||||
}
|
||||
|
||||
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
|
||||
@@ -146,10 +167,10 @@ export class AgentRoomClient {
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
|
||||
let value: unknown;
|
||||
try { value = await response.json(); }
|
||||
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
|
||||
catch { throw Object.assign(new Error(`Agent API returned ${response.status} without a JSON response.`), { status: response.status }); }
|
||||
if (!response.ok) {
|
||||
const error = object(value);
|
||||
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
|
||||
throw Object.assign(new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`), { status: response.status });
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
@@ -158,8 +179,8 @@ export class AgentRoomClient {
|
||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||
}
|
||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
|
||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
||||
}
|
||||
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
|
||||
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
|
||||
@@ -215,18 +236,75 @@ export function createAgentReadWriter(options: {
|
||||
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
|
||||
}) {
|
||||
let desired = "0";
|
||||
let stopped = false;
|
||||
let running: Promise<void> | undefined;
|
||||
const cancel = () => { stopped = true; desired = "0"; };
|
||||
const flush = (): Promise<void> => {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
while (BigInt(desired) > BigInt(options.current())) {
|
||||
while (!stopped && BigInt(desired) > BigInt(options.current())) {
|
||||
const captured = desired;
|
||||
const saved = await options.save(captured);
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
// A rejected/source-mismatched acknowledgment must never become a PUT loop.
|
||||
if (BigInt(options.current()) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
} catch (error) { cancel(); throw error; }
|
||||
}
|
||||
})().finally(() => { running = undefined; });
|
||||
return running;
|
||||
};
|
||||
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
|
||||
return { request(cursor: string) { if (!stopped) desired = advanceAgentCursor(desired, cursor); return flush(); }, flush, cancel };
|
||||
}
|
||||
|
||||
/** Pin reads to the source that owns this rendered room, including queued writes. */
|
||||
export function createAgentReadSession(options: {
|
||||
initial: AgentRoomState;
|
||||
save: (cursor: string, sourceId: string | null) => Promise<AgentReadState>;
|
||||
apply: (read: AgentReadState) => void;
|
||||
invalidated?: (error: AgentReadSourceChangedError) => void;
|
||||
}) {
|
||||
let binding = options.initial.sourceId, acknowledged = options.initial.readCursor;
|
||||
let failure: AgentReadSourceChangedError | undefined;
|
||||
const invalidate = () => {
|
||||
if (!failure) { failure = new AgentReadSourceChangedError(); writer.cancel(); options.invalidated?.(failure); }
|
||||
return failure;
|
||||
};
|
||||
const receive = (read: AgentReadState) => {
|
||||
if (failure) throw failure;
|
||||
try {
|
||||
const nextSource = readSource({ ...options.initial, sourceId: binding, readCursor: acknowledged }, read);
|
||||
const nextCursor = advanceAgentCursor(acknowledged, read.cursor);
|
||||
binding = nextSource; acknowledged = nextCursor;
|
||||
options.apply({ ...read, sourceId: binding });
|
||||
} catch (error) { if (error instanceof AgentReadSourceChangedError) throw invalidate(); throw error; }
|
||||
};
|
||||
const writer = createAgentReadWriter({
|
||||
current: () => acknowledged,
|
||||
save: async cursor => {
|
||||
if (failure || binding === undefined) throw invalidate();
|
||||
const source = binding;
|
||||
try { return await options.save(cursor, source); }
|
||||
catch (error) {
|
||||
if (error instanceof AgentReadSourceChangedError || object(error)?.status === 409) throw invalidate();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
apply: receive,
|
||||
});
|
||||
return {
|
||||
receive,
|
||||
request(cursor: string, renderedSourceId: string | null | undefined) {
|
||||
if (failure) return Promise.reject(failure);
|
||||
if (binding === undefined || renderedSourceId !== binding) return Promise.reject(invalidate());
|
||||
return writer.request(cursor);
|
||||
},
|
||||
flush: writer.flush,
|
||||
cancel: writer.cancel,
|
||||
sourceId: () => binding,
|
||||
readCursor: () => acknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
+13
-6
@@ -1,12 +1,19 @@
|
||||
export async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
||||
const registration = await navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
|
||||
// ready can otherwise remain pending forever after an install failure.
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("The notification worker did not become ready. Reload and try again.")), 15000);
|
||||
void navigator.serviceWorker.ready.then(ready => { clearTimeout(timeout); resolve(ready); }, () => {
|
||||
clearTimeout(timeout); reject(new Error("The notification worker is unavailable."));
|
||||
});
|
||||
void registration.update().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker
|
||||
.register("/sw.js", { updateViaCache: "none" })
|
||||
.then((registration) => registration.update())
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
void ensureServiceWorker().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const workerSource = await readFile(new URL("../public/sw.js", import.meta.url), "utf8");
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", otherSource = "550caef2-b4bc-482c-bd9f-58045ed0a8a8";
|
||||
const payload = (cursor = "5", extra = {}) => ({ version: 1, kind: "message", sourceId, messageId: `outbound:${cursor}`, cursor,
|
||||
title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}`, ...extra });
|
||||
function idb(records) {
|
||||
const request = value => { const result = {}; queueMicrotask(() => { result.result = structuredClone(value); result.onsuccess?.(); }); return result; };
|
||||
return { open() {
|
||||
const opening = {};
|
||||
queueMicrotask(() => {
|
||||
opening.result = { close() {}, transaction() {
|
||||
const transaction = { objectStore: () => ({
|
||||
get: key => request(records.get(key)), getAll: () => request([...records.values()]),
|
||||
put: value => records.set(value.sourceId, structuredClone(value)), delete: key => records.delete(key),
|
||||
}) };
|
||||
setImmediate(() => transaction.oncomplete?.()); return transaction;
|
||||
} };
|
||||
opening.onsuccess?.();
|
||||
}); return opening;
|
||||
} };
|
||||
}
|
||||
function fixture(sharedRecords = new Map()) {
|
||||
const handlers = new Map(), shown = [], notifications = [], routes = [], messages = [], fetches = [];
|
||||
const normalClient = { id: "client", type: "window", url: "https://sybil.example.test/?chat=somewhere", postMessage: value => messages.push(value),
|
||||
navigate: async url => { routes.push(["navigate", url]); return normalClient; }, focus: async () => { routes.push(["focus"]); return normalClient; } };
|
||||
let windows = [normalClient];
|
||||
const makeNotification = value => {
|
||||
const notification = { tag: value.tag, data: value.data, closed: false, close() { notification.closed = true; } };
|
||||
notifications.push(notification); return notification;
|
||||
};
|
||||
const self = {
|
||||
addEventListener: (name, handler) => handlers.set(name, handler), skipWaiting() {}, location: { origin: "https://sybil.example.test" },
|
||||
clients: { claim: async () => undefined, matchAll: async () => windows,
|
||||
get: async id => windows.find(client => client.id === id), openWindow: async url => { routes.push(["open", url]); } },
|
||||
registration: {
|
||||
showNotification: async (title, options) => {
|
||||
shown.push({ title, ...structuredClone(options) });
|
||||
for (const notification of notifications) if (notification.tag === options.tag) notification.close();
|
||||
makeNotification(options);
|
||||
},
|
||||
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt,
|
||||
fetch: async request => { fetches.push(request); throw new Error("Network blocked in worker tests"); }, Request });
|
||||
const emit = async (type, properties = {}) => {
|
||||
const waits = []; handlers.get(type)?.({ ...properties, waitUntil: value => waits.push(value) }); await Promise.all(waits);
|
||||
};
|
||||
return { shown, notifications, routes, messages, fetches, records: sharedRecords, makeNotification,
|
||||
push: value => emit("push", { data: { text: () => typeof value === "string" ? value : JSON.stringify(value) } }),
|
||||
click: notification => emit("notificationclick", { notification }),
|
||||
message: (data, id = "client") => emit("message", { data, source: { id }, ports: [] }),
|
||||
emit, windows: value => { windows = value; }, normalClient,
|
||||
};
|
||||
}
|
||||
|
||||
test("actual worker push handler shows fixed generic notification and never fetches credentials", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "You have a new message.");
|
||||
assert.deepEqual(f.shown[0].data, { version: 1, kind: "message", sourceId, messageId: "outbound:5", cursor: "5" });
|
||||
assert.equal(f.shown[0].tag, `sybil-agent:${sourceId}`); assert.equal(f.shown[0].renotify, false);
|
||||
assert.deepEqual(f.fetches, []); assert.ok(!JSON.stringify([...f.records]).includes("token"));
|
||||
});
|
||||
|
||||
test("every valid message push remains visible, including read or duplicate deliveries; stale payload never regresses identity", async () => {
|
||||
const records = new Map(), f = fixture(records);
|
||||
await f.message({ type: "sybil-agent-read", version: 1, sourceId, cursor: "10" });
|
||||
await f.push(payload("8")); await f.push(payload("8")); await f.push(payload("5"));
|
||||
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
|
||||
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
|
||||
const restarted = fixture(records); await restarted.push(payload("6"));
|
||||
assert.equal(restarted.shown[0].data.cursor, "8");
|
||||
assert.equal(records.get(sourceId).readCursor, "10");
|
||||
});
|
||||
|
||||
test("malformed/unexpected payloads cannot supply text, destinations, cross-source tags or silent read pushes", async () => {
|
||||
const f = fixture();
|
||||
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: "transcript" }),
|
||||
payload("1", { url: "https://evil.example/" }), payload("1", { tag: "some-other-app" }), payload("-1"), payload("9007199254740992"),
|
||||
payload("1", { sourceId: "../../bad" }), payload("1", { messageId: "<script>" }), { version: 1, kind: "read_state", sourceId, readCursor: "8" }]) await f.push(value);
|
||||
assert.equal(f.shown.length, 0); assert.equal(f.fetches.length, 0);
|
||||
});
|
||||
|
||||
test("authenticated page read sync closes only owned matching source at or below monotonic read cursor", async () => {
|
||||
const f = fixture();
|
||||
const older = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("4") });
|
||||
const newer = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("8") });
|
||||
const different = f.makeNotification({ tag: `sybil-agent:${otherSource}`, data: payload("1", { sourceId: otherSource }) });
|
||||
const unrelated = f.makeNotification({ tag: "calendar", data: payload("1") });
|
||||
const read = { type: "sybil-agent-read", version: 1, sourceId, cursor: "5" };
|
||||
await f.message(read, "unknown-client"); assert.equal(older.closed, false);
|
||||
f.windows([{ ...f.normalClient, id: "foreign", url: "https://evil.example/" }]);
|
||||
await f.message(read, "foreign"); assert.equal(older.closed, false);
|
||||
f.windows([f.normalClient]); await f.message(read); await f.message({ ...read, cursor: "2" });
|
||||
assert.equal(older.closed, true); assert.equal(newer.closed, false); assert.equal(different.closed, false); assert.equal(unrelated.closed, false);
|
||||
assert.equal(f.records.get(sourceId).readCursor, "5");
|
||||
await f.message({ ...read, cursor: "8" }); assert.equal(newer.closed, true);
|
||||
});
|
||||
|
||||
test("notification click closes and navigates a same-origin client using fixed root URL, without marking read", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
const notification = f.notifications[0]; notification.data.url = "https://evil.example/";
|
||||
await f.click(notification);
|
||||
assert.equal(notification.closed, true);
|
||||
assert.deepEqual(f.routes, [["navigate", "https://sybil.example.test/?agent=root"], ["focus"]]);
|
||||
assert.equal(f.records.get(sourceId).readCursor, undefined);
|
||||
f.windows([{ ...f.normalClient, url: "https://evil.example/" }]); await f.click(notification);
|
||||
assert.deepEqual(f.routes.at(-1), ["open", "https://sybil.example.test/?agent=root"]);
|
||||
});
|
||||
|
||||
test("logout closes only owned alerts and subscription change asks pages to re-register without network or secrets", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
const unrelated = f.makeNotification({ tag: "another-app", data: payload() });
|
||||
await f.message({ type: "sybil-agent-logout", version: 1 });
|
||||
assert.equal(f.notifications[0].closed, true); assert.equal(unrelated.closed, false);
|
||||
await f.emit("pushsubscriptionchange");
|
||||
assert.equal(f.messages.at(-1).type, "sybil-push-subscription-changed");
|
||||
assert.deepEqual(f.fetches, []);
|
||||
});
|
||||
|
||||
test("worker bounds durable source metadata", async () => {
|
||||
const f = fixture();
|
||||
for (let index = 0; index < 20; index++) await f.message({ type: "sybil-agent-read", version: 1, sourceId: `4599d348-e053-4937-987d-${String(index).padStart(12, "0")}`, cursor: "1" });
|
||||
assert.equal(f.records.size, 16);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import assert from "node:assert/strict";
|
||||
// Fresh isolated Chromium only. All REST, sockets and PushManager provider calls
|
||||
// are mocked. The real /sw.js and browser IndexedDB/notification APIs execute.
|
||||
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
|
||||
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/agent-push.browser.mjs
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, permissions: ["notifications"] });
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", requests = [], sockets = [], errors = [], blocked = [];
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId: null, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
let readCursor = "0", readSourceId = sourceId, failDelete = false;
|
||||
await context.addInitScript(() => {
|
||||
if (!localStorage.getItem("fixture-bootstrapped")) {
|
||||
localStorage.setItem("fixture-bootstrapped", "true"); localStorage.setItem("sybil_admin_token", "browser-fixture-token");
|
||||
}
|
||||
window.__pushCalls = [];
|
||||
window.__fixturePermission = localStorage.getItem("fixture-subscription") ? "granted" : "default";
|
||||
window.__fixtureDecision = "denied";
|
||||
Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__fixturePermission });
|
||||
Notification.requestPermission = () => {
|
||||
window.__pushCalls.push({ type: "permission", gesture: navigator.userActivation.isActive });
|
||||
window.__fixturePermission = window.__fixtureDecision; return Promise.resolve(window.__fixturePermission);
|
||||
};
|
||||
const subscription = () => ({
|
||||
toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/browser-fixture", expirationTime: null, keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }),
|
||||
unsubscribe: async () => { window.__pushCalls.push({ type: "unsubscribe" }); localStorage.removeItem("fixture-subscription"); return true; },
|
||||
});
|
||||
PushManager.prototype.getSubscription = async () => localStorage.getItem("fixture-subscription") ? subscription() : null;
|
||||
PushManager.prototype.subscribe = async options => {
|
||||
window.__pushCalls.push({ type: "subscribe", visible: options.userVisibleOnly, keyLength: options.applicationServerKey.byteLength });
|
||||
localStorage.setItem("fixture-subscription", "true"); return subscription();
|
||||
};
|
||||
});
|
||||
await context.route("**/*", async route => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
if (url.origin !== target.origin) { blocked.push(url.origin); await route.abort(); return; }
|
||||
if (!url.pathname.startsWith("/api/")) { await route.continue(); return; }
|
||||
const method = request.method(), path = url.pathname;
|
||||
const body = request.postData() ? request.postDataJSON() : undefined;
|
||||
requests.push({ path, method, body, authorization: request.headers().authorization });
|
||||
assert.ok(["Bearer browser-fixture-token", "Bearer browser-fixture-rotated-token"].includes(request.headers().authorization));
|
||||
let value;
|
||||
if (path.endsWith("/push/config")) value = config;
|
||||
else if (path.includes("/push/installations/")) {
|
||||
if (method === "DELETE") { await route.fulfill({ status: failDelete ? 503 : 204, ...(failDelete ? { json: { message: "offline" } } : {}) }); return; }
|
||||
value = { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 };
|
||||
} else if (path.includes("/presence/")) {
|
||||
if (method === "DELETE") { await route.fulfill({ status: 204 }); return; }
|
||||
value = { expiresAt: new Date(Date.now() + 60000).toISOString() };
|
||||
} else if (path.endsWith("/read-state")) {
|
||||
if (method === "PUT") readCursor = body.cursor;
|
||||
value = { cursor: readCursor, sourceId: readSourceId, updatedAt: null };
|
||||
} else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [], cursor: "0", beforeCursor: null, hasMore: false };
|
||||
else if (path.endsWith("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
|
||||
else if (path.includes("session")) value = { mode: "token", authenticated: true };
|
||||
else if (path.endsWith("/tools") || path.endsWith("/chat-tools")) value = { tools: [] };
|
||||
else if (path.endsWith("/models")) value = { models: [], providers: {} };
|
||||
else value = [];
|
||||
await route.fulfill({ json: value });
|
||||
});
|
||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => {
|
||||
socket.onMessage(raw => {
|
||||
const frame = JSON.parse(String(raw));
|
||||
if (frame.type === "auth") { sockets.push({ socket, frame }); socket.send(JSON.stringify({ type: "ready", conversationId: "1" })); }
|
||||
});
|
||||
});
|
||||
const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message));
|
||||
const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if (await predicate()) return; await new Promise(resolve => setTimeout(resolve, 50)); } assert.fail("Condition did not settle"); };
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await page.getByText("Notifications are not configured on this server yet.", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in");
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), []);
|
||||
config.sourceId = sourceId;
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0);
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByText(/Notifications were not allowed/).waitFor();
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0);
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), [{ type: "permission", gesture: true }]);
|
||||
await page.evaluate(() => { window.__fixturePermission = "default"; window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const registrations = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT");
|
||||
assert.equal(registrations.length, 1); assert.equal(registrations[0].body.transport, "web");
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls.filter(call => call.type === "subscribe")), [{ type: "subscribe", visible: true, keyLength: 65 }]);
|
||||
await expectEventually(() => sockets.length === 1);
|
||||
const firstSession = sockets[0].frame.presenceSessionId;
|
||||
assert.equal(sockets[0].frame.clientKind, "web");
|
||||
assert.ok(requests.some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible"));
|
||||
|
||||
// /search has no Agent pane or room socket but must still report attention.
|
||||
const second = await context.newPage(); second.on("pageerror", error => errors.push(error.message));
|
||||
await second.goto(new URL("/search", target).href, { waitUntil: "networkidle" });
|
||||
await second.bringToFront();
|
||||
await expectEventually(() => new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path)).size >= 2);
|
||||
const sessionPaths = [...new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path))];
|
||||
assert.equal(sessionPaths.length, 2);
|
||||
const secondPath = sessionPaths.find(path => !path.endsWith(firstSession));
|
||||
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.focused === true));
|
||||
await second.getByRole("textbox").first().click();
|
||||
await second.getByRole("textbox").first().pressSequentially("interaction");
|
||||
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.interacted === true));
|
||||
for (const path of sessionPaths) {
|
||||
const sequences = requests.filter(request => request.path === path && request.method === "PUT").map(request => request.body.sequence);
|
||||
assert.deepEqual(sequences, [...new Set(sequences)].sort((a, b) => a - b));
|
||||
}
|
||||
await page.bringToFront();
|
||||
const worker = context.serviceWorkers().find(worker => new URL(worker.url()).pathname === "/sw.js");
|
||||
assert.ok(worker, "actual service worker activated");
|
||||
const push = async cursor => worker.evaluate(async ({ cursor, sourceId }) => {
|
||||
const value = { version: 1, kind: "message", sourceId, cursor, messageId: `outbound:${cursor}`, title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}` };
|
||||
const event = new Event("push"), waits = [];
|
||||
Object.defineProperties(event, { data: { value: { text: () => JSON.stringify(value) } }, waitUntil: { value: promise => waits.push(promise) } });
|
||||
self.dispatchEvent(event); await Promise.all(waits);
|
||||
return (await self.registration.getNotifications()).map(notification => ({ title: notification.title, body: notification.body, tag: notification.tag, data: notification.data }));
|
||||
}, { cursor, sourceId });
|
||||
const notification = await push("8");
|
||||
assert.equal(notification.length, 1); assert.equal(notification[0].title, "Sybil Agent"); assert.equal(notification[0].body, "You have a new message.");
|
||||
assert.equal(notification[0].data.cursor, "8");
|
||||
assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "push reception never marks read");
|
||||
readCursor = "7";
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null }));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
assert.equal(await worker.evaluate(async () => (await self.registration.getNotifications()).length), 1);
|
||||
readCursor = "8";
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null }));
|
||||
await expectEventually(async () => await worker.evaluate(async () => (await self.registration.getNotifications()).length) === 0);
|
||||
const dbRows = await worker.evaluate(async () => {
|
||||
const db = await new Promise(resolve => { const request = indexedDB.open("sybil-agent-notifications-v1"); request.onsuccess = () => resolve(request.result); });
|
||||
const rows = await new Promise(resolve => { const request = db.transaction("sources").objectStore("sources").getAll(); request.onsuccess = () => resolve(request.result); });
|
||||
db.close(); return rows;
|
||||
});
|
||||
assert.equal(dbRows[0].readCursor, "8"); assert.doesNotMatch(JSON.stringify(dbRows), /browser-fixture-token|fcm.googleapis/);
|
||||
|
||||
// The rendered room belongs to source A. A B read frame must stop the room,
|
||||
// preserving A's cursor instead of relabeling old queued reads with B.
|
||||
await second.close();
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId: replacementSourceId, cursor: "0", updatedAt: null }));
|
||||
await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).waitFor();
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "source mismatch cannot produce relabeled writes or a write loop");
|
||||
|
||||
// Replacing the source at the same backend/token invalidates persisted push
|
||||
// consent. The new source requires another gesture and another installation.
|
||||
const oldConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
const registrationCount = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length;
|
||||
config.sourceId = replacementSourceId; readSourceId = replacementSourceId; readCursor = "0";
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const retiredConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(retiredConsent.enabled, false); assert.equal(retiredConsent.sourceId, sourceId);
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length, registrationCount);
|
||||
assert.ok(requests.some(request => request.path.endsWith(oldConsent.installationId) && request.method === "DELETE"));
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const replacementConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(replacementConsent.enabled, true); assert.equal(replacementConsent.sourceId, replacementSourceId);
|
||||
assert.equal(replacementConsent.scope, oldConsent.scope); assert.notEqual(replacementConsent.installationId, oldConsent.installationId);
|
||||
assert.ok((await page.evaluate(() => window.__pushCalls.filter(call => call.type === "permission"))).every(call => call.gesture === true));
|
||||
const socketCount = sockets.length;
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expectEventually(() => sockets.length > socketCount);
|
||||
assert.equal(await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).count(), 0, "explicit reload binds a fresh room to B");
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
|
||||
// Credential rotation is a new authority scope. The old UUID remains queued
|
||||
// locally even if this server would return204 for an unknown DELETE.
|
||||
const originalAuthority = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-rotated-token"));
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const rotatedOff = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.deepEqual(rotatedOff.pendingRevocations, [{ installationId: originalAuthority.installationId, scope: originalAuthority.scope }]);
|
||||
assert.ok(!requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId)
|
||||
&& request.authorization === "Bearer browser-fixture-rotated-token"), "rotated credentials cannot falsely acknowledge old-authority cleanup");
|
||||
await page.evaluate(() => { window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const rotatedOn = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(rotatedOn.enabled, true); assert.notEqual(rotatedOn.scope, originalAuthority.scope);
|
||||
assert.notEqual(rotatedOn.installationId, originalAuthority.installationId); assert.deepEqual(rotatedOn.pendingRevocations, rotatedOff.pendingRevocations);
|
||||
await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-token"));
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const returned = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.deepEqual(returned.pendingRevocations, [{ installationId: rotatedOn.installationId, scope: rotatedOn.scope }]);
|
||||
assert.ok(requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId)
|
||||
&& request.authorization === "Bearer browser-fixture-token"));
|
||||
await page.evaluate(() => { window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
|
||||
// Offline sign-out still removes the local subscription and leaves a tombstone.
|
||||
failDelete = true;
|
||||
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Sign in/i }).waitFor();
|
||||
const local = await page.evaluate(() => ({ token: localStorage.getItem("sybil_admin_token"), subscription: localStorage.getItem("fixture-subscription"), consent: JSON.parse(localStorage.getItem("sybil.agent.push.v1")) }));
|
||||
assert.equal(local.token, null); assert.equal(local.subscription, null); assert.equal(local.consent.enabled, false); assert.equal(local.consent.revocationPending, true);
|
||||
assert.ok(requests.some(request => request.method === "DELETE" && request.path.includes("/push/installations/") && request.authorization === "Bearer browser-fixture-token"));
|
||||
// Existing app fonts are blocked as well; nothing leaves the loopback fixture.
|
||||
assert.ok(blocked.every(origin => origin === "https://fonts.googleapis.com")); assert.deepEqual(errors, []);
|
||||
console.log("PASS real worker activation/showNotification/IndexedDB/read close; unknown-source/gesture denial/enable; two-tab + standalone search presence; auth-frame binding; source-replacement read quiescence and explicit re-opt-in/reload; authority-scoped cleanup/credential rotation/return retry; offline logout; no provider network");
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
@@ -0,0 +1,467 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AgentPushClient, agentPushAuthorityScope, agentPushCursor, createAgentPresenceReporter, createAgentPushController,
|
||||
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
||||
} from "../src/lib/agent-push.ts";
|
||||
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
||||
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
const configuration = () => ({ version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } });
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
function fixture(overrides = {}, shared = {}) {
|
||||
let record = shared.record || null, subscription = null, permission = "default", support = "supported", config = configuration();
|
||||
const calls = [], states = [];
|
||||
const api = {
|
||||
config: async () => config,
|
||||
register: async (id, value) => { calls.push(["register", id, value]); },
|
||||
revoke: async id => { calls.push(["revoke", id]); },
|
||||
...overrides.api,
|
||||
};
|
||||
const makeSubscription = (endpoint = "https://push.example.test/opaque") => ({
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: "fixture-public", auth: "fixture-auth" } }),
|
||||
unsubscribe: async () => { calls.push(["unsubscribe"]); subscription = null; return true; },
|
||||
});
|
||||
const platform = {
|
||||
support: () => support, permission: () => permission,
|
||||
requestPermission: () => { calls.push(["permission"]); permission = "granted"; return Promise.resolve(permission); },
|
||||
subscription: async () => subscription,
|
||||
subscribe: async key => { calls.push(["subscribe", key]); return subscription = makeSubscription(); },
|
||||
clearNotifications: async () => { calls.push(["clear"]); },
|
||||
...overrides.platform,
|
||||
};
|
||||
const options = {
|
||||
api, platform, read: () => record, write: value => { record = structuredClone(value); }, uuid: randomUUID,
|
||||
scope: async () => "a".repeat(64),
|
||||
lock: async operation => operation(), changed: state => states.push(state), ...shared.options,
|
||||
};
|
||||
const controller = createAgentPushController(options);
|
||||
return { controller, options, calls, states, api, platform, makeSubscription, record: () => record,
|
||||
setConfig(value) { config = value; }, setPermission(value) { permission = value; }, setSupport(value) { support = value; }, setSubscription(value) { subscription = value; } };
|
||||
}
|
||||
|
||||
test("explicit gesture requests permission before yielding; permission alone never opts in", async () => {
|
||||
const f = fixture(); f.setPermission("granted");
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
||||
const enabling = f.controller.enable();
|
||||
assert.deepEqual(f.calls, [["permission"]], "request runs directly inside the click stack");
|
||||
await enabling;
|
||||
assert.equal(f.record().enabled, true);
|
||||
assert.equal(f.record().sourceId, sourceId);
|
||||
assert.equal(f.record().revocationPending, false);
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
await f.controller.refresh(); await f.controller.refresh();
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "heartbeat does not repeatedly register an unchanged subscription");
|
||||
});
|
||||
|
||||
test("denial, disabled config, unsupported browser and iOS installation guidance do not subscribe", async () => {
|
||||
for (const support of ["unsupported", "install"]) {
|
||||
const f = fixture(); f.setSupport(support); await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.deepEqual(f.calls, []);
|
||||
assert.match(f.controller.state().message, support === "install" ? /Home Screen/ : /HTTPS/);
|
||||
}
|
||||
const disabled = fixture(); disabled.setConfig({ ...configuration(), enabled: false });
|
||||
await disabled.controller.refresh(); await disabled.controller.enable(); assert.deepEqual(disabled.calls, []);
|
||||
const denied = fixture({ platform: { requestPermission: async () => "denied" } });
|
||||
await denied.controller.refresh(); await denied.controller.enable();
|
||||
assert.equal(denied.record().enabled, false); assert.equal(denied.calls.length, 0);
|
||||
assert.match(denied.controller.state().error, /not allowed/);
|
||||
});
|
||||
|
||||
test("registration failure rolls back subscription, persists revoke intent and never echoes provider errors", async () => {
|
||||
const f = fixture({ api: { register: async () => { throw new Error("https://private-provider/token-secret"); }, revoke: async () => { throw new Error("offline"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
assert.doesNotMatch(JSON.stringify(f.states), /private-provider|token-secret/);
|
||||
assert.equal(f.controller.state().canEnable, false);
|
||||
});
|
||||
|
||||
test("offline logout writes tombstone synchronously, unsubscribes locally and retries before any enable", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const id = f.record().installationId;
|
||||
f.api.revoke = async () => { throw new Error("offline"); };
|
||||
const disabling = f.controller.disable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
||||
await disabling;
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
const count = f.calls.filter(([name]) => name === "register").length;
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
||||
f.api.revoke = async revoked => { assert.equal(revoked, id); };
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record().revocationPending, false); assert.equal(f.record().enabled, false);
|
||||
});
|
||||
|
||||
test("storage failures during logout still revoke, unsubscribe and close notifications", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
f.options.write = () => { throw new Error("quota"); };
|
||||
let removed = false; f.options.remove = () => { removed = true; };
|
||||
await f.controller.disable();
|
||||
assert.ok(removed); assert.equal(f.controller.state().enabled, false);
|
||||
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.controller.state().enabled, false);
|
||||
});
|
||||
|
||||
test("a stuck cross-tab lock cannot prevent server retirement or local logout cleanup", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
f.options.lock = async () => { throw new Error("lock acquisition expired"); };
|
||||
await f.controller.disable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
||||
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
||||
});
|
||||
|
||||
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
||||
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
|
||||
});
|
||||
|
||||
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const id = f.record().installationId;
|
||||
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
||||
await f.controller.refresh();
|
||||
const registrations = f.calls.filter(([name]) => name === "register");
|
||||
assert.equal(registrations.length, 2); assert.ok(registrations.every(([, installation]) => installation === id));
|
||||
f.setPermission("denied"); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
||||
});
|
||||
|
||||
test("logout while permission prompt is open cancels the older opt-in", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
await f.controller.disable(); prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false);
|
||||
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
});
|
||||
|
||||
test("two-tab enable → disable → enable uses permanent server tombstones and a new installation UUID", async () => {
|
||||
let sharedRecord = null, queue = Promise.resolve(), currentRegistration = false;
|
||||
const calls = [], pendingRegister = deferred(), retired = new Set(); let first = true;
|
||||
const common = { options: {
|
||||
read: () => sharedRecord, write: value => { sharedRecord = structuredClone(value); },
|
||||
lock: operation => { const running = queue.then(operation); queue = running.catch(() => undefined); return running; },
|
||||
} };
|
||||
const api = {
|
||||
register: async id => { calls.push("register"); if (first) { first = false; await pendingRegister.promise; } assert.ok(!retired.has(id), "retired UUID must never be reused"); currentRegistration = true; },
|
||||
revoke: async id => { calls.push("revoke"); retired.add(id); currentRegistration = false; },
|
||||
};
|
||||
const a = fixture({ api }, common), b = fixture({ api }, common);
|
||||
await a.controller.refresh(); await b.controller.refresh();
|
||||
const enabling = a.controller.enable(); await settle();
|
||||
assert.deepEqual(calls, ["register"]);
|
||||
const disabling = b.controller.disable();
|
||||
assert.equal(sharedRecord.enabled, false); assert.equal(sharedRecord.revocationPending, true);
|
||||
pendingRegister.resolve(); await Promise.all([enabling, disabling]);
|
||||
assert.equal(currentRegistration, false);
|
||||
const id = sharedRecord.installationId;
|
||||
await b.controller.enable();
|
||||
assert.equal(currentRegistration, true); assert.equal(sharedRecord.enabled, true); assert.notEqual(sharedRecord.installationId, id);
|
||||
assert.ok(retired.has(id));
|
||||
assert.equal(calls.at(-1), "register");
|
||||
});
|
||||
|
||||
test("a timed-out old PUT completing after DELETE cannot revive the retired installation or affect a new opt-in", async () => {
|
||||
const retired = new Set(), active = new Set(), late = deferred(); let firstId, lateStatus, first = true;
|
||||
const f = fixture({ api: {
|
||||
register: async id => {
|
||||
if (first) {
|
||||
first = false; firstId = id;
|
||||
late.promise.then(() => { lateStatus = retired.has(id) ? 409 : 200; if (lateStatus === 200) active.add(id); });
|
||||
throw new Error("request timed out while server is still processing");
|
||||
}
|
||||
assert.ok(!retired.has(id)); active.add(id);
|
||||
},
|
||||
revoke: async id => { retired.add(id); active.delete(id); },
|
||||
} });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false); assert.ok(retired.has(firstId));
|
||||
await f.controller.enable(); const currentId = f.record().installationId;
|
||||
assert.notEqual(currentId, firstId); assert.ok(active.has(currentId));
|
||||
late.resolve(); await settle();
|
||||
assert.equal(lateStatus, 409); assert.deepEqual([...active], [currentId]);
|
||||
});
|
||||
|
||||
test("authority changes retain original-server cleanup without issuing a misleading DELETE to the new server", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId, registrations = f.calls.filter(([name]) => name === "register").length;
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
await f.controller.refresh(); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false);
|
||||
assert.equal(f.record().revocationPending, false, "local unsubscribe succeeded, but old server cleanup stays queued");
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
assert.ok(!f.calls.some(([name]) => name === "revoke"), "new server's unknown-ID DELETE/204 cannot retire the original server registration");
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, registrations);
|
||||
await f.controller.enable();
|
||||
const newId = f.record().installationId;
|
||||
assert.notEqual(newId, oldId); assert.equal(f.record().scope, "b".repeat(64)); assert.equal(f.record().enabled, true);
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
const reloaded = createAgentPushController(f.options);
|
||||
await reloaded.refresh();
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }], "queue survives a reload on B");
|
||||
assert.ok(!f.calls.some(([name]) => name === "revoke"));
|
||||
f.options.scope = async () => "a".repeat(64);
|
||||
await reloaded.refresh();
|
||||
assert.deepEqual(f.calls.filter(([name]) => name === "revoke"), [["revoke", oldId]], "returning with A credentials retries only A's intent");
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: newId, scope: "b".repeat(64) }]);
|
||||
assert.equal(f.record().enabled, false);
|
||||
await reloaded.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "a".repeat(64));
|
||||
assert.notEqual(f.record().installationId, oldId);
|
||||
});
|
||||
|
||||
test("revoked old credentials do not prevent fresh opt-in after same-server credential rotation", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.api.revoke = async () => { throw new Error("old credentials revoked"); };
|
||||
await f.controller.disable();
|
||||
assert.equal(f.record().revocationPending, true);
|
||||
f.options.scope = async () => "c".repeat(64);
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "c".repeat(64));
|
||||
assert.notEqual(f.record().installationId, oldId);
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
assert.doesNotMatch(JSON.stringify(f.record()), /credentials|provider|opaque|fixture-auth/);
|
||||
});
|
||||
|
||||
test("failed old-authority retries retain intent while other queued authorities are never contacted", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
||||
const queue = [{ installationId: randomUUID(), scope: "a".repeat(64) }, { installationId: randomUUID(), scope: "b".repeat(64) }];
|
||||
f.options.write({ ...f.record(), pendingRevocations: queue });
|
||||
const attempted = []; f.api.revoke = async id => { attempted.push(id); throw new Error("offline"); };
|
||||
await f.controller.refresh();
|
||||
assert.deepEqual(attempted, [queue[0].installationId]); assert.deepEqual(f.record().pendingRevocations, queue);
|
||||
await f.controller.enable();
|
||||
assert.equal(f.record().enabled, true); assert.deepEqual(f.record().pendingRevocations, queue);
|
||||
});
|
||||
|
||||
test("bounded revocation queue never silently evicts cleanup and gives actionable opt-in recovery", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
||||
const queue = Array.from({ length: MAX_AGENT_PUSH_REVOCATIONS }, () => ({ installationId: randomUUID(), scope: "b".repeat(64) }));
|
||||
f.options.write({ ...f.record(), pendingRevocations: queue });
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /Sign in to a previous server/);
|
||||
const count = f.calls.filter(([name]) => name === "register").length;
|
||||
await f.controller.enable();
|
||||
assert.deepEqual(f.record().pendingRevocations, queue); assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
||||
assert.ok(parseAgentPushConsent(JSON.stringify(f.record())));
|
||||
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [...queue, queue[0]] })), null);
|
||||
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [{ installationId: queue[0].installationId, scope: "raw-token" }] })), null);
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
await f.controller.refresh();
|
||||
assert.deepEqual(f.record().pendingRevocations, []); assert.equal(f.controller.state().canEnable, true);
|
||||
});
|
||||
|
||||
test("in-flight cleanup cannot overwrite a newer tab's consent revision", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const entered = deferred(), release = deferred();
|
||||
let reads = 0;
|
||||
f.options.scope = async () => {
|
||||
if (++reads === 2) { entered.resolve(); await release.promise; }
|
||||
return "a".repeat(64);
|
||||
};
|
||||
const disabling = f.controller.disable(); await entered.promise;
|
||||
const newer = { ...f.record(), installationId: randomUUID(), revision: randomUUID(), enabled: true, revocationPending: false };
|
||||
f.options.write(newer); release.resolve(); await disabling;
|
||||
assert.deepEqual(f.record(), newer);
|
||||
});
|
||||
|
||||
test("principal scope changing while permission is pending prevents registration", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
});
|
||||
|
||||
test("authority changing during config fetch cannot enable against a mixed identity snapshot", async () => {
|
||||
const f = fixture({ api: { config: async () => { f.options.scope = async () => "b".repeat(64); return configuration(); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("same backend/token with a replaced source invalidates persisted consent on reload and requires a new opt-in", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const old = structuredClone(f.record());
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
||||
const reloaded = createAgentPushController(f.options);
|
||||
await reloaded.refresh(); await reloaded.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "source replacement cannot reuse old consent");
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === old.installationId));
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
await reloaded.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().sourceId, replacementSourceId);
|
||||
assert.equal(f.record().scope, old.scope); assert.notEqual(f.record().installationId, old.installationId);
|
||||
assert.equal(f.calls.filter(([name]) => name === "permission").length, 2, "replacement requires another explicit permission gesture");
|
||||
});
|
||||
|
||||
test("null or invalid source prevents new opt-in even when transport is configured", async () => {
|
||||
for (const unknown of [null, undefined, "not-a-source-uuid"]) {
|
||||
const f = fixture(); f.setConfig({ ...configuration(), sourceId: unknown });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null);
|
||||
assert.deepEqual(f.calls, [], "unknown source must not request permission, subscribe or register");
|
||||
}
|
||||
});
|
||||
|
||||
test("losing a known source retires old consent and never resumes it automatically", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.setConfig({ ...configuration(), sourceId: null });
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.controller.state().canEnable, false);
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
f.setConfig(configuration()); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
});
|
||||
|
||||
test("legacy or malformed source bindings retain installation identity for retirement, not re-enrollment", async () => {
|
||||
for (const invalid of [undefined, null, "invalid-source"]) {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
const stored = parseAgentPushConsent(JSON.stringify({ ...f.record(), sourceId: invalid }));
|
||||
assert.equal(stored.installationId, oldId); assert.equal(stored.sourceId, undefined);
|
||||
f.options.write(stored);
|
||||
await createAgentPushController(f.options).refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
}
|
||||
});
|
||||
|
||||
test("source change while permission is pending prevents subscription and retires the incomplete intent", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
||||
f.setPermission("granted"); prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
});
|
||||
|
||||
test("source change while subscribe is pending unsubscribes without registering", async () => {
|
||||
const entered = deferred(), release = deferred();
|
||||
const f = fixture({ platform: { subscribe: async () => {
|
||||
entered.resolve(); await release.promise;
|
||||
const subscription = f.makeSubscription(); f.setSubscription(subscription); return subscription;
|
||||
} } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 0);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("source change during initial registration rolls back instead of persisting enabled consent", async () => {
|
||||
const entered = deferred(), release = deferred(), registered = [], retired = [];
|
||||
const f = fixture({ api: {
|
||||
register: async id => { registered.push(id); entered.resolve(); await release.promise; },
|
||||
revoke: async id => { retired.push(id); },
|
||||
} });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.deepEqual(retired, registered); assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("source change during automatic rotation registration retires the source-bound installation", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId, entered = deferred(), release = deferred();
|
||||
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
||||
f.api.register = async id => { assert.equal(id, oldId); entered.resolve(); await release.promise; };
|
||||
const refreshing = f.controller.refresh(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await refreshing;
|
||||
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
});
|
||||
|
||||
test("source verification failure after registration fails closed and rolls back", async () => {
|
||||
const f = fixture({ api: { register: async () => { f.api.config = async () => { throw new Error("source unavailable"); }; } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name]) => name === "revoke"));
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("attention is per-tab, ordered, focused while idle, and recent input persists until acknowledged", async () => {
|
||||
const rows = [], failure = deferred(); let focus = true;
|
||||
const a = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: focus }), send: async value => { rows.push(["a", value]); } });
|
||||
const b = createAgentPresenceReporter({ snapshot: () => ({ visibility: "hidden", focused: true }), send: async value => { rows.push(["b", value]); } });
|
||||
await a.report(); await b.report();
|
||||
assert.equal(rows[0][1].focused, true); assert.equal(rows[0][1].interacted, false);
|
||||
assert.equal(rows[1][1].focused, false); assert.equal(rows[1][1].sequence, 1);
|
||||
a.interact(); focus = false; await a.report(); await a.report();
|
||||
assert.equal(rows[2][1].interacted, true); assert.equal(rows[2][1].focused, false);
|
||||
assert.equal(rows[3][1].interacted, false); assert.equal(rows[3][1].sequence, 3);
|
||||
let attempts = 0;
|
||||
const c = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: false }), send: async value => {
|
||||
assert.equal(value.interacted, true); if (++attempts === 1) return failure.promise;
|
||||
} });
|
||||
c.interact(); const report = c.report(); failure.reject(new Error("offline")); await assert.rejects(report); await c.report();
|
||||
const id = randomUUID(), frame = JSON.parse(agentAuthFrame("fixture-token", "2", id));
|
||||
assert.equal(frame.clientKind, "web"); assert.equal(frame.presenceSessionId, id);
|
||||
assert.equal(JSON.parse(agentAuthFrame(null, "0", "not-an-id")).clientKind, undefined);
|
||||
});
|
||||
|
||||
test("read frames validate source/cursor and preserve existing read semantics", () => {
|
||||
const event = parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "9", updatedAt: null, sourceId }));
|
||||
assert.equal(applyAgentEvent(createAgentRoomState(), event).readCursor, "9");
|
||||
for (const cursor of ["-1", "1e5", "9007199254740992", "1".repeat(200)]) {
|
||||
assert.equal(agentPushCursor(cursor), false);
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor, updatedAt: null, sourceId })));
|
||||
}
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
||||
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
||||
});
|
||||
|
||||
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
||||
const id = randomUUID(), requests = []; let mode = "config";
|
||||
const api = new AgentPushClient({ baseUrl: "/api", token: () => "fixture-bearer", fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
if (mode === "revoke") return new Response(null, { status: 204 });
|
||||
if (mode === "fail") return new Response(JSON.stringify({ error: "private-token-and-endpoint" }), { status: 500 });
|
||||
if (mode === "register") return Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
||||
if (mode === "read") return Response.json({ cursor: "0008", sourceId });
|
||||
return Response.json(configuration());
|
||||
} });
|
||||
await api.config(); mode = "register"; await api.register(id, { endpoint: "https://provider.test/endpoint-secret" });
|
||||
mode = "read"; assert.deepEqual(await api.readState(), { sourceId, cursor: "8" });
|
||||
mode = "revoke"; await api.revoke(id);
|
||||
mode = "fail"; await assert.rejects(api.revoke(id), error => !/private-token/.test(error.message));
|
||||
for (const request of requests) {
|
||||
assert.equal(request.init.headers.Authorization, "Bearer fixture-bearer");
|
||||
assert.equal(request.init.cache, "no-store"); assert.equal(request.init.redirect, "error");
|
||||
assert.doesNotMatch(request.url, /fixture-bearer|endpoint-secret/);
|
||||
}
|
||||
await assert.rejects(api.revoke("../../bad"));
|
||||
const unauthenticated = new AgentPushClient({ baseUrl: "/api", token: () => null, fetch: () => { throw new Error("must not send"); } });
|
||||
await assert.rejects(unauthenticated.config(), /Sign in/);
|
||||
});
|
||||
|
||||
test("authority-guarded API refuses mismatched credentials and pins the captured bearer across async hashing", async () => {
|
||||
const id = randomUUID(), requests = [], baseUrl = "https://sybil.example.test/api/";
|
||||
const scope = await agentPushAuthorityScope(baseUrl, "original-fixture-token");
|
||||
assert.equal(scope, await agentPushAuthorityScope("/api", "original-fixture-token", "https://sybil.example.test/"));
|
||||
assert.match(scope, /^[a-f0-9]{64}$/); assert.equal(await agentPushAuthorityScope(baseUrl, null), null);
|
||||
let token = "rotated-fixture-token";
|
||||
const api = new AgentPushClient({ baseUrl, token: () => token, fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return init.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
||||
} });
|
||||
await assert.rejects(api.revoke(id, scope));
|
||||
await assert.rejects(api.register(id, { endpoint: "https://provider.test/fixture" }, scope));
|
||||
assert.deepEqual(requests, [], "wrong authority cannot send a mutation or falsely acknowledge cleanup");
|
||||
token = "original-fixture-token";
|
||||
const deleting = api.revoke(id, scope); token = "rotated-fixture-token"; await deleting;
|
||||
token = "original-fixture-token";
|
||||
const registering = api.register(id, { endpoint: "https://provider.test/fixture" }, scope); token = "rotated-fixture-token"; await registering;
|
||||
assert.equal(requests.length, 2);
|
||||
assert.ok(requests.every(({ init }) => init.headers.Authorization === "Bearer original-fixture-token"));
|
||||
assert.ok(requests.every(({ url }) => !url.includes("fixture-token")));
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
|
||||
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
||||
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
||||
} from "../src/lib/agent-room.ts";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
@@ -16,6 +16,9 @@ const page = (cursor, messages = [], hasMore = false) => ({
|
||||
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
|
||||
});
|
||||
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
|
||||
const sourceA = "4599d348-e053-4937-987d-1392746fb2ce", sourceB = "22222222-2222-4222-8222-222222222222";
|
||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||
const sourceRoom = () => applyAgentPage(applyAgentReadState(createAgentRoomState(), { sourceId: sourceA, cursor: "90", updatedAt: null }), page("100", [message(100)]));
|
||||
|
||||
test("tool starts and results merge across replay without downgrading completed cards", () => {
|
||||
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
|
||||
@@ -113,6 +116,93 @@ test("read acknowledgments coalesce concurrent seen snapshots and reject unackno
|
||||
await assert.rejects(broken.request("2"), /did not acknowledge/);
|
||||
});
|
||||
|
||||
test("source replacement through GET or WS quiesces queued old-history reads without relabeling their cursor", async () => {
|
||||
for (const via of ["GET", "WS"]) {
|
||||
let room = sourceRoom(); const saved = [], invalidated = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); throw new Error("temporarily offline"); },
|
||||
apply: read => { room = applyAgentReadState(room, read); }, invalidated: error => invalidated.push(error),
|
||||
});
|
||||
await assert.rejects(session.request("100", sourceA), /temporarily offline/);
|
||||
const replacement = { cursor: "0", sourceId: sourceB, updatedAt: null };
|
||||
const incoming = via === "WS" ? parseAgentRoomEvent(JSON.stringify({ type: "read_state", ...replacement })) : replacement;
|
||||
assert.throws(() => session.receive(incoming), AgentReadSourceChangedError);
|
||||
for (let i = 0; i < 3; i++) await session.flush();
|
||||
await assert.rejects(session.request("100", sourceA), AgentReadSourceChangedError);
|
||||
await assert.rejects(session.request("5", sourceB), AgentReadSourceChangedError);
|
||||
assert.deepEqual(saved, [{ cursor: "100", sourceId: sourceA }], `${via}: no write to B and no retry loop`);
|
||||
assert.equal(room.sourceId, sourceA); assert.equal(room.readCursor, "90"); assert.equal(room.cursor, "100");
|
||||
assert.equal(session.sourceId(), sourceA); assert.equal(invalidated.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("source replacement cancels an in-flight old read and prevents a coalesced larger write", async () => {
|
||||
let room = sourceRoom(); const gate = deferred(), saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return gate.promise; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
const first = session.request("100", sourceA), second = session.request("110", sourceA);
|
||||
assert.throws(() => session.receive({ cursor: "0", sourceId: sourceB, updatedAt: null }), AgentReadSourceChangedError);
|
||||
gate.resolve({ cursor: "100", sourceId: sourceA, updatedAt: null });
|
||||
await Promise.all([first, second]); await session.flush();
|
||||
assert.deepEqual(saved, [{ cursor: "100", sourceId: sourceA }]);
|
||||
assert.equal(room.sourceId, sourceA); assert.equal(room.readCursor, "90");
|
||||
});
|
||||
|
||||
test("mismatched acknowledgment and server409 stop retries without applying a read to new history", async () => {
|
||||
for (const mode of ["ack", "409"]) {
|
||||
let room = sourceRoom(), saves = 0, invalidated = 0;
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async () => { saves++; if (mode === "409") throw Object.assign(new Error("cursor not valid for this source"), { status: 409 });
|
||||
return { cursor: "100", sourceId: sourceB, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); }, invalidated: () => invalidated++,
|
||||
});
|
||||
await assert.rejects(session.request("100", sourceA), AgentReadSourceChangedError);
|
||||
await session.flush(); await session.flush();
|
||||
await assert.rejects(session.request("101", sourceA), AgentReadSourceChangedError);
|
||||
assert.equal(saves, 1); assert.equal(invalidated, 1); assert.equal(room.readCursor, "90"); assert.equal(room.sourceId, sourceA);
|
||||
}
|
||||
});
|
||||
|
||||
test("read writer stops after an ignored acknowledgment instead of repeating the same PUT forever", async () => {
|
||||
let saves = 0;
|
||||
const writer = createAgentReadWriter({ current: () => "0", save: async cursor => { saves++; return { cursor, updatedAt: null }; }, apply: () => {} });
|
||||
await assert.rejects(writer.request("2"), /did not acknowledge/);
|
||||
await writer.flush(); await writer.request("3"); assert.equal(saves, 1);
|
||||
});
|
||||
|
||||
test("legacy source-absent sessions stay compatible but cannot adopt a source after rendering old history", async () => {
|
||||
let room = applyAgentPage(createAgentRoomState(), page("5", [message(5)])); const saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return { cursor, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
session.receive({ cursor: "0", updatedAt: null });
|
||||
assert.equal(room.sourceId, null);
|
||||
await session.request("5", room.sourceId);
|
||||
assert.deepEqual(saved, [{ cursor: "5", sourceId: null }]); assert.equal(room.readCursor, "5");
|
||||
assert.throws(() => session.receive({ cursor: "0", sourceId: sourceB, updatedAt: null }), AgentReadSourceChangedError);
|
||||
await session.flush(); assert.equal(saved.length, 1);
|
||||
assert.throws(() => applyAgentReadState(applyAgentPage(createAgentRoomState(), page("5", [message(5)])), { cursor: "0", sourceId: sourceA, updatedAt: null }), AgentReadSourceChangedError);
|
||||
assert.throws(() => applyAgentReadState(sourceRoom(), { cursor: "0", updatedAt: null }), AgentReadSourceChangedError);
|
||||
});
|
||||
|
||||
test("rendered snapshot source must match the pinned session and new sessions cannot read before binding", async () => {
|
||||
for (const initial of [createAgentRoomState(), sourceRoom()]) {
|
||||
const session = createAgentReadSession({ initial, save: async () => assert.fail("must not send unbound or relabeled reads"), apply: () => {} });
|
||||
await assert.rejects(session.request("100", sourceB), AgentReadSourceChangedError); await session.flush();
|
||||
}
|
||||
let room = createAgentRoomState(); const saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return { cursor, sourceId, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
session.receive({ cursor: "0", sourceId: sourceA, updatedAt: null });
|
||||
room = applyAgentPage(room, page("5", [message(5)])); await session.request(room.cursor, room.sourceId);
|
||||
assert.deepEqual(saved, [{ cursor: "5", sourceId: sourceA }]); assert.equal(room.readCursor, "5");
|
||||
});
|
||||
|
||||
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
|
||||
let stored = [], writes = 0;
|
||||
const storage = { read: () => structuredClone(stored), write: rows => {
|
||||
@@ -168,6 +258,18 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
|
||||
});
|
||||
|
||||
test("client sends the pinned source and exposes409 without requiring a JSON response body", async () => {
|
||||
for (const json of [true, false]) {
|
||||
let body;
|
||||
const client = new AgentRoomClient({ fetch: async (_url, init) => {
|
||||
body = JSON.parse(init.body);
|
||||
return json ? Response.json({ message: "Read source changed" }, { status: 409 }) : new Response(null, { status: 409 });
|
||||
} });
|
||||
await assert.rejects(client.markRead("100", undefined, sourceA), error => error.status === 409);
|
||||
assert.deepEqual(body, { cursor: "100", sourceId: sourceA });
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
|
||||
const requests = [];
|
||||
let response = {};
|
||||
|
||||
Reference in New Issue
Block a user