rastrillo / aviso Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Aviso — Web Push as a rastrillo addon, extracted from Eleven
2
3**Date:** 2026-09-07 · **Status:** DRAFT for review ·
4**Source:** Eleven messenger (`github.com/elevenmessenger/messenger`):
5`push.go`, `web/static/push.js`, the notification half of
6`web/static/sw.js`, and their Go tests. **First consumer:**
7birthday-alarm (reminders go out by mail today). **Second, when it asks:**
8oficina/calendar.
9
10Brainstormed by Claude and Codex in two rounds on 2026-09-07; every
11ruling below was agreed by both, and the two points they argued are
12recorded in §14 with the position that won.
13
14## 0. What this is, and the answer to the question
15
16The question was whether rastrillo should have a "PWA library with push
17notifications" extracted from the Eleven PWA. The answer is **yes to
18push, as an addon, and no to a PWA library.**
19
20Rastrillo today has no web app manifest, no service worker support and no
21Web Push (`manifest.go` is the *resource* manifest, CRUD sugar; `icons.go`
22is an icon vocabulary). Eleven is the only app in the family that has
23push, and its code splits cleanly: a transport half — SSRF-guarded client,
24endpoint validation, subscription CRUD, VAPID keys, a bounded fan-out that
25prunes dead endpoints — that is generic; and a policy half — who gets
26told, what the payload means, how the worker decrypts and routes it —
27that is Eleven's message model and nothing else's. The transport half is
28about 230 lines of Go, 180 of browser JS and 40 of worker JS. That is
29what this spec extracts.
30
31There is no "PWA library" to extract. Eleven's service worker does no
32caching and has no offline page; its manifest handler is twenty lines of
33app identity. Installability is a recipe an app follows (§11), not code a
34package can own — and it is needed, because iOS only delivers push to an
35installed app.
36
37Two rulings bind everything, both inherited from the addons doctrine
38(`docs/site/addons.md`): **the arrow points one way** — aviso depends on
39rastrillo, rastrillo never learns aviso exists; and **the app owns
40policy** — aviso moves bytes to devices a subject enrolled, and never
41decides who should be told what.
42
43## 1. Rulings
44
45Each one line, with the failure it prevents.
46
47- **Addon, not core.** Push is real for many apps and wrong for every app
48 to carry; a dependency (`webpush-go`) in core would be paid by apps
49 that never push.
50- **Named `aviso`** ("notice"): it names the user outcome, in the family's
51 Spanish-word style, and collides with nothing in the family.
52- **Keep `github.com/SherClockHolmes/webpush-go` v1.4.0, behind hidden
53 types.** Extraction must not become a cryptography rewrite; RFC 8291's
54 aes128gcm record format and RFC 8292's VAPID JWT do not fall out of
55 rastrillo/crypto's envelope (which is P-256 ECDH/ECDSA, AES-256-GCM,
56 domain-separated — the right curve, the wrong construction). A stdlib
57 implementation is separately reviewed work, inside the addon, later or
58 never.
59- **The session subject owns a subscription.** Ownership is
60 `sessions.Current(r).Subject`, never a client-supplied field and never
61 `rastrillo.Actor` (which says human-or-agent, not who). Eleven's
62 per-subscription bearer token (`token_hash`) does not carry over: it
63 existed because Eleven has no server session.
64- **Reject cross-owner endpoint upserts.** Eleven's unconditional
65 `ON CONFLICT(endpoint) DO UPDATE` (`push.go:88`) would let a second
66 account on the same browser silently take over the first account's
67 device. Aviso answers 409.
68- **Revision-conditional pruning.** A 404/410 deletes a row only if the
69 revision the send captured is still current, so a slow send cannot
70 delete a subscription the browser refreshed meanwhile.
71- **Schema merges into `BootSchema`**, namespaced `aviso`, and has no
72 foreign key to any users table: apps own their identity schema.
73- **No boot-time key generation.** The VAPID private key is provisioned
74 like every other family secret (one env var, refused when empty, §8).
75 Eleven's mint-if-missing sidecar is the thing this rules out.
76- **`enable` needs a gesture; `reconcile` never prompts; the worker helper
77 never calls `skipWaiting` or `clients.claim`.** Permission prompts
78 outside a click are denied by browsers and resented by people; lifecycle
79 is the app's worker's business.
80- **Every push shows a notification.** WebKit revokes push for a worker
81 that receives without displaying; silent suppression is done
82 server-side by not sending.
83
84## 2. Package layout
85
86A separate repository and module, `amadan.net/rastrillo/aviso`, on the
87idear pattern (`docs/site/addons.md`; source `github.com/rastrillo/idear`):
88
89```
90aviso.go Config, New, Service, errors
91store.go List, DeleteSubject, Sweep, the CRUD the handlers use
92send.go Send, SendTo, fan-out, pruning
93http.go PublicKey, Subscribe, Unsubscribe
94vapid.go GenerateKey, key parsing, key id
95ssrf.go dial guard and endpoint validation (from Eleven's
96 push.go:25-70 and unfurl.go:160)
97migrations.go Schema = migrate.MustFromFS(migrationFS, "aviso")
98migrations/0001_init.sql
99js.go JS() and WorkerJS()
100js/push.mjs browser module (from push.js)
101js/aviso-sw.js classic worker helper, exposes AvisoSW
102js/*.test.mjs node tests, the vault/js precedent
103cmd/aviso-key prints one private key
104SKILL.md byte-budgeted, tested, like the framework's
105docs/installable.md the §11 recipe
106```
107
108Nothing in `github.com/carlosframework/rastrillo` imports aviso. The
109directory page in `docs/site/addons.md` gains one entry.
110
111## 3. Go API
112
113```go
114package aviso
115
116type Config struct {
117 DB *sql.DB
118 PrivateKey string // unpadded base64url, 32-byte P-256 scalar; required
119 Contact string // VAPID "sub": a mailto: or https: the push service may contact
120 Origin string // the app's origin, for csrf.SameOrigin and click-URL checks
121 Concurrency int // in-flight sends across the Service; 0 means 32
122 Logger *slog.Logger
123}
124
125type Subscription struct{ Endpoint, P256dh, Auth string }
126
127type Stored struct {
128 ID, Subject, VAPIDKeyID string
129 Revision int64
130 Subscription
131}
132
133type Options struct {
134 TTL time.Duration // whole seconds, >= 0; 0 means the push service's default
135 Urgency string // "very-low" | "low" | "normal" | "high"; "" means normal
136 Topic string // RFC 8030 topic, <= 32 URL-safe chars; "" means none
137}
138
139type Result struct {
140 ID string
141 Status int // push-service status; 0 when Err is transport-level
142 RetryAfter time.Duration // from a 429/503, else 0
143 Err error
144}
145
146var (
147 Schema *migrate.Set
148 ErrEmptyPrivateKey error
149 ErrInvalidPrivateKey error
150)
151
152func GenerateKey() (string, error)
153func New(cfg Config) (*Service, error)
154
155func (s *Service) List(ctx context.Context, subject string) ([]Stored, error)
156func (s *Service) DeleteSubject(ctx context.Context, subject string) error
157func (s *Service) Sweep(ctx context.Context, notConfirmedSince time.Time) error
158
159func (s *Service) Send(ctx context.Context, to []Stored, payload []byte, o Options) ([]Result, error)
160func (s *Service) SendTo(ctx context.Context, subject string, payload []byte, o Options) ([]Result, error)
161
162func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request)
163func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request)
164func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request)
165
166func JS() []byte
167func WorkerJS() []byte
168```
169
170`SendTo` is the common case — every device one subject enrolled — so
171birthday-alarm never touches `Stored`. `Send` is for apps that select
172devices. Both return a batch `error` for what stops the batch (selection
173query failed, payload over bound, invalid options, context cancelled) and
174a `Result` per attempted device; a database failure must not look like
175"zero devices". A `Result` reports the push service's *acceptance*, not
176delivery, and aviso never retries: `RetryAfter` is for the app's own
177scheduler. Rows whose `VAPIDKeyID` is not the Service's are skipped with
178a Result error, not sent with a key that cannot sign for them.
179
180Concurrency is bounded across the Service, not per call, and the
181semaphore wait honours `ctx`. Each request has a 30 s timeout.
182
183## 4. HTTP routes and their gating
184
185Handlers, not a router: the app mounts them where it likes (the SKILL.md
186suggests `/aviso/…`). Each enforces its method.
187
188| Route | Body | Reply |
189|---|---|---|
190| `GET …/public-key` | — | `{"publicKey": "<base64url>"}`; `Cache-Control: no-cache` |
191| `POST …/subscribe` | `{"subscription": {endpoint, keys:{p256dh, auth}}, "publicKey": "…", "previousEndpoint"?: "…"}` | 204 |
192| `POST …/unsubscribe` | `{"endpoint": "…"}` | 204, idempotent |
193
194The two mutations require the app's session middleware to have run, a
195non-empty `sessions.Current(r).Subject`, and `csrf.SameOrigin(r,
196cfg.Origin)`: 401 without a session, 403 on origin failure. `publicKey`
197must equal the Service's, else 409 — a browser subscribed under a rotated
198key must re-enrol, not be stored unsendable. An endpoint already owned by
199another subject is 409. `previousEndpoint` is deleted in the same
200transaction, and only if the same subject owns it; the subscribe body is
201capped at 8 KiB and the endpoint at 2048 bytes (Eleven's bound).
202Unsubscribe deletes only the caller's own row and answers 204 either way.
203
204A re-subscribe of an endpoint the same subject already owns replaces the
205keys, bumps `revision`, and sets `last_confirmed_at`.
206
207## 5. Schema
208
209`migrations/0001_init.sql`, immutable once released:
210
211```sql
212CREATE TABLE aviso_subscriptions (
213 id TEXT NOT NULL PRIMARY KEY,
214 endpoint TEXT NOT NULL UNIQUE,
215 subject TEXT NOT NULL CHECK (length(subject) > 0),
216 p256dh TEXT NOT NULL,
217 auth TEXT NOT NULL,
218 vapid_key_id TEXT NOT NULL,
219 revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
220 created_at INTEGER NOT NULL,
221 last_confirmed_at INTEGER NOT NULL
222);
223CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject);
224CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at);
225```
226
227Times are UTC Unix seconds. `id` is 16 random bytes, base64url, never
228reused. `vapid_key_id` is base64url SHA-256 of the uncompressed public
229key, so a rotated key is visible per row. Pruning and the post-send
230confirmation bump both match `id` **and** the `revision` captured when the
231batch was selected.
232
233The app merges: `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)`.
234
235## 6. Browser module (`js/push.mjs`)
236
237```js
238export function capabilities() // {serviceWorker, push, notifications, standalone}
239export async function status(registration) // {permission, subscription|null}
240export async function enable({registration, publicKey, save}) // from a click
241export async function reconcile({registration, publicKey, save}) // on every load
242export async function disable({registration, remove})
243```
244
245`enable` calls `Notification.requestPermission()` **first**, synchronously
246inside the gesture, then awaits `registration.pushManager.subscribe`.
247`reconcile` never prompts: with permission already granted it compares
248the existing subscription's `applicationServerKey` to `publicKey`,
249re-subscribes if they differ (Eleven's self-heal, `push.js`
250`sameAppServerKey`), and calls `save` so the server's `last_confirmed_at`
251moves. `save(body)` is app-supplied — a same-origin `fetch` to the
252subscribe route — and must reject on non-2xx; Eleven's version ignores
253the response (`push.js:177`), which is how a failed save goes unnoticed
254until the first missed notification. `disable` removes the server row
255first, then unsubscribes the browser, so a crash between the two leaves a
256harmless orphan rather than a row that sends to nothing. Base64url
257conversion stays private. Worker registration is the app's.
258
259## 7. Worker helper (`js/aviso-sw.js`) and the payload contract
260
261A classic script (workers cannot `import` reliably everywhere), loaded by
262the app's own `sw.js` via `importScripts`, exposing:
263
264```js
265AvisoSW.handlePush(event, {decode, fallback}) // -> Promise
266AvisoSW.handleClick(event, {fallbackURL}) // -> Promise
267AvisoSW.handleSubscriptionChange(event, {renew, save})
268```
269
270The app attaches each to `event.waitUntil` in its own listener, so the
271helper never owns the worker's lifecycle.
272
273**Payload contract.** The default decoder accepts JSON
274`{"title","body","url","tag"}`; `title` is required, `url` must be a
275root-relative path on the app's origin (§10). It yields
276`{title, options}` with the validated URL in `options.data.url`. An app
277with its own shape (Eleven's encrypted blob, say) supplies `decode`,
278which may be async. `fallback` is **required** and produces the
279notification shown when the payload is missing or malformed — because
280every push shows a notification (§1), and the alternative is WebKit
281revoking the subscription.
282
283`handleClick` closes the notification, focuses a client whose URL matches
284exactly, else opens the validated destination, else `fallbackURL`.
285
286`handleSubscriptionChange` uses `event.newSubscription` or `renew(event)`
287(a `pushManager.subscribe` with the stored key), then `save` posts to the
288subscribe route with `mode: "same-origin"`, `credentials: "same-origin"`,
289`redirect: "error"`. Fetch's default credentials mode is already
290`same-origin`, so eligible cookies — HttpOnly included — go with the
291request; what nothing can guarantee is that an installed app still has a
292live session. When it does not, renewal fails silently, without
293prompting, and `reconcile` repairs it on the next page open after
294sign-in. The spec says this out loud so nobody builds a retry loop in a
295worker.
296
297## 8. VAPID custody
298
299`Config.PrivateKey` is the unpadded base64url encoding of a 32-byte P-256
300scalar (webpush-go's own format). `New` checks it is in range and derives
301the public key; empty is `ErrEmptyPrivateKey`, malformed
302`ErrInvalidPrivateKey`, and both stop boot.
303
304Provisioning is explicit and once:
305
306```sh
307go run amadan.net/rastrillo/aviso/cmd/aviso-key # prints the private key, nothing else
308```
309
310The app reads it the way birthday-alarm reads its instance key
311(`BIRTHDAYALARM_INSTANCE_KEY`, refused when empty): one env var,
312`<APP>_VAPID_PRIVATE_KEY`. Not a file (Eleven's `<db>.vapid` sidecar has
313no equivalent in a `STATE_DIRECTORY` that holds only the DB), not a table
314row (Eleven scrubbed exactly that, `push.go:208`, because DB snapshots
315travel), and not derived from `InstanceKey` (rotating one secret must not
316silently rotate another). Rotation means every browser re-enrols on its
317next `reconcile`; rows under the old key are skipped, never sent.
318
319## 9. Retention and revocation
320
321`Sweep(ctx, t)` deletes rows with `last_confirmed_at` before `t`. That
322time advances on `reconcile` (a page opened) and on a revision-matched
3232xx acceptance. It measures whether the *subscription* is alive, not
324whether a person still wants notifications: an accepted send keeps an
325unopened device forever, which is the push service's contract too. The
326SKILL.md recommends 90 days, run from a `carlos.Tick` handler.
327
328Session expiry does **not** revoke a subscription — the subject still
329owns the device. Apps call `disable` before sign-out or account switch,
330`DeleteSubject` on account deletion, and re-check that a recipient is
331still entitled before every `SendTo`. An accepted notification cannot be
332recalled.
333
334## 10. Security rulings
335
336- **SSRF.** Endpoints are https only, no userinfo, no fragment, ≤ 2048
337 bytes; the client follows no redirects, uses no environment proxy, and
338 its dialer rejects loopback, private, link-local and reserved ranges
339 **at connect time** (IPv4-mapped IPv6 included), so DNS rebinding after
340 validation still fails. This is Eleven's `guardDialControl`
341 (`push.go:25-45`, `unfurl.go:160`) and its `push_ssrf_test.go`, lifted.
342- **Bounds.** Plaintext ≤ 3993 bytes (RFC 8291 §4's one-record limit);
343 push-service response bodies read to a small cap.
344- **Click URLs.** Root-relative paths only, resolved against the origin
345 and required to stay under it; protocol-relative (`//`) and
346 backslash-bearing paths are rejected. Applied to the default decoder,
347 to custom decoder output, and to `fallbackURL`.
348- **Redaction.** Endpoints, `p256dh`, `auth`, payloads, `Authorization`
349 headers, push-service response bodies and URL-bearing transport errors
350 never reach logs; an endpoint is logged as its row `id`.
351- **Ownership.** Nothing in a request body names a subject; the session
352 does.
353
354## 11. Installability recipe (docs, not code)
355
356What an app does so that a phone can install it and iOS will deliver
357push (16.4+, Home Screen only):
358
3591. Serve `manifest.webmanifest` with a stable `id`, `name`, `short_name`,
360 `start_url`, `scope`, `display: standalone`, `theme_color`,
361 `background_color`, and 192 px and 512 px icons.
3622. In the layout `<head>`: `<link rel="manifest">`,
363 `<meta name="theme-color">`, a 180 px `apple-touch-icon`.
3643. Serve the worker at the scope it should control, `Cache-Control:
365 no-cache`, as Eleven's `serveSW` does (`main.go:4168`).
3664. Tell the person, in the app, to add it to the Home Screen and sign in
367 inside the installed copy before pressing the enable button
368 (`capabilities().standalone` decides whether to show that coaching).
369
370Eleven's `serveManifest` (`main.go:4181`) is the twenty-line model.
371Rastrillo's scaffold is not changed by this spec.
372
373## 12. Testing
374
375**Go.** A recording transport behind a private seam (production guards
376stay on): asserts `TTL`, `Urgency`, `Topic` and a VAPID `Authorization`
377header per send; 404/410 prunes only when the revision still matches;
378429/503 surface `RetryAfter`; cancellation stops the batch. Handlers:
379401/403/409 paths, cross-owner upsert refused, `previousEndpoint` only
380deleted when owned, body and endpoint bounds. SSRF: loopback and private
381endpoints refused at dial, redirect refused, rebinding case. Keys:
382`GenerateKey` round-trips through `New`, invalid scalars refused, the
383same key yields the same `VAPIDKeyID` after restart. Eleven's
384`push_ssrf_test.go` and `push_token_hash_test.go` are the seed corpus.
385
386**Node.** `js/push.test.mjs` against a fake `registration`/`PushManager`:
387permission denied, reconcile with matching and mismatched keys, failed
388`save` rejects, `disable` ordering. `js/aviso-sw.test.mjs` against a fake
389`self`/`clients`: default decoder, malformed payload falls back, click
390focuses vs opens, subscription change renews then saves, expired session
391fails silently.
392
393**Browser.** One chromedp test (the `webauthn/browser_test.go` precedent)
394that registers a worker, calls `enable` with a **declared subscription
395double** — a stubbed `pushManager` — and asserts the row lands through the
396real handlers. It proves the wiring, not Chromium's subscription service
397or encrypted delivery; nothing short of a real push service does, and
398CI must not depend on one. iOS is a manual smoke test, written down.
399
400**Gates.** The repo's own gate (`go vet`, `gofmt -l`, `go test`), the
401node tests, and the browser test, mirrored in `Makefile` `ci` and
402`.amadan/ci.d/`. The example app under `example/` is its own module and
403is tested from its own directory, as `AGENTS.md` requires.
404
405## 13. Out of scope, named
406
407Offline caching and app-shell workers; an installability package or
408scaffold change; Badging API and `beforeinstallprompt` UI; Eleven's
409native APNs relay (`docs/push-relay.md`); declarative push; payload
410encryption above RFC 8291 (Eleven's E2EE blob stays Eleven's `decode`);
411recipient policy, mute/block, presence ("skip active users"); durable
412queues, delivery receipts, automatic retries; replacing webpush-go.
413
414## 14. The two points argued, and who won
415
416- **Send returns `([]Result, error)`, not `[]Result`.** Claude proposed
417 results-only for `SendTo`; Codex objected that a selection failure
418 would then read as "no devices". Codex's version stands (§3).
419- **The browser test uses a subscription double, not a fake push
420 service.** Claude proposed a chromedp test round-tripping through a
421 local fake push endpoint; Codex objected that Chromium's own
422 subscription path cannot be pointed at it, so the test would prove
423 less than it claimed. Codex's version stands (§12).
424
425No open questions remain that the code cannot settle. The one decision
426that is Paul's: whether `aviso` is the name.
427