rastrillo / aviso Public

Add SKILL.md, the installability recipe and the README

SKILL.md is what an agent loads instead of the source, so it is
byte-budgeted and its facts are pinned by test: every exported name,
the payload bound, the TTL default, the worker's on-demand key fetch.
docs/installable.md is the four-step recipe a phone needs before it
will deliver push; it is documentation rather than code because a
manifest is the app's identity and rastrillo's scaffold is not the
addon's to change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a5c2bcd07dec4ff3929a4ee77d7b492bdad05713 parent da04de6
4 files changed, +238 −2
  • README.md +14 −2
  • SKILL.md +117 −0
  • docs/installable.md +71 −0
  • skillmd_test.go +36 −0
diff --git a/README.md b/README.md
index 2f422bd..3eba1de 100644
--- a/README.md
+++ b/README.md
@@ -8,5 +8,17 @@ depends on rastrillo, never the reverse.
go get amadan.net/rastrillo/aviso
cat "$(go list -m -f '{{.Dir}}' amadan.net/rastrillo/aviso)/SKILL.md"
-Design: `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`.
-Gate: `make ci`.
+`SKILL.md` is the authoring doc: how to wire it, send, and write the
+browser and worker halves. `docs/installable.md` is the recipe for
+making the app installable, which a phone requires before it will
+deliver push. `example/` is the smallest app that wires every seam.
+
+## What it is not
+
+It does not decide who is told what — recipient selection and payload
+meaning are the app's. It does not cache, work offline, or own the
+service worker's lifecycle. It does not retry, queue, or report
+delivery; a push service's acceptance is where its knowledge ends.
+
+Design: `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`,
+extracted from Eleven messenger's push transport. Gate: `make ci`.
diff --git a/SKILL.md b/SKILL.md
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.
diff --git a/docs/installable.md b/docs/installable.md
new file mode 100644
index 0000000..1b9194d
--- /dev/null
+++ b/docs/installable.md
@@ -0,0 +1,71 @@
+# Making a rastrillo app installable
+
+Aviso does not own any of this, on purpose: a manifest is the app's
+identity, and rastrillo's scaffold is not changed by the addon. But a
+phone will only deliver push to an app it has installed — iOS and
+iPadOS from 16.4, and only from the Home Screen copy — so an app that
+wants push wants this recipe too. It is four things.
+
+## 1. A manifest
+
+Serve `manifest.webmanifest` (content type
+`application/manifest+json`) with, at least:
+
+```json
+{
+ "id": "/",
+ "name": "Birthday Alarm",
+ "short_name": "Birthdays",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "theme_color": "#5b6cff",
+ "background_color": "#f6f7fb",
+ "icons": [
+ { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
+ ]
+}
+```
+
+`id` is what keeps an installed app the same app across a renamed
+`start_url`; set it once and never change it. A twenty-line handler
+that writes this from the app's name is enough — Eleven's
+`serveManifest` is the model — and a static file is enough too.
+
+## 2. Three tags in the layout's head
+
+```html
+<link rel="manifest" href="/manifest.webmanifest">
+<meta name="theme-color" content="#5b6cff">
+<link rel="apple-touch-icon" href="/static/icon-180.png">
+```
+
+Safari reads the Apple touch icon, not the manifest's icons, for the
+Home Screen tile; 180 px is the size it wants.
+
+## 3. The worker at its scope
+
+Serve `sw.js` at the path whose scope it should control — `/sw.js`
+for the whole app — with `Cache-Control: no-cache`, so a new worker is
+noticed on the next load rather than after a cache expiry nobody
+chose. Load the helper from inside it:
+
+```js
+importScripts("/static/aviso/aviso-sw.js");
+```
+
+The rest of the worker is in `SKILL.md`.
+
+## 4. Coaching, keyed on `capabilities()`
+
+`capabilities()` from `push.mjs` reports `standalone`: whether this
+page is running as an installed app. On a phone that is not
+standalone, show the person how to add the app to their Home Screen
+(Share → Add to Home Screen on iOS) and to sign in inside the
+installed copy before pressing the enable button — the browser's
+cookies do not travel into the installed app. Show the enable button
+only when `capabilities().push` is true.
+
+That is the whole recipe. None of it is Web Push; all of it is what
+Web Push needs on a phone.
diff --git a/skillmd_test.go b/skillmd_test.go
new file mode 100644
index 0000000..e25fdee
--- /dev/null
+++ b/skillmd_test.go
@@ -0,0 +1,36 @@
+package aviso
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// skillBudget is the byte ceiling for SKILL.md. It is what an agent
+// loads instead of reading the source, so it is reviewed like code and
+// kept short; raise it here, with a reason, rather than trimming a
+// load-bearing fact to fit.
+const skillBudget = 9000
+
+func TestSkillMDIsWithinBudgetAndNamesTheSurface(t *testing.T) {
+ b, err := os.ReadFile("SKILL.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(b) > skillBudget {
+ t.Fatalf("SKILL.md is %d bytes, budget %d", len(b), skillBudget)
+ }
+ s := string(b)
+ for _, want := range []string{
+ "aviso.New", "aviso.Schema", "BootSchema", "PrivateKey", "aviso-key",
+ "SendTo", "Send(", "Sweep", "DeleteSubject",
+ "PublicKey", "Subscribe", "Unsubscribe",
+ "JS()", "WorkerJS()", "importScripts", "enable(", "reconcile(", "disable(",
+ "handlePush", "handleClick", "handleSubscriptionChange", "fallback", "publicKey:",
+ "docs/installable.md", "3993", "24 hours",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("SKILL.md does not mention %q", want)
+ }
+ }
+}