---
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

```js
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)

```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.
