Skip to content

Commit a7e46d5

Browse files
committed
feat(fe): service worker pulls and renders notifications
Replaces the placeholder push worker with a bundled SvelteKit service worker (registered on opt-in, no fetch handler). On a ping it reads the sealed routing origin, loads that app's stored credential, resolves the dApp canister from the origin's discovery document, and pulls the pending notifications as the user's per-app identity, rendering each. With no usable credential or an unreachable canister it shows a generic, app-attributed notification; a click focuses or opens the app. The credential now carries the IC host so the worker can build an agent without the browser-only globals.
1 parent 5b70f37 commit a7e46d5

6 files changed

Lines changed: 288 additions & 27 deletions

File tree

src/frontend/src/lib/utils/notifications/enableNotifications.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { ActorSubclass } from "@icp-sdk/core/agent";
77
import type { _SERVICE } from "$lib/generated/internet_identity_types";
8+
import { agentOptions } from "$lib/globals";
89
import { throwTextCanisterError } from "$lib/utils/utils";
910
import { generateVapidKeypair, signJwtPool } from "./vapidPool";
1011
import {
@@ -64,6 +65,7 @@ export const enableNotifications = async ({
6465
identityNumber,
6566
accountNumber,
6667
origin,
68+
host: agentOptions.host ?? self.location.origin,
6769
actor,
6870
}),
6971
);

src/frontend/src/lib/utils/notifications/pullCredential.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,34 @@
77

88
import type { ActorSubclass } from "@icp-sdk/core/agent";
99
import type { _SERVICE } from "$lib/generated/internet_identity_types";
10-
import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity";
10+
import {
11+
DelegationChain,
12+
DelegationIdentity,
13+
ECDSAKeyIdentity,
14+
} from "@icp-sdk/core/identity";
1115
import { toPermissionsArg } from "$lib/utils/accessLevel";
1216
import {
1317
throwCanisterError,
1418
transformSignedDelegation,
1519
} from "$lib/utils/utils";
16-
import { createStore, del as idbDel, set as idbSet } from "idb-keyval";
20+
import {
21+
createStore,
22+
del as idbDel,
23+
get as idbGet,
24+
set as idbSet,
25+
} from "idb-keyval";
1726

1827
const CREDENTIAL_STORE = createStore("ii-notification-credentials", "keys");
28+
// The canister to pull from is a lookup off the origin's well-known, not a trust
29+
// anchor, so it's cached separately and refreshed on a TTL / on pull failure —
30+
// a canister-id change then self-heals without re-consent.
31+
const CANISTER_STORE = createStore("ii-notification-canisters", "keys");
1932

2033
export interface NotificationCredentialRecord {
2134
/** dApp origin, keyed on and also the routing target the SW pulls for. */
2235
origin: string;
36+
/** IC host the agent talks to, captured here because the SW can't read globals. */
37+
host: string;
2338
keyPair: CryptoKeyPair;
2439
chainJson: string;
2540
expiresAtMillis: number;
@@ -34,11 +49,13 @@ export const mintNotificationCredential = async ({
3449
identityNumber,
3550
accountNumber,
3651
origin,
52+
host,
3753
actor,
3854
}: {
3955
identityNumber: bigint;
4056
accountNumber?: bigint;
4157
origin: string;
58+
host: string;
4259
actor: ActorSubclass<_SERVICE>;
4360
}): Promise<NotificationCredentialRecord> => {
4461
const sessionIdentity = await ECDSAKeyIdentity.generate({
@@ -77,6 +94,7 @@ export const mintNotificationCredential = async ({
7794

7895
return {
7996
origin,
97+
host,
8098
keyPair: sessionIdentity.getKeyPair(),
8199
chainJson: JSON.stringify(chain.toJSON()),
82100
expiresAtMillis: Number(expiration / BigInt(1_000_000)),
@@ -87,5 +105,41 @@ export const storeNotificationCredential = (
87105
record: NotificationCredentialRecord,
88106
): Promise<void> => idbSet(record.origin, record, CREDENTIAL_STORE);
89107

90-
export const purgeNotificationCredential = (origin: string): Promise<void> =>
91-
idbDel(origin, CREDENTIAL_STORE);
108+
export const purgeNotificationCredential = async (
109+
origin: string,
110+
): Promise<void> => {
111+
await idbDel(origin, CREDENTIAL_STORE);
112+
await idbDel(origin, CANISTER_STORE);
113+
};
114+
115+
export interface CachedCanister {
116+
canisterId: string;
117+
resolvedAtMillis: number;
118+
}
119+
120+
/** The SW's cached `origin -> canister` resolution, if any. */
121+
export const loadCachedCanister = (
122+
origin: string,
123+
): Promise<CachedCanister | undefined> => idbGet(origin, CANISTER_STORE);
124+
125+
/** Records the canister resolved for `origin` from its well-known. */
126+
export const cacheCanister = (
127+
origin: string,
128+
canisterId: string,
129+
): Promise<void> =>
130+
idbSet(origin, { canisterId, resolvedAtMillis: Date.now() }, CANISTER_STORE);
131+
132+
/** The service worker's read side: the stored credential for `origin`, if any. */
133+
export const loadNotificationCredential = (
134+
origin: string,
135+
): Promise<NotificationCredentialRecord | undefined> =>
136+
idbGet(origin, CREDENTIAL_STORE);
137+
138+
/** Rebuilds the delegation identity the credential stands for. */
139+
export const notificationIdentity = async (
140+
record: NotificationCredentialRecord,
141+
): Promise<DelegationIdentity> => {
142+
const identity = await ECDSAKeyIdentity.fromKeyPair(record.keyPair);
143+
const chain = DelegationChain.fromJSON(JSON.parse(record.chainJson));
144+
return DelegationIdentity.fromDelegation(identity, chain);
145+
};

src/frontend/src/lib/utils/notifications/pushSubscription.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import { bufFromBufLike } from "$lib/utils/utils";
77

8-
const SERVICE_WORKER_URL = "/push-sw.js";
8+
// SvelteKit bundles the worker here; it is registered on opt-in rather than on
9+
// every load (kit.serviceWorker.register is off).
10+
const SERVICE_WORKER_URL = "/service-worker.js";
911

1012
export interface PushSubscriptionKeys {
1113
endpoint: string;
@@ -25,7 +27,9 @@ export const requestNotificationPermission = async (): Promise<boolean> =>
2527
(await Notification.requestPermission()) === "granted";
2628

2729
const registerServiceWorker = async (): Promise<ServiceWorkerRegistration> => {
28-
await navigator.serviceWorker.register(SERVICE_WORKER_URL);
30+
await navigator.serviceWorker.register(SERVICE_WORKER_URL, {
31+
type: "module",
32+
});
2933
return navigator.serviceWorker.ready;
3034
};
3135

src/frontend/src/service-worker.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/// <reference types="@sveltejs/kit" />
2+
/// <reference lib="webworker" />
3+
4+
// The push service worker. The browser decrypts II's sealed ping before `push`
5+
// fires, so the payload is just the routing origin (`{"o":"<origin>"}`). The
6+
// worker pulls the real content from the dApp as the user's per-app identity
7+
// and renders it; anything missing — no credential, an expired one, an
8+
// unreachable canister — falls back to a generic notification. It has no
9+
// `fetch` handler, so it never intercepts requests on the auth origin.
10+
11+
import { Actor, HttpAgent, type Identity } from "@icp-sdk/core/agent";
12+
import { IDL } from "@icp-sdk/core/candid";
13+
import { Principal } from "@icp-sdk/core/principal";
14+
import {
15+
cacheCanister,
16+
loadCachedCanister,
17+
loadNotificationCredential,
18+
notificationIdentity,
19+
} from "$lib/utils/notifications/pullCredential";
20+
21+
const sw = self as unknown as ServiceWorkerGlobalScope;
22+
23+
// Re-resolve origin -> canister at most this often; a stale entry refreshes
24+
// lazily on the next push (or immediately on a pull failure).
25+
const CANISTER_TTL_MS = 24 * 60 * 60 * 1000;
26+
// Bound resolve + pull so a slow dApp can't blow the push handler's budget; on
27+
// timeout the generic notification still shows.
28+
const PULL_TIMEOUT_MS = 8_000;
29+
30+
// Provisional pull interface: the dApp returns the pending notifications for
31+
// the authenticated caller. Finalized alongside the client crate.
32+
interface PulledNotification {
33+
title: string;
34+
body: [] | [string];
35+
}
36+
interface PullService {
37+
ii_pending_notifications: () => Promise<PulledNotification[]>;
38+
}
39+
const pullIdl: IDL.InterfaceFactory = ({ IDL }) =>
40+
IDL.Service({
41+
ii_pending_notifications: IDL.Func(
42+
[],
43+
[IDL.Vec(IDL.Record({ title: IDL.Text, body: IDL.Opt(IDL.Text) }))],
44+
["query"],
45+
),
46+
});
47+
48+
sw.addEventListener("install", () => sw.skipWaiting());
49+
sw.addEventListener("activate", (event) => event.waitUntil(sw.clients.claim()));
50+
51+
sw.addEventListener("push", (event) => {
52+
event.waitUntil(handlePush(event.data?.json()));
53+
});
54+
55+
sw.addEventListener("notificationclick", (event) => {
56+
const origin = (event.notification.data as { origin?: string } | null)
57+
?.origin;
58+
event.notification.close();
59+
event.waitUntil(openApp(origin));
60+
});
61+
62+
const handlePush = async (payload: unknown): Promise<void> => {
63+
const origin = originOf(payload);
64+
const pulled =
65+
origin === undefined
66+
? []
67+
: await withTimeout(pull(origin), PULL_TIMEOUT_MS, []);
68+
69+
if (pulled.length === 0) {
70+
await sw.registration.showNotification(appName(origin), {
71+
body: "You have a new notification.",
72+
icon: "/favicon.svg",
73+
tag: "ii-notification",
74+
data: { origin },
75+
});
76+
return;
77+
}
78+
await Promise.all(
79+
pulled.map((notification) =>
80+
sw.registration.showNotification(notification.title, {
81+
body: notification.body[0],
82+
icon: "/favicon.svg",
83+
data: { origin },
84+
}),
85+
),
86+
);
87+
};
88+
89+
const originOf = (payload: unknown): string | undefined => {
90+
if (payload !== null && typeof payload === "object" && "o" in payload) {
91+
const origin = (payload as { o: unknown }).o;
92+
return typeof origin === "string" ? origin : undefined;
93+
}
94+
return undefined;
95+
};
96+
97+
const appName = (origin: string | undefined): string => {
98+
if (origin === undefined) return "Internet Identity";
99+
try {
100+
return new URL(origin).host;
101+
} catch {
102+
return "Internet Identity";
103+
}
104+
};
105+
106+
const pull = async (origin: string): Promise<PulledNotification[]> => {
107+
const record = await loadNotificationCredential(origin);
108+
if (record === undefined || record.expiresAtMillis <= Date.now()) {
109+
return [];
110+
}
111+
const identity = await notificationIdentity(record);
112+
const canisterId = await resolveCanister(origin, false);
113+
if (canisterId === undefined) {
114+
return [];
115+
}
116+
try {
117+
return await pullFrom(canisterId, identity, record.host);
118+
} catch {
119+
// The cached canister may be stale (the dApp changed its id). Re-resolve
120+
// from the well-known and retry once, but only if it actually moved.
121+
const fresh = await resolveCanister(origin, true);
122+
if (fresh === undefined || fresh.toText() === canisterId.toText()) {
123+
return [];
124+
}
125+
return pullFrom(fresh, identity, record.host).catch(() => []);
126+
}
127+
};
128+
129+
const pullFrom = async (
130+
canisterId: Principal,
131+
identity: Identity,
132+
host: string,
133+
): Promise<PulledNotification[]> => {
134+
const agent = await HttpAgent.create({
135+
identity,
136+
host,
137+
shouldFetchRootKey: isLocalHost(host),
138+
});
139+
const actor = Actor.createActor<PullService>(pullIdl, { agent, canisterId });
140+
return actor.ii_pending_notifications();
141+
};
142+
143+
// The canister comes from the consented origin's own well-known, never from the
144+
// ping (a forged ping can only name an origin II already sealed for). It's cached
145+
// and reused within a TTL; a stale entry is re-resolved here or on a pull failure.
146+
const resolveCanister = async (
147+
origin: string,
148+
forceRefresh: boolean,
149+
): Promise<Principal | undefined> => {
150+
if (!forceRefresh) {
151+
const cached = await loadCachedCanister(origin);
152+
if (
153+
cached !== undefined &&
154+
cached.resolvedAtMillis + CANISTER_TTL_MS > Date.now()
155+
) {
156+
return principalOf(cached.canisterId);
157+
}
158+
}
159+
const fetched = await fetchSenderCanister(origin);
160+
if (fetched !== undefined) {
161+
await cacheCanister(origin, fetched.toText());
162+
}
163+
return fetched;
164+
};
165+
166+
const fetchSenderCanister = async (
167+
origin: string,
168+
): Promise<Principal | undefined> => {
169+
try {
170+
const response = await fetch(
171+
`${origin}/.well-known/ii-notification-senders`,
172+
);
173+
if (!response.ok) return undefined;
174+
const doc: unknown = await response.json();
175+
const senders =
176+
doc !== null && typeof doc === "object" && "senders" in doc
177+
? (doc as { senders: unknown }).senders
178+
: undefined;
179+
const first = Array.isArray(senders) ? senders[0] : undefined;
180+
return typeof first === "string" ? principalOf(first) : undefined;
181+
} catch {
182+
return undefined;
183+
}
184+
};
185+
186+
const principalOf = (text: string): Principal | undefined => {
187+
try {
188+
return Principal.fromText(text);
189+
} catch {
190+
return undefined;
191+
}
192+
};
193+
194+
// Resolves to `fallback` if `promise` rejects or outlives `ms` — a slow or
195+
// failing pull must never leave the push handler without a notification.
196+
const withTimeout = <T>(
197+
promise: Promise<T>,
198+
ms: number,
199+
fallback: T,
200+
): Promise<T> =>
201+
Promise.race([
202+
promise.catch(() => fallback),
203+
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms)),
204+
]);
205+
206+
const isLocalHost = (host: string): boolean =>
207+
host.includes("localhost") || host.includes("127.0.0.1");
208+
209+
const openApp = async (origin: string | undefined): Promise<void> => {
210+
const url = origin ?? "/";
211+
const windows = await sw.clients.matchAll({ type: "window" });
212+
const existing = windows.find((client) => client.url.startsWith(url));
213+
if (existing !== undefined) {
214+
await existing.focus();
215+
return;
216+
}
217+
await sw.clients.openWindow(url);
218+
};

src/frontend/static/push-sw.js

Lines changed: 0 additions & 21 deletions
This file was deleted.

svelte.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ const config = {
1919
lib: "src/frontend/src/lib",
2020
routes: "src/frontend/src/routes",
2121
assets: "src/frontend/static",
22+
serviceWorker: "src/frontend/src/service-worker",
2223
hooks: {
2324
client: "src/frontend/src/hooks.client",
2425
server: "src/frontend/src/hooks.server",
2526
universal: "src/frontend/src/hooks",
2627
},
2728
},
29+
// The push worker is registered explicitly at opt-in (not on every load),
30+
// so SvelteKit only bundles it.
31+
serviceWorker: { register: false },
2832
// The OpenID provider's `response_mode=form_post` callback is a
2933
// cross-origin form POST to /callback, which SvelteKit's CSRF origin
3034
// check would reject with a 403 before the server hook that forwards it

0 commit comments

Comments
 (0)