rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

--- name: aviso description: Web Push for a rastrillo app — enrol devices, sign with one VAPID key, fan a payload out to a subject's browsers. Load before wiring push into an app. ---

aviso — Web Push for rastrillo apps

Aviso moves bytes to devices a signed-in person enrolled. It never decides who is told what: recipient selection, payload meaning and the service worker's lifecycle are the app's.

Wire it

  1. Mint one key, once, into the app's secrets: APP_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)". Empty is refused at boot (aviso.ErrEmptyPrivateKey); nothing mints a key for you, because a key minted into local state is lost at the next restore. Rotating it makes every browser re-enrol.
  2. BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema) — BootSchema, never Schema, or rastrillo migration check proposes dropping the addon's table.
  3. svc, err := aviso.New(aviso.Config{DB: writer, PrivateKey: key, Contact: "mailto:ops@example", Origin: origin}). One per process; the send-concurrency bound lives on it. Origin is exactly scheme://host[:port], no path, no trailing slash — when a browser sends no Sec-Fetch-Site, CSRF compares its Origin or Referer origin to this string byte for byte.
  4. Mount, behind your session middleware: GET /aviso/public-key → svc.PublicKey, POST /aviso/subscribe → svc.Subscribe, POST /aviso/unsubscribe → svc.Unsubscribe. Ownership is sessions.Current(r).Subject; the body never names one. 409 means the endpoint is another account's, or the browser subscribed under a different server key.
  5. Serve aviso.JS() as /static/aviso/push.mjs and aviso.WorkerJS() as /static/aviso/aviso-sw.js, both with Content-Type: text/javascript — they are bytes, not handlers, and Go's default text/plain makes a browser refuse both a module and a worker script. Serve your own sw.js the same way, at the scope it should control, with Cache-Control: no-cache.

Send

svc.SendTo(ctx, subject, payload, aviso.Options{}) — every device the subject enrolled. svc.Send(ctx, stored, payload, opts) when you select devices yourself (svc.List(ctx, subject) returns them). Both return ([]Result, error): the error is what stopped the batch (query, bounds, cancellation), each Result one device's acceptance by the push service — not delivery. No retries; Result.RetryAfter is for your scheduler. Payload ≤ 3993 bytes. Options.TTL zero means 24 hours; Urgency "" means normal; Topic collapses pending messages. Default payload the worker helper understands: {"title","body","url","tag"}, url a root-relative path on your origin. Rows enrolled under a rotated key are skipped (aviso.ErrKeyMismatch), never sent.

Browser

import { enable, reconcile, disable, capabilities } from "/static/aviso/push.mjs";
const post = (path) => (b) => fetch(path, { method: "POST", credentials: "same-origin",
  headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe");
await navigator.serviceWorker.register("/sw.js");
const registration = await navigator.serviceWorker.ready; // active, not merely registered
const { publicKey } = await (await fetch("/aviso/public-key")).json();
await reconcile({ registration, publicKey, save }).catch(console.warn); // every load; never prompts
button.onclick = () => enable({ registration, publicKey, save }); // from the click; prompts

reconcile only repairs a subscription that exists; it never creates one, because permission stays granted after disable and a reconcile that subscribed whenever it could would undo the person's opt-out on their next visit. ready matters: subscribe() on a registration whose worker is still installing rejects with InvalidStateError. enable must be called synchronously from the click handler — it prompts before its first await, and a prompt after an await is denied. It resolves null when denied. disable({registration, remove}) removes the server row first, then the browser subscription; call it before sign-out. save/remove may resolve to nothing; a rejection or an {ok: false} return counts as failure. capabilities() says whether to show the enable button (push) and, on iOS, the Home Screen coaching (standalone false).

Worker (your sw.js)

importScripts("/static/aviso/aviso-sw.js");
self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, {
  fallback: () => ({ title: "New activity", options: { data: { url: "/" } } }),
})));
self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" })));
self.addEventListener("pushsubscriptionchange", (e) => e.waitUntil(AvisoSW.handleSubscriptionChange(e, {
  publicKey: () => fetch("/aviso/public-key").then((r) => r.json()).then((j) => j.publicKey),
  save: (body) => fetch("/aviso/subscribe", { method: "POST", credentials: "same-origin",
    mode: "same-origin", redirect: "error", headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body) }),
})));

fallback is required: every push shows a notification, or WebKit revokes the subscription. Supply decode(event) returning {title, options} for your own payload shape; its options.data.url is validated to your origin too. publicKey() is called on demand because a terminated worker forgets its variables. The helper never calls skipWaiting or clients.claim; a renewal that cannot be saved (no session) fails silently and reconcile repairs on the next open.

Retention and revocation

svc.Sweep(ctx, time.Now().AddDate(0, 0, -90)) from a carlos.Tick handler removes subscriptions not confirmed in 90 days (confirmation moves on reconcile and on an accepted send; it measures the subscription, not the person's wishes). Session expiry does not revoke; call disable before sign-out and svc.DeleteSubject on account deletion. Re-check entitlement before every SendTo.

Installability

Android and desktop browsers deliver push to an ordinary website. iOS and iPadOS deliver it only to a Home Screen app, and only after a tap in the installed copy. The manifest, head tags and iOS coaching are yours: see docs/installable.md.

Rulings

Endpoints https only, no credentials or fragment, ≤ 2048 bytes, refused at dial for loopback/private/reserved addresses. 409 on an endpoint another subject holds. Unsubscribe is 204 either way. Logs carry subscription ids, never endpoints, keys or payloads.