feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s
TestFlight / Build and upload (push) Successful in 1m53s
This commit is contained in:
+11
-5
@@ -17,6 +17,8 @@ self.addEventListener("fetch", (event) => {
|
||||
// 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 bytes = value => new TextEncoder().encode(value).length;
|
||||
const preview = value => typeof value === "string" && value.trim().length > 0 && bytes(value) <= 3000;
|
||||
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);
|
||||
@@ -67,20 +69,24 @@ async function announce(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; }
|
||||
try { const raw = event.data?.text(); if (!raw || bytes(raw) > 3993) 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;
|
||||
|| value.title !== PUSH_TITLE || !preview(value.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 : {
|
||||
const stale = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor);
|
||||
const identity = stale ? previous.latest : {
|
||||
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
|
||||
};
|
||||
// Keep the displayed text paired with the retained identity across late pushes/restarts.
|
||||
// Older workers persisted identity only, so their retained notifications use the fallback.
|
||||
const body = stale ? preview(previous.latestBody) ? previous.latestBody : PUSH_BODY : value.body;
|
||||
await self.registration.showNotification(PUSH_TITLE, {
|
||||
body: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
|
||||
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 save({ ...previous, sourceId: identity.sourceId, latest: identity, latestBody: body }).catch(() => undefined);
|
||||
await announce("sybil-push-shown");
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
|
||||
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>Notifications include Agent message text and may appear on your lock screen.</p>
|
||||
<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">
|
||||
|
||||
@@ -135,7 +135,7 @@ export function createAgentPushController(options: {
|
||||
: 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."
|
||||
: enabled ? "Notifications are on for this browser."
|
||||
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
|
||||
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ function fixture(sharedRecords = new Map()) {
|
||||
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt,
|
||||
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt, TextEncoder,
|
||||
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);
|
||||
@@ -58,9 +58,9 @@ function fixture(sharedRecords = new Map()) {
|
||||
};
|
||||
}
|
||||
|
||||
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.");
|
||||
test("actual worker push handler displays supplied public message text and never fetches credentials", async () => {
|
||||
const f = fixture(); await f.push(payload("5", { body: "Your report is ready. 日本語 🌧️" }));
|
||||
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "Your report is ready. 日本語 🌧️");
|
||||
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"));
|
||||
@@ -69,22 +69,36 @@ test("actual worker push handler shows fixed generic notification and never fetc
|
||||
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"));
|
||||
await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("5", { body: "Older reply" }));
|
||||
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
|
||||
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
|
||||
assert.ok(f.shown.every(notification => notification.body === "Latest reply"));
|
||||
const restarted = fixture(records); await restarted.push(payload("6"));
|
||||
assert.equal(restarted.shown[0].data.cursor, "8");
|
||||
assert.equal(restarted.shown[0].body, "Latest reply");
|
||||
assert.equal(records.get(sourceId).readCursor, "10");
|
||||
});
|
||||
|
||||
test("malformed/unexpected payloads cannot supply text, destinations, cross-source tags or silent read pushes", async () => {
|
||||
test("malformed/unexpected payloads cannot supply invalid bodies, 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" }),
|
||||
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: { text: "invalid" } }),
|
||||
payload("1", { body: " " }), payload("1", { body: "界".repeat(1001) }), payload("1", { body: "x".repeat(3001) }),
|
||||
payload("1", { body: "界".repeat(900), extra: "界".repeat(500) }),
|
||||
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("worker accepts bounded text, renders HTML literally and remains compatible with old generic pushes and stored identities", async () => {
|
||||
const f = fixture();
|
||||
for (const body of ["界".repeat(1000), "🌧".repeat(750), '<b>public literal text</b>', "You have a new message."]) {
|
||||
await f.push(payload("9", { body })); assert.equal(f.shown.at(-1).body, body);
|
||||
}
|
||||
const records = new Map([[sourceId, { sourceId, latest: payload("10") }]]);
|
||||
const upgraded = fixture(records); await upgraded.push(payload("8", { body: "Earlier reply" }));
|
||||
assert.equal(upgraded.shown[0].data.cursor, "10"); assert.equal(upgraded.shown[0].body, "You have a new message.");
|
||||
});
|
||||
|
||||
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") });
|
||||
|
||||
Reference in New Issue
Block a user