rastrillo / aviso Public
--- 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
- 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. BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)— BootSchema, never Schema, orrastrillo migration checkproposes dropping the addon's table.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.Originis exactlyscheme://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.- Mount, behind your session middleware:
GET /aviso/public-key → svc.PublicKey,POST /aviso/subscribe → svc.Subscribe,POST /aviso/unsubscribe → svc.Unsubscribe. Ownership issessions.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. - Serve
aviso.JS()as/static/aviso/push.mjsandaviso.WorkerJS()as/static/aviso/aviso-sw.js, both withContent-Type: text/javascript— they are bytes, not handlers, and Go's defaulttext/plainmakes a browser refuse both a module and a worker script. Serve your ownsw.jsthe same way, at the scope it should control, withCache-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; promptsreconcile 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.