rastrillo / aviso Public

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

Plain git — no account needed to clone.

Download

Download this file

1---
2name: aviso
3description: 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
8Aviso moves bytes to devices a signed-in person enrolled. It never
9decides who is told what: recipient selection, payload meaning and the
10service worker's lifecycle are the app's.
11
12## Wire it
13
141. 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.
192. `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)` —
20 BootSchema, never Schema, or `rastrillo migration check` proposes
21 dropping the addon's table.
223. `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.
284. 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.
345. 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
44subject enrolled. `svc.Send(ctx, stored, payload, opts)` when you
45select devices yourself (`svc.List(ctx, subject)` returns them). Both
46return `([]Result, error)`: the error is what stopped the batch (query,
47bounds, cancellation), each Result one device's *acceptance* by the
48push service — not delivery. No retries; `Result.RetryAfter` is for
49your scheduler. Payload ≤ 3993 bytes. `Options.TTL` zero means
5024 hours; `Urgency` "" means normal; `Topic` collapses pending messages.
51Default payload the worker helper understands:
52`{"title","body","url","tag"}`, `url` a root-relative path on your
53origin. Rows enrolled under a rotated key are skipped
54(`aviso.ErrKeyMismatch`), never sent.
55
56## Browser
57
58```js
59import { enable, reconcile, disable, capabilities } from "/static/aviso/push.mjs";
60const post = (path) => (b) => fetch(path, { method: "POST", credentials: "same-origin",
61 headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
62const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe");
63await navigator.serviceWorker.register("/sw.js");
64const registration = await navigator.serviceWorker.ready; // active, not merely registered
65const { publicKey } = await (await fetch("/aviso/public-key")).json();
66await reconcile({ registration, publicKey, save }).catch(console.warn); // every load; never prompts
67button.onclick = () => enable({ registration, publicKey, save }); // from the click; prompts
68```
69
70`reconcile` only repairs a subscription that exists; it never creates
71one, because permission stays granted after `disable` and a reconcile
72that subscribed whenever it could would undo the person's opt-out on
73their next visit. `ready` matters: `subscribe()` on a registration
74whose worker is still installing rejects with InvalidStateError.
75`enable` must be called
76synchronously from the click handler — it prompts before its first
77await, and a prompt after an await is denied. It resolves null when
78denied. `disable({registration, remove})` removes the server row
79first, then the browser subscription; call it before sign-out.
80`save`/`remove` may resolve to nothing; a rejection or an `{ok: false}`
81return counts as failure. `capabilities()` says whether to show the
82enable button (`push`) and, on iOS, the Home Screen coaching
83(`standalone` false).
84
85## Worker (your sw.js)
86
87```js
88importScripts("/static/aviso/aviso-sw.js");
89self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, {
90 fallback: () => ({ title: "New activity", options: { data: { url: "/" } } }),
91})));
92self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" })));
93self.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
102revokes the subscription. Supply `decode(event)` returning
103`{title, options}` for your own payload shape; its `options.data.url`
104is validated to your origin too. `publicKey()` is called on demand
105because a terminated worker forgets its variables. The helper never
106calls `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`
112handler removes subscriptions not confirmed in 90 days (confirmation
113moves on reconcile and on an accepted send; it measures the
114subscription, not the person's wishes). Session expiry does not
115revoke; call `disable` before sign-out and `svc.DeleteSubject` on
116account deletion. Re-check entitlement before every `SendTo`.
117
118## Installability
119
120Android and desktop browsers deliver push to an ordinary website. iOS
121and iPadOS deliver it only to a Home Screen app, and only after a tap
122in the installed copy. The manifest, head tags and iOS coaching are
123yours: see `docs/installable.md`.
124
125## Rulings
126
127Endpoints https only, no credentials or fragment, ≤ 2048 bytes,
128refused at dial for loopback/private/reserved addresses. 409 on an
129endpoint another subject holds. Unsubscribe is 204 either way. Logs
130carry subscription ids, never endpoints, keys or payloads.
131