rastrillo / aviso Public

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>
Paul Campbell pushed by paul@keymail.dev 7e8d50760b06aca896ccb1ed60d46bbfd6e284d2 parent e71b6bb
5 files changed, +384 −0
  • js.go +17 −0
  • js/aviso-sw.js +2 −0
  • js/push.mjs +139 −0
  • js/push.test.mjs +176 −0
  • js_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()})
+}