| 1 | --- |
| 2 | name: aviso |
| 3 | 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. |
| 4 | --- |
| 5 | |
| 6 | # aviso — Web Push for rastrillo apps |
| 7 | |
| 8 | Aviso moves bytes to devices a signed-in person enrolled. It never |
| 9 | decides who is told what: recipient selection, payload meaning and the |
| 10 | service worker's lifecycle are the app's. |
| 11 | |
| 12 | ## Wire it |
| 13 | |
| 14 | 1. Mint one key, once, into the app's secrets: |
| 15 | `APP_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)"`. |
| 16 | Empty is refused at boot (`aviso.ErrEmptyPrivateKey`); nothing |
| 17 | mints a key for you, because a key minted into local state is lost |
| 18 | at the next restore. Rotating it makes every browser re-enrol. |
| 19 | 2. `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)` — |
| 20 | BootSchema, never Schema, or `rastrillo migration check` proposes |
| 21 | dropping the addon's table. |
| 22 | 3. `svc, err := aviso.New(aviso.Config{DB: writer, PrivateKey: key, |
| 23 | Contact: "mailto:ops@example", Origin: origin})`. One per process; |
| 24 | the send-concurrency bound lives on it. `Origin` is exactly |
| 25 | `scheme://host[:port]`, no path, no trailing slash — when a browser |
| 26 | sends no Sec-Fetch-Site, CSRF compares its Origin or Referer origin |
| 27 | to this string byte for byte. |
| 28 | 4. Mount, behind your session middleware: |
| 29 | `GET /aviso/public-key → svc.PublicKey`, `POST /aviso/subscribe → |
| 30 | svc.Subscribe`, `POST /aviso/unsubscribe → svc.Unsubscribe`. |
| 31 | Ownership is `sessions.Current(r).Subject`; the body never names one. |
| 32 | 409 means the endpoint is another account's, or the browser |
| 33 | subscribed under a different server key. |
| 34 | 5. Serve `aviso.JS()` as `/static/aviso/push.mjs` and `aviso.WorkerJS()` |
| 35 | as `/static/aviso/aviso-sw.js`, both with |
| 36 | `Content-Type: text/javascript` — they are bytes, not handlers, and |
| 37 | Go's default `text/plain` makes a browser refuse both a module and |
| 38 | a worker script. Serve your own `sw.js` the same way, at the scope |
| 39 | it should control, with `Cache-Control: no-cache`. |
| 40 | |
| 41 | ## Send |
| 42 | |
| 43 | `svc.SendTo(ctx, subject, payload, aviso.Options{})` — every device the |
| 44 | subject enrolled. `svc.Send(ctx, stored, payload, opts)` when you |
| 45 | select devices yourself (`svc.List(ctx, subject)` returns them). Both |
| 46 | return `([]Result, error)`: the error is what stopped the batch (query, |
| 47 | bounds, cancellation), each Result one device's *acceptance* by the |
| 48 | push service — not delivery. No retries; `Result.RetryAfter` is for |
| 49 | your scheduler. Payload ≤ 3993 bytes. `Options.TTL` zero means |
| 50 | 24 hours; `Urgency` "" means normal; `Topic` collapses pending messages. |
| 51 | Default payload the worker helper understands: |
| 52 | `{"title","body","url","tag"}`, `url` a root-relative path on your |
| 53 | origin. Rows enrolled under a rotated key are skipped |
| 54 | (`aviso.ErrKeyMismatch`), never sent. |
| 55 | |
| 56 | ## Browser |
| 57 | |
| 58 | ```js |
| 59 | import { enable, reconcile, disable, capabilities } from "/static/aviso/push.mjs"; |
| 60 | const post = (path) => (b) => fetch(path, { method: "POST", credentials: "same-origin", |
| 61 | headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) }); |
| 62 | const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe"); |
| 63 | await navigator.serviceWorker.register("/sw.js"); |
| 64 | const registration = await navigator.serviceWorker.ready; // active, not merely registered |
| 65 | const { publicKey } = await (await fetch("/aviso/public-key")).json(); |
| 66 | await reconcile({ registration, publicKey, save }).catch(console.warn); // every load; never prompts |
| 67 | button.onclick = () => enable({ registration, publicKey, save }); // from the click; prompts |
| 68 | ``` |
| 69 | |
| 70 | `reconcile` only repairs a subscription that exists; it never creates |
| 71 | one, because permission stays granted after `disable` and a reconcile |
| 72 | that subscribed whenever it could would undo the person's opt-out on |
| 73 | their next visit. `ready` matters: `subscribe()` on a registration |
| 74 | whose worker is still installing rejects with InvalidStateError. |
| 75 | `enable` must be called |
| 76 | synchronously from the click handler — it prompts before its first |
| 77 | await, and a prompt after an await is denied. It resolves null when |
| 78 | denied. `disable({registration, remove})` removes the server row |
| 79 | first, then the browser subscription; call it before sign-out. |
| 80 | `save`/`remove` may resolve to nothing; a rejection or an `{ok: false}` |
| 81 | return counts as failure. `capabilities()` says whether to show the |
| 82 | enable button (`push`) and, on iOS, the Home Screen coaching |
| 83 | (`standalone` false). |
| 84 | |
| 85 | ## Worker (your sw.js) |
| 86 | |
| 87 | ```js |
| 88 | importScripts("/static/aviso/aviso-sw.js"); |
| 89 | self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, { |
| 90 | fallback: () => ({ title: "New activity", options: { data: { url: "/" } } }), |
| 91 | }))); |
| 92 | self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" }))); |
| 93 | self.addEventListener("pushsubscriptionchange", (e) => e.waitUntil(AvisoSW.handleSubscriptionChange(e, { |
| 94 | publicKey: () => fetch("/aviso/public-key").then((r) => r.json()).then((j) => j.publicKey), |
| 95 | save: (body) => fetch("/aviso/subscribe", { method: "POST", credentials: "same-origin", |
| 96 | mode: "same-origin", redirect: "error", headers: { "Content-Type": "application/json" }, |
| 97 | body: JSON.stringify(body) }), |
| 98 | }))); |
| 99 | ``` |
| 100 | |
| 101 | `fallback` is required: every push shows a notification, or WebKit |
| 102 | revokes the subscription. Supply `decode(event)` returning |
| 103 | `{title, options}` for your own payload shape; its `options.data.url` |
| 104 | is validated to your origin too. `publicKey()` is called on demand |
| 105 | because a terminated worker forgets its variables. The helper never |
| 106 | calls `skipWaiting` or `clients.claim`; a renewal that cannot be saved |
| 107 | (no session) fails silently and `reconcile` repairs on the next open. |
| 108 | |
| 109 | ## Retention and revocation |
| 110 | |
| 111 | `svc.Sweep(ctx, time.Now().AddDate(0, 0, -90))` from a `carlos.Tick` |
| 112 | handler removes subscriptions not confirmed in 90 days (confirmation |
| 113 | moves on reconcile and on an accepted send; it measures the |
| 114 | subscription, not the person's wishes). Session expiry does not |
| 115 | revoke; call `disable` before sign-out and `svc.DeleteSubject` on |
| 116 | account deletion. Re-check entitlement before every `SendTo`. |
| 117 | |
| 118 | ## Installability |
| 119 | |
| 120 | Android and desktop browsers deliver push to an ordinary website. iOS |
| 121 | and iPadOS deliver it only to a Home Screen app, and only after a tap |
| 122 | in the installed copy. The manifest, head tags and iOS coaching are |
| 123 | yours: see `docs/installable.md`. |
| 124 | |
| 125 | ## Rulings |
| 126 | |
| 127 | Endpoints https only, no credentials or fragment, ≤ 2048 bytes, |
| 128 | refused at dial for loopback/private/reserved addresses. 409 on an |
| 129 | endpoint another subject holds. Unsubscribe is 204 either way. Logs |
| 130 | carry subscription ids, never endpoints, keys or payloads. |
| 131 | |