rastrillo / aviso Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Aviso Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Build `amadan.net/rastrillo/aviso`, the Web Push addon for rastrillo, extracted from Eleven messenger's transport half.
6
7**Architecture:** One Go package (`aviso`) owning a subscriptions table, VAPID key handling, an SSRF-guarded sender over `webpush-go`, and three HTTP handlers gated by rastrillo sessions and CSRF; plus two embedded JS files — a browser ES module for enrolment and a classic service-worker helper — each with node tests run from Go. The app owns policy: who is notified, what the payload means, and the worker's lifecycle.
8
9**Tech Stack:** Go 1.25, `amadan.net/rastrillo/rastrillo` v0.26.0 (`migrate`, `db`, `sessions`, `csrf`, `harness`), `github.com/SherClockHolmes/webpush-go` v1.4.0, SQLite via rastrillo's `db`, node 24 (`node --test`), chromedp via rastrillo's `harness` for the one browser test.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md` (copied from rastrillo's `docs/superpowers/specs/`, merged there as 2aa3da2).
12
13**Review gate:** Codex reviews every task before it is marked done. After the task's commit, run from the repo root:
14
15```sh
16codex exec --sandbox read-only --skip-git-repo-check -o /tmp/aviso-review-N.md \
17 "Review the last commit of this repo (git show HEAD) against docs/superpowers/specs/2026-09-07-aviso-web-push-design.md and the task N section of docs/superpowers/plans/2026-09-07-aviso.md. Report defects, spec deviations and missing tests as a numbered list with file:line; say 'LGTM' if none."
18```
19
20Address every finding or record in the branch discussion why not. A task is done when Codex says LGTM or every remaining finding has a recorded reason.
21
22## Global Constraints
23
24- Module path `amadan.net/rastrillo/aviso`; `go 1.25.0`; requires `amadan.net/rastrillo/rastrillo v0.26.0` and `github.com/SherClockHolmes/webpush-go v1.4.0`, nothing else direct. `webpush-go` types never appear in the exported API.
25- Migration namespace `aviso`; table `aviso_subscriptions`; `migrations/0001_init.sql` is immutable once released; apps merge `aviso.Schema` into `BootSchema`.
26- Ownership is `sessions.Current(r).Subject`; no request body ever names a subject.
27- `Config.PrivateKey` is an unpadded base64url 32-byte P-256 scalar; empty → `ErrEmptyPrivateKey`; malformed or out of range → `ErrInvalidPrivateKey`. Nothing generates a key at boot.
28- Bounds: subscribe body ≤ 8192 bytes; endpoint ≤ 2048 bytes; payload ≤ 3993 bytes; push-service response body read ≤ 4096 bytes.
29- Endpoints: `https` only, no userinfo, no fragment; client follows no redirects, uses no proxy, refuses loopback/private/link-local/reserved/IPv4-mapped destinations at connect time.
30- Concurrency across the Service (default 32); 30 s per request; no retries.
31- Every push shows a notification; the worker helper never calls `skipWaiting`/`clients.claim`; `enable` prompts only from a gesture; `reconcile` never prompts.
32- Logs never carry endpoints, `p256dh`, `auth`, payloads, `Authorization` headers, push-service bodies or URL-bearing errors; a subscription is logged by its `id`.
33- Comments say why, naming the failure prevented (rastrillo `AGENTS.md`). Commit subjects imperative; bodies explain why.
34- Gate: `make ci` = `go vet ./...`, `gofmt -l .` empty, `go test ./... -count=1`, `CGO_ENABLED=1 go test ./... -race -count=1`. Node tests skip when `node` is absent. Browser test is `-tags browser`.
35- Git: work on branch `build`, push after every task, `amadan task advance` per task, `amadan branch describe` kept true.
36
37---
38
39## Review amendments (Codex, 2026-09-07, before Task 2)
40
41Codex reviewed this plan against the spec, rastrillo's source and
42webpush-go v1.4.0. Eleven findings; every one is applied to the task
43it names, and the task text below is superseded where they conflict.
44
451. **Task 6, Send.** The sketch raced (`countSends` read results the
46 goroutines were writing) and leaked goroutines on cancellation.
47 Send now decides up front which rows it will attempt, uses a
48 `sync.WaitGroup`, marks rows it never started with `ctx.Err()` when
49 cancelled while waiting for a slot, always waits for started
50 goroutines, and returns `ctx.Err()` as the batch error whenever the
51 context was cancelled at any point.
522. **Task 5, guard.** `guardedIP` admitted `0.0.0.0/8`, `192.0.0.0/24`,
53 `192.0.2.0/24`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`,
54 `240.0.0.0/4`, `2001:db8::/32`, `64:ff9b::/96`. It now carries an
55 explicit reserved-CIDR list on top of the net.IP predicates, and the
56 test corpus names each range.
573. **Task 6.** `sendOne` calls `validateEndpoint` before sending: a
58 caller can hand `Send` a `Stored` it edited.
594. **Task 6.** webpush-go prefixes `mailto:` to any subscriber not
60 starting with `https:` (`vapid.go:74`). `New` keeps `Contact` as
61 given for validation and stores the mailto-stripped form for the
62 wire; the header test decodes the JWT and checks `sub`.
635. **Task 4/6 tests.** `sub()` generates a real P-256 point for
64 `P256dh` and 16 random bytes for `Auth`; webpush-go decodes both
65 before any HTTP happens.
666. **Task 7/9/11.** Browser `PushSubscription.toJSON()` carries
67 `expirationTime`. The handler no longer uses `DisallowUnknownFields`
68 (the byte cap and field validation are the defence), and every
69 client path projects to `{endpoint, keys}` anyway.
707. **Task 8 test.** `KEY` is a real 65-byte point, base64url'd in the
71 test from bytes, so `sameKey` round-trips.
728. **Task 6, TTL.** webpush-go always sends the `TTL` header and `0`
73 means "deliver now or drop", not "service default". Ruling:
74 `Options.TTL == 0` means 24 hours. Recorded as a spec deviation in
75 the branch discussion.
769. **Task 8/9, callbacks.** `save`/`remove` may resolve to nothing (a
77 callback that validated its own response); only an explicit
78 `{ok: false}`-shaped return, or a rejection, counts as failure.
7910. **Task 9/11/12, worker key.** A worker forgets a module-level
80 variable when it is terminated. `handleSubscriptionChange` takes
81 `{publicKey, save}` where `publicKey()` fetches the key on demand;
82 the helper performs the renewal subscribe itself (from
83 `event.newSubscription`, else `pushManager.subscribe` with the
84 fetched key) and builds the full subscribe body, so the app's
85 `save` only posts.
8611. **Task 13/1, CI.** A `browser` make target
87 (`go test -tags browser -count=1 -run Browser .`) joins `ci` and
88 gets `.amadan/ci.d/50-browser`; it fails, not skips, without
89 Chromium — rastrillo's own rule (`RASTRILLO_BROWSER_OPTIONAL`
90 unset).
91
92---
93
94## File structure
95
96| File | Responsibility |
97|---|---|
98| `go.mod`, `go.sum` | module identity and the two direct deps |
99| `Makefile`, `.amadan/ci`, `.amadan/ci.d/{10-vet,20-fmt,30-test,40-race}` | the one gate |
100| `doc.go` | package comment: what aviso is, the two rulings |
101| `vapid.go` | `GenerateKey`, private-key parsing, public key and key id derivation, key errors |
102| `migrations/0001_init.sql`, `migrations.go` | `Schema` |
103| `aviso.go` | `Config`, `Service`, `New`, `Subscription`, `Stored`, shared errors |
104| `store.go` | every SQL statement: put with ownership rules, list, delete, sweep, confirm, prune |
105| `ssrf.go` | endpoint validation and the guarded HTTP client |
106| `send.go` | `Options`, `Result`, `Send`, `SendTo` |
107| `http.go` | `PublicKey`, `Subscribe`, `Unsubscribe` |
108| `js.go`, `js/push.mjs`, `js/aviso-sw.js`, `js/*.test.mjs` | the browser halves and their node tests |
109| `cmd/aviso-key/main.go` | prints one private key |
110| `browser_test.go` (tag `browser`) | chromedp wiring proof |
111| `SKILL.md`, `README.md`, `docs/installable.md` | what an agent loads, what a person reads, the installability recipe |
112| `example/` | own module: a minimal rastrillo app wiring aviso |
113
114---
115
116### Task 1: Module scaffold and gate
117
118**Files:**
119- Create: `go.mod`, `doc.go`, `Makefile`, `.amadan/ci`, `.amadan/ci.d/10-vet`, `.amadan/ci.d/20-fmt`, `.amadan/ci.d/30-test`, `.amadan/ci.d/40-race`, `README.md`
120- Already present: `.gitignore`, `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`, this plan
121
122**Interfaces:**
123- Produces: a module every later task builds inside; `make ci` as the gate.
124
125- [ ] **Step 1: Create the branch and push it, per the amadan house rules**
126
127```sh
128cd /home/paulca/github.com/rastrillo/aviso
129git checkout -b build
130```
131
132(Push happens at the end of this task once there is a commit.)
133
134- [ ] **Step 2: Write go.mod**
135
136```
137module amadan.net/rastrillo/aviso
138
139go 1.25.0
140
141require (
142 amadan.net/rastrillo/rastrillo v0.26.0
143 github.com/SherClockHolmes/webpush-go v1.4.0
144)
145```
146
147- [ ] **Step 3: Write doc.go**
148
149```go
150// Package aviso is rastrillo's Web Push addon: the subscriptions a
151// signed-in person enrols from their browsers, the VAPID key that
152// signs for them, and a sender that moves an app's payload to those
153// devices through the browser vendors' push services.
154//
155// Two rulings bind everything here, both from rastrillo's addons
156// doctrine. The arrow points one way: aviso depends on rastrillo,
157// rastrillo never learns aviso exists. The app owns policy: aviso
158// moves bytes to devices a subject enrolled and never decides who
159// should be told what — recipient selection, payload meaning and the
160// service worker's lifecycle are the app's.
161//
162// Design: docs/superpowers/specs/2026-09-07-aviso-web-push-design.md.
163package aviso
164```
165
166- [ ] **Step 4: Write the Makefile (idear's, with the reason node is not a separate target)**
167
168```make
169.PHONY: vet fmt-check test race ci
170
171# ci is the one gate: what a runner executes and what you run before
172# pushing are the same definition (amadan's own rule — CI steps
173# delegate to make targets, never keep their own copies).
174#
175# The node tests are not a separate target: js_test.go runs them from
176# inside `go test`, skipping when node is absent, so a runner without
177# node still reports the Go half honestly instead of failing a step
178# it cannot run.
179ci: vet fmt-check test race
180
181vet:
182 go vet ./...
183
184fmt-check:
185 @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
186
187# -count=1 on purpose: these tests build a real SQLite database and
188# race goroutines against it; a cached PASS re-reports one scheduling
189# as though it were every scheduling.
190test:
191 go test ./... -count=1
192
193# -race needs cgo; everything else runs with the default toolchain, so
194# this target sets CGO_ENABLED for its own command only.
195race:
196 CGO_ENABLED=1 go test ./... -race -count=1
197```
198
199- [ ] **Step 5: Write the CI entry and steps**
200
201`.amadan/ci`:
202```sh
203#!/bin/sh
204# amadan CI entry (single-script fallback for runners without step
205# support). The steps in ci.d/ are the same targets, reported one by
206# one. Must stay executable: a non-executable script resolves "skipped".
207set -e
208exec make ci
209```
210
211`.amadan/ci.d/10-vet`, `20-fmt`, `30-test`, `40-race` — each:
212```sh
213#!/bin/sh
214set -e
215exec make vet
216```
217(with `fmt-check`, `test`, `race` respectively). Then `chmod +x .amadan/ci .amadan/ci.d/*`.
218
219- [ ] **Step 6: Write README.md (short; the SKILL.md in Task 11 is the authoring doc)**
220
221```markdown
222# aviso
223
224Web Push for rastrillo apps: subscriptions a signed-in person enrols
225from their browsers, one VAPID key per app, and a sender that fans a
226payload out to those devices. An addon — `amadan.net/rastrillo/aviso`
227depends on rastrillo, never the reverse.
228
229 go get amadan.net/rastrillo/aviso
230 cat "$(go list -m -f '{{.Dir}}' amadan.net/rastrillo/aviso)/SKILL.md"
231
232Design: `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`.
233Gate: `make ci`.
234```
235
236- [ ] **Step 7: Resolve deps and run the gate**
237
238Run: `go mod tidy && make ci`
239Expected: tidy writes go.sum; `ci` passes with "no test files".
240
241- [ ] **Step 8: Commit and push; describe the branch; add the tasks**
242
243```sh
244git add -A
245git commit -m "Scaffold the aviso module and its gate
246
247The addon starts with the gate rather than the code so every later
248commit is checked the same way CI checks it (amadan's rule: CI steps
249delegate to make targets, never copy them).
250
251Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
252git push -u origin build
253amadan branch describe rastrillo/aviso build -body - <<'EOF'
254Builds the aviso Web Push addon from its spec (docs/superpowers/specs/2026-09-07-aviso-web-push-design.md). Plan at docs/superpowers/plans/2026-09-07-aviso.md; Codex reviews each task's commit before it is marked done.
255EOF
256for t in "1 scaffold and gate" "2 VAPID keys" "3 schema and Service" "4 store" "5 SSRF guard" "6 send" "7 HTTP handlers" "8 browser module" "9 worker helper" "10 aviso-key" "11 SKILL.md and docs" "12 example app" "13 browser test" "14 rastrillo directory entry"; do amadan task add rastrillo/aviso -branch build -title "Task $t"; done
257```
258
259- [ ] **Step 9: Codex review (see Review gate), then `amadan task advance` Task 1**
260
261---
262
263### Task 2: VAPID keys
264
265**Files:**
266- Create: `vapid.go`, `vapid_test.go`
267
268**Interfaces:**
269- Produces: `func GenerateKey() (string, error)`; `var ErrEmptyPrivateKey, ErrInvalidPrivateKey error`; unexported `func parsePrivateKey(s string) (pub string, keyID string, err error)` — `pub` is the unpadded base64url 65-byte uncompressed public point (what `applicationServerKey` and webpush-go both take), `keyID` is unpadded base64url SHA-256 of those 65 bytes.
270
271- [ ] **Step 1: Write the failing tests**
272
273`vapid_test.go`:
274```go
275package aviso
276
277import (
278 "crypto/ecdh"
279 "crypto/rand"
280 "encoding/base64"
281 "errors"
282 "strings"
283 "testing"
284)
285
286func TestGenerateKeyRoundTrips(t *testing.T) {
287 priv, err := GenerateKey()
288 if err != nil {
289 t.Fatal(err)
290 }
291 raw, err := base64.RawURLEncoding.DecodeString(priv)
292 if err != nil || len(raw) != 32 {
293 t.Fatalf("private key = %q: want 32 unpadded base64url bytes (err %v)", priv, err)
294 }
295 pub, id, err := parsePrivateKey(priv)
296 if err != nil {
297 t.Fatal(err)
298 }
299 pubRaw, err := base64.RawURLEncoding.DecodeString(pub)
300 if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 {
301 t.Fatalf("public key = %q: want 65-byte uncompressed point", pub)
302 }
303 if id == "" || strings.ContainsAny(id, "+/=") {
304 t.Fatalf("key id = %q: want unpadded base64url", id)
305 }
306 // The same key must yield the same id after a restart — Sweep and
307 // Send match rows on it.
308 _, id2, _ := parsePrivateKey(priv)
309 if id != id2 {
310 t.Fatal("key id not deterministic")
311 }
312}
313
314func TestParsePrivateKeyRefusesBadInput(t *testing.T) {
315 if _, _, err := parsePrivateKey(""); !errors.Is(err, ErrEmptyPrivateKey) {
316 t.Fatalf("empty: got %v, want ErrEmptyPrivateKey", err)
317 }
318 zero := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
319 for name, in := range map[string]string{
320 "not base64": "!!!",
321 "short": base64.RawURLEncoding.EncodeToString([]byte("short")),
322 "zero scalar": zero,
323 "padded": zero + "=",
324 } {
325 if _, _, err := parsePrivateKey(in); !errors.Is(err, ErrInvalidPrivateKey) {
326 t.Errorf("%s: got %v, want ErrInvalidPrivateKey", name, err)
327 }
328 }
329}
330
331func TestParsePrivateKeyAgreesWithECDH(t *testing.T) {
332 k, err := ecdh.P256().GenerateKey(rand.Reader)
333 if err != nil {
334 t.Fatal(err)
335 }
336 priv := base64.RawURLEncoding.EncodeToString(k.Bytes())
337 pub, _, err := parsePrivateKey(priv)
338 if err != nil {
339 t.Fatal(err)
340 }
341 want := base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
342 if pub != want {
343 t.Fatalf("public key = %s, want %s", pub, want)
344 }
345}
346```
347
348- [ ] **Step 2: Run to verify failure**
349
350Run: `go test ./... -run 'TestGenerateKey|TestParsePrivateKey' -v`
351Expected: FAIL, undefined: GenerateKey, parsePrivateKey, ErrEmptyPrivateKey.
352
353- [ ] **Step 3: Implement vapid.go**
354
355```go
356package aviso
357
358import (
359 "crypto/ecdh"
360 "crypto/rand"
361 "crypto/sha256"
362 "encoding/base64"
363 "errors"
364 "strings"
365)
366
367// ErrEmptyPrivateKey means Config.PrivateKey was empty. It is refused
368// rather than minted: a key generated at boot into local state is a
369// key lost at the next restore, and every browser then holds a
370// subscription nobody can sign for.
371var ErrEmptyPrivateKey = errors.New("aviso: Config.PrivateKey must not be empty; mint one with `go run amadan.net/rastrillo/aviso/cmd/aviso-key`")
372
373// ErrInvalidPrivateKey means Config.PrivateKey is not an unpadded
374// base64url 32-byte P-256 scalar in range.
375var ErrInvalidPrivateKey = errors.New("aviso: Config.PrivateKey is not an unpadded base64url 32-byte P-256 scalar")
376
377// GenerateKey mints a VAPID private key in Config.PrivateKey's format.
378// The public half is derived, never stored: one secret to provision.
379func GenerateKey() (string, error) {
380 k, err := ecdh.P256().GenerateKey(rand.Reader)
381 if err != nil {
382 return "", err
383 }
384 return base64.RawURLEncoding.EncodeToString(k.Bytes()), nil
385}
386
387// parsePrivateKey validates the scalar and derives the two things the
388// rest of the package needs from it: the uncompressed public point
389// (applicationServerKey on the browser side, the VAPID public key on
390// the wire) and a key id — SHA-256 of that point — stored on every
391// row so a rotated key is visible per subscription.
392func parsePrivateKey(s string) (pub, keyID string, err error) {
393 if s == "" {
394 return "", "", ErrEmptyPrivateKey
395 }
396 // Padding is refused, not tolerated: two encodings of one key
397 // would give two key ids for the same rows.
398 if strings.ContainsAny(s, "=+/") {
399 return "", "", ErrInvalidPrivateKey
400 }
401 raw, err := base64.RawURLEncoding.DecodeString(s)
402 if err != nil || len(raw) != 32 {
403 return "", "", ErrInvalidPrivateKey
404 }
405 // NewPrivateKey rejects zero and out-of-range scalars.
406 k, err := ecdh.P256().NewPrivateKey(raw)
407 if err != nil {
408 return "", "", ErrInvalidPrivateKey
409 }
410 point := k.PublicKey().Bytes()
411 sum := sha256.Sum256(point)
412 return base64.RawURLEncoding.EncodeToString(point),
413 base64.RawURLEncoding.EncodeToString(sum[:]), nil
414}
415```
416
417- [ ] **Step 4: Run to verify pass**
418
419Run: `go test ./... -run 'TestGenerateKey|TestParsePrivateKey' -v`
420Expected: PASS ×3.
421
422- [ ] **Step 5: Commit, push, Codex review, advance task**
423
424```sh
425git add vapid.go vapid_test.go
426git commit -m "Add VAPID key parsing and generation
427
428The public key is derived from the scalar rather than stored beside
429it, so an app provisions exactly one secret; the key id is on every
430row so a rotated key shows up per subscription instead of as a silent
431signing failure.
432
433Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
434git push
435```
436
437---
438
439### Task 3: Schema, Config, New, Service
440
441**Files:**
442- Create: `migrations/0001_init.sql`, `migrations.go`, `aviso.go`, `schema_test.go`, `aviso_test.go`
443
444**Interfaces:**
445- Consumes: `parsePrivateKey` (Task 2).
446- Produces: `type Config`, `type Service` (fields `cfg Config`, `pub, keyID string`, `client *http.Client`, `sem chan struct{}`, `now func() time.Time`), `func New(Config) (*Service, error)`, `var Schema *migrate.Set`, `type Subscription`, `type Stored`, `var ErrOwnedElsewhere`, `var ErrKeyMismatch`. `Service.client` is nil until Task 5 sets it; `Service.sem` is sized in New.
447
448- [ ] **Step 1: Write the migration**
449
450`migrations/0001_init.sql`:
451```sql
452-- One row per browser subscription. `endpoint` is the push service's
453-- unguessable URL and is unique by construction; `subject` is the
454-- rastrillo session subject that enrolled it. No foreign key to any
455-- users table: apps own their identity schema.
456CREATE TABLE aviso_subscriptions (
457 id TEXT NOT NULL PRIMARY KEY,
458 endpoint TEXT NOT NULL UNIQUE,
459 subject TEXT NOT NULL CHECK (length(subject) > 0),
460 p256dh TEXT NOT NULL,
461 auth TEXT NOT NULL,
462 vapid_key_id TEXT NOT NULL,
463 revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
464 created_at INTEGER NOT NULL,
465 last_confirmed_at INTEGER NOT NULL
466);
467CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject);
468CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at);
469```
470
471- [ ] **Step 2: Write migrations.go**
472
473```go
474package aviso
475
476import (
477 "embed"
478
479 "amadan.net/rastrillo/rastrillo/migrate"
480)
481
482//go:embed migrations/*.sql
483var migrationFS embed.FS
484
485// Schema is the addon's migration set. Merge it into the app's
486// BootSchema, never its Schema: `rastrillo migration check` diffs
487// Schema against Models and would propose dropping a table Models
488// does not know about.
489var Schema = migrate.MustFromFS(migrationFS, "aviso")
490```
491
492- [ ] **Step 3: Write aviso.go**
493
494```go
495package aviso
496
497import (
498 "database/sql"
499 "errors"
500 "log/slog"
501 "net/http"
502 "strings"
503 "time"
504)
505
506// Config configures New. DB, PrivateKey, Contact and Origin are
507// required.
508type Config struct {
509 // DB is the app's writer. Schema must have been applied.
510 DB *sql.DB
511 // PrivateKey is the VAPID private key: unpadded base64url, 32-byte
512 // P-256 scalar, as cmd/aviso-key prints it. Provisioned, never
513 // minted here — see ErrEmptyPrivateKey.
514 PrivateKey string
515 // Contact is the VAPID "sub" claim — a mailto: or https: URL a push
516 // service may use to reach the operator about abuse.
517 Contact string
518 // Origin is the app's external origin, scheme included, for
519 // csrf.SameOrigin on the mutating handlers.
520 Origin string
521 // Concurrency bounds in-flight sends across the whole Service.
522 // 0 means 32.
523 Concurrency int
524 Logger *slog.Logger
525}
526
527// Subscription is what the browser hands the app: the push service's
528// endpoint and the two keys RFC 8291 encrypts to.
529type Subscription struct {
530 Endpoint string
531 P256dh string
532 Auth string
533}
534
535// Stored is one enrolled device: a Subscription plus its row identity.
536// Revision changes on every re-subscribe, and Send matches on it so a
537// slow send cannot prune a subscription the browser refreshed
538// meanwhile.
539type Stored struct {
540 ID string
541 Subject string
542 VAPIDKeyID string
543 Revision int64
544 Subscription
545}
546
547// ErrOwnedElsewhere is Subscribe's refusal to move an endpoint between
548// subjects: a second account on the same browser must re-enrol, not
549// silently take over the first account's device.
550var ErrOwnedElsewhere = errors.New("aviso: endpoint is enrolled by another subject")
551
552// ErrKeyMismatch marks a Result for a row enrolled under a VAPID key
553// other than this Service's: it cannot be signed for, so it is skipped
554// rather than sent to fail.
555var ErrKeyMismatch = errors.New("aviso: subscription was enrolled under a different VAPID key")
556
557// Service is the wired addon. Build one per process and share it: the
558// concurrency bound lives on it.
559type Service struct {
560 cfg Config
561 pub string
562 keyID string
563 client *http.Client // set by newClient (ssrf.go); tests may replace it
564 sem chan struct{}
565 now func() time.Time
566}
567
568// New validates cfg and returns a ready *Service.
569func New(cfg Config) (*Service, error) {
570 if cfg.DB == nil {
571 return nil, errors.New("aviso: Config.DB is required")
572 }
573 pub, keyID, err := parsePrivateKey(cfg.PrivateKey)
574 if err != nil {
575 return nil, err
576 }
577 if !strings.HasPrefix(cfg.Contact, "mailto:") && !strings.HasPrefix(cfg.Contact, "https://") {
578 return nil, errors.New("aviso: Config.Contact must be a mailto: or https: URL")
579 }
580 if !strings.HasPrefix(cfg.Origin, "https://") && !strings.HasPrefix(cfg.Origin, "http://") {
581 return nil, errors.New("aviso: Config.Origin must be an absolute origin like https://app.example.com")
582 }
583 if cfg.Concurrency <= 0 {
584 cfg.Concurrency = 32
585 }
586 if cfg.Logger == nil {
587 cfg.Logger = slog.Default()
588 }
589 return &Service{
590 cfg: cfg,
591 pub: pub,
592 keyID: keyID,
593 sem: make(chan struct{}, cfg.Concurrency),
594 now: time.Now,
595 }, nil
596}
597
598// PublicKeyString is the applicationServerKey the browser subscribes
599// with: unpadded base64url of the uncompressed P-256 point.
600func (s *Service) PublicKeyString() string { return s.pub }
601```
602
603- [ ] **Step 4: Write the failing tests**
604
605`schema_test.go`:
606```go
607package aviso_test
608
609import (
610 "context"
611 "database/sql"
612 "path/filepath"
613 "testing"
614
615 "amadan.net/rastrillo/rastrillo/db"
616 "amadan.net/rastrillo/rastrillo/migrate"
617
618 "amadan.net/rastrillo/aviso"
619)
620
621// openDB is a fresh on-disk rastrillo db with aviso.Schema applied —
622// what every store and handler test starts from.
623func openDB(t *testing.T) *sql.DB {
624 t.Helper()
625 d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
626 if err != nil {
627 t.Fatalf("db.Open: %v", err)
628 }
629 t.Cleanup(func() { d.Close() })
630 if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil {
631 t.Fatalf("migrate.Apply: %v", err)
632 }
633 return d.Writer()
634}
635
636func TestSchemaCreatesTheTableAndReplaysCleanly(t *testing.T) {
637 d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
638 if err != nil {
639 t.Fatal(err)
640 }
641 defer d.Close()
642 for i := 0; i < 2; i++ { // second Apply must be a no-op, not a failure
643 if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil {
644 t.Fatalf("apply %d: %v", i, err)
645 }
646 }
647 var name string
648 err = d.Writer().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='aviso_subscriptions'`).Scan(&name)
649 if err != nil {
650 t.Fatalf("table missing: %v", err)
651 }
652}
653```
654
655`aviso_test.go`:
656```go
657package aviso_test
658
659import (
660 "errors"
661 "testing"
662
663 "amadan.net/rastrillo/aviso"
664)
665
666func newService(t *testing.T) *aviso.Service {
667 t.Helper()
668 key, err := aviso.GenerateKey()
669 if err != nil {
670 t.Fatal(err)
671 }
672 s, err := aviso.New(aviso.Config{
673 DB: openDB(t), PrivateKey: key,
674 Contact: "mailto:ops@example.test", Origin: "https://app.example.test",
675 })
676 if err != nil {
677 t.Fatal(err)
678 }
679 return s
680}
681
682func TestNewRefusesBadConfig(t *testing.T) {
683 key, _ := aviso.GenerateKey()
684 good := aviso.Config{DB: openDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"}
685 if _, err := aviso.New(good); err != nil {
686 t.Fatalf("good config refused: %v", err)
687 }
688 c := good
689 c.PrivateKey = ""
690 if _, err := aviso.New(c); !errors.Is(err, aviso.ErrEmptyPrivateKey) {
691 t.Errorf("empty key: %v", err)
692 }
693 c = good
694 c.DB = nil
695 if _, err := aviso.New(c); err == nil {
696 t.Error("nil DB accepted")
697 }
698 c = good
699 c.Contact = "ops@example.test"
700 if _, err := aviso.New(c); err == nil {
701 t.Error("bare address accepted as Contact")
702 }
703 c = good
704 c.Origin = "app.example.test"
705 if _, err := aviso.New(c); err == nil {
706 t.Error("schemeless Origin accepted")
707 }
708}
709
710func TestPublicKeyStringIsStable(t *testing.T) {
711 s := newService(t)
712 if s.PublicKeyString() == "" {
713 t.Fatal("empty public key")
714 }
715}
716```
717
718- [ ] **Step 5: Run, expect failure on missing symbols; then run again after Steps 1-3 are in place**
719
720Run: `go test ./... -count=1`
721Expected: PASS (schema applies twice, New validates).
722
723- [ ] **Step 6: Commit, push, Codex review, advance task**
724
725```sh
726git add migrations migrations.go aviso.go schema_test.go aviso_test.go
727git commit -m "Add the schema, Config and Service
728
729One table, no users-table foreign key (apps own identity), key id on
730every row so a rotated VAPID key is visible per subscription. New
731refuses an empty key instead of minting one: a key minted at boot is a
732key lost at the next restore.
733
734Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
735git push
736```
737
738---
739
740### Task 4: Store
741
742**Files:**
743- Create: `store.go`, `store_test.go`
744
745**Interfaces:**
746- Consumes: `Service`, `Stored`, `Subscription`, `ErrOwnedElsewhere`.
747- Produces (all methods on `*Service`, unexported ones used by Tasks 6-7):
748 - `List(ctx, subject string) ([]Stored, error)`
749 - `DeleteSubject(ctx, subject string) error`
750 - `Sweep(ctx, notConfirmedSince time.Time) error`
751 - `put(ctx, subject string, sub Subscription, previousEndpoint string) error` — insert or same-owner update in one transaction; `ErrOwnedElsewhere` if another subject holds the endpoint.
752 - `deleteOwn(ctx, subject, endpoint string) error` — deletes only the caller's row; nil either way.
753 - `confirm(ctx, id string, revision int64) error` — bumps `last_confirmed_at` only if revision matches.
754 - `prune(ctx, id string, revision int64) error` — deletes only if revision matches.
755 - `newID() (string, error)` — 16 random bytes, unpadded base64url.
756
757- [ ] **Step 1: Write the failing tests**
758
759`store_test.go` (package `aviso`, internal, so it can call `put`/`confirm`/`prune`; it needs its own `openDB` since the external one is in `aviso_test`):
760```go
761package aviso
762
763import (
764 "context"
765 "database/sql"
766 "errors"
767 "path/filepath"
768 "testing"
769 "time"
770
771 "amadan.net/rastrillo/rastrillo/db"
772 "amadan.net/rastrillo/rastrillo/migrate"
773)
774
775func openInternalDB(t *testing.T) *sql.DB {
776 t.Helper()
777 d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
778 if err != nil {
779 t.Fatal(err)
780 }
781 t.Cleanup(func() { d.Close() })
782 if _, err := migrate.Apply(context.Background(), d, Schema); err != nil {
783 t.Fatal(err)
784 }
785 return d.Writer()
786}
787
788func newInternalService(t *testing.T) *Service {
789 t.Helper()
790 key, _ := GenerateKey()
791 s, err := New(Config{DB: openInternalDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"})
792 if err != nil {
793 t.Fatal(err)
794 }
795 return s
796}
797
798func sub(endpoint string) Subscription {
799 return Subscription{Endpoint: endpoint, P256dh: "BP256", Auth: "auth"}
800}
801
802func TestPutInsertsThenUpdatesSameOwner(t *testing.T) {
803 s := newInternalService(t)
804 ctx := context.Background()
805 if err := s.put(ctx, "alice", sub("https://push.example/1"), ""); err != nil {
806 t.Fatal(err)
807 }
808 rows, _ := s.List(ctx, "alice")
809 if len(rows) != 1 || rows[0].Revision != 1 || rows[0].VAPIDKeyID != s.keyID {
810 t.Fatalf("after insert: %+v", rows)
811 }
812 again := sub("https://push.example/1")
813 again.Auth = "auth2"
814 if err := s.put(ctx, "alice", again, ""); err != nil {
815 t.Fatal(err)
816 }
817 rows, _ = s.List(ctx, "alice")
818 if len(rows) != 1 || rows[0].Revision != 2 || rows[0].Auth != "auth2" {
819 t.Fatalf("after update: %+v", rows)
820 }
821}
822
823func TestPutRefusesCrossOwner(t *testing.T) {
824 s := newInternalService(t)
825 ctx := context.Background()
826 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
827 err := s.put(ctx, "bob", sub("https://push.example/1"), "")
828 if !errors.Is(err, ErrOwnedElsewhere) {
829 t.Fatalf("got %v, want ErrOwnedElsewhere", err)
830 }
831 rows, _ := s.List(ctx, "alice")
832 if len(rows) != 1 {
833 t.Fatal("alice lost her row")
834 }
835}
836
837func TestPutDeletesPreviousOnlyWhenOwned(t *testing.T) {
838 s := newInternalService(t)
839 ctx := context.Background()
840 _ = s.put(ctx, "alice", sub("https://push.example/old"), "")
841 _ = s.put(ctx, "bob", sub("https://push.example/bobs"), "")
842 // alice re-subscribes and names her old endpoint: gone.
843 if err := s.put(ctx, "alice", sub("https://push.example/new"), "https://push.example/old"); err != nil {
844 t.Fatal(err)
845 }
846 rows, _ := s.List(ctx, "alice")
847 if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
848 t.Fatalf("alice rows: %+v", rows)
849 }
850 // alice names bob's endpoint as previous: bob keeps it.
851 _ = s.put(ctx, "alice", sub("https://push.example/new2"), "https://push.example/bobs")
852 rows, _ = s.List(ctx, "bob")
853 if len(rows) != 1 {
854 t.Fatal("bob's row deleted by alice's previousEndpoint")
855 }
856}
857
858func TestDeleteOwnIsOwnerScoped(t *testing.T) {
859 s := newInternalService(t)
860 ctx := context.Background()
861 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
862 if err := s.deleteOwn(ctx, "bob", "https://push.example/1"); err != nil {
863 t.Fatal(err)
864 }
865 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
866 t.Fatal("bob deleted alice's row")
867 }
868 _ = s.deleteOwn(ctx, "alice", "https://push.example/1")
869 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
870 t.Fatal("own delete did nothing")
871 }
872}
873
874func TestConfirmAndPruneAreRevisionConditional(t *testing.T) {
875 s := newInternalService(t)
876 ctx := context.Background()
877 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
878 before, _ := s.List(ctx, "alice")
879 id := before[0].ID
880 // Browser refreshes: revision 2.
881 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
882 // A send that captured revision 1 comes back 410: must not prune.
883 if err := s.prune(ctx, id, 1); err != nil {
884 t.Fatal(err)
885 }
886 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
887 t.Fatal("stale prune deleted a refreshed subscription")
888 }
889 // Stale confirm must not touch last_confirmed_at.
890 s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
891 _ = s.confirm(ctx, id, 1)
892 var got int64
893 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, id).Scan(&got)
894 if got == 1_800_000_000 {
895 t.Fatal("stale confirm bumped last_confirmed_at")
896 }
897 _ = s.confirm(ctx, id, 2)
898 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, id).Scan(&got)
899 if got != 1_800_000_000 {
900 t.Fatalf("current confirm did not bump: %d", got)
901 }
902 if err := s.prune(ctx, id, 2); err != nil {
903 t.Fatal(err)
904 }
905 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
906 t.Fatal("current prune did not delete")
907 }
908}
909
910func TestSweepAndDeleteSubject(t *testing.T) {
911 s := newInternalService(t)
912 ctx := context.Background()
913 s.now = func() time.Time { return time.Unix(1000, 0) }
914 _ = s.put(ctx, "alice", sub("https://push.example/old"), "")
915 s.now = func() time.Time { return time.Unix(2000, 0) }
916 _ = s.put(ctx, "alice", sub("https://push.example/new"), "")
917 _ = s.put(ctx, "bob", sub("https://push.example/bob"), "")
918 if err := s.Sweep(ctx, time.Unix(1500, 0)); err != nil {
919 t.Fatal(err)
920 }
921 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
922 t.Fatalf("sweep: %+v", rows)
923 }
924 if err := s.DeleteSubject(ctx, "bob"); err != nil {
925 t.Fatal(err)
926 }
927 if rows, _ := s.List(ctx, "bob"); len(rows) != 0 {
928 t.Fatal("DeleteSubject left rows")
929 }
930 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
931 t.Fatal("DeleteSubject touched another subject")
932 }
933}
934```
935
936- [ ] **Step 2: Run to verify failure**
937
938Run: `go test ./... -count=1 -run 'TestPut|TestDeleteOwn|TestConfirm|TestSweep'`
939Expected: FAIL, undefined methods.
940
941- [ ] **Step 3: Implement store.go**
942
943```go
944package aviso
945
946import (
947 "context"
948 "crypto/rand"
949 "database/sql"
950 "encoding/base64"
951 "errors"
952 "fmt"
953 "time"
954)
955
956func newID() (string, error) {
957 b := make([]byte, 16)
958 if _, err := rand.Read(b); err != nil {
959 return "", err
960 }
961 return base64.RawURLEncoding.EncodeToString(b), nil
962}
963
964const selectCols = `id, endpoint, subject, p256dh, auth, vapid_key_id, revision`
965
966func scanStored(rows *sql.Rows) ([]Stored, error) {
967 var out []Stored
968 for rows.Next() {
969 var st Stored
970 if err := rows.Scan(&st.ID, &st.Endpoint, &st.Subject, &st.P256dh, &st.Auth, &st.VAPIDKeyID, &st.Revision); err != nil {
971 return nil, err
972 }
973 out = append(out, st)
974 }
975 return out, rows.Err()
976}
977
978// List returns every device subject has enrolled, oldest first.
979func (s *Service) List(ctx context.Context, subject string) ([]Stored, error) {
980 rows, err := s.cfg.DB.QueryContext(ctx,
981 `SELECT `+selectCols+` FROM aviso_subscriptions WHERE subject = ? ORDER BY created_at, id`, subject)
982 if err != nil {
983 return nil, fmt.Errorf("aviso: list: %w", err)
984 }
985 defer rows.Close()
986 return scanStored(rows)
987}
988
989// DeleteSubject removes every device subject enrolled — account
990// deletion's hook.
991func (s *Service) DeleteSubject(ctx context.Context, subject string) error {
992 _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE subject = ?`, subject)
993 if err != nil {
994 return fmt.Errorf("aviso: delete subject: %w", err)
995 }
996 return nil
997}
998
999// Sweep deletes subscriptions not confirmed since t. Confirmation
1000// moves on reconcile and on an accepted send, so this measures whether
1001// the subscription is alive, not whether the person still wants it.
1002func (s *Service) Sweep(ctx context.Context, t time.Time) error {
1003 _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE last_confirmed_at < ?`, t.Unix())
1004 if err != nil {
1005 return fmt.Errorf("aviso: sweep: %w", err)
1006 }
1007 return nil
1008}
1009
1010// put is Subscribe's write: insert, or update when the same subject
1011// already holds the endpoint, in one transaction with the optional
1012// previousEndpoint delete — which only removes the caller's own row,
1013// so naming someone else's endpoint is a no-op rather than a weapon.
1014func (s *Service) put(ctx context.Context, subject string, sub Subscription, previousEndpoint string) error {
1015 tx, err := s.cfg.DB.BeginTx(ctx, nil)
1016 if err != nil {
1017 return fmt.Errorf("aviso: put: %w", err)
1018 }
1019 defer tx.Rollback()
1020 now := s.now().Unix()
1021 var owner string
1022 err = tx.QueryRowContext(ctx, `SELECT subject FROM aviso_subscriptions WHERE endpoint = ?`, sub.Endpoint).Scan(&owner)
1023 switch {
1024 case err == nil && owner != subject:
1025 return ErrOwnedElsewhere
1026 case err == nil:
1027 _, err = tx.ExecContext(ctx, `UPDATE aviso_subscriptions
1028 SET p256dh = ?, auth = ?, vapid_key_id = ?, revision = revision + 1, last_confirmed_at = ?
1029 WHERE endpoint = ?`, sub.P256dh, sub.Auth, s.keyID, now, sub.Endpoint)
1030 case errors.Is(err, sql.ErrNoRows):
1031 var id string
1032 if id, err = newID(); err != nil {
1033 return err
1034 }
1035 _, err = tx.ExecContext(ctx, `INSERT INTO aviso_subscriptions
1036 (id, endpoint, subject, p256dh, auth, vapid_key_id, revision, created_at, last_confirmed_at)
1037 VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, id, sub.Endpoint, subject, sub.P256dh, sub.Auth, s.keyID, now, now)
1038 }
1039 if err != nil {
1040 return fmt.Errorf("aviso: put: %w", err)
1041 }
1042 if previousEndpoint != "" && previousEndpoint != sub.Endpoint {
1043 if _, err := tx.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE endpoint = ? AND subject = ?`, previousEndpoint, subject); err != nil {
1044 return fmt.Errorf("aviso: put: %w", err)
1045 }
1046 }
1047 return tx.Commit()
1048}
1049
1050// deleteOwn removes endpoint only if subject holds it; nil either way,
1051// because Unsubscribe is idempotent and must not confirm whether an
1052// endpoint exists to someone who does not own it.
1053func (s *Service) deleteOwn(ctx context.Context, subject, endpoint string) error {
1054 _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE endpoint = ? AND subject = ?`, endpoint, subject)
1055 if err != nil {
1056 return fmt.Errorf("aviso: unsubscribe: %w", err)
1057 }
1058 return nil
1059}
1060
1061// confirm bumps last_confirmed_at for an accepted send, only if the
1062// row is still at the revision the send captured.
1063func (s *Service) confirm(ctx context.Context, id string, revision int64) error {
1064 _, err := s.cfg.DB.ExecContext(ctx, `UPDATE aviso_subscriptions SET last_confirmed_at = ? WHERE id = ? AND revision = ?`, s.now().Unix(), id, revision)
1065 return err
1066}
1067
1068// prune deletes a row the push service reported gone, only if it is
1069// still at the revision the send captured: a browser that refreshed
1070// meanwhile has a live subscription under the same id.
1071func (s *Service) prune(ctx context.Context, id string, revision int64) error {
1072 _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE id = ? AND revision = ?`, id, revision)
1073 return err
1074}
1075```
1076
1077- [ ] **Step 4: Run to verify pass**
1078
1079Run: `go test ./... -count=1 && CGO_ENABLED=1 go test ./... -race -count=1`
1080Expected: PASS.
1081
1082- [ ] **Step 5: Commit, push, Codex review, advance task**
1083
1084```sh
1085git add store.go store_test.go
1086git commit -m "Add the subscription store
1087
1088Ownership rules live in one transaction: an endpoint held by another
1089subject is refused rather than reassigned, and previousEndpoint only
1090deletes the caller's own row. confirm and prune match on the revision
1091the send captured, so a slow send cannot delete a subscription the
1092browser refreshed meanwhile.
1093
1094Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1095git push
1096```
1097
1098---
1099
1100### Task 5: SSRF guard and endpoint validation
1101
1102**Files:**
1103- Create: `ssrf.go`, `ssrf_test.go`
1104- Modify: `aviso.go` — `New` sets `client: newClient()`
1105
1106**Interfaces:**
1107- Produces: `func validateEndpoint(raw string) error` (returns `ErrBadEndpoint` wrapped with a reason); `var ErrBadEndpoint error`; `func newClient() *http.Client` (no redirects, no proxy, dial-time IP guard, 30 s timeout); `func guardedIP(ip net.IP) error`.
1108
1109- [ ] **Step 1: Write the failing tests**
1110
1111`ssrf_test.go` (package `aviso`):
1112```go
1113package aviso
1114
1115import (
1116 "context"
1117 "errors"
1118 "net"
1119 "net/http"
1120 "net/http/httptest"
1121 "strings"
1122 "testing"
1123)
1124
1125func TestValidateEndpoint(t *testing.T) {
1126 ok := "https://fcm.googleapis.com/fcm/send/abc"
1127 if err := validateEndpoint(ok); err != nil {
1128 t.Fatalf("good endpoint refused: %v", err)
1129 }
1130 bad := map[string]string{
1131 "http": "http://fcm.googleapis.com/x",
1132 "userinfo": "https://user:pw@fcm.googleapis.com/x",
1133 "fragment": "https://fcm.googleapis.com/x#frag",
1134 "empty": "",
1135 "no host": "https:///x",
1136 "too long": "https://fcm.googleapis.com/" + strings.Repeat("a", 2048),
1137 "loopback": "https://127.0.0.1/x",
1138 "ip6 loop": "https://[::1]/x",
1139 "private": "https://10.0.0.5/x",
1140 "linklocal": "https://169.254.169.254/latest",
1141 "mapped": "https://[::ffff:10.0.0.5]/x",
1142 }
1143 for name, in := range bad {
1144 if err := validateEndpoint(in); !errors.Is(err, ErrBadEndpoint) {
1145 t.Errorf("%s (%q): got %v, want ErrBadEndpoint", name, in, err)
1146 }
1147 }
1148}
1149
1150func TestGuardedIP(t *testing.T) {
1151 for _, ip := range []string{"127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.1.1", "::1", "fe80::1", "fc00::1", "::ffff:192.168.1.1", "0.0.0.0", "100.64.0.1"} {
1152 if err := guardedIP(net.ParseIP(ip)); err == nil {
1153 t.Errorf("%s allowed", ip)
1154 }
1155 }
1156 for _, ip := range []string{"142.250.72.14", "2607:f8b0::1"} {
1157 if err := guardedIP(net.ParseIP(ip)); err != nil {
1158 t.Errorf("%s refused: %v", ip, err)
1159 }
1160 }
1161}
1162
1163// The guard is at connect time, so a hostname that resolves to a
1164// loopback address — DNS rebinding's shape — fails even though the URL
1165// looked fine. httptest's server IS loopback, which makes it the
1166// perfect hostile target.
1167func TestClientRefusesLoopbackAtDial(t *testing.T) {
1168 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
1169 defer srv.Close()
1170 c := newClient()
1171 c.Transport.(*http.Transport).TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig
1172 req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, strings.Replace(srv.URL, "127.0.0.1", "localhost", 1), nil)
1173 _, err := c.Do(req)
1174 if err == nil || !strings.Contains(err.Error(), "aviso") {
1175 t.Fatalf("loopback dial allowed or wrong error: %v", err)
1176 }
1177}
1178
1179func TestClientRefusesRedirects(t *testing.T) {
1180 c := newClient()
1181 req, _ := http.NewRequest(http.MethodGet, "https://example.invalid/", nil)
1182 resp := &http.Response{StatusCode: 302}
1183 if err := c.CheckRedirect(req, []*http.Request{req}); err == nil {
1184 t.Fatalf("redirect followed: %v", resp)
1185 }
1186}
1187```
1188
1189- [ ] **Step 2: Run to verify failure**
1190
1191Run: `go test ./... -count=1 -run 'TestValidateEndpoint|TestGuardedIP|TestClient'`
1192Expected: FAIL, undefined.
1193
1194- [ ] **Step 3: Implement ssrf.go**
1195
1196```go
1197package aviso
1198
1199import (
1200 "errors"
1201 "fmt"
1202 "net"
1203 "net/http"
1204 "net/url"
1205 "syscall"
1206 "time"
1207)
1208
1209// ErrBadEndpoint means a subscription endpoint was refused before any
1210// request: wrong scheme, credentials, fragment, too long, or an
1211// address no push service lives at.
1212var ErrBadEndpoint = errors.New("aviso: endpoint refused")
1213
1214const maxEndpointLen = 2048
1215
1216// validateEndpoint is the request-time half of the SSRF guard: shape
1217// and literal-IP checks. The dial-time half (guardedIP in the dialer's
1218// Control) catches what a hostname resolves to, which this cannot.
1219func validateEndpoint(raw string) error {
1220 if raw == "" || len(raw) > maxEndpointLen {
1221 return fmt.Errorf("%w: empty or over %d bytes", ErrBadEndpoint, maxEndpointLen)
1222 }
1223 u, err := url.Parse(raw)
1224 if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.RawFragment != "" {
1225 return fmt.Errorf("%w: must be https, no credentials, no fragment", ErrBadEndpoint)
1226 }
1227 if ip := net.ParseIP(u.Hostname()); ip != nil {
1228 if err := guardedIP(ip); err != nil {
1229 return fmt.Errorf("%w: %v", ErrBadEndpoint, err)
1230 }
1231 }
1232 return nil
1233}
1234
1235// guardedIP refuses every address a push service cannot legitimately
1236// have: loopback, private, link-local, unspecified, CGNAT and their
1237// IPv4-mapped forms. Applied at connect time so DNS rebinding after
1238// validation still fails.
1239func guardedIP(ip net.IP) error {
1240 if ip4 := ip.To4(); ip4 != nil {
1241 ip = ip4
1242 }
1243 switch {
1244 case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(),
1245 ip.IsUnspecified(), ip.IsMulticast(), ip.IsInterfaceLocalMulticast():
1246 return fmt.Errorf("address %s is not routable to a push service", ip)
1247 }
1248 if ip.To4() != nil && ip[0] == 100 && ip[1]&0xc0 == 64 { // 100.64.0.0/10
1249 return fmt.Errorf("address %s is not routable to a push service", ip)
1250 }
1251 return nil
1252}
1253
1254// newClient is the only HTTP client that ever talks to a push
1255// service: no redirects (a push service never redirects, and following
1256// one is how a validated URL turns into an internal one), no proxy
1257// from the environment (same reason), and the IP guard inside the
1258// dialer's Control so it runs on the address actually connected to.
1259func newClient() *http.Client {
1260 dialer := &net.Dialer{
1261 Timeout: 10 * time.Second,
1262 Control: func(network, address string, _ syscall.RawConn) error {
1263 host, _, err := net.SplitHostPort(address)
1264 if err != nil {
1265 return fmt.Errorf("aviso: dial %q: %w", address, err)
1266 }
1267 ip := net.ParseIP(host)
1268 if ip == nil {
1269 return fmt.Errorf("aviso: dial: %q is not an IP", host)
1270 }
1271 if err := guardedIP(ip); err != nil {
1272 return fmt.Errorf("aviso: dial refused: %w", err)
1273 }
1274 return nil
1275 },
1276 }
1277 return &http.Client{
1278 Timeout: 30 * time.Second,
1279 Transport: &http.Transport{
1280 Proxy: nil,
1281 DialContext: dialer.DialContext,
1282 TLSHandshakeTimeout: 10 * time.Second,
1283 MaxIdleConns: 64,
1284 IdleConnTimeout: 90 * time.Second,
1285 },
1286 CheckRedirect: func(*http.Request, []*http.Request) error {
1287 return errors.New("aviso: push service redirected; refused")
1288 },
1289 }
1290}
1291```
1292
1293And in `aviso.go`'s `New`, add `client: newClient(),` to the struct literal.
1294
1295- [ ] **Step 4: Run to verify pass**
1296
1297Run: `go test ./... -count=1 -run 'TestValidateEndpoint|TestGuardedIP|TestClient' -v`
1298Expected: PASS ×4. If `TestClientRefusesLoopbackAtDial` fails because the TLS config assignment panics, replace the client's Transport TLS config with `srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone()` before the request.
1299
1300- [ ] **Step 5: Commit, push, Codex review, advance task**
1301
1302```sh
1303git add ssrf.go ssrf_test.go aviso.go
1304git commit -m "Add the SSRF guard: endpoint validation and a dial-time IP check
1305
1306Two halves on purpose. Validation catches shape and literal IPs at
1307subscribe time; the dialer's Control runs on the address actually
1308connected to, so a hostname that resolves to loopback after validation
1309(DNS rebinding) still fails. No redirects and no proxy for the same
1310reason: both turn a validated URL into an unvalidated one.
1311
1312Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1313git push
1314```
1315
1316---
1317
1318### Task 6: Send and SendTo
1319
1320**Files:**
1321- Create: `send.go`, `send_test.go`
1322
1323**Interfaces:**
1324- Consumes: `Service.client`, `Service.sem`, `confirm`, `prune`, `List`, `ErrKeyMismatch`.
1325- Produces: `type Options struct{ TTL time.Duration; Urgency, Topic string }`, `type Result struct{ ID string; Status int; RetryAfter time.Duration; Err error }`, `var ErrPayloadTooLarge, ErrBadOptions error`, `func (s *Service) Send(ctx, to []Stored, payload []byte, o Options) ([]Result, error)`, `func (s *Service) SendTo(ctx, subject string, payload []byte, o Options) ([]Result, error)`.
1326
1327- [ ] **Step 1: Write the failing tests**
1328
1329`send_test.go` (package `aviso`). The test push service is an httptest TLS server; the SSRF guard would refuse it, so the test swaps `s.client` for the server's own client — the private seam the spec names. Endpoints in rows use the server URL, which `put` accepts because `validateEndpoint` is only applied by the handlers (Task 7), not by `put`.
1330
1331```go
1332package aviso
1333
1334import (
1335 "context"
1336 "errors"
1337 "net/http"
1338 "net/http/httptest"
1339 "strings"
1340 "sync/atomic"
1341 "testing"
1342 "time"
1343)
1344
1345type pushRecorder struct {
1346 srv *httptest.Server
1347 status atomic.Int32
1348 hdr chan http.Header
1349 retry string
1350}
1351
1352func newPushRecorder(t *testing.T) *pushRecorder {
1353 t.Helper()
1354 p := &pushRecorder{hdr: make(chan http.Header, 64)}
1355 p.status.Store(201)
1356 p.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1357 p.hdr <- r.Header.Clone()
1358 if p.retry != "" {
1359 w.Header().Set("Retry-After", p.retry)
1360 }
1361 w.WriteHeader(int(p.status.Load()))
1362 }))
1363 t.Cleanup(p.srv.Close)
1364 return p
1365}
1366
1367func serviceAgainst(t *testing.T, p *pushRecorder) *Service {
1368 t.Helper()
1369 s := newInternalService(t)
1370 s.client = p.srv.Client() // the private seam: production guards stay in newClient
1371 return s
1372}
1373
1374func TestSendToSetsHeadersAndConfirms(t *testing.T) {
1375 p := newPushRecorder(t)
1376 s := serviceAgainst(t, p)
1377 ctx := context.Background()
1378 _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
1379 s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
1380 res, err := s.SendTo(ctx, "alice", []byte(`{"title":"hi"}`), Options{TTL: 90 * time.Second, Urgency: "high", Topic: "t1"})
1381 if err != nil || len(res) != 1 || res[0].Err != nil || res[0].Status != 201 {
1382 t.Fatalf("res=%+v err=%v", res, err)
1383 }
1384 h := <-p.hdr
1385 if h.Get("TTL") != "90" || h.Get("Urgency") != "high" || h.Get("Topic") != "t1" ||
1386 !strings.HasPrefix(h.Get("Authorization"), "vapid t=") || h.Get("Content-Encoding") != "aes128gcm" {
1387 t.Fatalf("headers: %v", h)
1388 }
1389 var confirmed int64
1390 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE subject='alice'`).Scan(&confirmed)
1391 if confirmed != 1_800_000_000 {
1392 t.Fatalf("2xx did not confirm: %d", confirmed)
1393 }
1394}
1395
1396func TestSendPrunesOnGoneOnlyAtSameRevision(t *testing.T) {
1397 p := newPushRecorder(t)
1398 s := serviceAgainst(t, p)
1399 ctx := context.Background()
1400 _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
1401 rows, _ := s.List(ctx, "alice")
1402 stale := rows[0]
1403 _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "") // revision 2
1404 p.status.Store(410)
1405 res, _ := s.Send(ctx, []Stored{stale}, []byte("x"), Options{})
1406 if res[0].Status != 410 || res[0].Err == nil {
1407 t.Fatalf("res=%+v", res)
1408 }
1409 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
1410 t.Fatal("410 at a stale revision pruned a refreshed row")
1411 }
1412 current, _ := s.List(ctx, "alice")
1413 _, _ = s.Send(ctx, current, []byte("x"), Options{})
1414 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
1415 t.Fatal("410 at the current revision did not prune")
1416 }
1417}
1418
1419func TestSendReportsRetryAfter(t *testing.T) {
1420 p := newPushRecorder(t)
1421 p.retry = "120"
1422 p.status.Store(429)
1423 s := serviceAgainst(t, p)
1424 ctx := context.Background()
1425 _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
1426 res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
1427 if res[0].RetryAfter != 120*time.Second || res[0].Err == nil {
1428 t.Fatalf("res=%+v", res)
1429 }
1430}
1431
1432func TestSendSkipsRowsUnderAnotherKey(t *testing.T) {
1433 p := newPushRecorder(t)
1434 s := serviceAgainst(t, p)
1435 ctx := context.Background()
1436 _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
1437 _, _ = s.cfg.DB.Exec(`UPDATE aviso_subscriptions SET vapid_key_id = 'other'`)
1438 res, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
1439 if err != nil || len(res) != 1 || !errors.Is(res[0].Err, ErrKeyMismatch) {
1440 t.Fatalf("res=%+v err=%v", res, err)
1441 }
1442 select {
1443 case <-p.hdr:
1444 t.Fatal("sent despite key mismatch")
1445 default:
1446 }
1447}
1448
1449func TestSendValidatesPayloadAndOptions(t *testing.T) {
1450 s := newInternalService(t)
1451 ctx := context.Background()
1452 if _, err := s.Send(ctx, nil, make([]byte, 3994), Options{}); !errors.Is(err, ErrPayloadTooLarge) {
1453 t.Errorf("oversize payload: %v", err)
1454 }
1455 for name, o := range map[string]Options{
1456 "neg ttl": {TTL: -time.Second},
1457 "frac ttl": {TTL: 1500 * time.Millisecond},
1458 "urgency": {Urgency: "urgent"},
1459 "topic chars": {Topic: "a b"},
1460 "topic long": {Topic: strings.Repeat("a", 33)},
1461 } {
1462 if _, err := s.Send(ctx, nil, []byte("x"), o); !errors.Is(err, ErrBadOptions) {
1463 t.Errorf("%s: %v", name, err)
1464 }
1465 }
1466}
1467
1468func TestSendHonoursCancellation(t *testing.T) {
1469 p := newPushRecorder(t)
1470 s := serviceAgainst(t, p)
1471 ctx, cancel := context.WithCancel(context.Background())
1472 cancel()
1473 _ = s.put(context.Background(), "alice", sub(p.srv.URL+"/one"), "")
1474 _, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
1475 if !errors.Is(err, context.Canceled) {
1476 t.Fatalf("got %v, want context.Canceled", err)
1477 }
1478}
1479
1480func TestSendRedactsEndpointFromErrors(t *testing.T) {
1481 s := newInternalService(t)
1482 ctx := context.Background()
1483 // Unroutable endpoint: the guard refuses at dial; the error must
1484 // not carry the URL.
1485 _ = s.put(ctx, "alice", sub("https://10.0.0.9/secret-path"), "")
1486 res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
1487 if res[0].Err == nil || strings.Contains(res[0].Err.Error(), "secret-path") {
1488 t.Fatalf("error carries the endpoint: %v", res[0].Err)
1489 }
1490}
1491```
1492
1493- [ ] **Step 2: Run to verify failure**
1494
1495Run: `go test ./... -count=1 -run 'TestSend'`
1496Expected: FAIL, undefined.
1497
1498- [ ] **Step 3: Implement send.go**
1499
1500```go
1501package aviso
1502
1503import (
1504 "context"
1505 "errors"
1506 "fmt"
1507 "io"
1508 "net/http"
1509 "net/url"
1510 "strconv"
1511 "strings"
1512 "time"
1513
1514 webpush "github.com/SherClockHolmes/webpush-go"
1515)
1516
1517// Options tune one batch. Zero values mean the push service's
1518// defaults.
1519type Options struct {
1520 // TTL is how long the push service may hold the message; whole
1521 // seconds, >= 0. 0 means the service's default.
1522 TTL time.Duration
1523 // Urgency is "very-low", "low", "normal" or "high"; "" means normal.
1524 Urgency string
1525 // Topic collapses pending messages with the same topic; <= 32
1526 // URL-safe characters; "" means none.
1527 Topic string
1528}
1529
1530// Result is one device's outcome. Status is the push service's
1531// acceptance, not delivery; a 2xx means the service took it.
1532type Result struct {
1533 ID string
1534 Status int // 0 when Err is transport-level
1535 RetryAfter time.Duration // from a 429/503, else 0
1536 Err error
1537}
1538
1539// ErrPayloadTooLarge means the plaintext exceeds RFC 8291's one-record
1540// limit; a larger payload would be split, which no browser accepts.
1541var ErrPayloadTooLarge = errors.New("aviso: payload over 3993 bytes")
1542
1543// ErrBadOptions means Options failed validation.
1544var ErrBadOptions = errors.New("aviso: invalid Options")
1545
1546const maxPayload = 3993
1547
1548func (o Options) validate() error {
1549 if o.TTL < 0 || o.TTL%time.Second != 0 {
1550 return fmt.Errorf("%w: TTL must be whole non-negative seconds", ErrBadOptions)
1551 }
1552 switch o.Urgency {
1553 case "", "very-low", "low", "normal", "high":
1554 default:
1555 return fmt.Errorf("%w: Urgency %q", ErrBadOptions, o.Urgency)
1556 }
1557 if len(o.Topic) > 32 || strings.Trim(o.Topic, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
1558 return fmt.Errorf("%w: Topic must be <= 32 URL-safe characters", ErrBadOptions)
1559 }
1560 return nil
1561}
1562
1563// SendTo fans payload out to every device subject enrolled — the
1564// common case, so an app never touches Stored. The batch error covers
1565// what stops the batch (the query, validation, cancellation); each
1566// Result covers one device.
1567func (s *Service) SendTo(ctx context.Context, subject string, payload []byte, o Options) ([]Result, error) {
1568 if err := s.checkBatch(ctx, payload, o); err != nil {
1569 return nil, err
1570 }
1571 rows, err := s.List(ctx, subject)
1572 if err != nil {
1573 return nil, err
1574 }
1575 return s.Send(ctx, rows, payload, o)
1576}
1577
1578// Send delivers payload to each of to, bounded by Config.Concurrency
1579// across the Service. It never retries: RetryAfter is for the app's
1580// own scheduler.
1581func (s *Service) Send(ctx context.Context, to []Stored, payload []byte, o Options) ([]Result, error) {
1582 if err := s.checkBatch(ctx, payload, o); err != nil {
1583 return nil, err
1584 }
1585 results := make([]Result, len(to))
1586 done := make(chan struct{})
1587 for i, st := range to {
1588 i, st := i, st
1589 if st.VAPIDKeyID != s.keyID {
1590 results[i] = Result{ID: st.ID, Err: ErrKeyMismatch}
1591 continue
1592 }
1593 select {
1594 case s.sem <- struct{}{}:
1595 case <-ctx.Done():
1596 return results, ctx.Err()
1597 }
1598 go func() {
1599 defer func() { <-s.sem; done <- struct{}{} }()
1600 results[i] = s.sendOne(ctx, st, payload, o)
1601 }()
1602 }
1603 for range countSends(results, to) {
1604 <-done
1605 }
1606 return results, nil
1607}
1608
1609// countSends is how many goroutines Send started: every row not
1610// short-circuited by a key mismatch.
1611func countSends(results []Result, to []Stored) int {
1612 n := 0
1613 for i := range to {
1614 if !errors.Is(results[i].Err, ErrKeyMismatch) {
1615 n++
1616 }
1617 }
1618 return n
1619}
1620
1621func (s *Service) checkBatch(ctx context.Context, payload []byte, o Options) error {
1622 if err := ctx.Err(); err != nil {
1623 return err
1624 }
1625 if len(payload) > maxPayload {
1626 return ErrPayloadTooLarge
1627 }
1628 return o.validate()
1629}
1630
1631func (s *Service) sendOne(ctx context.Context, st Stored, payload []byte, o Options) Result {
1632 res := Result{ID: st.ID}
1633 ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
1634 defer cancel()
1635 urgency := webpush.Urgency(o.Urgency)
1636 if urgency == "" {
1637 urgency = webpush.UrgencyNormal
1638 }
1639 resp, err := webpush.SendNotificationWithContext(ctx, payload,
1640 &webpush.Subscription{Endpoint: st.Endpoint, Keys: webpush.Keys{P256dh: st.P256dh, Auth: st.Auth}},
1641 &webpush.Options{
1642 HTTPClient: s.client,
1643 Subscriber: s.cfg.Contact,
1644 TTL: int(o.TTL / time.Second),
1645 Urgency: urgency,
1646 Topic: o.Topic,
1647 VAPIDPublicKey: s.pub,
1648 VAPIDPrivateKey: s.cfg.PrivateKey,
1649 })
1650 if err != nil {
1651 res.Err = redact(err)
1652 return res
1653 }
1654 defer resp.Body.Close()
1655 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) // drain for keep-alive; never logged
1656 res.Status = resp.StatusCode
1657 switch {
1658 case resp.StatusCode >= 200 && resp.StatusCode < 300:
1659 if err := s.confirm(context.WithoutCancel(ctx), st.ID, st.Revision); err != nil {
1660 s.cfg.Logger.Warn("aviso: confirm failed", "id", st.ID, "err", err)
1661 }
1662 case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
1663 res.Err = fmt.Errorf("aviso: push service says subscription gone (%d)", resp.StatusCode)
1664 if err := s.prune(context.WithoutCancel(ctx), st.ID, st.Revision); err != nil {
1665 s.cfg.Logger.Warn("aviso: prune failed", "id", st.ID, "err", err)
1666 }
1667 case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable:
1668 res.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), s.now())
1669 res.Err = fmt.Errorf("aviso: push service throttled (%d)", resp.StatusCode)
1670 default:
1671 res.Err = fmt.Errorf("aviso: push service refused (%d)", resp.StatusCode)
1672 }
1673 return res
1674}
1675
1676// redact strips the request URL from a transport error: *url.Error
1677// prints it, and the endpoint is the one secret in this package that
1678// would otherwise reach a log.
1679func redact(err error) error {
1680 var ue *url.Error
1681 if errors.As(err, &ue) {
1682 return fmt.Errorf("aviso: %s: %w", ue.Op, ue.Err)
1683 }
1684 return fmt.Errorf("aviso: send: %w", err)
1685}
1686
1687func parseRetryAfter(v string, now time.Time) time.Duration {
1688 if v == "" {
1689 return 0
1690 }
1691 if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
1692 return time.Duration(secs) * time.Second
1693 }
1694 if t, err := http.ParseTime(v); err == nil && t.After(now) {
1695 return t.Sub(now)
1696 }
1697 return 0
1698}
1699```
1700
1701- [ ] **Step 4: Run to verify pass, including -race**
1702
1703Run: `go test ./... -count=1 -run TestSend -v && CGO_ENABLED=1 go test ./... -race -count=1`
1704Expected: PASS. Watch `TestSendRedactsEndpointFromErrors`: the guard's dial error wraps the IP:port, not the path — assert only that `secret-path` is absent.
1705
1706- [ ] **Step 5: Commit, push, Codex review, advance task**
1707
1708```sh
1709git add send.go send_test.go
1710git commit -m "Add Send and SendTo over webpush-go
1711
1712webpush-go's types stay behind the package boundary. Results report
1713acceptance per device and the batch error what stopped the batch, so a
1714failed query never reads as zero devices. 404/410 prune and 2xx confirm
1715both match the captured revision; transport errors are stripped of the
1716endpoint URL before they can reach a log.
1717
1718Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1719git push
1720```
1721
1722---
1723
1724### Task 7: HTTP handlers
1725
1726**Files:**
1727- Create: `http.go`, `http_test.go`
1728
1729**Interfaces:**
1730- Consumes: `sessions.Current`, `csrf.SameOrigin`, `validateEndpoint`, `put`, `deleteOwn`, `Service.pub`, `ErrOwnedElsewhere`.
1731- Produces: `func (s *Service) PublicKey(w, r)`, `Subscribe(w, r)`, `Unsubscribe(w, r)`.
1732
1733- [ ] **Step 1: Write the failing tests**
1734
1735`http_test.go` (package `aviso_test`; uses `openDB`/`newService` from Task 3):
1736```go
1737package aviso_test
1738
1739import (
1740 "encoding/json"
1741 "net/http"
1742 "net/http/httptest"
1743 "strings"
1744 "testing"
1745
1746 "amadan.net/rastrillo/rastrillo/sessions"
1747
1748 "amadan.net/rastrillo/aviso"
1749)
1750
1751func subscribeBody(s *aviso.Service, endpoint string) string {
1752 b, _ := json.Marshal(map[string]any{
1753 "subscription": map[string]any{"endpoint": endpoint, "keys": map[string]string{"p256dh": "BP", "auth": "au"}},
1754 "publicKey": s.PublicKeyString(),
1755 })
1756 return string(b)
1757}
1758
1759func post(t *testing.T, h http.HandlerFunc, body, subject string, sameOrigin bool) *httptest.ResponseRecorder {
1760 t.Helper()
1761 r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(body))
1762 r.Header.Set("Content-Type", "application/json")
1763 if sameOrigin {
1764 r.Header.Set("Sec-Fetch-Site", "same-origin")
1765 } else {
1766 r.Header.Set("Sec-Fetch-Site", "cross-site")
1767 }
1768 if subject != "" {
1769 r = sessions.WithSession(r, sessions.Session{Subject: subject})
1770 }
1771 w := httptest.NewRecorder()
1772 h(w, r)
1773 return w
1774}
1775
1776func TestPublicKey(t *testing.T) {
1777 s := newService(t)
1778 w := httptest.NewRecorder()
1779 s.PublicKey(w, httptest.NewRequest(http.MethodGet, "/aviso/public-key", nil))
1780 var got struct{ PublicKey string }
1781 if err := json.NewDecoder(w.Body).Decode(&got); err != nil || got.PublicKey != s.PublicKeyString() {
1782 t.Fatalf("status %d body %s", w.Code, w.Body)
1783 }
1784 if w.Header().Get("Cache-Control") != "no-cache" {
1785 t.Fatal("public key cacheable")
1786 }
1787}
1788
1789func TestSubscribeGating(t *testing.T) {
1790 s := newService(t)
1791 body := subscribeBody(s, "https://push.example/e1")
1792 if w := post(t, s.Subscribe, body, "", true); w.Code != http.StatusUnauthorized {
1793 t.Errorf("no session: %d", w.Code)
1794 }
1795 if w := post(t, s.Subscribe, body, "alice", false); w.Code != http.StatusForbidden {
1796 t.Errorf("cross-site: %d", w.Code)
1797 }
1798 if w := post(t, s.Subscribe, body, "alice", true); w.Code != http.StatusNoContent {
1799 t.Errorf("good: %d %s", w.Code, w.Body)
1800 }
1801 if rows, _ := s.List(t.Context(), "alice"); len(rows) != 1 {
1802 t.Fatal("row not stored")
1803 }
1804 if w := post(t, s.Subscribe, body, "bob", true); w.Code != http.StatusConflict {
1805 t.Errorf("cross-owner: %d", w.Code)
1806 }
1807 wrongKey := strings.Replace(body, s.PublicKeyString(), "BOTHER", 1)
1808 if w := post(t, s.Subscribe, wrongKey, "alice", true); w.Code != http.StatusConflict {
1809 t.Errorf("wrong key: %d", w.Code)
1810 }
1811 if w := post(t, s.Subscribe, subscribeBody(s, "http://push.example/e1"), "alice", true); w.Code != http.StatusBadRequest {
1812 t.Errorf("http endpoint: %d", w.Code)
1813 }
1814 if w := post(t, s.Subscribe, strings.Repeat("x", 8193), "alice", true); w.Code != http.StatusRequestEntityTooLarge && w.Code != http.StatusBadRequest {
1815 t.Errorf("oversize body: %d", w.Code)
1816 }
1817 r := httptest.NewRequest(http.MethodGet, "/aviso/subscribe", nil)
1818 w := httptest.NewRecorder()
1819 s.Subscribe(w, r)
1820 if w.Code != http.StatusMethodNotAllowed {
1821 t.Errorf("GET: %d", w.Code)
1822 }
1823}
1824
1825func TestSubscribeHonoursPreviousEndpoint(t *testing.T) {
1826 s := newService(t)
1827 _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true)
1828 b, _ := json.Marshal(map[string]any{
1829 "subscription": map[string]any{"endpoint": "https://push.example/new", "keys": map[string]string{"p256dh": "BP", "auth": "au"}},
1830 "publicKey": s.PublicKeyString(),
1831 "previousEndpoint": "https://push.example/old",
1832 })
1833 if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent {
1834 t.Fatalf("%d %s", w.Code, w.Body)
1835 }
1836 rows, _ := s.List(t.Context(), "alice")
1837 if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
1838 t.Fatalf("rows: %+v", rows)
1839 }
1840}
1841
1842func TestUnsubscribe(t *testing.T) {
1843 s := newService(t)
1844 _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true)
1845 body := `{"endpoint":"https://push.example/e1"}`
1846 if w := post(t, s.Unsubscribe, body, "", true); w.Code != http.StatusUnauthorized {
1847 t.Errorf("no session: %d", w.Code)
1848 }
1849 if w := post(t, s.Unsubscribe, body, "bob", true); w.Code != http.StatusNoContent {
1850 t.Errorf("other subject: %d", w.Code)
1851 }
1852 if rows, _ := s.List(t.Context(), "alice"); len(rows) != 1 {
1853 t.Fatal("bob's unsubscribe removed alice's row")
1854 }
1855 if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent {
1856 t.Errorf("own: %d", w.Code)
1857 }
1858 if rows, _ := s.List(t.Context(), "alice"); len(rows) != 0 {
1859 t.Fatal("own unsubscribe did nothing")
1860 }
1861 if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent {
1862 t.Errorf("repeat: %d", w.Code)
1863 }
1864}
1865```
1866
1867- [ ] **Step 2: Run to verify failure**
1868
1869Run: `go test ./... -count=1 -run 'TestPublicKey|TestSubscribe|TestUnsubscribe'`
1870Expected: FAIL, undefined.
1871
1872- [ ] **Step 3: Implement http.go**
1873
1874```go
1875package aviso
1876
1877import (
1878 "encoding/json"
1879 "errors"
1880 "io"
1881 "net/http"
1882
1883 "amadan.net/rastrillo/rastrillo/csrf"
1884 "amadan.net/rastrillo/rastrillo/sessions"
1885)
1886
1887const maxSubscribeBody = 8192
1888
1889// PublicKey answers GET with {"publicKey": ...}. no-cache so a rotated
1890// key reaches browsers on their next load rather than after a cache
1891// expiry nobody chose.
1892func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request) {
1893 if r.Method != http.MethodGet {
1894 w.Header().Set("Allow", http.MethodGet)
1895 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1896 return
1897 }
1898 w.Header().Set("Content-Type", "application/json")
1899 w.Header().Set("Cache-Control", "no-cache")
1900 _ = json.NewEncoder(w).Encode(map[string]string{"publicKey": s.pub})
1901}
1902
1903// gate is what both mutations require: POST, a session with a subject,
1904// and a same-origin request. It writes the refusal and returns "" when
1905// the caller must stop.
1906func (s *Service) gate(w http.ResponseWriter, r *http.Request) string {
1907 if r.Method != http.MethodPost {
1908 w.Header().Set("Allow", http.MethodPost)
1909 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1910 return ""
1911 }
1912 sess, ok := sessions.Current(r)
1913 if !ok || sess.Subject == "" {
1914 http.Error(w, "sign in first", http.StatusUnauthorized)
1915 return ""
1916 }
1917 if !csrf.SameOrigin(r, s.cfg.Origin) {
1918 http.Error(w, "cross-origin request refused", http.StatusForbidden)
1919 return ""
1920 }
1921 return sess.Subject
1922}
1923
1924func decodeBody(w http.ResponseWriter, r *http.Request, into any) bool {
1925 body := http.MaxBytesReader(w, r.Body, maxSubscribeBody)
1926 dec := json.NewDecoder(body)
1927 dec.DisallowUnknownFields()
1928 if err := dec.Decode(into); err != nil {
1929 var mbe *http.MaxBytesError
1930 if errors.As(err, &mbe) {
1931 http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
1932 return false
1933 }
1934 http.Error(w, "bad request body", http.StatusBadRequest)
1935 return false
1936 }
1937 if _, err := io.Copy(io.Discard, body); err != nil { // trailing bytes over the cap
1938 http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
1939 return false
1940 }
1941 return true
1942}
1943
1944type subscribeRequest struct {
1945 Subscription struct {
1946 Endpoint string `json:"endpoint"`
1947 Keys struct {
1948 P256dh string `json:"p256dh"`
1949 Auth string `json:"auth"`
1950 } `json:"keys"`
1951 } `json:"subscription"`
1952 PublicKey string `json:"publicKey"`
1953 PreviousEndpoint string `json:"previousEndpoint"`
1954}
1955
1956// Subscribe stores the caller's subscription. 409 when the endpoint is
1957// another subject's or the browser subscribed under a key that is not
1958// ours — storing that row would be storing one nothing can sign for.
1959func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request) {
1960 subject := s.gate(w, r)
1961 if subject == "" {
1962 return
1963 }
1964 var req subscribeRequest
1965 if !decodeBody(w, r, &req) {
1966 return
1967 }
1968 if req.PublicKey != s.pub {
1969 http.Error(w, "subscribed under a different application server key; re-enrol", http.StatusConflict)
1970 return
1971 }
1972 if err := validateEndpoint(req.Subscription.Endpoint); err != nil {
1973 http.Error(w, "endpoint refused", http.StatusBadRequest)
1974 return
1975 }
1976 if req.Subscription.Keys.P256dh == "" || req.Subscription.Keys.Auth == "" {
1977 http.Error(w, "subscription keys missing", http.StatusBadRequest)
1978 return
1979 }
1980 if req.PreviousEndpoint != "" && validateEndpoint(req.PreviousEndpoint) != nil {
1981 http.Error(w, "previousEndpoint refused", http.StatusBadRequest)
1982 return
1983 }
1984 sub := Subscription{Endpoint: req.Subscription.Endpoint, P256dh: req.Subscription.Keys.P256dh, Auth: req.Subscription.Keys.Auth}
1985 switch err := s.put(r.Context(), subject, sub, req.PreviousEndpoint); {
1986 case errors.Is(err, ErrOwnedElsewhere):
1987 http.Error(w, "endpoint enrolled by another account", http.StatusConflict)
1988 case err != nil:
1989 s.cfg.Logger.Error("aviso: subscribe", "err", err)
1990 http.Error(w, "could not store subscription", http.StatusInternalServerError)
1991 default:
1992 w.WriteHeader(http.StatusNoContent)
1993 }
1994}
1995
1996// Unsubscribe removes the caller's own row for the endpoint. 204
1997// whether or not it existed: the endpoint's existence is not the
1998// caller's to learn unless they own it.
1999func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request) {
2000 subject := s.gate(w, r)
2001 if subject == "" {
2002 return
2003 }
2004 var req struct {
2005 Endpoint string `json:"endpoint"`
2006 }
2007 if !decodeBody(w, r, &req) {
2008 return
2009 }
2010 if req.Endpoint == "" || len(req.Endpoint) > maxEndpointLen {
2011 http.Error(w, "endpoint missing", http.StatusBadRequest)
2012 return
2013 }
2014 if err := s.deleteOwn(r.Context(), subject, req.Endpoint); err != nil {
2015 s.cfg.Logger.Error("aviso: unsubscribe", "err", err)
2016 http.Error(w, "could not remove subscription", http.StatusInternalServerError)
2017 return
2018 }
2019 w.WriteHeader(http.StatusNoContent)
2020}
2021```
2022
2023- [ ] **Step 4: Run to verify pass**
2024
2025Run: `make ci`
2026Expected: PASS. If `t.Context()` is unavailable, use `context.Background()`.
2027
2028- [ ] **Step 5: Commit, push, Codex review, advance task**
2029
2030```sh
2031git add http.go http_test.go
2032git commit -m "Add the three handlers, gated by session and origin
2033
2034Ownership comes from sessions.Current, never the body. A subscription
2035made under a different application server key is refused rather than
2036stored unsendable. Unsubscribe is 204 either way so an endpoint's
2037existence is not learnable by someone who does not own it.
2038
2039Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2040git push
2041```
2042
2043---
2044
2045### Task 8: Browser module
2046
2047**Files:**
2048- Create: `js.go`, `js/push.mjs`, `js/push.test.mjs`, `js_test.go`
2049
2050**Interfaces:**
2051- Produces: `func JS() []byte`; ES module exports `capabilities()`, `status(registration)`, `enable({registration, publicKey, save})`, `reconcile({registration, publicKey, save})`, `disable({registration, remove})`. `save(body)` receives the exact subscribe-request object; `remove({endpoint})` the unsubscribe body.
2052
2053- [ ] **Step 1: Write js/push.mjs**
2054
2055```js
2056// Browser half of aviso: enrol this device for push against the app's
2057// server. The app supplies `save` and `remove` — same-origin fetches
2058// to the Subscribe and Unsubscribe handlers — and owns the service
2059// worker registration. Nothing here prompts except `enable`, and it
2060// prompts synchronously inside the caller's gesture, because a prompt
2061// after an await is denied by browsers and resented by people.
2062
2063function toBytes(base64url) {
2064 const pad = "=".repeat((4 - (base64url.length % 4)) % 4);
2065 const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/");
2066 const raw = atob(b64);
2067 const out = new Uint8Array(raw.length);
2068 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
2069 return out;
2070}
2071
2072function toBase64url(buf) {
2073 let s = "";
2074 const bytes = new Uint8Array(buf);
2075 for (const b of bytes) s += String.fromCharCode(b);
2076 return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2077}
2078
2079function sameKey(subscription, publicKey) {
2080 const key = subscription.options && subscription.options.applicationServerKey;
2081 if (!key) return false;
2082 return toBase64url(key) === publicKey;
2083}
2084
2085function body(subscription, publicKey, previousEndpoint) {
2086 const json = subscription.toJSON();
2087 const out = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey };
2088 if (previousEndpoint) out.previousEndpoint = previousEndpoint;
2089 return out;
2090}
2091
2092async function persist(save, payload) {
2093 const resp = await save(payload);
2094 if (!resp || resp.ok !== true) {
2095 throw new Error("aviso: save rejected: " + (resp && resp.status));
2096 }
2097}
2098
2099// capabilities reports what this browser can do. `standalone` is what
2100// the installability recipe keys its coaching on: iOS delivers push
2101// only to an installed app.
2102export function capabilities(env = globalThis) {
2103 const nav = env.navigator || {};
2104 const standalone = nav.standalone === true ||
2105 (env.matchMedia && env.matchMedia("(display-mode: standalone)").matches) || false;
2106 return {
2107 serviceWorker: !!nav.serviceWorker,
2108 push: typeof env.PushManager !== "undefined",
2109 notifications: typeof env.Notification !== "undefined",
2110 standalone,
2111 };
2112}
2113
2114// status resolves the current permission and subscription, prompting
2115// nothing.
2116export async function status(registration, env = globalThis) {
2117 const permission = env.Notification ? env.Notification.permission : "default";
2118 const subscription = await registration.pushManager.getSubscription();
2119 return { permission, subscription };
2120}
2121
2122// enable asks permission (synchronously, first), subscribes, and
2123// saves. Resolves the subscription, or null when permission was
2124// denied. Anything else rejects.
2125export async function enable({ registration, publicKey, save }, env = globalThis) {
2126 const permission = await env.Notification.requestPermission();
2127 if (permission !== "granted") return null;
2128 const existing = await registration.pushManager.getSubscription();
2129 let previous = "";
2130 if (existing && !sameKey(existing, publicKey)) {
2131 previous = existing.endpoint;
2132 await existing.unsubscribe();
2133 }
2134 const sub = (existing && !previous) ? existing : await registration.pushManager.subscribe({
2135 userVisibleOnly: true,
2136 applicationServerKey: toBytes(publicKey),
2137 });
2138 await persist(save, body(sub, publicKey, previous));
2139 return sub;
2140}
2141
2142// reconcile repairs on every page load without prompting: with
2143// permission granted it re-subscribes if the server key changed and
2144// re-saves so the server's last_confirmed_at moves. Resolves the
2145// subscription or null.
2146export async function reconcile({ registration, publicKey, save }, env = globalThis) {
2147 if (!env.Notification || env.Notification.permission !== "granted") return null;
2148 const existing = await registration.pushManager.getSubscription();
2149 if (existing && sameKey(existing, publicKey)) {
2150 await persist(save, body(existing, publicKey, ""));
2151 return existing;
2152 }
2153 let previous = "";
2154 if (existing) {
2155 previous = existing.endpoint;
2156 await existing.unsubscribe();
2157 }
2158 const sub = await registration.pushManager.subscribe({
2159 userVisibleOnly: true,
2160 applicationServerKey: toBytes(publicKey),
2161 });
2162 await persist(save, body(sub, publicKey, previous));
2163 return sub;
2164}
2165
2166// disable removes the server row first, then the browser subscription:
2167// a crash between the two leaves a harmless orphan in the browser
2168// rather than a server row that sends to nothing.
2169export async function disable({ registration, remove }) {
2170 const existing = await registration.pushManager.getSubscription();
2171 if (!existing) return;
2172 const resp = await remove({ endpoint: existing.endpoint });
2173 if (!resp || resp.ok !== true) {
2174 throw new Error("aviso: remove rejected: " + (resp && resp.status));
2175 }
2176 await existing.unsubscribe();
2177}
2178```
2179
2180- [ ] **Step 2: Write js/push.test.mjs**
2181
2182```js
2183import { test } from "node:test";
2184import assert from "node:assert/strict";
2185import { capabilities, status, enable, reconcile, disable } from "./push.mjs";
2186
2187const KEY = "BAbcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu";
2188
2189function keyBytes(s) {
2190 const pad = "=".repeat((4 - (s.length % 4)) % 4);
2191 return Uint8Array.from(Buffer.from(s + pad, "base64"));
2192}
2193
2194function fakeSub(endpoint, key) {
2195 return {
2196 endpoint,
2197 options: { applicationServerKey: keyBytes(key) },
2198 unsubscribed: false,
2199 toJSON() { return { endpoint, keys: { p256dh: "P", auth: "A" } }; },
2200 async unsubscribe() { this.unsubscribed = true; return true; },
2201 };
2202}
2203
2204function fakeRegistration(existing) {
2205 const reg = {
2206 subscribed: [],
2207 pushManager: {
2208 async getSubscription() { return existing; },
2209 async subscribe(opts) {
2210 assert.equal(opts.userVisibleOnly, true);
2211 const s = fakeSub("https://push.example/new", KEY);
2212 reg.subscribed.push(s);
2213 return s;
2214 },
2215 },
2216 };
2217 return reg;
2218}
2219
2220function env(permission, requested = permission) {
2221 const calls = [];
2222 return {
2223 calls,
2224 Notification: {
2225 permission,
2226 async requestPermission() { calls.push("prompt"); return requested; },
2227 },
2228 navigator: { serviceWorker: {} },
2229 PushManager: function () {},
2230 };
2231}
2232
2233const okSave = () => { const saved = []; const save = async (b) => { saved.push(b); return { ok: true, status: 204 }; }; return { saved, save }; };
2234
2235test("capabilities reports the four booleans", () => {
2236 const c = capabilities({ navigator: { serviceWorker: {}, standalone: true }, PushManager: function () {}, Notification: {} });
2237 assert.deepEqual(c, { serviceWorker: true, push: true, notifications: true, standalone: true });
2238});
2239
2240test("enable prompts, subscribes and saves", async () => {
2241 const e = env("default", "granted");
2242 const reg = fakeRegistration(null);
2243 const { saved, save } = okSave();
2244 const sub = await enable({ registration: reg, publicKey: KEY, save }, e);
2245 assert.ok(sub);
2246 assert.deepEqual(e.calls, ["prompt"]);
2247 assert.equal(saved.length, 1);
2248 assert.equal(saved[0].publicKey, KEY);
2249 assert.equal(saved[0].subscription.endpoint, "https://push.example/new");
2250 assert.equal("previousEndpoint" in saved[0], false);
2251});
2252
2253test("enable resolves null when denied and never subscribes", async () => {
2254 const e = env("default", "denied");
2255 const reg = fakeRegistration(null);
2256 const { saved, save } = okSave();
2257 assert.equal(await enable({ registration: reg, publicKey: KEY, save }, e), null);
2258 assert.equal(reg.subscribed.length, 0);
2259 assert.equal(saved.length, 0);
2260});
2261
2262test("enable rejects when save fails", async () => {
2263 const e = env("granted");
2264 const reg = fakeRegistration(null);
2265 await assert.rejects(enable({ registration: reg, publicKey: KEY, save: async () => ({ ok: false, status: 500 }) }, e), /save rejected/);
2266});
2267
2268test("reconcile never prompts and re-saves a matching subscription", async () => {
2269 const e = env("granted");
2270 const existing = fakeSub("https://push.example/old", KEY);
2271 const reg = fakeRegistration(existing);
2272 const { saved, save } = okSave();
2273 const sub = await reconcile({ registration: reg, publicKey: KEY, save }, e);
2274 assert.equal(sub, existing);
2275 assert.deepEqual(e.calls, []);
2276 assert.equal(saved.length, 1);
2277 assert.equal(reg.subscribed.length, 0);
2278});
2279
2280test("reconcile re-subscribes under a new key and names the old endpoint", async () => {
2281 const e = env("granted");
2282 const existing = fakeSub("https://push.example/old", "BOLDKEY");
2283 const reg = fakeRegistration(existing);
2284 const { saved, save } = okSave();
2285 await reconcile({ registration: reg, publicKey: KEY, save }, e);
2286 assert.equal(existing.unsubscribed, true);
2287 assert.equal(reg.subscribed.length, 1);
2288 assert.equal(saved[0].previousEndpoint, "https://push.example/old");
2289});
2290
2291test("reconcile does nothing without permission", async () => {
2292 const e = env("default");
2293 const reg = fakeRegistration(null);
2294 const { saved, save } = okSave();
2295 assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null);
2296 assert.equal(saved.length, 0);
2297 assert.deepEqual(e.calls, []);
2298});
2299
2300test("status reads permission and subscription", async () => {
2301 const existing = fakeSub("https://push.example/x", KEY);
2302 const s = await status(fakeRegistration(existing), env("granted"));
2303 assert.equal(s.permission, "granted");
2304 assert.equal(s.subscription, existing);
2305});
2306
2307test("disable removes server-side first, then the browser subscription", async () => {
2308 const existing = fakeSub("https://push.example/x", KEY);
2309 const order = [];
2310 const remove = async (b) => { order.push("remove:" + b.endpoint); return { ok: true }; };
2311 existing.unsubscribe = async () => { order.push("unsubscribe"); return true; };
2312 await disable({ registration: fakeRegistration(existing), remove });
2313 assert.deepEqual(order, ["remove:https://push.example/x", "unsubscribe"]);
2314});
2315
2316test("disable keeps the browser subscription when remove fails", async () => {
2317 const existing = fakeSub("https://push.example/x", KEY);
2318 await assert.rejects(disable({ registration: fakeRegistration(existing), remove: async () => ({ ok: false, status: 500 }) }), /remove rejected/);
2319 assert.equal(existing.unsubscribed, false);
2320});
2321```
2322
2323- [ ] **Step 3: Write js.go and js_test.go**
2324
2325`js.go`:
2326```go
2327package aviso
2328
2329import _ "embed"
2330
2331//go:embed js/push.mjs
2332var pushJS []byte
2333
2334//go:embed js/aviso-sw.js
2335var workerJS []byte
2336
2337// JS is the browser module (js/push.mjs), for the app to serve as a
2338// static asset and import from its page script.
2339func JS() []byte { return pushJS }
2340
2341// WorkerJS is the classic service-worker helper (js/aviso-sw.js), for
2342// the app to serve and load from its own sw.js via importScripts.
2343func WorkerJS() []byte { return workerJS }
2344```
2345
2346`js_test.go`:
2347```go
2348package aviso
2349
2350import (
2351 "bytes"
2352 "os"
2353 "os/exec"
2354 "path/filepath"
2355 "testing"
2356)
2357
2358func TestJSEmbedded(t *testing.T) {
2359 if !bytes.Contains(JS(), []byte("export async function enable(")) {
2360 t.Fatal("JS() does not look like push.mjs")
2361 }
2362 if !bytes.Contains(WorkerJS(), []byte("AvisoSW")) {
2363 t.Fatal("WorkerJS() does not look like aviso-sw.js")
2364 }
2365}
2366
2367// runNodeTests materialises the embedded files beside the named test
2368// in a temp dir and runs `node --test` there — so what is tested is
2369// the bytes the binary serves, not a sibling file that could drift.
2370func runNodeTests(t *testing.T, testFile string, files map[string][]byte) {
2371 t.Helper()
2372 node, err := exec.LookPath("node")
2373 if err != nil {
2374 t.Skip("node not on PATH; JS half not exercised")
2375 }
2376 dir := t.TempDir()
2377 src, err := os.ReadFile(filepath.Join("js", testFile))
2378 if err != nil {
2379 t.Fatal(err)
2380 }
2381 files[testFile] = src
2382 for name, b := range files {
2383 if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
2384 t.Fatal(err)
2385 }
2386 }
2387 cmd := exec.Command(node, "--test", testFile)
2388 cmd.Dir = dir
2389 if out, err := cmd.CombinedOutput(); err != nil {
2390 t.Fatalf("node --test %s failed: %v\n%s", testFile, err, out)
2391 }
2392}
2393
2394func TestPushModule(t *testing.T) {
2395 runNodeTests(t, "push.test.mjs", map[string][]byte{"push.mjs": JS()})
2396}
2397```
2398
2399For this task, `js/aviso-sw.js` must exist for the embed to compile: create it with a one-line placeholder header that Task 9 replaces:
2400```js
2401// aviso-sw.js — the classic service-worker helper; filled in by Task 9.
2402(function (root) { root.AvisoSW = {}; })(typeof self !== "undefined" ? self : globalThis);
2403```
2404
2405- [ ] **Step 4: Run**
2406
2407Run: `go test ./... -count=1 -run 'TestJS|TestPushModule' -v`
2408Expected: PASS; node output shows 10 passing tests.
2409
2410- [ ] **Step 5: Commit, push, Codex review, advance task**
2411
2412```sh
2413git add js.go js js_test.go
2414git commit -m "Add the browser module and its node tests
2415
2416enable prompts synchronously inside the gesture; reconcile never
2417prompts and re-saves on every load so the server's confirmation moves;
2418disable removes the server row before the browser subscription so a
2419crash between the two cannot leave a row that sends to nothing. Tests
2420run from go test against the embedded bytes.
2421
2422Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2423git push
2424```
2425
2426---
2427
2428### Task 9: Worker helper
2429
2430**Files:**
2431- Create: `js/aviso-sw.js` (replace placeholder), `js/aviso-sw.test.mjs`
2432- Modify: `js_test.go` — add `TestWorkerHelper`
2433
2434**Interfaces:**
2435- Produces: global `AvisoSW` with `handlePush(event, {decode, fallback})`, `handleClick(event, {fallbackURL})`, `handleSubscriptionChange(event, {renew, save})`, all returning promises; and `AvisoSW.validateURL(raw, origin)` used by both the default decoder and the click handler.
2436
2437- [ ] **Step 1: Write js/aviso-sw.js**
2438
2439```js
2440// Service-worker half of aviso, a classic script the app's own sw.js
2441// loads with importScripts. It owns nothing about the worker's
2442// lifecycle — no skipWaiting, no clients.claim, no listeners of its
2443// own; the app attaches these handlers inside its listeners and passes
2444// the result to event.waitUntil.
2445(function (root) {
2446 "use strict";
2447
2448 // validateURL admits only a root-relative path on the app's own
2449 // origin: no scheme, no protocol-relative "//", no backslashes, and
2450 // the resolved URL must still be on origin. Applied to the default
2451 // decoder, custom decoder output and fallbackURL alike — a click
2452 // must never navigate off the app.
2453 function validateURL(raw, origin) {
2454 if (typeof raw !== "string" || raw === "" || raw[0] !== "/" || raw[1] === "/" || raw.indexOf("\\") !== -1) {
2455 return null;
2456 }
2457 let u;
2458 try { u = new URL(raw, origin); } catch (e) { return null; }
2459 if (u.origin !== origin) return null;
2460 return u.href;
2461 }
2462
2463 // decodeDefault reads {title, body, url, tag}; title is required.
2464 function decodeDefault(event, origin) {
2465 if (!event.data) return null;
2466 let p;
2467 try { p = event.data.json(); } catch (e) { return null; }
2468 if (!p || typeof p.title !== "string" || p.title === "") return null;
2469 const options = { data: {} };
2470 if (typeof p.body === "string") options.body = p.body;
2471 if (typeof p.tag === "string") options.tag = p.tag;
2472 if (p.url !== undefined) {
2473 const href = validateURL(p.url, origin);
2474 if (href) options.data.url = href;
2475 }
2476 return { title: p.title, options };
2477 }
2478
2479 // handlePush always ends in a visible notification: WebKit revokes
2480 // push for a worker that receives without showing, so a payload the
2481 // decoder cannot read shows the app's fallback rather than nothing.
2482 async function handlePush(event, opts) {
2483 const origin = root.location.origin;
2484 const reg = root.registration;
2485 let n = null;
2486 try {
2487 n = opts && opts.decode ? await opts.decode(event) : decodeDefault(event, origin);
2488 } catch (e) { n = null; }
2489 if (!n || typeof n.title !== "string" || n.title === "") {
2490 if (!opts || typeof opts.fallback !== "function") {
2491 throw new Error("aviso: fallback is required");
2492 }
2493 n = await opts.fallback(event);
2494 }
2495 const options = Object.assign({}, n.options || {});
2496 options.data = Object.assign({}, options.data || {});
2497 if (options.data.url !== undefined) {
2498 const href = validateURL(typeof options.data.url === "string" && options.data.url.indexOf(origin) === 0
2499 ? options.data.url.slice(origin.length) : options.data.url, origin);
2500 if (href) options.data.url = href; else delete options.data.url;
2501 }
2502 return reg.showNotification(n.title, options);
2503 }
2504
2505 // handleClick closes the notification, focuses a window already at
2506 // the destination, else opens it, else the fallback.
2507 async function handleClick(event, opts) {
2508 const origin = root.location.origin;
2509 event.notification.close();
2510 const data = event.notification.data || {};
2511 let href = data.url ? validateURL(data.url.indexOf(origin) === 0 ? data.url.slice(origin.length) : data.url, origin) : null;
2512 if (!href && opts && opts.fallbackURL) href = validateURL(opts.fallbackURL, origin);
2513 if (!href) return;
2514 const all = await root.clients.matchAll({ type: "window", includeUncontrolled: true });
2515 for (const c of all) {
2516 if (c.url === href && "focus" in c) return c.focus();
2517 }
2518 return root.clients.openWindow(href);
2519 }
2520
2521 // handleSubscriptionChange renews and saves. Saving can fail — an
2522 // installed app with no live session — and then it fails silently:
2523 // reconcile() repairs on the next page open after sign-in. Nothing
2524 // here retries and nothing here prompts.
2525 async function handleSubscriptionChange(event, opts) {
2526 let sub = event.newSubscription || null;
2527 if (!sub && opts && typeof opts.renew === "function") {
2528 try { sub = await opts.renew(event); } catch (e) { sub = null; }
2529 }
2530 if (!sub) return false;
2531 try {
2532 const resp = await opts.save(sub);
2533 return !!(resp && resp.ok === true);
2534 } catch (e) {
2535 return false;
2536 }
2537 }
2538
2539 root.AvisoSW = { handlePush, handleClick, handleSubscriptionChange, validateURL };
2540})(typeof self !== "undefined" ? self : globalThis);
2541```
2542
2543- [ ] **Step 2: Write js/aviso-sw.test.mjs**
2544
2545```js
2546import { test } from "node:test";
2547import assert from "node:assert/strict";
2548
2549globalThis.self = globalThis;
2550globalThis.location = { origin: "https://app.example" };
2551await import("./aviso-sw.js");
2552const { AvisoSW } = globalThis;
2553
2554function rig() {
2555 const shown = [], opened = [], focused = [];
2556 globalThis.registration = { async showNotification(title, options) { shown.push({ title, options }); } };
2557 globalThis.clients = {
2558 windows: [],
2559 async matchAll() { return this.windows; },
2560 async openWindow(u) { opened.push(u); },
2561 };
2562 return { shown, opened, focused };
2563}
2564
2565function pushEvent(payload) {
2566 return { data: payload === undefined ? null : { json() { return JSON.parse(payload); } } };
2567}
2568
2569test("validateURL admits only root-relative same-origin paths", () => {
2570 const o = "https://app.example";
2571 assert.equal(AvisoSW.validateURL("/inbox?x=1", o), "https://app.example/inbox?x=1");
2572 for (const bad of ["", "inbox", "//evil.example/x", "https://evil.example/x", "/a\\b", "javascript:alert(1)", 42, null]) {
2573 assert.equal(AvisoSW.validateURL(bad, o), null, String(bad));
2574 }
2575});
2576
2577test("handlePush shows the default payload and validates url", async () => {
2578 const r = rig();
2579 await AvisoSW.handlePush(pushEvent('{"title":"Hi","body":"b","url":"/inbox","tag":"t"}'), { fallback: () => ({ title: "fb" }) });
2580 assert.equal(r.shown.length, 1);
2581 assert.equal(r.shown[0].title, "Hi");
2582 assert.equal(r.shown[0].options.body, "b");
2583 assert.equal(r.shown[0].options.tag, "t");
2584 assert.equal(r.shown[0].options.data.url, "https://app.example/inbox");
2585});
2586
2587test("handlePush drops an off-origin url but still shows", async () => {
2588 const r = rig();
2589 await AvisoSW.handlePush(pushEvent('{"title":"Hi","url":"https://evil.example/"}'), { fallback: () => ({ title: "fb" }) });
2590 assert.equal(r.shown[0].title, "Hi");
2591 assert.equal("url" in r.shown[0].options.data, false);
2592});
2593
2594test("handlePush falls back on malformed or missing payload", async () => {
2595 const r = rig();
2596 await AvisoSW.handlePush(pushEvent('{"nope":1}'), { fallback: () => ({ title: "fb", options: { data: { url: "/" } } }) });
2597 await AvisoSW.handlePush(pushEvent(undefined), { fallback: () => ({ title: "fb2" }) });
2598 assert.deepEqual(r.shown.map((s) => s.title), ["fb", "fb2"]);
2599 assert.equal(r.shown[0].options.data.url, "https://app.example/");
2600});
2601
2602test("handlePush requires a fallback", async () => {
2603 rig();
2604 await assert.rejects(AvisoSW.handlePush(pushEvent('{"x":1}'), {}), /fallback is required/);
2605});
2606
2607test("handlePush uses a custom decoder and validates its url", async () => {
2608 const r = rig();
2609 await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
2610 decode: async () => ({ title: "Custom", options: { data: { url: "//evil.example/" } } }),
2611 fallback: () => ({ title: "fb" }),
2612 });
2613 assert.equal(r.shown[0].title, "Custom");
2614 assert.equal("url" in r.shown[0].options.data, false);
2615});
2616
2617test("handleClick focuses a matching window, else opens, else fallback", async () => {
2618 const r = rig();
2619 let focusedURL = null;
2620 globalThis.clients.windows = [{ url: "https://app.example/inbox", async focus() { focusedURL = this.url; } }];
2621 const ev = (url) => ({ notification: { close() {}, data: url === undefined ? {} : { url } } });
2622 await AvisoSW.handleClick(ev("https://app.example/inbox"), {});
2623 assert.equal(focusedURL, "https://app.example/inbox");
2624 await AvisoSW.handleClick(ev("/other"), {});
2625 assert.deepEqual(r.opened, ["https://app.example/other"]);
2626 await AvisoSW.handleClick(ev(undefined), { fallbackURL: "/" });
2627 assert.deepEqual(r.opened, ["https://app.example/other", "https://app.example/"]);
2628 await AvisoSW.handleClick(ev("https://evil.example/"), {});
2629 assert.equal(r.opened.length, 2);
2630});
2631
2632test("handleSubscriptionChange renews then saves, and fails silently", async () => {
2633 const saved = [];
2634 const ok = await AvisoSW.handleSubscriptionChange({ newSubscription: { endpoint: "e" } }, { save: async (s) => { saved.push(s); return { ok: true }; } });
2635 assert.equal(ok, true);
2636 assert.equal(saved[0].endpoint, "e");
2637 const renewed = await AvisoSW.handleSubscriptionChange({}, { renew: async () => ({ endpoint: "r" }), save: async () => ({ ok: true }) });
2638 assert.equal(renewed, true);
2639 const expired = await AvisoSW.handleSubscriptionChange({ newSubscription: { endpoint: "e" } }, { save: async () => ({ ok: false, status: 401 }) });
2640 assert.equal(expired, false);
2641 const threw = await AvisoSW.handleSubscriptionChange({ newSubscription: { endpoint: "e" } }, { save: async () => { throw new Error("net"); } });
2642 assert.equal(threw, false);
2643});
2644```
2645
2646- [ ] **Step 3: Add to js_test.go**
2647
2648```go
2649func TestWorkerHelper(t *testing.T) {
2650 runNodeTests(t, "aviso-sw.test.mjs", map[string][]byte{"aviso-sw.js": WorkerJS()})
2651}
2652```
2653
2654- [ ] **Step 4: Run**
2655
2656Run: `go test ./... -count=1 -run 'TestWorkerHelper|TestJSEmbedded' -v`
2657Expected: PASS. If node treats `aviso-sw.js` as ESM because of a package.json above the temp dir, none exists there; the side-effect import of a CommonJS-parsed classic script sets the global.
2658
2659- [ ] **Step 5: Commit, push, Codex review, advance task**
2660
2661```sh
2662git add js js_test.go
2663git commit -m "Add the service-worker helper
2664
2665Every push ends in a visible notification, with the app's required
2666fallback covering payloads the decoder cannot read: WebKit revokes push
2667for a worker that receives silently. Click URLs are validated to the
2668app's origin wherever they come from, including custom decoders. A
2669subscription change saves once and fails silently — the page repairs
2670on the next open after sign-in — because a retry loop in a worker
2671without a session has nothing to retry with.
2672
2673Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2674git push
2675```
2676
2677---
2678
2679### Task 10: aviso-key
2680
2681**Files:**
2682- Create: `cmd/aviso-key/main.go`, `cmd/aviso-key/main_test.go`
2683
2684- [ ] **Step 1: Write the test**
2685
2686```go
2687package main
2688
2689import (
2690 "bytes"
2691 "encoding/base64"
2692 "strings"
2693 "testing"
2694)
2695
2696func TestRunPrintsOnePrivateKey(t *testing.T) {
2697 var out bytes.Buffer
2698 if err := run(&out); err != nil {
2699 t.Fatal(err)
2700 }
2701 s := strings.TrimSpace(out.String())
2702 if strings.Contains(s, "\n") {
2703 t.Fatalf("more than one line: %q", s)
2704 }
2705 raw, err := base64.RawURLEncoding.DecodeString(s)
2706 if err != nil || len(raw) != 32 {
2707 t.Fatalf("not a 32-byte base64url key: %q", s)
2708 }
2709}
2710```
2711
2712- [ ] **Step 2: Write main.go**
2713
2714```go
2715// Command aviso-key prints one VAPID private key, and nothing else, so
2716// it can be captured straight into a secret store:
2717//
2718// APP_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)"
2719//
2720// The public key is derived by aviso.New; there is no second value to
2721// keep.
2722package main
2723
2724import (
2725 "fmt"
2726 "io"
2727 "os"
2728
2729 "amadan.net/rastrillo/aviso"
2730)
2731
2732func run(w io.Writer) error {
2733 k, err := aviso.GenerateKey()
2734 if err != nil {
2735 return err
2736 }
2737 _, err = fmt.Fprintln(w, k)
2738 return err
2739}
2740
2741func main() {
2742 if err := run(os.Stdout); err != nil {
2743 fmt.Fprintln(os.Stderr, "aviso-key:", err)
2744 os.Exit(1)
2745 }
2746}
2747```
2748
2749- [ ] **Step 3: Run, commit, push, Codex review, advance**
2750
2751Run: `go test ./cmd/... -count=1 && go run ./cmd/aviso-key | wc -c` (expect 44 bytes incl. newline).
2752
2753```sh
2754git add cmd
2755git commit -m "Add aviso-key: prints one private key
2756
2757Stdout carries the key alone so a shell substitution can capture it
2758into a secret store without parsing.
2759
2760Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2761git push
2762```
2763
2764---
2765
2766### Task 11: SKILL.md, README, installability recipe
2767
2768**Files:**
2769- Create: `SKILL.md`, `docs/installable.md`, `skillmd_test.go`
2770- Modify: `README.md`
2771
2772- [ ] **Step 1: Write skillmd_test.go**
2773
2774```go
2775package aviso
2776
2777import (
2778 "os"
2779 "strings"
2780 "testing"
2781)
2782
2783// skillBudget is the byte ceiling for SKILL.md. It is what an agent
2784// loads instead of reading the source, so it is reviewed like code and
2785// kept short; raise it here, with a reason, rather than trimming a
2786// load-bearing fact to fit.
2787const skillBudget = 9000
2788
2789func TestSkillMDIsWithinBudgetAndNamesTheSurface(t *testing.T) {
2790 b, err := os.ReadFile("SKILL.md")
2791 if err != nil {
2792 t.Fatal(err)
2793 }
2794 if len(b) > skillBudget {
2795 t.Fatalf("SKILL.md is %d bytes, budget %d", len(b), skillBudget)
2796 }
2797 s := string(b)
2798 for _, want := range []string{
2799 "aviso.New", "aviso.Schema", "BootSchema", "PrivateKey", "aviso-key",
2800 "SendTo", "Send(", "Sweep", "DeleteSubject",
2801 "PublicKey", "Subscribe", "Unsubscribe",
2802 "JS()", "WorkerJS()", "importScripts", "enable(", "reconcile(", "disable(",
2803 "handlePush", "handleClick", "handleSubscriptionChange", "fallback",
2804 "docs/installable.md",
2805 } {
2806 if !strings.Contains(s, want) {
2807 t.Errorf("SKILL.md does not mention %q", want)
2808 }
2809 }
2810}
2811```
2812
2813- [ ] **Step 2: Write SKILL.md** (under 9000 bytes; the sections and facts below, in prose that says why)
2814
2815```markdown
2816---
2817name: aviso
2818description: 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.
2819---
2820
2821# aviso — Web Push for rastrillo apps
2822
2823Aviso moves bytes to devices a signed-in person enrolled. It never
2824decides who is told what: recipient selection, payload meaning and the
2825service worker's lifecycle are the app's.
2826
2827## Wire it
2828
28291. Mint one key, once, into the app's secrets:
2830 `APP_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)"`.
2831 Empty is refused at boot (`aviso.ErrEmptyPrivateKey`); nothing
2832 mints a key for you, because a key minted into local state is lost
2833 at the next restore.
28342. `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)` —
2835 BootSchema, never Schema, or `rastrillo migration check` proposes
2836 dropping the addon's table.
28373. `svc, err := aviso.New(aviso.Config{DB: writer, PrivateKey: key,
2838 Contact: "mailto:ops@example", Origin: origin})`. One per process.
28394. Mount, behind your session middleware:
2840 `GET /aviso/public-key → svc.PublicKey`, `POST /aviso/subscribe →
2841 svc.Subscribe`, `POST /aviso/unsubscribe → svc.Unsubscribe`.
2842 Ownership is `sessions.Current(r).Subject`; the body never names one.
28435. Serve `aviso.JS()` as `/static/aviso/push.mjs` and `aviso.WorkerJS()`
2844 as `/static/aviso/aviso-sw.js`; serve your own `sw.js` at the scope
2845 it should control with `Cache-Control: no-cache`.
2846
2847## Send
2848
2849`svc.SendTo(ctx, subject, payload, aviso.Options{})` — every device the
2850subject enrolled. `svc.Send(ctx, stored, payload, opts)` when you
2851select devices yourself. Both return `([]Result, error)`: the error is
2852what stopped the batch (query, bounds, cancellation), each Result one
2853device's acceptance — not delivery. No retries; `Result.RetryAfter`
2854is for your scheduler. Payload ≤ 3993 bytes. Default payload the
2855worker helper understands: `{"title","body","url","tag"}` with `url` a
2856root-relative path on your origin.
2857
2858## Browser
2859
2860```js
2861import { enable, reconcile, disable, capabilities } from "/static/aviso/push.mjs";
2862const save = (b) => fetch("/aviso/subscribe", { method: "POST", credentials: "same-origin",
2863 headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
2864const remove = (b) => fetch("/aviso/unsubscribe", { method: "POST", credentials: "same-origin",
2865 headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
2866const registration = await navigator.serviceWorker.register("/sw.js");
2867const { publicKey } = await (await fetch("/aviso/public-key")).json();
2868await reconcile({ registration, publicKey, save }); // every load, never prompts
2869button.onclick = () => enable({ registration, publicKey, save }); // from the click, prompts
2870```
2871
2872`enable` resolves null when denied. `disable({registration, remove})`
2873removes the server row first. Call `disable` before sign-out.
2874
2875## Worker (your sw.js)
2876
2877```js
2878importScripts("/static/aviso/aviso-sw.js");
2879self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, {
2880 fallback: () => ({ title: "New activity", options: { data: { url: "/" } } }),
2881})));
2882self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" })));
2883self.addEventListener("pushsubscriptionchange", (e) => e.waitUntil(AvisoSW.handleSubscriptionChange(e, {
2884 save: (sub) => fetch("/aviso/subscribe", { method: "POST", credentials: "same-origin", mode: "same-origin",
2885 redirect: "error", headers: { "Content-Type": "application/json" },
2886 body: JSON.stringify({ subscription: sub.toJSON(), publicKey: PUBLIC_KEY }) }),
2887})));
2888```
2889
2890`fallback` is required: every push shows a notification, or WebKit
2891revokes the subscription. Supply `decode(event)` for your own payload
2892shape; its `options.data.url` is validated to your origin too. The
2893helper never calls `skipWaiting` or `clients.claim`.
2894
2895## Retention and revocation
2896
2897`svc.Sweep(ctx, time.Now().AddDate(0,0,-90))` from a `carlos.Tick`
2898handler removes subscriptions not confirmed in 90 days (confirmation
2899moves on reconcile and on an accepted send). Session expiry does not
2900revoke; call `disable` before sign-out and `svc.DeleteSubject` on
2901account deletion. Re-check entitlement before every SendTo.
2902
2903## Installability
2904
2905iOS delivers push only to a Home Screen app. The manifest, head tags
2906and coaching are yours: see `docs/installable.md`.
2907
2908## Rulings
2909
2910Endpoints https only, SSRF-guarded at dial. 409 on an endpoint another
2911subject holds, and on a subscription made under a different server
2912key. Logs carry subscription ids, never endpoints or keys.
2913```
2914
2915- [ ] **Step 3: Write docs/installable.md** — spec §11 as prose: the manifest fields, the three head tags, the worker's scope and `no-cache`, in-app coaching keyed on `capabilities().standalone`, iOS 16.4+; point at Eleven's `serveManifest` shape as a twenty-line model without copying it.
2916
2917- [ ] **Step 4: Update README.md** — add "What it is not" (the spec's §13 non-goals in three lines) and a link to `docs/installable.md`.
2918
2919- [ ] **Step 5: Run `make ci`; commit, push, Codex review (ask Codex specifically whether SKILL.md contradicts the code), advance**
2920
2921```sh
2922git add SKILL.md docs/installable.md README.md skillmd_test.go
2923git commit -m "Add SKILL.md, the installability recipe and the README
2924
2925SKILL.md is what an agent loads instead of the source, so it is
2926byte-budgeted and its facts are pinned by test.
2927
2928Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2929git push
2930```
2931
2932---
2933
2934### Task 12: Example app
2935
2936**Files:**
2937- Create: `example/go.mod` (module `example`, `replace amadan.net/rastrillo/aviso => ../`), `example/main.go`, `example/static/sw.js`, `example/static/app.js`, `example/index.html`, `example/main_test.go`
2938
2939The example is the smallest rastrillo-shaped app that wires every seam: sessions (a fixed dev subject via `sessions.WithSession` middleware for the example only), the three handlers, the two JS files, a page with an Enable button, and a `/notify` POST that calls `SendTo` for the dev subject.
2940
2941- [ ] **Step 1: Write example/main.go**
2942
2943```go
2944// Command example is the smallest app that wires every aviso seam. It
2945// signs everyone in as "dev" — an example, not a pattern — so the
2946// enrol/send loop can be driven from one browser.
2947package main
2948
2949import (
2950 "context"
2951 "embed"
2952 "encoding/json"
2953 "io/fs"
2954 "log"
2955 "net/http"
2956 "os"
2957 "time"
2958
2959 "amadan.net/rastrillo/rastrillo/db"
2960 "amadan.net/rastrillo/rastrillo/migrate"
2961 "amadan.net/rastrillo/rastrillo/sessions"
2962
2963 "amadan.net/rastrillo/aviso"
2964)
2965
2966//go:embed index.html static/*
2967var site embed.FS
2968
2969func handler(svc *aviso.Service) http.Handler {
2970 mux := http.NewServeMux()
2971 static, _ := fs.Sub(site, "static")
2972 mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
2973 mux.HandleFunc("GET /static/aviso/push.mjs", func(w http.ResponseWriter, r *http.Request) {
2974 w.Header().Set("Content-Type", "text/javascript")
2975 w.Write(aviso.JS())
2976 })
2977 mux.HandleFunc("GET /static/aviso/aviso-sw.js", func(w http.ResponseWriter, r *http.Request) {
2978 w.Header().Set("Content-Type", "text/javascript")
2979 w.Write(aviso.WorkerJS())
2980 })
2981 mux.HandleFunc("GET /sw.js", func(w http.ResponseWriter, r *http.Request) {
2982 w.Header().Set("Content-Type", "text/javascript")
2983 w.Header().Set("Cache-Control", "no-cache")
2984 b, _ := site.ReadFile("static/sw.js")
2985 w.Write(b)
2986 })
2987 mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
2988 b, _ := site.ReadFile("index.html")
2989 w.Header().Set("Content-Type", "text/html")
2990 w.Write(b)
2991 })
2992 mux.HandleFunc("GET /aviso/public-key", svc.PublicKey)
2993 mux.HandleFunc("POST /aviso/subscribe", svc.Subscribe)
2994 mux.HandleFunc("POST /aviso/unsubscribe", svc.Unsubscribe)
2995 mux.HandleFunc("POST /notify", func(w http.ResponseWriter, r *http.Request) {
2996 payload, _ := json.Marshal(map[string]string{"title": "Hello from aviso", "body": time.Now().Format(time.Kitchen), "url": "/"})
2997 res, err := svc.SendTo(r.Context(), "dev", payload, aviso.Options{TTL: 60 * time.Second})
2998 if err != nil {
2999 http.Error(w, err.Error(), 500)
3000 return
3001 }
3002 json.NewEncoder(w).Encode(res)
3003 })
3004 // Example-only: everyone is "dev". A real app runs sessions.Middleware.
3005 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3006 mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "dev"}))
3007 })
3008}
3009
3010func main() {
3011 origin := os.Getenv("ORIGIN")
3012 if origin == "" {
3013 origin = "http://localhost:8080"
3014 }
3015 d, err := db.Open("example.db", nil)
3016 if err != nil {
3017 log.Fatal(err)
3018 }
3019 if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil {
3020 log.Fatal(err)
3021 }
3022 svc, err := aviso.New(aviso.Config{DB: d.Writer(), PrivateKey: os.Getenv("EXAMPLE_VAPID_PRIVATE_KEY"), Contact: "mailto:ops@example.test", Origin: origin})
3023 if err != nil {
3024 log.Fatal(err)
3025 }
3026 log.Println("listening on :8080 as", origin)
3027 log.Fatal(http.ListenAndServe(":8080", handler(svc)))
3028}
3029```
3030
3031- [ ] **Step 2: Write example/index.html, static/app.js, static/sw.js** — index loads `/static/app.js` as a module with an `<button id="enable">Enable</button>`, `<button id="notify">Notify me</button>`, `<pre id="log"></pre>`; `app.js` is the SKILL.md "Browser" snippet; `sw.js` is the SKILL.md "Worker" snippet with `PUBLIC_KEY` fetched at `install` and kept in a module-level variable.
3032
3033- [ ] **Step 3: Write example/main_test.go** — builds the handler with a temp DB and a generated key, GETs `/`, `/static/aviso/push.mjs`, `/sw.js`, `/aviso/public-key`, and POSTs `/aviso/subscribe` with a valid body expecting 204.
3034
3035- [ ] **Step 4: Run from the example dir; commit, push, Codex review, advance**
3036
3037```sh
3038cd example && go mod tidy && go test ./... -count=1 && cd ..
3039git add example
3040git commit -m "Add the example app: every seam wired, everyone signed in as dev
3041
3042Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3043git push
3044```
3045
3046Add to the Makefile's `test` target: `cd example && go test ./... -count=1` (the root `./...` does not cross module boundaries).
3047
3048---
3049
3050### Task 13: Browser test
3051
3052**Files:**
3053- Create: `browser_test.go` (`//go:build browser`)
3054
3055Uses rastrillo's `harness.New(t, build func(origin string) http.Handler)` (see `webauthn/browser_test.go` in rastrillo for `rig.Run` and `chromedp.Evaluate` usage). The page under drive: a fixture handler in the test serving a driver HTML that imports `/push.mjs` (from `JS()`), registers `/sw.js` (a two-line worker that `importScripts("/aviso-sw.js")`), and **stubs `registration.pushManager`** with a declared subscription double (an object with `endpoint`, `options.applicationServerKey`, `toJSON`, `unsubscribe`) before calling `enable`. `Notification.requestPermission` is stubbed to resolve `"granted"`. The Go side wires the real `Subscribe` handler behind a middleware that injects `sessions.Session{Subject: "drive"}`, with `Origin` set to the rig's origin.
3056
3057- [ ] **Step 1: Write the test** — assert: after `enable`, `svc.List(ctx, "drive")` has one row whose endpoint is the double's; after `disable`, zero rows. Run: `go test -tags browser -run TestBrowserEnrolment -count=1 -v ./...`. Skip (not fail) when `harness.ChromePath` reports no Chromium.
3058
3059- [ ] **Step 2: Commit, push, Codex review, advance**
3060
3061```sh
3062git add browser_test.go
3063git commit -m "Add the browser wiring test
3064
3065Proves the module, the worker import and the real handlers agree in
3066Chromium, with a declared subscription double: Chromium's own push
3067subscription cannot be pointed at a test service, and a test that
3068pretended otherwise would prove less than it claimed.
3069
3070Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3071git push
3072```
3073
3074---
3075
3076### Task 14: Land, tag, and list the addon in rastrillo's directory
3077
3078- [ ] **Step 1: Final gate and Codex whole-branch review**
3079
3080Run `make ci` and `cd example && go test ./...`. Then ask Codex for a whole-branch review against the spec (`git diff main...build`, or the full tree if `main` is empty). Address findings.
3081
3082- [ ] **Step 2: Update the branch description to its final state; mark all tasks Done**
3083
3084- [ ] **Step 3: Merge through the gate and tag v0.1.0**
3085
3086```sh
3087amadan ci status rastrillo/aviso -branch build
3088amadan branch merge rastrillo/aviso build -expect "$(git rev-parse HEAD)"
3089git fetch origin main && git checkout main && git merge --ff-only origin/main
3090git tag -a v0.1.0 -m "aviso v0.1.0: Web Push addon" && git push origin v0.1.0
3091```
3092
3093- [ ] **Step 4: Directory entry in rastrillo**
3094
3095In the rastrillo checkout, on a fresh branch off origin/main (`make mirror-check` first): add to `docs/site/addons.md` under "The directory", after idear, an entry in the same shape — status "released, v0.1.0", module `amadan.net/rastrillo/aviso`, source `https://amadan.net/rastrillo/aviso`, one paragraph (from the spec's §0 second paragraph), and the `go get` + `cat SKILL.md` block. Run rastrillo's gate (`GOFLAGS=-mod=mod go test ./...` locally), push, describe, merge through `amadan branch merge`, then `make mirror`.
3096
3097---
3098
3099## Self-review
3100
3101**Spec coverage.** §1 rulings → Tasks 2-9; §2 layout → file structure; §3 API → Tasks 3, 4, 6, 8 (`PublicKeyString` added beyond the spec because the JS needs the string and tests need it; an additive accessor); §4 routes → Task 7; §5 schema → Task 3; §6 module → Task 8; §7 worker + payload → Task 9; §8 custody → Tasks 2, 3, 10; §9 retention → Task 4 (`Sweep`, `DeleteSubject`) and SKILL.md; §10 security → Tasks 5, 6, 7, 9; §11 recipe → Task 11; §12 testing → every task, browser in 13; §13 non-goals → README; §14 → `([]Result, error)` in Task 6 and the double in Task 13. Directory entry → Task 14.
3102
3103**Types.** `Stored` embeds `Subscription` (Tasks 3, 4, 6); `put(ctx, subject, Subscription, previousEndpoint)` (4, 7); `confirm/prune(ctx, id, revision int64)` (4, 6); `Result{ID, Status, RetryAfter, Err}` (6, example); `validateEndpoint` returns `error` wrapping `ErrBadEndpoint` (5, 7); `s.client *http.Client` (3, 5, 6); `runNodeTests(t, testFile, files)` (8, 9).
3104
3105**Known deviation to record in the branch discussion:** `Service.PublicKeyString()` is not in the spec's API list.
3106