Add the browser module and its node tests
enable prompts synchronously inside the gesture; reconcile never
prompts and re-saves on every load so the server's confirmation moves;
disable removes the server row before the browser subscription so a
crash between the two cannot leave a row that sends to nothing. The
save body is projected to endpoint and keys rather than forwarding
toJSON() whole. Callbacks may resolve to nothing; only a rejection or
an {ok: false} return counts as failure. Tests run from go test
against the embedded bytes, with a real 65-byte point as the key so
the same-key comparison round-trips. aviso-sw.js is a placeholder the
next commit fills in; it exists so the embed compiles.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+384
−0
js.go+17 −0js/aviso-sw.js+2 −0js/push.mjs+139 −0js/push.test.mjs+176 −0js_test.go+50 −0
diff --git a/js.go b/js.go| new file mode 100644 |
| index 0000000..6f536d7 |
| --- /dev/null |
| +++ b/js.go |
| @@ -0,0 +1,17 @@ |
| +package aviso |
| + |
| +import _ "embed" |
| + |
| +//go:embed js/push.mjs |
| +var pushJS []byte |
| + |
| +//go:embed js/aviso-sw.js |
| +var workerJS []byte |
| + |
| +// JS is the browser module (js/push.mjs), for the app to serve as a |
| +// static asset and import from its page script. |
| +func JS() []byte { return pushJS } |
| + |
| +// WorkerJS is the classic service-worker helper (js/aviso-sw.js), for |
| +// the app to serve and load from its own sw.js via importScripts. |
| +func WorkerJS() []byte { return workerJS } |
diff --git a/js/aviso-sw.js b/js/aviso-sw.js| new file mode 100644 |
| index 0000000..9b3807c |
| --- /dev/null |
| +++ b/js/aviso-sw.js |
| @@ -0,0 +1,2 @@ |
| +// aviso-sw.js — the classic service-worker helper; filled in by Task 9. |
| +(function (root) { root.AvisoSW = {}; })(typeof self !== "undefined" ? self : globalThis); |
diff --git a/js/push.mjs b/js/push.mjs| new file mode 100644 |
| index 0000000..7bd3f26 |
| --- /dev/null |
| +++ b/js/push.mjs |
| @@ -0,0 +1,139 @@ |
| +// Browser half of aviso: enrol this device for push against the app's |
| +// server. The app supplies `save` and `remove` — same-origin fetches |
| +// to the Subscribe and Unsubscribe handlers — and owns the service |
| +// worker registration. Nothing here prompts except `enable`, and it |
| +// prompts synchronously inside the caller's gesture, because a prompt |
| +// after an await is denied by browsers and resented by people. |
| +// |
| +// Callback contract: `save(body)` and `remove(body)` may resolve to |
| +// anything. A rejection, or a resolved value shaped like a failed |
| +// Response ({ok: false}), counts as failure; anything else — including |
| +// undefined from a callback that checked its own response — is success. |
| + |
| +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; |
| +} |
| + |
| +function toBase64url(buf) { |
| + let s = ""; |
| + const bytes = new Uint8Array(buf); |
| + for (const b of bytes) s += String.fromCharCode(b); |
| + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| +} |
| + |
| +// sameKey reports whether an existing subscription was made under the |
| +// server's current key. A rotated key means every browser re-enrols. |
| +function sameKey(subscription, publicKey) { |
| + const key = subscription.options && subscription.options.applicationServerKey; |
| + if (!key) return false; |
| + return toBase64url(key) === publicKey; |
| +} |
| + |
| +// body projects the browser's subscription to exactly what Subscribe |
| +// reads — endpoint and keys — rather than forwarding toJSON() whole. |
| +function body(subscription, publicKey, previousEndpoint) { |
| + const json = subscription.toJSON(); |
| + const out = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey }; |
| + if (previousEndpoint) out.previousEndpoint = previousEndpoint; |
| + return out; |
| +} |
| + |
| +function failed(resp) { |
| + return resp && typeof resp === "object" && resp.ok === false; |
| +} |
| + |
| +async function persist(save, payload) { |
| + const resp = await save(payload); |
| + if (failed(resp)) { |
| + throw new Error("aviso: save rejected: " + resp.status); |
| + } |
| +} |
| + |
| +// capabilities reports what this browser can do. `standalone` is what |
| +// the installability recipe keys its coaching on: iOS delivers push |
| +// only to an installed app. |
| +export function capabilities(env = globalThis) { |
| + const nav = env.navigator || {}; |
| + const standalone = nav.standalone === true || |
| + (typeof env.matchMedia === "function" && env.matchMedia("(display-mode: standalone)").matches) || false; |
| + return { |
| + serviceWorker: !!nav.serviceWorker, |
| + push: typeof env.PushManager !== "undefined", |
| + notifications: typeof env.Notification !== "undefined", |
| + standalone, |
| + }; |
| +} |
| + |
| +// status resolves the current permission and subscription, prompting |
| +// nothing. |
| +export async function status(registration, env = globalThis) { |
| + const permission = env.Notification ? env.Notification.permission : "default"; |
| + const subscription = await registration.pushManager.getSubscription(); |
| + return { permission, subscription }; |
| +} |
| + |
| +// enable asks permission (synchronously, first), subscribes, and |
| +// saves. Resolves the subscription, or null when permission was |
| +// denied. Anything else rejects. |
| +export async function enable({ registration, publicKey, save }, env = globalThis) { |
| + const permission = await env.Notification.requestPermission(); |
| + if (permission !== "granted") return null; |
| + const existing = await registration.pushManager.getSubscription(); |
| + if (existing && sameKey(existing, publicKey)) { |
| + await persist(save, body(existing, publicKey, "")); |
| + return existing; |
| + } |
| + let previous = ""; |
| + if (existing) { |
| + previous = existing.endpoint; |
| + await existing.unsubscribe(); |
| + } |
| + const sub = await registration.pushManager.subscribe({ |
| + userVisibleOnly: true, |
| + applicationServerKey: toBytes(publicKey), |
| + }); |
| + await persist(save, body(sub, publicKey, previous)); |
| + return sub; |
| +} |
| + |
| +// reconcile repairs on every page load without prompting: with |
| +// permission granted it re-subscribes if the server key changed and |
| +// re-saves so the server's last_confirmed_at moves. Resolves the |
| +// subscription or null. |
| +export async function reconcile({ registration, publicKey, save }, env = globalThis) { |
| + if (!env.Notification || env.Notification.permission !== "granted") return null; |
| + const existing = await registration.pushManager.getSubscription(); |
| + if (existing && sameKey(existing, publicKey)) { |
| + await persist(save, body(existing, publicKey, "")); |
| + return existing; |
| + } |
| + let previous = ""; |
| + if (existing) { |
| + previous = existing.endpoint; |
| + await existing.unsubscribe(); |
| + } |
| + const sub = await registration.pushManager.subscribe({ |
| + userVisibleOnly: true, |
| + applicationServerKey: toBytes(publicKey), |
| + }); |
| + await persist(save, body(sub, publicKey, previous)); |
| + return sub; |
| +} |
| + |
| +// disable removes the server row first, then the browser subscription: |
| +// a crash between the two leaves a harmless orphan in the browser |
| +// rather than a server row that sends to nothing. |
| +export async function disable({ registration, remove }) { |
| + const existing = await registration.pushManager.getSubscription(); |
| + if (!existing) return; |
| + const resp = await remove({ endpoint: existing.endpoint }); |
| + if (failed(resp)) { |
| + throw new Error("aviso: remove rejected: " + resp.status); |
| + } |
| + await existing.unsubscribe(); |
| +} |
diff --git a/js/push.test.mjs b/js/push.test.mjs| new file mode 100644 |
| index 0000000..38e9ea2 |
| --- /dev/null |
| +++ b/js/push.test.mjs |
| @@ -0,0 +1,176 @@ |
| +import { test } from "node:test"; |
| +import assert from "node:assert/strict"; |
| +import { capabilities, status, enable, reconcile, disable } from "./push.mjs"; |
| + |
| +// A real 65-byte uncompressed point (0x04 + 64 bytes), base64url'd |
| +// from bytes so sameKey's round trip is exact. |
| +const KEY_BYTES = Uint8Array.from({ length: 65 }, (_, i) => (i === 0 ? 4 : (i * 37) & 0xff)); |
| +const KEY = Buffer.from(KEY_BYTES).toString("base64url"); |
| +const OLD_KEY_BYTES = Uint8Array.from({ length: 65 }, (_, i) => (i === 0 ? 4 : (i * 11) & 0xff)); |
| + |
| +function fakeSub(endpoint, keyBytes) { |
| + return { |
| + endpoint, |
| + options: { applicationServerKey: keyBytes }, |
| + unsubscribed: false, |
| + toJSON() { return { endpoint, expirationTime: null, keys: { p256dh: "P", auth: "A" } }; }, |
| + async unsubscribe() { this.unsubscribed = true; return true; }, |
| + }; |
| +} |
| + |
| +function fakeRegistration(existing) { |
| + const reg = { |
| + subscribed: [], |
| + pushManager: { |
| + async getSubscription() { return existing; }, |
| + async subscribe(opts) { |
| + assert.equal(opts.userVisibleOnly, true); |
| + assert.deepEqual(Array.from(opts.applicationServerKey), Array.from(KEY_BYTES)); |
| + const s = fakeSub("https://push.example/new", opts.applicationServerKey); |
| + reg.subscribed.push(s); |
| + return s; |
| + }, |
| + }, |
| + }; |
| + return reg; |
| +} |
| + |
| +function env(permission, requested = permission) { |
| + const calls = []; |
| + return { |
| + calls, |
| + Notification: { |
| + permission, |
| + async requestPermission() { calls.push("prompt"); return requested; }, |
| + }, |
| + navigator: { serviceWorker: {} }, |
| + PushManager: function () {}, |
| + }; |
| +} |
| + |
| +const okSave = () => { |
| + const saved = []; |
| + const save = async (b) => { saved.push(b); return { ok: true, status: 204 }; }; |
| + return { saved, save }; |
| +}; |
| + |
| +test("capabilities reports the four booleans", () => { |
| + const c = capabilities({ navigator: { serviceWorker: {}, standalone: true }, PushManager: function () {}, Notification: {} }); |
| + assert.deepEqual(c, { serviceWorker: true, push: true, notifications: true, standalone: true }); |
| + assert.deepEqual(capabilities({}), { serviceWorker: false, push: false, notifications: false, standalone: false }); |
| +}); |
| + |
| +test("enable prompts, subscribes and saves a projected body", async () => { |
| + const e = env("default", "granted"); |
| + const reg = fakeRegistration(null); |
| + const { saved, save } = okSave(); |
| + const sub = await enable({ registration: reg, publicKey: KEY, save }, e); |
| + assert.ok(sub); |
| + assert.deepEqual(e.calls, ["prompt"]); |
| + assert.equal(saved.length, 1); |
| + assert.deepEqual(saved[0], { |
| + subscription: { endpoint: "https://push.example/new", keys: { p256dh: "P", auth: "A" } }, |
| + publicKey: KEY, |
| + }); |
| +}); |
| + |
| +test("enable resolves null when denied and never subscribes", async () => { |
| + const e = env("default", "denied"); |
| + const reg = fakeRegistration(null); |
| + const { saved, save } = okSave(); |
| + assert.equal(await enable({ registration: reg, publicKey: KEY, save }, e), null); |
| + assert.equal(reg.subscribed.length, 0); |
| + assert.equal(saved.length, 0); |
| +}); |
| + |
| +test("enable re-saves a matching subscription instead of replacing it", async () => { |
| + const e = env("granted"); |
| + const existing = fakeSub("https://push.example/old", KEY_BYTES); |
| + const reg = fakeRegistration(existing); |
| + const { saved, save } = okSave(); |
| + const sub = await enable({ registration: reg, publicKey: KEY, save }, e); |
| + assert.equal(sub, existing); |
| + assert.equal(reg.subscribed.length, 0); |
| + assert.equal(saved.length, 1); |
| +}); |
| + |
| +test("enable rejects when save reports failure, and accepts a bare resolve", async () => { |
| + const e = env("granted"); |
| + await assert.rejects( |
| + enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => ({ ok: false, status: 500 }) }, e), |
| + /save rejected: 500/, |
| + ); |
| + await assert.rejects( |
| + enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => { throw new Error("net"); } }, e), |
| + /net/, |
| + ); |
| + const sub = await enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => undefined }, e); |
| + assert.ok(sub); |
| +}); |
| + |
| +test("reconcile never prompts and re-saves a matching subscription", async () => { |
| + const e = env("granted"); |
| + const existing = fakeSub("https://push.example/old", KEY_BYTES); |
| + const reg = fakeRegistration(existing); |
| + const { saved, save } = okSave(); |
| + const sub = await reconcile({ registration: reg, publicKey: KEY, save }, e); |
| + assert.equal(sub, existing); |
| + assert.deepEqual(e.calls, []); |
| + assert.equal(saved.length, 1); |
| + assert.equal(reg.subscribed.length, 0); |
| + assert.equal("previousEndpoint" in saved[0], false); |
| +}); |
| + |
| +test("reconcile re-subscribes under a new key and names the old endpoint", async () => { |
| + const e = env("granted"); |
| + const existing = fakeSub("https://push.example/old", OLD_KEY_BYTES); |
| + const reg = fakeRegistration(existing); |
| + const { saved, save } = okSave(); |
| + await reconcile({ registration: reg, publicKey: KEY, save }, e); |
| + assert.equal(existing.unsubscribed, true); |
| + assert.equal(reg.subscribed.length, 1); |
| + assert.equal(saved[0].previousEndpoint, "https://push.example/old"); |
| + assert.equal(saved[0].subscription.endpoint, "https://push.example/new"); |
| +}); |
| + |
| +test("reconcile does nothing without permission", async () => { |
| + for (const perm of ["default", "denied"]) { |
| + const e = env(perm); |
| + const reg = fakeRegistration(null); |
| + const { saved, save } = okSave(); |
| + assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null); |
| + assert.equal(saved.length, 0); |
| + assert.deepEqual(e.calls, []); |
| + } |
| +}); |
| + |
| +test("status reads permission and subscription", async () => { |
| + const existing = fakeSub("https://push.example/x", KEY_BYTES); |
| + const s = await status(fakeRegistration(existing), env("granted")); |
| + assert.equal(s.permission, "granted"); |
| + assert.equal(s.subscription, existing); |
| +}); |
| + |
| +test("disable removes server-side first, then the browser subscription", async () => { |
| + const existing = fakeSub("https://push.example/x", KEY_BYTES); |
| + const order = []; |
| + const remove = async (b) => { order.push("remove:" + b.endpoint); return { ok: true }; }; |
| + existing.unsubscribe = async () => { order.push("unsubscribe"); return true; }; |
| + await disable({ registration: fakeRegistration(existing), remove }); |
| + assert.deepEqual(order, ["remove:https://push.example/x", "unsubscribe"]); |
| +}); |
| + |
| +test("disable keeps the browser subscription when remove fails", async () => { |
| + const existing = fakeSub("https://push.example/x", KEY_BYTES); |
| + await assert.rejects( |
| + disable({ registration: fakeRegistration(existing), remove: async () => ({ ok: false, status: 500 }) }), |
| + /remove rejected/, |
| + ); |
| + assert.equal(existing.unsubscribed, false); |
| +}); |
| + |
| +test("disable is a no-op without a subscription", async () => { |
| + let called = false; |
| + await disable({ registration: fakeRegistration(null), remove: async () => { called = true; } }); |
| + assert.equal(called, false); |
| +}); |
diff --git a/js_test.go b/js_test.go| new file mode 100644 |
| index 0000000..f5d4223 |
| --- /dev/null |
| +++ b/js_test.go |
| @@ -0,0 +1,50 @@ |
| +package aviso |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestJSEmbedded(t *testing.T) { |
| + if !bytes.Contains(JS(), []byte("export async function enable(")) { |
| + t.Fatal("JS() does not look like push.mjs") |
| + } |
| + if !bytes.Contains(WorkerJS(), []byte("AvisoSW")) { |
| + t.Fatal("WorkerJS() does not look like aviso-sw.js") |
| + } |
| +} |
| + |
| +// runNodeTests materialises the embedded files beside the named test |
| +// in a temp dir and runs `node --test` there — so what is tested is |
| +// the bytes the binary serves, not a sibling file that could drift. |
| +// Skipped without node, rastrillo/crypto's rule; CI has node. |
| +func runNodeTests(t *testing.T, testFile string, files map[string][]byte) { |
| + t.Helper() |
| + node, err := exec.LookPath("node") |
| + if err != nil { |
| + t.Skip("node not on PATH; JS half not exercised") |
| + } |
| + dir := t.TempDir() |
| + src, err := os.ReadFile(filepath.Join("js", testFile)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + files[testFile] = src |
| + for name, b := range files { |
| + if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cmd := exec.Command(node, "--test", testFile) |
| + cmd.Dir = dir |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("node --test %s failed: %v\n%s", testFile, err, out) |
| + } |
| +} |
| + |
| +func TestPushModule(t *testing.T) { |
| + runNodeTests(t, "push.test.mjs", map[string][]byte{"push.mjs": JS()}) |
| +} |