rastrillo / aviso Public

Add the service-worker helper

Every push ends in a visible notification, with the app's required
fallback covering payloads the decoder cannot read: WebKit revokes push
for a worker that receives silently. Click URLs are validated to the
app's origin wherever they come from, including custom decoders and
the app's own fallbackURL. A subscription change fetches the public
key on demand — a terminated worker forgets its variables — performs
the renewal itself, builds the full Subscribe body, and saves once,
failing silently: a worker without a session has nothing to retry
with, and the page repairs on the next open after sign-in.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev c0b4a14620364de60a797c3a6a98ecef75a8e560 parent d0b4a44
3 files changed, +279 −2
  • js/aviso-sw.js +127 −2
  • js/aviso-sw.test.mjs +148 −0
  • js_test.go +4 −0
diff --git a/js/aviso-sw.js b/js/aviso-sw.js
index 9b3807c..4708b89 100644
--- a/js/aviso-sw.js
+++ b/js/aviso-sw.js
@@ -1,2 +1,127 @@
-// aviso-sw.js — the classic service-worker helper; filled in by Task 9.
-(function (root) { root.AvisoSW = {}; })(typeof self !== "undefined" ? self : globalThis);
+// Service-worker half of aviso, a classic script the app's own sw.js
+// loads with importScripts. It owns nothing about the worker's
+// lifecycle — no skipWaiting, no clients.claim, no listeners of its
+// own; the app attaches these handlers inside its listeners and passes
+// the result to event.waitUntil.
+(function (root) {
+ "use strict";
+
+ function toBytes(base64url) {
+ const pad = "=".repeat((4 - (base64url.length % 4)) % 4);
+ const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/");
+ const raw = atob(b64);
+ const out = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+ }
+
+ // validateURL admits only a root-relative path on the app's own
+ // origin: no scheme, no protocol-relative "//", no backslashes, and
+ // the resolved URL must still be on origin. Applied to the default
+ // decoder, custom decoder output and fallbackURL alike — a click
+ // must never navigate off the app. An absolute URL on the origin
+ // (what a previous handlePush stored) is reduced to its path first.
+ function validateURL(raw, origin) {
+ if (typeof raw !== "string" || raw === "") return null;
+ if (raw.indexOf(origin + "/") === 0) raw = raw.slice(origin.length);
+ if (raw[0] !== "/" || raw[1] === "/" || raw.indexOf("\\") !== -1) return null;
+ let u;
+ try { u = new URL(raw, origin); } catch (e) { return null; }
+ if (u.origin !== origin) return null;
+ return u.href;
+ }
+
+ // decodeDefault reads {title, body, url, tag}; title is required.
+ function decodeDefault(event, origin) {
+ if (!event.data) return null;
+ let p;
+ try { p = event.data.json(); } catch (e) { return null; }
+ if (!p || typeof p.title !== "string" || p.title === "") return null;
+ const options = { data: {} };
+ if (typeof p.body === "string") options.body = p.body;
+ if (typeof p.tag === "string") options.tag = p.tag;
+ if (p.url !== undefined) {
+ const href = validateURL(p.url, origin);
+ if (href) options.data.url = href;
+ }
+ return { title: p.title, options };
+ }
+
+ // handlePush always ends in a visible notification: WebKit revokes
+ // push for a worker that receives without showing, so a payload the
+ // decoder cannot read shows the app's fallback rather than nothing.
+ async function handlePush(event, opts) {
+ const origin = root.location.origin;
+ let n = null;
+ try {
+ n = opts && opts.decode ? await opts.decode(event) : decodeDefault(event, origin);
+ } catch (e) { n = null; }
+ if (!n || typeof n.title !== "string" || n.title === "") {
+ if (!opts || typeof opts.fallback !== "function") {
+ throw new Error("aviso: fallback is required");
+ }
+ n = await opts.fallback(event);
+ }
+ const options = Object.assign({}, n.options || {});
+ options.data = Object.assign({}, options.data || {});
+ if (options.data.url !== undefined) {
+ const href = validateURL(options.data.url, origin);
+ if (href) options.data.url = href; else delete options.data.url;
+ }
+ return root.registration.showNotification(n.title, options);
+ }
+
+ // handleClick closes the notification, focuses a window already at
+ // the destination, else opens it, else the fallback.
+ async function handleClick(event, opts) {
+ const origin = root.location.origin;
+ event.notification.close();
+ const data = event.notification.data || {};
+ let href = data.url ? validateURL(data.url, origin) : null;
+ if (!href && opts && opts.fallbackURL) href = validateURL(opts.fallbackURL, origin);
+ if (!href) return;
+ const all = await root.clients.matchAll({ type: "window", includeUncontrolled: true });
+ for (const c of all) {
+ if (c.url === href && "focus" in c) return c.focus();
+ }
+ return root.clients.openWindow(href);
+ }
+
+ // handleSubscriptionChange renews and saves. A worker forgets its
+ // variables when it is terminated, so the key is fetched on demand
+ // through `publicKey()`; the helper subscribes and builds the full
+ // Subscribe body itself, and the app's `save(body)` only posts.
+ // Saving can fail — an installed app with no live session — and then
+ // it fails silently: reconcile() repairs on the next page open after
+ // sign-in. Nothing here retries and nothing here prompts.
+ async function handleSubscriptionChange(event, opts) {
+ if (!opts || typeof opts.publicKey !== "function" || typeof opts.save !== "function") {
+ throw new Error("aviso: publicKey() and save() are required");
+ }
+ let key;
+ try { key = await opts.publicKey(); } catch (e) { return false; }
+ if (typeof key !== "string" || key === "") return false;
+ let sub = event.newSubscription || null;
+ if (!sub) {
+ try {
+ sub = await root.registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: toBytes(key),
+ });
+ } catch (e) { sub = null; }
+ }
+ if (!sub) return false;
+ const json = sub.toJSON();
+ const body = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey: key };
+ const old = event.oldSubscription;
+ if (old && old.endpoint && old.endpoint !== json.endpoint) body.previousEndpoint = old.endpoint;
+ try {
+ const resp = await opts.save(body);
+ return !(resp && typeof resp === "object" && resp.ok === false);
+ } catch (e) {
+ return false;
+ }
+ }
+
+ root.AvisoSW = { handlePush, handleClick, handleSubscriptionChange, validateURL };
+})(typeof self !== "undefined" ? self : globalThis);
diff --git a/js/aviso-sw.test.mjs b/js/aviso-sw.test.mjs
new file mode 100644
index 0000000..68c749d
--- /dev/null
+++ b/js/aviso-sw.test.mjs
@@ -0,0 +1,148 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { generateKeyPairSync } from "node:crypto";
+
+globalThis.self = globalThis;
+globalThis.location = { origin: "https://app.example" };
+await import("./aviso-sw.js");
+const { AvisoSW } = globalThis;
+
+// A real P-256 point: a browser's subscribe() rejects anything else.
+const { publicKey: pk } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
+const jwk = pk.export({ format: "jwk" });
+const KEY_BYTES = Uint8Array.from([4, ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url")]);
+const KEY = Buffer.from(KEY_BYTES).toString("base64url");
+
+function rig() {
+ const shown = [], opened = [], subscribed = [];
+ globalThis.registration = {
+ async showNotification(title, options) { shown.push({ title, options }); },
+ pushManager: {
+ async subscribe(opts) {
+ assert.equal(opts.userVisibleOnly, true);
+ assert.deepEqual(Array.from(opts.applicationServerKey), Array.from(KEY_BYTES));
+ const s = { endpoint: "https://push.example/renewed", toJSON() { return { endpoint: this.endpoint, expirationTime: null, keys: { p256dh: "P", auth: "A" } }; } };
+ subscribed.push(s);
+ return s;
+ },
+ },
+ };
+ globalThis.clients = {
+ windows: [],
+ async matchAll() { return this.windows; },
+ async openWindow(u) { opened.push(u); },
+ };
+ return { shown, opened, subscribed };
+}
+
+function pushEvent(payload) {
+ return { data: payload === undefined ? null : { json() { return JSON.parse(payload); } } };
+}
+
+test("validateURL admits only root-relative same-origin paths", () => {
+ const o = "https://app.example";
+ assert.equal(AvisoSW.validateURL("/inbox?x=1", o), "https://app.example/inbox?x=1");
+ assert.equal(AvisoSW.validateURL("https://app.example/inbox", o), "https://app.example/inbox");
+ for (const bad of ["", "inbox", "//evil.example/x", "https://evil.example/x", "/a\\b", "javascript:alert(1)", "https://app.example.evil/x", 42, null]) {
+ assert.equal(AvisoSW.validateURL(bad, o), null, String(bad));
+ }
+});
+
+test("handlePush shows the default payload and validates url", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"title":"Hi","body":"b","url":"/inbox","tag":"t"}'), { fallback: () => ({ title: "fb" }) });
+ assert.equal(r.shown.length, 1);
+ assert.equal(r.shown[0].title, "Hi");
+ assert.equal(r.shown[0].options.body, "b");
+ assert.equal(r.shown[0].options.tag, "t");
+ assert.equal(r.shown[0].options.data.url, "https://app.example/inbox");
+});
+
+test("handlePush drops an off-origin url but still shows", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"title":"Hi","url":"https://evil.example/"}'), { fallback: () => ({ title: "fb" }) });
+ assert.equal(r.shown[0].title, "Hi");
+ assert.equal("url" in r.shown[0].options.data, false);
+});
+
+test("handlePush falls back on malformed or missing payload", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"nope":1}'), { fallback: () => ({ title: "fb", options: { data: { url: "/" } } }) });
+ await AvisoSW.handlePush(pushEvent(undefined), { fallback: () => ({ title: "fb2" }) });
+ await AvisoSW.handlePush({ data: { json() { throw new Error("not json"); } } }, { fallback: () => ({ title: "fb3" }) });
+ assert.deepEqual(r.shown.map((s) => s.title), ["fb", "fb2", "fb3"]);
+ assert.equal(r.shown[0].options.data.url, "https://app.example/");
+});
+
+test("handlePush requires a fallback", async () => {
+ rig();
+ await assert.rejects(AvisoSW.handlePush(pushEvent('{"x":1}'), {}), /fallback is required/);
+});
+
+test("handlePush uses a custom decoder and validates its url; a throwing decoder falls back", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
+ decode: async () => ({ title: "Custom", options: { data: { url: "//evil.example/" } } }),
+ fallback: () => ({ title: "fb" }),
+ });
+ assert.equal(r.shown[0].title, "Custom");
+ assert.equal("url" in r.shown[0].options.data, false);
+ await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
+ decode: async () => { throw new Error("cannot decrypt"); },
+ fallback: () => ({ title: "fb" }),
+ });
+ assert.equal(r.shown[1].title, "fb");
+});
+
+test("handleClick focuses a matching window, else opens, else fallback, never off-origin", async () => {
+ const r = rig();
+ let focusedURL = null;
+ globalThis.clients.windows = [{ url: "https://app.example/inbox", async focus() { focusedURL = this.url; } }];
+ let closed = 0;
+ const ev = (url) => ({ notification: { close() { closed++; }, data: url === undefined ? {} : { url } } });
+ await AvisoSW.handleClick(ev("https://app.example/inbox"), {});
+ assert.equal(focusedURL, "https://app.example/inbox");
+ await AvisoSW.handleClick(ev("/other"), {});
+ assert.deepEqual(r.opened, ["https://app.example/other"]);
+ await AvisoSW.handleClick(ev(undefined), { fallbackURL: "/" });
+ assert.deepEqual(r.opened, ["https://app.example/other", "https://app.example/"]);
+ await AvisoSW.handleClick(ev("https://evil.example/"), {});
+ await AvisoSW.handleClick(ev("https://evil.example/"), { fallbackURL: "//evil.example/" });
+ assert.equal(r.opened.length, 2);
+ assert.equal(closed, 5);
+});
+
+test("handleSubscriptionChange saves a new subscription with a fetched key", async () => {
+ rig();
+ const saved = [];
+ const sub = { endpoint: "https://push.example/new", toJSON() { return { endpoint: this.endpoint, expirationTime: 1, keys: { p256dh: "P", auth: "A" } }; } };
+ const ok = await AvisoSW.handleSubscriptionChange(
+ { newSubscription: sub, oldSubscription: { endpoint: "https://push.example/old" } },
+ { publicKey: async () => KEY, save: async (b) => { saved.push(b); return { ok: true }; } },
+ );
+ assert.equal(ok, true);
+ assert.deepEqual(saved[0], {
+ subscription: { endpoint: "https://push.example/new", keys: { p256dh: "P", auth: "A" } },
+ publicKey: KEY,
+ previousEndpoint: "https://push.example/old",
+ });
+});
+
+test("handleSubscriptionChange renews itself when the event carries no subscription", async () => {
+ const r = rig();
+ const saved = [];
+ const ok = await AvisoSW.handleSubscriptionChange({}, { publicKey: async () => KEY, save: async (b) => { saved.push(b); } });
+ assert.equal(ok, true);
+ assert.equal(r.subscribed.length, 1);
+ assert.equal(saved[0].subscription.endpoint, "https://push.example/renewed");
+ assert.equal("previousEndpoint" in saved[0], false);
+});
+
+test("handleSubscriptionChange fails silently on an expired session, a failed key fetch, or a throw", async () => {
+ rig();
+ const sub = { endpoint: "e", toJSON() { return { endpoint: "e", keys: {} }; } };
+ assert.equal(await AvisoSW.handleSubscriptionChange({ newSubscription: sub }, { publicKey: async () => KEY, save: async () => ({ ok: false, status: 401 }) }), false);
+ assert.equal(await AvisoSW.handleSubscriptionChange({ newSubscription: sub }, { publicKey: async () => { throw new Error("offline"); }, save: async () => ({ ok: true }) }), false);
+ assert.equal(await AvisoSW.handleSubscriptionChange({ newSubscription: sub }, { publicKey: async () => KEY, save: async () => { throw new Error("net"); } }), false);
+ await assert.rejects(AvisoSW.handleSubscriptionChange({ newSubscription: sub }, {}), /publicKey\(\) and save\(\) are required/);
+});
diff --git a/js_test.go b/js_test.go
index f5d4223..5fc22e8 100644
--- a/js_test.go
+++ b/js_test.go
@@ -48,3 +48,7 @@ func runNodeTests(t *testing.T, testFile string, files map[string][]byte) {
func TestPushModule(t *testing.T) {
runNodeTests(t, "push.test.mjs", map[string][]byte{"push.mjs": JS()})
}
+
+func TestWorkerHelper(t *testing.T) {
+ runNodeTests(t, "aviso-sw.test.mjs", map[string][]byte{"aviso-sw.js": WorkerJS()})
+}