| new file mode 100644 |
| index 0000000..e00e5fd |
| --- /dev/null |
| +++ b/SKILL.md |
| @@ -0,0 +1,117 @@ |
| +--- |
| +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 — CSRF compares |
| + the browser's Origin header to it 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`; serve your own `sw.js` 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"); |
| +const registration = await navigator.serviceWorker.register("/sw.js"); |
| +const { publicKey } = await (await fetch("/aviso/public-key")).json(); |
| +await reconcile({ registration, publicKey, save }); // every load; never prompts |
| +button.onclick = () => enable({ registration, publicKey, save }); // from the click; prompts |
| +``` |
| + |
| +`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()` tells |
| +you whether to show the enable button and the Home Screen coaching. |
| + |
| +## 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 |
| + |
| +iOS delivers push only to a Home Screen app, and only after a tap in |
| +the installed copy. The manifest, head tags and 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. |