rastrillo / aviso Public

Scaffold the aviso module and its gate

The addon starts with the gate rather than the code so every later
commit is checked the same way CI checks it (amadan's rule: CI steps
delegate to make targets, never copy them). The spec and plan travel
with the module so a reader of the code has the reasoning beside it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev acb1fef93bc507c43af1b31cf6cef3ab89055ec6
12 files changed, +3555 −0
  • .amadan/ci +6 −0
  • .amadan/ci.d/10-vet +3 −0
  • .amadan/ci.d/20-fmt +3 −0
  • .amadan/ci.d/30-test +3 −0
  • .amadan/ci.d/40-race +3 −0
  • .gitignore +4 −0
  • Makefile +28 −0
  • README.md +12 −0
  • doc.go +14 −0
  • docs/superpowers/plans/2026-09-07-aviso.md +3050 −0
  • docs/superpowers/specs/2026-09-07-aviso-web-push-design.md +426 −0
  • go.mod +3 −0
diff --git a/.amadan/ci b/.amadan/ci
new file mode 100755
index 0000000..8c547ee
--- /dev/null
+++ b/.amadan/ci
@@ -0,0 +1,6 @@
+#!/bin/sh
+# amadan CI entry (single-script fallback for runners without step
+# support). The steps in ci.d/ are the same targets, reported one by
+# one. Must stay executable: a non-executable script resolves "skipped".
+set -e
+exec make ci
diff --git a/.amadan/ci.d/10-vet b/.amadan/ci.d/10-vet
new file mode 100755
index 0000000..5c35ae6
--- /dev/null
+++ b/.amadan/ci.d/10-vet
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make vet
diff --git a/.amadan/ci.d/20-fmt b/.amadan/ci.d/20-fmt
new file mode 100755
index 0000000..f78b24e
--- /dev/null
+++ b/.amadan/ci.d/20-fmt
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make fmt-check
diff --git a/.amadan/ci.d/30-test b/.amadan/ci.d/30-test
new file mode 100755
index 0000000..ea440b4
--- /dev/null
+++ b/.amadan/ci.d/30-test
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make test
diff --git a/.amadan/ci.d/40-race b/.amadan/ci.d/40-race
new file mode 100755
index 0000000..1da1751
--- /dev/null
+++ b/.amadan/ci.d/40-race
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make race
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..47a58c5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+# Build output only — nothing in here is source.
+/releases/
+# Node's install tree, if anyone runs the js tests with a local node_modules.
+/node_modules/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7edbc81
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,28 @@
+.PHONY: vet fmt-check test race ci
+
+# ci is the one gate: what a runner executes and what you run before
+# pushing are the same definition (amadan's own rule — CI steps
+# delegate to make targets, never keep their own copies).
+#
+# The node tests are not a separate target: js_test.go runs them from
+# inside `go test`, skipping when node is absent, so a runner without
+# node still reports the Go half honestly instead of failing a step
+# it cannot run.
+ci: vet fmt-check test race
+
+vet:
+ go vet ./...
+
+fmt-check:
+ @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
+
+# -count=1 on purpose: these tests build a real SQLite database and
+# race goroutines against it; a cached PASS re-reports one scheduling
+# as though it were every scheduling.
+test:
+ go test ./... -count=1
+
+# -race needs cgo; everything else runs with the default toolchain, so
+# this target sets CGO_ENABLED for its own command only.
+race:
+ CGO_ENABLED=1 go test ./... -race -count=1
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2f422bd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+# aviso
+
+Web Push for rastrillo apps: subscriptions a signed-in person enrols
+from their browsers, one VAPID key per app, and a sender that fans a
+payload out to those devices. An addon — `amadan.net/rastrillo/aviso`
+depends on rastrillo, never the reverse.
+
+ go get amadan.net/rastrillo/aviso
+ cat "$(go list -m -f '{{.Dir}}' amadan.net/rastrillo/aviso)/SKILL.md"
+
+Design: `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`.
+Gate: `make ci`.
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..6762f39
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,14 @@
+// Package aviso is rastrillo's Web Push addon: the subscriptions a
+// signed-in person enrols from their browsers, the VAPID key that
+// signs for them, and a sender that moves an app's payload to those
+// devices through the browser vendors' push services.
+//
+// Two rulings bind everything here, both from rastrillo's addons
+// doctrine. The arrow points one way: aviso depends on rastrillo,
+// rastrillo never learns aviso exists. The app owns policy: aviso
+// moves bytes to devices a subject enrolled and never decides who
+// should be told what — recipient selection, payload meaning and the
+// service worker's lifecycle are the app's.
+//
+// Design: docs/superpowers/specs/2026-09-07-aviso-web-push-design.md.
+package aviso
diff --git a/docs/superpowers/plans/2026-09-07-aviso.md b/docs/superpowers/plans/2026-09-07-aviso.md
new file mode 100644
index 0000000..da6c6bf
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-aviso.md
@@ -0,0 +1,3050 @@
+# Aviso Implementation Plan
+
+> **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.
+
+**Goal:** Build `amadan.net/rastrillo/aviso`, the Web Push addon for rastrillo, extracted from Eleven messenger's transport half.
+
+**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.
+
+**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.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md` (copied from rastrillo's `docs/superpowers/specs/`, merged there as 2aa3da2).
+
+**Review gate:** Codex reviews every task before it is marked done. After the task's commit, run from the repo root:
+
+```sh
+codex exec --sandbox read-only --skip-git-repo-check -o /tmp/aviso-review-N.md \
+ "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."
+```
+
+Address 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.
+
+## Global Constraints
+
+- 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.
+- Migration namespace `aviso`; table `aviso_subscriptions`; `migrations/0001_init.sql` is immutable once released; apps merge `aviso.Schema` into `BootSchema`.
+- Ownership is `sessions.Current(r).Subject`; no request body ever names a subject.
+- `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.
+- Bounds: subscribe body ≤ 8192 bytes; endpoint ≤ 2048 bytes; payload ≤ 3993 bytes; push-service response body read ≤ 4096 bytes.
+- 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.
+- Concurrency across the Service (default 32); 30 s per request; no retries.
+- Every push shows a notification; the worker helper never calls `skipWaiting`/`clients.claim`; `enable` prompts only from a gesture; `reconcile` never prompts.
+- Logs never carry endpoints, `p256dh`, `auth`, payloads, `Authorization` headers, push-service bodies or URL-bearing errors; a subscription is logged by its `id`.
+- Comments say why, naming the failure prevented (rastrillo `AGENTS.md`). Commit subjects imperative; bodies explain why.
+- 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`.
+- Git: work on branch `build`, push after every task, `amadan task advance` per task, `amadan branch describe` kept true.
+
+---
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| `go.mod`, `go.sum` | module identity and the two direct deps |
+| `Makefile`, `.amadan/ci`, `.amadan/ci.d/{10-vet,20-fmt,30-test,40-race}` | the one gate |
+| `doc.go` | package comment: what aviso is, the two rulings |
+| `vapid.go` | `GenerateKey`, private-key parsing, public key and key id derivation, key errors |
+| `migrations/0001_init.sql`, `migrations.go` | `Schema` |
+| `aviso.go` | `Config`, `Service`, `New`, `Subscription`, `Stored`, shared errors |
+| `store.go` | every SQL statement: put with ownership rules, list, delete, sweep, confirm, prune |
+| `ssrf.go` | endpoint validation and the guarded HTTP client |
+| `send.go` | `Options`, `Result`, `Send`, `SendTo` |
+| `http.go` | `PublicKey`, `Subscribe`, `Unsubscribe` |
+| `js.go`, `js/push.mjs`, `js/aviso-sw.js`, `js/*.test.mjs` | the browser halves and their node tests |
+| `cmd/aviso-key/main.go` | prints one private key |
+| `browser_test.go` (tag `browser`) | chromedp wiring proof |
+| `SKILL.md`, `README.md`, `docs/installable.md` | what an agent loads, what a person reads, the installability recipe |
+| `example/` | own module: a minimal rastrillo app wiring aviso |
+
+---
+
+### Task 1: Module scaffold and gate
+
+**Files:**
+- 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`
+- Already present: `.gitignore`, `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`, this plan
+
+**Interfaces:**
+- Produces: a module every later task builds inside; `make ci` as the gate.
+
+- [ ] **Step 1: Create the branch and push it, per the amadan house rules**
+
+```sh
+cd /home/paulca/github.com/rastrillo/aviso
+git checkout -b build
+```
+
+(Push happens at the end of this task once there is a commit.)
+
+- [ ] **Step 2: Write go.mod**
+
+```
+module amadan.net/rastrillo/aviso
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/rastrillo v0.26.0
+ github.com/SherClockHolmes/webpush-go v1.4.0
+)
+```
+
+- [ ] **Step 3: Write doc.go**
+
+```go
+// Package aviso is rastrillo's Web Push addon: the subscriptions a
+// signed-in person enrols from their browsers, the VAPID key that
+// signs for them, and a sender that moves an app's payload to those
+// devices through the browser vendors' push services.
+//
+// Two rulings bind everything here, both from rastrillo's addons
+// doctrine. The arrow points one way: aviso depends on rastrillo,
+// rastrillo never learns aviso exists. The app owns policy: aviso
+// moves bytes to devices a subject enrolled and never decides who
+// should be told what — recipient selection, payload meaning and the
+// service worker's lifecycle are the app's.
+//
+// Design: docs/superpowers/specs/2026-09-07-aviso-web-push-design.md.
+package aviso
+```
+
+- [ ] **Step 4: Write the Makefile (idear's, with the reason node is not a separate target)**
+
+```make
+.PHONY: vet fmt-check test race ci
+
+# ci is the one gate: what a runner executes and what you run before
+# pushing are the same definition (amadan's own rule — CI steps
+# delegate to make targets, never keep their own copies).
+#
+# The node tests are not a separate target: js_test.go runs them from
+# inside `go test`, skipping when node is absent, so a runner without
+# node still reports the Go half honestly instead of failing a step
+# it cannot run.
+ci: vet fmt-check test race
+
+vet:
+ go vet ./...
+
+fmt-check:
+ @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
+
+# -count=1 on purpose: these tests build a real SQLite database and
+# race goroutines against it; a cached PASS re-reports one scheduling
+# as though it were every scheduling.
+test:
+ go test ./... -count=1
+
+# -race needs cgo; everything else runs with the default toolchain, so
+# this target sets CGO_ENABLED for its own command only.
+race:
+ CGO_ENABLED=1 go test ./... -race -count=1
+```
+
+- [ ] **Step 5: Write the CI entry and steps**
+
+`.amadan/ci`:
+```sh
+#!/bin/sh
+# amadan CI entry (single-script fallback for runners without step
+# support). The steps in ci.d/ are the same targets, reported one by
+# one. Must stay executable: a non-executable script resolves "skipped".
+set -e
+exec make ci
+```
+
+`.amadan/ci.d/10-vet`, `20-fmt`, `30-test`, `40-race` — each:
+```sh
+#!/bin/sh
+set -e
+exec make vet
+```
+(with `fmt-check`, `test`, `race` respectively). Then `chmod +x .amadan/ci .amadan/ci.d/*`.
+
+- [ ] **Step 6: Write README.md (short; the SKILL.md in Task 11 is the authoring doc)**
+
+```markdown
+# aviso
+
+Web Push for rastrillo apps: subscriptions a signed-in person enrols
+from their browsers, one VAPID key per app, and a sender that fans a
+payload out to those devices. An addon — `amadan.net/rastrillo/aviso`
+depends on rastrillo, never the reverse.
+
+ go get amadan.net/rastrillo/aviso
+ cat "$(go list -m -f '{{.Dir}}' amadan.net/rastrillo/aviso)/SKILL.md"
+
+Design: `docs/superpowers/specs/2026-09-07-aviso-web-push-design.md`.
+Gate: `make ci`.
+```
+
+- [ ] **Step 7: Resolve deps and run the gate**
+
+Run: `go mod tidy && make ci`
+Expected: tidy writes go.sum; `ci` passes with "no test files".
+
+- [ ] **Step 8: Commit and push; describe the branch; add the tasks**
+
+```sh
+git add -A
+git commit -m "Scaffold the aviso module and its gate
+
+The addon starts with the gate rather than the code so every later
+commit is checked the same way CI checks it (amadan's rule: CI steps
+delegate to make targets, never copy them).
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push -u origin build
+amadan branch describe rastrillo/aviso build -body - <<'EOF'
+Builds 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.
+EOF
+for 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
+```
+
+- [ ] **Step 9: Codex review (see Review gate), then `amadan task advance` Task 1**
+
+---
+
+### Task 2: VAPID keys
+
+**Files:**
+- Create: `vapid.go`, `vapid_test.go`
+
+**Interfaces:**
+- 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.
+
+- [ ] **Step 1: Write the failing tests**
+
+`vapid_test.go`:
+```go
+package aviso
+
+import (
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestGenerateKeyRoundTrips(t *testing.T) {
+ priv, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(priv)
+ if err != nil || len(raw) != 32 {
+ t.Fatalf("private key = %q: want 32 unpadded base64url bytes (err %v)", priv, err)
+ }
+ pub, id, err := parsePrivateKey(priv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pubRaw, err := base64.RawURLEncoding.DecodeString(pub)
+ if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 {
+ t.Fatalf("public key = %q: want 65-byte uncompressed point", pub)
+ }
+ if id == "" || strings.ContainsAny(id, "+/=") {
+ t.Fatalf("key id = %q: want unpadded base64url", id)
+ }
+ // The same key must yield the same id after a restart — Sweep and
+ // Send match rows on it.
+ _, id2, _ := parsePrivateKey(priv)
+ if id != id2 {
+ t.Fatal("key id not deterministic")
+ }
+}
+
+func TestParsePrivateKeyRefusesBadInput(t *testing.T) {
+ if _, _, err := parsePrivateKey(""); !errors.Is(err, ErrEmptyPrivateKey) {
+ t.Fatalf("empty: got %v, want ErrEmptyPrivateKey", err)
+ }
+ zero := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
+ for name, in := range map[string]string{
+ "not base64": "!!!",
+ "short": base64.RawURLEncoding.EncodeToString([]byte("short")),
+ "zero scalar": zero,
+ "padded": zero + "=",
+ } {
+ if _, _, err := parsePrivateKey(in); !errors.Is(err, ErrInvalidPrivateKey) {
+ t.Errorf("%s: got %v, want ErrInvalidPrivateKey", name, err)
+ }
+ }
+}
+
+func TestParsePrivateKeyAgreesWithECDH(t *testing.T) {
+ k, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv := base64.RawURLEncoding.EncodeToString(k.Bytes())
+ pub, _, err := parsePrivateKey(priv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
+ if pub != want {
+ t.Fatalf("public key = %s, want %s", pub, want)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./... -run 'TestGenerateKey|TestParsePrivateKey' -v`
+Expected: FAIL, undefined: GenerateKey, parsePrivateKey, ErrEmptyPrivateKey.
+
+- [ ] **Step 3: Implement vapid.go**
+
+```go
+package aviso
+
+import (
+ "crypto/ecdh"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "strings"
+)
+
+// ErrEmptyPrivateKey means Config.PrivateKey was empty. It is refused
+// rather than minted: a key generated at boot into local state is a
+// key lost at the next restore, and every browser then holds a
+// subscription nobody can sign for.
+var ErrEmptyPrivateKey = errors.New("aviso: Config.PrivateKey must not be empty; mint one with `go run amadan.net/rastrillo/aviso/cmd/aviso-key`")
+
+// ErrInvalidPrivateKey means Config.PrivateKey is not an unpadded
+// base64url 32-byte P-256 scalar in range.
+var ErrInvalidPrivateKey = errors.New("aviso: Config.PrivateKey is not an unpadded base64url 32-byte P-256 scalar")
+
+// GenerateKey mints a VAPID private key in Config.PrivateKey's format.
+// The public half is derived, never stored: one secret to provision.
+func GenerateKey() (string, error) {
+ k, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(k.Bytes()), nil
+}
+
+// parsePrivateKey validates the scalar and derives the two things the
+// rest of the package needs from it: the uncompressed public point
+// (applicationServerKey on the browser side, the VAPID public key on
+// the wire) and a key id — SHA-256 of that point — stored on every
+// row so a rotated key is visible per subscription.
+func parsePrivateKey(s string) (pub, keyID string, err error) {
+ if s == "" {
+ return "", "", ErrEmptyPrivateKey
+ }
+ // Padding is refused, not tolerated: two encodings of one key
+ // would give two key ids for the same rows.
+ if strings.ContainsAny(s, "=+/") {
+ return "", "", ErrInvalidPrivateKey
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil || len(raw) != 32 {
+ return "", "", ErrInvalidPrivateKey
+ }
+ // NewPrivateKey rejects zero and out-of-range scalars.
+ k, err := ecdh.P256().NewPrivateKey(raw)
+ if err != nil {
+ return "", "", ErrInvalidPrivateKey
+ }
+ point := k.PublicKey().Bytes()
+ sum := sha256.Sum256(point)
+ return base64.RawURLEncoding.EncodeToString(point),
+ base64.RawURLEncoding.EncodeToString(sum[:]), nil
+}
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `go test ./... -run 'TestGenerateKey|TestParsePrivateKey' -v`
+Expected: PASS ×3.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add vapid.go vapid_test.go
+git commit -m "Add VAPID key parsing and generation
+
+The public key is derived from the scalar rather than stored beside
+it, so an app provisions exactly one secret; the key id is on every
+row so a rotated key shows up per subscription instead of as a silent
+signing failure.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 3: Schema, Config, New, Service
+
+**Files:**
+- Create: `migrations/0001_init.sql`, `migrations.go`, `aviso.go`, `schema_test.go`, `aviso_test.go`
+
+**Interfaces:**
+- Consumes: `parsePrivateKey` (Task 2).
+- 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.
+
+- [ ] **Step 1: Write the migration**
+
+`migrations/0001_init.sql`:
+```sql
+-- One row per browser subscription. `endpoint` is the push service's
+-- unguessable URL and is unique by construction; `subject` is the
+-- rastrillo session subject that enrolled it. No foreign key to any
+-- users table: apps own their identity schema.
+CREATE TABLE aviso_subscriptions (
+ id TEXT NOT NULL PRIMARY KEY,
+ endpoint TEXT NOT NULL UNIQUE,
+ subject TEXT NOT NULL CHECK (length(subject) > 0),
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ vapid_key_id TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_at INTEGER NOT NULL,
+ last_confirmed_at INTEGER NOT NULL
+);
+CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject);
+CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at);
+```
+
+- [ ] **Step 2: Write migrations.go**
+
+```go
+package aviso
+
+import (
+ "embed"
+
+ "amadan.net/rastrillo/rastrillo/migrate"
+)
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is the addon's migration set. Merge it into the app's
+// BootSchema, never its Schema: `rastrillo migration check` diffs
+// Schema against Models and would propose dropping a table Models
+// does not know about.
+var Schema = migrate.MustFromFS(migrationFS, "aviso")
+```
+
+- [ ] **Step 3: Write aviso.go**
+
+```go
+package aviso
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Config configures New. DB, PrivateKey, Contact and Origin are
+// required.
+type Config struct {
+ // DB is the app's writer. Schema must have been applied.
+ DB *sql.DB
+ // PrivateKey is the VAPID private key: unpadded base64url, 32-byte
+ // P-256 scalar, as cmd/aviso-key prints it. Provisioned, never
+ // minted here — see ErrEmptyPrivateKey.
+ PrivateKey string
+ // Contact is the VAPID "sub" claim — a mailto: or https: URL a push
+ // service may use to reach the operator about abuse.
+ Contact string
+ // Origin is the app's external origin, scheme included, for
+ // csrf.SameOrigin on the mutating handlers.
+ Origin string
+ // Concurrency bounds in-flight sends across the whole Service.
+ // 0 means 32.
+ Concurrency int
+ Logger *slog.Logger
+}
+
+// Subscription is what the browser hands the app: the push service's
+// endpoint and the two keys RFC 8291 encrypts to.
+type Subscription struct {
+ Endpoint string
+ P256dh string
+ Auth string
+}
+
+// Stored is one enrolled device: a Subscription plus its row identity.
+// Revision changes on every re-subscribe, and Send matches on it so a
+// slow send cannot prune a subscription the browser refreshed
+// meanwhile.
+type Stored struct {
+ ID string
+ Subject string
+ VAPIDKeyID string
+ Revision int64
+ Subscription
+}
+
+// ErrOwnedElsewhere is Subscribe's refusal to move an endpoint between
+// subjects: a second account on the same browser must re-enrol, not
+// silently take over the first account's device.
+var ErrOwnedElsewhere = errors.New("aviso: endpoint is enrolled by another subject")
+
+// ErrKeyMismatch marks a Result for a row enrolled under a VAPID key
+// other than this Service's: it cannot be signed for, so it is skipped
+// rather than sent to fail.
+var ErrKeyMismatch = errors.New("aviso: subscription was enrolled under a different VAPID key")
+
+// Service is the wired addon. Build one per process and share it: the
+// concurrency bound lives on it.
+type Service struct {
+ cfg Config
+ pub string
+ keyID string
+ client *http.Client // set by newClient (ssrf.go); tests may replace it
+ sem chan struct{}
+ now func() time.Time
+}
+
+// New validates cfg and returns a ready *Service.
+func New(cfg Config) (*Service, error) {
+ if cfg.DB == nil {
+ return nil, errors.New("aviso: Config.DB is required")
+ }
+ pub, keyID, err := parsePrivateKey(cfg.PrivateKey)
+ if err != nil {
+ return nil, err
+ }
+ if !strings.HasPrefix(cfg.Contact, "mailto:") && !strings.HasPrefix(cfg.Contact, "https://") {
+ return nil, errors.New("aviso: Config.Contact must be a mailto: or https: URL")
+ }
+ if !strings.HasPrefix(cfg.Origin, "https://") && !strings.HasPrefix(cfg.Origin, "http://") {
+ return nil, errors.New("aviso: Config.Origin must be an absolute origin like https://app.example.com")
+ }
+ if cfg.Concurrency <= 0 {
+ cfg.Concurrency = 32
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ return &Service{
+ cfg: cfg,
+ pub: pub,
+ keyID: keyID,
+ sem: make(chan struct{}, cfg.Concurrency),
+ now: time.Now,
+ }, nil
+}
+
+// PublicKeyString is the applicationServerKey the browser subscribes
+// with: unpadded base64url of the uncompressed P-256 point.
+func (s *Service) PublicKeyString() string { return s.pub }
+```
+
+- [ ] **Step 4: Write the failing tests**
+
+`schema_test.go`:
+```go
+package aviso_test
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+// openDB is a fresh on-disk rastrillo db with aviso.Schema applied —
+// what every store and handler test starts from.
+func openDB(t *testing.T) *sql.DB {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+ return d.Writer()
+}
+
+func TestSchemaCreatesTheTableAndReplaysCleanly(t *testing.T) {
+ d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ for i := 0; i < 2; i++ { // second Apply must be a no-op, not a failure
+ if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil {
+ t.Fatalf("apply %d: %v", i, err)
+ }
+ }
+ var name string
+ err = d.Writer().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='aviso_subscriptions'`).Scan(&name)
+ if err != nil {
+ t.Fatalf("table missing: %v", err)
+ }
+}
+```
+
+`aviso_test.go`:
+```go
+package aviso_test
+
+import (
+ "errors"
+ "testing"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+func newService(t *testing.T) *aviso.Service {
+ t.Helper()
+ key, err := aviso.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ s, err := aviso.New(aviso.Config{
+ DB: openDB(t), PrivateKey: key,
+ Contact: "mailto:ops@example.test", Origin: "https://app.example.test",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func TestNewRefusesBadConfig(t *testing.T) {
+ key, _ := aviso.GenerateKey()
+ good := aviso.Config{DB: openDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"}
+ if _, err := aviso.New(good); err != nil {
+ t.Fatalf("good config refused: %v", err)
+ }
+ c := good
+ c.PrivateKey = ""
+ if _, err := aviso.New(c); !errors.Is(err, aviso.ErrEmptyPrivateKey) {
+ t.Errorf("empty key: %v", err)
+ }
+ c = good
+ c.DB = nil
+ if _, err := aviso.New(c); err == nil {
+ t.Error("nil DB accepted")
+ }
+ c = good
+ c.Contact = "ops@example.test"
+ if _, err := aviso.New(c); err == nil {
+ t.Error("bare address accepted as Contact")
+ }
+ c = good
+ c.Origin = "app.example.test"
+ if _, err := aviso.New(c); err == nil {
+ t.Error("schemeless Origin accepted")
+ }
+}
+
+func TestPublicKeyStringIsStable(t *testing.T) {
+ s := newService(t)
+ if s.PublicKeyString() == "" {
+ t.Fatal("empty public key")
+ }
+}
+```
+
+- [ ] **Step 5: Run, expect failure on missing symbols; then run again after Steps 1-3 are in place**
+
+Run: `go test ./... -count=1`
+Expected: PASS (schema applies twice, New validates).
+
+- [ ] **Step 6: Commit, push, Codex review, advance task**
+
+```sh
+git add migrations migrations.go aviso.go schema_test.go aviso_test.go
+git commit -m "Add the schema, Config and Service
+
+One table, no users-table foreign key (apps own identity), key id on
+every row so a rotated VAPID key is visible per subscription. New
+refuses an empty key instead of minting one: a key minted at boot is a
+key lost at the next restore.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 4: Store
+
+**Files:**
+- Create: `store.go`, `store_test.go`
+
+**Interfaces:**
+- Consumes: `Service`, `Stored`, `Subscription`, `ErrOwnedElsewhere`.
+- Produces (all methods on `*Service`, unexported ones used by Tasks 6-7):
+ - `List(ctx, subject string) ([]Stored, error)`
+ - `DeleteSubject(ctx, subject string) error`
+ - `Sweep(ctx, notConfirmedSince time.Time) error`
+ - `put(ctx, subject string, sub Subscription, previousEndpoint string) error` — insert or same-owner update in one transaction; `ErrOwnedElsewhere` if another subject holds the endpoint.
+ - `deleteOwn(ctx, subject, endpoint string) error` — deletes only the caller's row; nil either way.
+ - `confirm(ctx, id string, revision int64) error` — bumps `last_confirmed_at` only if revision matches.
+ - `prune(ctx, id string, revision int64) error` — deletes only if revision matches.
+ - `newID() (string, error)` — 16 random bytes, unpadded base64url.
+
+- [ ] **Step 1: Write the failing tests**
+
+`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`):
+```go
+package aviso
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+)
+
+func openInternalDB(t *testing.T) *sql.DB {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, Schema); err != nil {
+ t.Fatal(err)
+ }
+ return d.Writer()
+}
+
+func newInternalService(t *testing.T) *Service {
+ t.Helper()
+ key, _ := GenerateKey()
+ s, err := New(Config{DB: openInternalDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func sub(endpoint string) Subscription {
+ return Subscription{Endpoint: endpoint, P256dh: "BP256", Auth: "auth"}
+}
+
+func TestPutInsertsThenUpdatesSameOwner(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ if err := s.put(ctx, "alice", sub("https://push.example/1"), ""); err != nil {
+ t.Fatal(err)
+ }
+ rows, _ := s.List(ctx, "alice")
+ if len(rows) != 1 || rows[0].Revision != 1 || rows[0].VAPIDKeyID != s.keyID {
+ t.Fatalf("after insert: %+v", rows)
+ }
+ again := sub("https://push.example/1")
+ again.Auth = "auth2"
+ if err := s.put(ctx, "alice", again, ""); err != nil {
+ t.Fatal(err)
+ }
+ rows, _ = s.List(ctx, "alice")
+ if len(rows) != 1 || rows[0].Revision != 2 || rows[0].Auth != "auth2" {
+ t.Fatalf("after update: %+v", rows)
+ }
+}
+
+func TestPutRefusesCrossOwner(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
+ err := s.put(ctx, "bob", sub("https://push.example/1"), "")
+ if !errors.Is(err, ErrOwnedElsewhere) {
+ t.Fatalf("got %v, want ErrOwnedElsewhere", err)
+ }
+ rows, _ := s.List(ctx, "alice")
+ if len(rows) != 1 {
+ t.Fatal("alice lost her row")
+ }
+}
+
+func TestPutDeletesPreviousOnlyWhenOwned(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub("https://push.example/old"), "")
+ _ = s.put(ctx, "bob", sub("https://push.example/bobs"), "")
+ // alice re-subscribes and names her old endpoint: gone.
+ if err := s.put(ctx, "alice", sub("https://push.example/new"), "https://push.example/old"); err != nil {
+ t.Fatal(err)
+ }
+ rows, _ := s.List(ctx, "alice")
+ if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
+ t.Fatalf("alice rows: %+v", rows)
+ }
+ // alice names bob's endpoint as previous: bob keeps it.
+ _ = s.put(ctx, "alice", sub("https://push.example/new2"), "https://push.example/bobs")
+ rows, _ = s.List(ctx, "bob")
+ if len(rows) != 1 {
+ t.Fatal("bob's row deleted by alice's previousEndpoint")
+ }
+}
+
+func TestDeleteOwnIsOwnerScoped(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
+ if err := s.deleteOwn(ctx, "bob", "https://push.example/1"); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("bob deleted alice's row")
+ }
+ _ = s.deleteOwn(ctx, "alice", "https://push.example/1")
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
+ t.Fatal("own delete did nothing")
+ }
+}
+
+func TestConfirmAndPruneAreRevisionConditional(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
+ before, _ := s.List(ctx, "alice")
+ id := before[0].ID
+ // Browser refreshes: revision 2.
+ _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
+ // A send that captured revision 1 comes back 410: must not prune.
+ if err := s.prune(ctx, id, 1); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("stale prune deleted a refreshed subscription")
+ }
+ // Stale confirm must not touch last_confirmed_at.
+ s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+ _ = s.confirm(ctx, id, 1)
+ var got int64
+ _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, id).Scan(&got)
+ if got == 1_800_000_000 {
+ t.Fatal("stale confirm bumped last_confirmed_at")
+ }
+ _ = s.confirm(ctx, id, 2)
+ _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, id).Scan(&got)
+ if got != 1_800_000_000 {
+ t.Fatalf("current confirm did not bump: %d", got)
+ }
+ if err := s.prune(ctx, id, 2); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
+ t.Fatal("current prune did not delete")
+ }
+}
+
+func TestSweepAndDeleteSubject(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ s.now = func() time.Time { return time.Unix(1000, 0) }
+ _ = s.put(ctx, "alice", sub("https://push.example/old"), "")
+ s.now = func() time.Time { return time.Unix(2000, 0) }
+ _ = s.put(ctx, "alice", sub("https://push.example/new"), "")
+ _ = s.put(ctx, "bob", sub("https://push.example/bob"), "")
+ if err := s.Sweep(ctx, time.Unix(1500, 0)); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
+ t.Fatalf("sweep: %+v", rows)
+ }
+ if err := s.DeleteSubject(ctx, "bob"); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := s.List(ctx, "bob"); len(rows) != 0 {
+ t.Fatal("DeleteSubject left rows")
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("DeleteSubject touched another subject")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./... -count=1 -run 'TestPut|TestDeleteOwn|TestConfirm|TestSweep'`
+Expected: FAIL, undefined methods.
+
+- [ ] **Step 3: Implement store.go**
+
+```go
+package aviso
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "time"
+)
+
+func newID() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+const selectCols = `id, endpoint, subject, p256dh, auth, vapid_key_id, revision`
+
+func scanStored(rows *sql.Rows) ([]Stored, error) {
+ var out []Stored
+ for rows.Next() {
+ var st Stored
+ if err := rows.Scan(&st.ID, &st.Endpoint, &st.Subject, &st.P256dh, &st.Auth, &st.VAPIDKeyID, &st.Revision); err != nil {
+ return nil, err
+ }
+ out = append(out, st)
+ }
+ return out, rows.Err()
+}
+
+// List returns every device subject has enrolled, oldest first.
+func (s *Service) List(ctx context.Context, subject string) ([]Stored, error) {
+ rows, err := s.cfg.DB.QueryContext(ctx,
+ `SELECT `+selectCols+` FROM aviso_subscriptions WHERE subject = ? ORDER BY created_at, id`, subject)
+ if err != nil {
+ return nil, fmt.Errorf("aviso: list: %w", err)
+ }
+ defer rows.Close()
+ return scanStored(rows)
+}
+
+// DeleteSubject removes every device subject enrolled — account
+// deletion's hook.
+func (s *Service) DeleteSubject(ctx context.Context, subject string) error {
+ _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE subject = ?`, subject)
+ if err != nil {
+ return fmt.Errorf("aviso: delete subject: %w", err)
+ }
+ return nil
+}
+
+// Sweep deletes subscriptions not confirmed since t. Confirmation
+// moves on reconcile and on an accepted send, so this measures whether
+// the subscription is alive, not whether the person still wants it.
+func (s *Service) Sweep(ctx context.Context, t time.Time) error {
+ _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE last_confirmed_at < ?`, t.Unix())
+ if err != nil {
+ return fmt.Errorf("aviso: sweep: %w", err)
+ }
+ return nil
+}
+
+// put is Subscribe's write: insert, or update when the same subject
+// already holds the endpoint, in one transaction with the optional
+// previousEndpoint delete — which only removes the caller's own row,
+// so naming someone else's endpoint is a no-op rather than a weapon.
+func (s *Service) put(ctx context.Context, subject string, sub Subscription, previousEndpoint string) error {
+ tx, err := s.cfg.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("aviso: put: %w", err)
+ }
+ defer tx.Rollback()
+ now := s.now().Unix()
+ var owner string
+ err = tx.QueryRowContext(ctx, `SELECT subject FROM aviso_subscriptions WHERE endpoint = ?`, sub.Endpoint).Scan(&owner)
+ switch {
+ case err == nil && owner != subject:
+ return ErrOwnedElsewhere
+ case err == nil:
+ _, err = tx.ExecContext(ctx, `UPDATE aviso_subscriptions
+ SET p256dh = ?, auth = ?, vapid_key_id = ?, revision = revision + 1, last_confirmed_at = ?
+ WHERE endpoint = ?`, sub.P256dh, sub.Auth, s.keyID, now, sub.Endpoint)
+ case errors.Is(err, sql.ErrNoRows):
+ var id string
+ if id, err = newID(); err != nil {
+ return err
+ }
+ _, err = tx.ExecContext(ctx, `INSERT INTO aviso_subscriptions
+ (id, endpoint, subject, p256dh, auth, vapid_key_id, revision, created_at, last_confirmed_at)
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, id, sub.Endpoint, subject, sub.P256dh, sub.Auth, s.keyID, now, now)
+ }
+ if err != nil {
+ return fmt.Errorf("aviso: put: %w", err)
+ }
+ if previousEndpoint != "" && previousEndpoint != sub.Endpoint {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE endpoint = ? AND subject = ?`, previousEndpoint, subject); err != nil {
+ return fmt.Errorf("aviso: put: %w", err)
+ }
+ }
+ return tx.Commit()
+}
+
+// deleteOwn removes endpoint only if subject holds it; nil either way,
+// because Unsubscribe is idempotent and must not confirm whether an
+// endpoint exists to someone who does not own it.
+func (s *Service) deleteOwn(ctx context.Context, subject, endpoint string) error {
+ _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE endpoint = ? AND subject = ?`, endpoint, subject)
+ if err != nil {
+ return fmt.Errorf("aviso: unsubscribe: %w", err)
+ }
+ return nil
+}
+
+// confirm bumps last_confirmed_at for an accepted send, only if the
+// row is still at the revision the send captured.
+func (s *Service) confirm(ctx context.Context, id string, revision int64) error {
+ _, err := s.cfg.DB.ExecContext(ctx, `UPDATE aviso_subscriptions SET last_confirmed_at = ? WHERE id = ? AND revision = ?`, s.now().Unix(), id, revision)
+ return err
+}
+
+// prune deletes a row the push service reported gone, only if it is
+// still at the revision the send captured: a browser that refreshed
+// meanwhile has a live subscription under the same id.
+func (s *Service) prune(ctx context.Context, id string, revision int64) error {
+ _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE id = ? AND revision = ?`, id, revision)
+ return err
+}
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `go test ./... -count=1 && CGO_ENABLED=1 go test ./... -race -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add store.go store_test.go
+git commit -m "Add the subscription store
+
+Ownership rules live in one transaction: an endpoint held by another
+subject is refused rather than reassigned, and previousEndpoint only
+deletes the caller's own row. confirm and prune match on the revision
+the send captured, so a slow send cannot delete a subscription the
+browser refreshed meanwhile.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 5: SSRF guard and endpoint validation
+
+**Files:**
+- Create: `ssrf.go`, `ssrf_test.go`
+- Modify: `aviso.go` — `New` sets `client: newClient()`
+
+**Interfaces:**
+- 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`.
+
+- [ ] **Step 1: Write the failing tests**
+
+`ssrf_test.go` (package `aviso`):
+```go
+package aviso
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestValidateEndpoint(t *testing.T) {
+ ok := "https://fcm.googleapis.com/fcm/send/abc"
+ if err := validateEndpoint(ok); err != nil {
+ t.Fatalf("good endpoint refused: %v", err)
+ }
+ bad := map[string]string{
+ "http": "http://fcm.googleapis.com/x",
+ "userinfo": "https://user:pw@fcm.googleapis.com/x",
+ "fragment": "https://fcm.googleapis.com/x#frag",
+ "empty": "",
+ "no host": "https:///x",
+ "too long": "https://fcm.googleapis.com/" + strings.Repeat("a", 2048),
+ "loopback": "https://127.0.0.1/x",
+ "ip6 loop": "https://[::1]/x",
+ "private": "https://10.0.0.5/x",
+ "linklocal": "https://169.254.169.254/latest",
+ "mapped": "https://[::ffff:10.0.0.5]/x",
+ }
+ for name, in := range bad {
+ if err := validateEndpoint(in); !errors.Is(err, ErrBadEndpoint) {
+ t.Errorf("%s (%q): got %v, want ErrBadEndpoint", name, in, err)
+ }
+ }
+}
+
+func TestGuardedIP(t *testing.T) {
+ 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"} {
+ if err := guardedIP(net.ParseIP(ip)); err == nil {
+ t.Errorf("%s allowed", ip)
+ }
+ }
+ for _, ip := range []string{"142.250.72.14", "2607:f8b0::1"} {
+ if err := guardedIP(net.ParseIP(ip)); err != nil {
+ t.Errorf("%s refused: %v", ip, err)
+ }
+ }
+}
+
+// The guard is at connect time, so a hostname that resolves to a
+// loopback address — DNS rebinding's shape — fails even though the URL
+// looked fine. httptest's server IS loopback, which makes it the
+// perfect hostile target.
+func TestClientRefusesLoopbackAtDial(t *testing.T) {
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ defer srv.Close()
+ c := newClient()
+ c.Transport.(*http.Transport).TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, strings.Replace(srv.URL, "127.0.0.1", "localhost", 1), nil)
+ _, err := c.Do(req)
+ if err == nil || !strings.Contains(err.Error(), "aviso") {
+ t.Fatalf("loopback dial allowed or wrong error: %v", err)
+ }
+}
+
+func TestClientRefusesRedirects(t *testing.T) {
+ c := newClient()
+ req, _ := http.NewRequest(http.MethodGet, "https://example.invalid/", nil)
+ resp := &http.Response{StatusCode: 302}
+ if err := c.CheckRedirect(req, []*http.Request{req}); err == nil {
+ t.Fatalf("redirect followed: %v", resp)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./... -count=1 -run 'TestValidateEndpoint|TestGuardedIP|TestClient'`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Implement ssrf.go**
+
+```go
+package aviso
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "syscall"
+ "time"
+)
+
+// ErrBadEndpoint means a subscription endpoint was refused before any
+// request: wrong scheme, credentials, fragment, too long, or an
+// address no push service lives at.
+var ErrBadEndpoint = errors.New("aviso: endpoint refused")
+
+const maxEndpointLen = 2048
+
+// validateEndpoint is the request-time half of the SSRF guard: shape
+// and literal-IP checks. The dial-time half (guardedIP in the dialer's
+// Control) catches what a hostname resolves to, which this cannot.
+func validateEndpoint(raw string) error {
+ if raw == "" || len(raw) > maxEndpointLen {
+ return fmt.Errorf("%w: empty or over %d bytes", ErrBadEndpoint, maxEndpointLen)
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.RawFragment != "" {
+ return fmt.Errorf("%w: must be https, no credentials, no fragment", ErrBadEndpoint)
+ }
+ if ip := net.ParseIP(u.Hostname()); ip != nil {
+ if err := guardedIP(ip); err != nil {
+ return fmt.Errorf("%w: %v", ErrBadEndpoint, err)
+ }
+ }
+ return nil
+}
+
+// guardedIP refuses every address a push service cannot legitimately
+// have: loopback, private, link-local, unspecified, CGNAT and their
+// IPv4-mapped forms. Applied at connect time so DNS rebinding after
+// validation still fails.
+func guardedIP(ip net.IP) error {
+ if ip4 := ip.To4(); ip4 != nil {
+ ip = ip4
+ }
+ switch {
+ case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(),
+ ip.IsUnspecified(), ip.IsMulticast(), ip.IsInterfaceLocalMulticast():
+ return fmt.Errorf("address %s is not routable to a push service", ip)
+ }
+ if ip.To4() != nil && ip[0] == 100 && ip[1]&0xc0 == 64 { // 100.64.0.0/10
+ return fmt.Errorf("address %s is not routable to a push service", ip)
+ }
+ return nil
+}
+
+// newClient is the only HTTP client that ever talks to a push
+// service: no redirects (a push service never redirects, and following
+// one is how a validated URL turns into an internal one), no proxy
+// from the environment (same reason), and the IP guard inside the
+// dialer's Control so it runs on the address actually connected to.
+func newClient() *http.Client {
+ dialer := &net.Dialer{
+ Timeout: 10 * time.Second,
+ Control: func(network, address string, _ syscall.RawConn) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return fmt.Errorf("aviso: dial %q: %w", address, err)
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return fmt.Errorf("aviso: dial: %q is not an IP", host)
+ }
+ if err := guardedIP(ip); err != nil {
+ return fmt.Errorf("aviso: dial refused: %w", err)
+ }
+ return nil
+ },
+ }
+ return &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ Proxy: nil,
+ DialContext: dialer.DialContext,
+ TLSHandshakeTimeout: 10 * time.Second,
+ MaxIdleConns: 64,
+ IdleConnTimeout: 90 * time.Second,
+ },
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return errors.New("aviso: push service redirected; refused")
+ },
+ }
+}
+```
+
+And in `aviso.go`'s `New`, add `client: newClient(),` to the struct literal.
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `go test ./... -count=1 -run 'TestValidateEndpoint|TestGuardedIP|TestClient' -v`
+Expected: 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.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add ssrf.go ssrf_test.go aviso.go
+git commit -m "Add the SSRF guard: endpoint validation and a dial-time IP check
+
+Two halves on purpose. Validation catches shape and literal IPs at
+subscribe time; the dialer's Control runs on the address actually
+connected to, so a hostname that resolves to loopback after validation
+(DNS rebinding) still fails. No redirects and no proxy for the same
+reason: both turn a validated URL into an unvalidated one.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 6: Send and SendTo
+
+**Files:**
+- Create: `send.go`, `send_test.go`
+
+**Interfaces:**
+- Consumes: `Service.client`, `Service.sem`, `confirm`, `prune`, `List`, `ErrKeyMismatch`.
+- 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)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+`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`.
+
+```go
+package aviso
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type pushRecorder struct {
+ srv *httptest.Server
+ status atomic.Int32
+ hdr chan http.Header
+ retry string
+}
+
+func newPushRecorder(t *testing.T) *pushRecorder {
+ t.Helper()
+ p := &pushRecorder{hdr: make(chan http.Header, 64)}
+ p.status.Store(201)
+ p.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p.hdr <- r.Header.Clone()
+ if p.retry != "" {
+ w.Header().Set("Retry-After", p.retry)
+ }
+ w.WriteHeader(int(p.status.Load()))
+ }))
+ t.Cleanup(p.srv.Close)
+ return p
+}
+
+func serviceAgainst(t *testing.T, p *pushRecorder) *Service {
+ t.Helper()
+ s := newInternalService(t)
+ s.client = p.srv.Client() // the private seam: production guards stay in newClient
+ return s
+}
+
+func TestSendToSetsHeadersAndConfirms(t *testing.T) {
+ p := newPushRecorder(t)
+ s := serviceAgainst(t, p)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
+ s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+ res, err := s.SendTo(ctx, "alice", []byte(`{"title":"hi"}`), Options{TTL: 90 * time.Second, Urgency: "high", Topic: "t1"})
+ if err != nil || len(res) != 1 || res[0].Err != nil || res[0].Status != 201 {
+ t.Fatalf("res=%+v err=%v", res, err)
+ }
+ h := <-p.hdr
+ if h.Get("TTL") != "90" || h.Get("Urgency") != "high" || h.Get("Topic") != "t1" ||
+ !strings.HasPrefix(h.Get("Authorization"), "vapid t=") || h.Get("Content-Encoding") != "aes128gcm" {
+ t.Fatalf("headers: %v", h)
+ }
+ var confirmed int64
+ _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE subject='alice'`).Scan(&confirmed)
+ if confirmed != 1_800_000_000 {
+ t.Fatalf("2xx did not confirm: %d", confirmed)
+ }
+}
+
+func TestSendPrunesOnGoneOnlyAtSameRevision(t *testing.T) {
+ p := newPushRecorder(t)
+ s := serviceAgainst(t, p)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
+ rows, _ := s.List(ctx, "alice")
+ stale := rows[0]
+ _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "") // revision 2
+ p.status.Store(410)
+ res, _ := s.Send(ctx, []Stored{stale}, []byte("x"), Options{})
+ if res[0].Status != 410 || res[0].Err == nil {
+ t.Fatalf("res=%+v", res)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("410 at a stale revision pruned a refreshed row")
+ }
+ current, _ := s.List(ctx, "alice")
+ _, _ = s.Send(ctx, current, []byte("x"), Options{})
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
+ t.Fatal("410 at the current revision did not prune")
+ }
+}
+
+func TestSendReportsRetryAfter(t *testing.T) {
+ p := newPushRecorder(t)
+ p.retry = "120"
+ p.status.Store(429)
+ s := serviceAgainst(t, p)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
+ res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if res[0].RetryAfter != 120*time.Second || res[0].Err == nil {
+ t.Fatalf("res=%+v", res)
+ }
+}
+
+func TestSendSkipsRowsUnderAnotherKey(t *testing.T) {
+ p := newPushRecorder(t)
+ s := serviceAgainst(t, p)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub(p.srv.URL+"/one"), "")
+ _, _ = s.cfg.DB.Exec(`UPDATE aviso_subscriptions SET vapid_key_id = 'other'`)
+ res, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if err != nil || len(res) != 1 || !errors.Is(res[0].Err, ErrKeyMismatch) {
+ t.Fatalf("res=%+v err=%v", res, err)
+ }
+ select {
+ case <-p.hdr:
+ t.Fatal("sent despite key mismatch")
+ default:
+ }
+}
+
+func TestSendValidatesPayloadAndOptions(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ if _, err := s.Send(ctx, nil, make([]byte, 3994), Options{}); !errors.Is(err, ErrPayloadTooLarge) {
+ t.Errorf("oversize payload: %v", err)
+ }
+ for name, o := range map[string]Options{
+ "neg ttl": {TTL: -time.Second},
+ "frac ttl": {TTL: 1500 * time.Millisecond},
+ "urgency": {Urgency: "urgent"},
+ "topic chars": {Topic: "a b"},
+ "topic long": {Topic: strings.Repeat("a", 33)},
+ } {
+ if _, err := s.Send(ctx, nil, []byte("x"), o); !errors.Is(err, ErrBadOptions) {
+ t.Errorf("%s: %v", name, err)
+ }
+ }
+}
+
+func TestSendHonoursCancellation(t *testing.T) {
+ p := newPushRecorder(t)
+ s := serviceAgainst(t, p)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _ = s.put(context.Background(), "alice", sub(p.srv.URL+"/one"), "")
+ _, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("got %v, want context.Canceled", err)
+ }
+}
+
+func TestSendRedactsEndpointFromErrors(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ // Unroutable endpoint: the guard refuses at dial; the error must
+ // not carry the URL.
+ _ = s.put(ctx, "alice", sub("https://10.0.0.9/secret-path"), "")
+ res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if res[0].Err == nil || strings.Contains(res[0].Err.Error(), "secret-path") {
+ t.Fatalf("error carries the endpoint: %v", res[0].Err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./... -count=1 -run 'TestSend'`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Implement send.go**
+
+```go
+package aviso
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ webpush "github.com/SherClockHolmes/webpush-go"
+)
+
+// Options tune one batch. Zero values mean the push service's
+// defaults.
+type Options struct {
+ // TTL is how long the push service may hold the message; whole
+ // seconds, >= 0. 0 means the service's default.
+ TTL time.Duration
+ // Urgency is "very-low", "low", "normal" or "high"; "" means normal.
+ Urgency string
+ // Topic collapses pending messages with the same topic; <= 32
+ // URL-safe characters; "" means none.
+ Topic string
+}
+
+// Result is one device's outcome. Status is the push service's
+// acceptance, not delivery; a 2xx means the service took it.
+type Result struct {
+ ID string
+ Status int // 0 when Err is transport-level
+ RetryAfter time.Duration // from a 429/503, else 0
+ Err error
+}
+
+// ErrPayloadTooLarge means the plaintext exceeds RFC 8291's one-record
+// limit; a larger payload would be split, which no browser accepts.
+var ErrPayloadTooLarge = errors.New("aviso: payload over 3993 bytes")
+
+// ErrBadOptions means Options failed validation.
+var ErrBadOptions = errors.New("aviso: invalid Options")
+
+const maxPayload = 3993
+
+func (o Options) validate() error {
+ if o.TTL < 0 || o.TTL%time.Second != 0 {
+ return fmt.Errorf("%w: TTL must be whole non-negative seconds", ErrBadOptions)
+ }
+ switch o.Urgency {
+ case "", "very-low", "low", "normal", "high":
+ default:
+ return fmt.Errorf("%w: Urgency %q", ErrBadOptions, o.Urgency)
+ }
+ if len(o.Topic) > 32 || strings.Trim(o.Topic, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
+ return fmt.Errorf("%w: Topic must be <= 32 URL-safe characters", ErrBadOptions)
+ }
+ return nil
+}
+
+// SendTo fans payload out to every device subject enrolled — the
+// common case, so an app never touches Stored. The batch error covers
+// what stops the batch (the query, validation, cancellation); each
+// Result covers one device.
+func (s *Service) SendTo(ctx context.Context, subject string, payload []byte, o Options) ([]Result, error) {
+ if err := s.checkBatch(ctx, payload, o); err != nil {
+ return nil, err
+ }
+ rows, err := s.List(ctx, subject)
+ if err != nil {
+ return nil, err
+ }
+ return s.Send(ctx, rows, payload, o)
+}
+
+// Send delivers payload to each of to, bounded by Config.Concurrency
+// across the Service. It never retries: RetryAfter is for the app's
+// own scheduler.
+func (s *Service) Send(ctx context.Context, to []Stored, payload []byte, o Options) ([]Result, error) {
+ if err := s.checkBatch(ctx, payload, o); err != nil {
+ return nil, err
+ }
+ results := make([]Result, len(to))
+ done := make(chan struct{})
+ for i, st := range to {
+ i, st := i, st
+ if st.VAPIDKeyID != s.keyID {
+ results[i] = Result{ID: st.ID, Err: ErrKeyMismatch}
+ continue
+ }
+ select {
+ case s.sem <- struct{}{}:
+ case <-ctx.Done():
+ return results, ctx.Err()
+ }
+ go func() {
+ defer func() { <-s.sem; done <- struct{}{} }()
+ results[i] = s.sendOne(ctx, st, payload, o)
+ }()
+ }
+ for range countSends(results, to) {
+ <-done
+ }
+ return results, nil
+}
+
+// countSends is how many goroutines Send started: every row not
+// short-circuited by a key mismatch.
+func countSends(results []Result, to []Stored) int {
+ n := 0
+ for i := range to {
+ if !errors.Is(results[i].Err, ErrKeyMismatch) {
+ n++
+ }
+ }
+ return n
+}
+
+func (s *Service) checkBatch(ctx context.Context, payload []byte, o Options) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if len(payload) > maxPayload {
+ return ErrPayloadTooLarge
+ }
+ return o.validate()
+}
+
+func (s *Service) sendOne(ctx context.Context, st Stored, payload []byte, o Options) Result {
+ res := Result{ID: st.ID}
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ urgency := webpush.Urgency(o.Urgency)
+ if urgency == "" {
+ urgency = webpush.UrgencyNormal
+ }
+ resp, err := webpush.SendNotificationWithContext(ctx, payload,
+ &webpush.Subscription{Endpoint: st.Endpoint, Keys: webpush.Keys{P256dh: st.P256dh, Auth: st.Auth}},
+ &webpush.Options{
+ HTTPClient: s.client,
+ Subscriber: s.cfg.Contact,
+ TTL: int(o.TTL / time.Second),
+ Urgency: urgency,
+ Topic: o.Topic,
+ VAPIDPublicKey: s.pub,
+ VAPIDPrivateKey: s.cfg.PrivateKey,
+ })
+ if err != nil {
+ res.Err = redact(err)
+ return res
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) // drain for keep-alive; never logged
+ res.Status = resp.StatusCode
+ switch {
+ case resp.StatusCode >= 200 && resp.StatusCode < 300:
+ if err := s.confirm(context.WithoutCancel(ctx), st.ID, st.Revision); err != nil {
+ s.cfg.Logger.Warn("aviso: confirm failed", "id", st.ID, "err", err)
+ }
+ case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
+ res.Err = fmt.Errorf("aviso: push service says subscription gone (%d)", resp.StatusCode)
+ if err := s.prune(context.WithoutCancel(ctx), st.ID, st.Revision); err != nil {
+ s.cfg.Logger.Warn("aviso: prune failed", "id", st.ID, "err", err)
+ }
+ case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable:
+ res.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), s.now())
+ res.Err = fmt.Errorf("aviso: push service throttled (%d)", resp.StatusCode)
+ default:
+ res.Err = fmt.Errorf("aviso: push service refused (%d)", resp.StatusCode)
+ }
+ return res
+}
+
+// redact strips the request URL from a transport error: *url.Error
+// prints it, and the endpoint is the one secret in this package that
+// would otherwise reach a log.
+func redact(err error) error {
+ var ue *url.Error
+ if errors.As(err, &ue) {
+ return fmt.Errorf("aviso: %s: %w", ue.Op, ue.Err)
+ }
+ return fmt.Errorf("aviso: send: %w", err)
+}
+
+func parseRetryAfter(v string, now time.Time) time.Duration {
+ if v == "" {
+ return 0
+ }
+ if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
+ return time.Duration(secs) * time.Second
+ }
+ if t, err := http.ParseTime(v); err == nil && t.After(now) {
+ return t.Sub(now)
+ }
+ return 0
+}
+```
+
+- [ ] **Step 4: Run to verify pass, including -race**
+
+Run: `go test ./... -count=1 -run TestSend -v && CGO_ENABLED=1 go test ./... -race -count=1`
+Expected: PASS. Watch `TestSendRedactsEndpointFromErrors`: the guard's dial error wraps the IP:port, not the path — assert only that `secret-path` is absent.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add send.go send_test.go
+git commit -m "Add Send and SendTo over webpush-go
+
+webpush-go's types stay behind the package boundary. Results report
+acceptance per device and the batch error what stopped the batch, so a
+failed query never reads as zero devices. 404/410 prune and 2xx confirm
+both match the captured revision; transport errors are stripped of the
+endpoint URL before they can reach a log.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 7: HTTP handlers
+
+**Files:**
+- Create: `http.go`, `http_test.go`
+
+**Interfaces:**
+- Consumes: `sessions.Current`, `csrf.SameOrigin`, `validateEndpoint`, `put`, `deleteOwn`, `Service.pub`, `ErrOwnedElsewhere`.
+- Produces: `func (s *Service) PublicKey(w, r)`, `Subscribe(w, r)`, `Unsubscribe(w, r)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+`http_test.go` (package `aviso_test`; uses `openDB`/`newService` from Task 3):
+```go
+package aviso_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+func subscribeBody(s *aviso.Service, endpoint string) string {
+ b, _ := json.Marshal(map[string]any{
+ "subscription": map[string]any{"endpoint": endpoint, "keys": map[string]string{"p256dh": "BP", "auth": "au"}},
+ "publicKey": s.PublicKeyString(),
+ })
+ return string(b)
+}
+
+func post(t *testing.T, h http.HandlerFunc, body, subject string, sameOrigin bool) *httptest.ResponseRecorder {
+ t.Helper()
+ r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ if sameOrigin {
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ } else {
+ r.Header.Set("Sec-Fetch-Site", "cross-site")
+ }
+ if subject != "" {
+ r = sessions.WithSession(r, sessions.Session{Subject: subject})
+ }
+ w := httptest.NewRecorder()
+ h(w, r)
+ return w
+}
+
+func TestPublicKey(t *testing.T) {
+ s := newService(t)
+ w := httptest.NewRecorder()
+ s.PublicKey(w, httptest.NewRequest(http.MethodGet, "/aviso/public-key", nil))
+ var got struct{ PublicKey string }
+ if err := json.NewDecoder(w.Body).Decode(&got); err != nil || got.PublicKey != s.PublicKeyString() {
+ t.Fatalf("status %d body %s", w.Code, w.Body)
+ }
+ if w.Header().Get("Cache-Control") != "no-cache" {
+ t.Fatal("public key cacheable")
+ }
+}
+
+func TestSubscribeGating(t *testing.T) {
+ s := newService(t)
+ body := subscribeBody(s, "https://push.example/e1")
+ if w := post(t, s.Subscribe, body, "", true); w.Code != http.StatusUnauthorized {
+ t.Errorf("no session: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, body, "alice", false); w.Code != http.StatusForbidden {
+ t.Errorf("cross-site: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, body, "alice", true); w.Code != http.StatusNoContent {
+ t.Errorf("good: %d %s", w.Code, w.Body)
+ }
+ if rows, _ := s.List(t.Context(), "alice"); len(rows) != 1 {
+ t.Fatal("row not stored")
+ }
+ if w := post(t, s.Subscribe, body, "bob", true); w.Code != http.StatusConflict {
+ t.Errorf("cross-owner: %d", w.Code)
+ }
+ wrongKey := strings.Replace(body, s.PublicKeyString(), "BOTHER", 1)
+ if w := post(t, s.Subscribe, wrongKey, "alice", true); w.Code != http.StatusConflict {
+ t.Errorf("wrong key: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, subscribeBody(s, "http://push.example/e1"), "alice", true); w.Code != http.StatusBadRequest {
+ t.Errorf("http endpoint: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, strings.Repeat("x", 8193), "alice", true); w.Code != http.StatusRequestEntityTooLarge && w.Code != http.StatusBadRequest {
+ t.Errorf("oversize body: %d", w.Code)
+ }
+ r := httptest.NewRequest(http.MethodGet, "/aviso/subscribe", nil)
+ w := httptest.NewRecorder()
+ s.Subscribe(w, r)
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("GET: %d", w.Code)
+ }
+}
+
+func TestSubscribeHonoursPreviousEndpoint(t *testing.T) {
+ s := newService(t)
+ _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true)
+ b, _ := json.Marshal(map[string]any{
+ "subscription": map[string]any{"endpoint": "https://push.example/new", "keys": map[string]string{"p256dh": "BP", "auth": "au"}},
+ "publicKey": s.PublicKeyString(),
+ "previousEndpoint": "https://push.example/old",
+ })
+ if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent {
+ t.Fatalf("%d %s", w.Code, w.Body)
+ }
+ rows, _ := s.List(t.Context(), "alice")
+ if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
+ t.Fatalf("rows: %+v", rows)
+ }
+}
+
+func TestUnsubscribe(t *testing.T) {
+ s := newService(t)
+ _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true)
+ body := `{"endpoint":"https://push.example/e1"}`
+ if w := post(t, s.Unsubscribe, body, "", true); w.Code != http.StatusUnauthorized {
+ t.Errorf("no session: %d", w.Code)
+ }
+ if w := post(t, s.Unsubscribe, body, "bob", true); w.Code != http.StatusNoContent {
+ t.Errorf("other subject: %d", w.Code)
+ }
+ if rows, _ := s.List(t.Context(), "alice"); len(rows) != 1 {
+ t.Fatal("bob's unsubscribe removed alice's row")
+ }
+ if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent {
+ t.Errorf("own: %d", w.Code)
+ }
+ if rows, _ := s.List(t.Context(), "alice"); len(rows) != 0 {
+ t.Fatal("own unsubscribe did nothing")
+ }
+ if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent {
+ t.Errorf("repeat: %d", w.Code)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./... -count=1 -run 'TestPublicKey|TestSubscribe|TestUnsubscribe'`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Implement http.go**
+
+```go
+package aviso
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+const maxSubscribeBody = 8192
+
+// PublicKey answers GET with {"publicKey": ...}. no-cache so a rotated
+// key reaches browsers on their next load rather than after a cache
+// expiry nobody chose.
+func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", http.MethodGet)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-cache")
+ _ = json.NewEncoder(w).Encode(map[string]string{"publicKey": s.pub})
+}
+
+// gate is what both mutations require: POST, a session with a subject,
+// and a same-origin request. It writes the refusal and returns "" when
+// the caller must stop.
+func (s *Service) gate(w http.ResponseWriter, r *http.Request) string {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return ""
+ }
+ sess, ok := sessions.Current(r)
+ if !ok || sess.Subject == "" {
+ http.Error(w, "sign in first", http.StatusUnauthorized)
+ return ""
+ }
+ if !csrf.SameOrigin(r, s.cfg.Origin) {
+ http.Error(w, "cross-origin request refused", http.StatusForbidden)
+ return ""
+ }
+ return sess.Subject
+}
+
+func decodeBody(w http.ResponseWriter, r *http.Request, into any) bool {
+ body := http.MaxBytesReader(w, r.Body, maxSubscribeBody)
+ dec := json.NewDecoder(body)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(into); err != nil {
+ var mbe *http.MaxBytesError
+ if errors.As(err, &mbe) {
+ http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
+ return false
+ }
+ http.Error(w, "bad request body", http.StatusBadRequest)
+ return false
+ }
+ if _, err := io.Copy(io.Discard, body); err != nil { // trailing bytes over the cap
+ http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
+ return false
+ }
+ return true
+}
+
+type subscribeRequest struct {
+ Subscription struct {
+ Endpoint string `json:"endpoint"`
+ Keys struct {
+ P256dh string `json:"p256dh"`
+ Auth string `json:"auth"`
+ } `json:"keys"`
+ } `json:"subscription"`
+ PublicKey string `json:"publicKey"`
+ PreviousEndpoint string `json:"previousEndpoint"`
+}
+
+// Subscribe stores the caller's subscription. 409 when the endpoint is
+// another subject's or the browser subscribed under a key that is not
+// ours — storing that row would be storing one nothing can sign for.
+func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request) {
+ subject := s.gate(w, r)
+ if subject == "" {
+ return
+ }
+ var req subscribeRequest
+ if !decodeBody(w, r, &req) {
+ return
+ }
+ if req.PublicKey != s.pub {
+ http.Error(w, "subscribed under a different application server key; re-enrol", http.StatusConflict)
+ return
+ }
+ if err := validateEndpoint(req.Subscription.Endpoint); err != nil {
+ http.Error(w, "endpoint refused", http.StatusBadRequest)
+ return
+ }
+ if req.Subscription.Keys.P256dh == "" || req.Subscription.Keys.Auth == "" {
+ http.Error(w, "subscription keys missing", http.StatusBadRequest)
+ return
+ }
+ if req.PreviousEndpoint != "" && validateEndpoint(req.PreviousEndpoint) != nil {
+ http.Error(w, "previousEndpoint refused", http.StatusBadRequest)
+ return
+ }
+ sub := Subscription{Endpoint: req.Subscription.Endpoint, P256dh: req.Subscription.Keys.P256dh, Auth: req.Subscription.Keys.Auth}
+ switch err := s.put(r.Context(), subject, sub, req.PreviousEndpoint); {
+ case errors.Is(err, ErrOwnedElsewhere):
+ http.Error(w, "endpoint enrolled by another account", http.StatusConflict)
+ case err != nil:
+ s.cfg.Logger.Error("aviso: subscribe", "err", err)
+ http.Error(w, "could not store subscription", http.StatusInternalServerError)
+ default:
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// Unsubscribe removes the caller's own row for the endpoint. 204
+// whether or not it existed: the endpoint's existence is not the
+// caller's to learn unless they own it.
+func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request) {
+ subject := s.gate(w, r)
+ if subject == "" {
+ return
+ }
+ var req struct {
+ Endpoint string `json:"endpoint"`
+ }
+ if !decodeBody(w, r, &req) {
+ return
+ }
+ if req.Endpoint == "" || len(req.Endpoint) > maxEndpointLen {
+ http.Error(w, "endpoint missing", http.StatusBadRequest)
+ return
+ }
+ if err := s.deleteOwn(r.Context(), subject, req.Endpoint); err != nil {
+ s.cfg.Logger.Error("aviso: unsubscribe", "err", err)
+ http.Error(w, "could not remove subscription", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `make ci`
+Expected: PASS. If `t.Context()` is unavailable, use `context.Background()`.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add http.go http_test.go
+git commit -m "Add the three handlers, gated by session and origin
+
+Ownership comes from sessions.Current, never the body. A subscription
+made under a different application server key is refused rather than
+stored unsendable. Unsubscribe is 204 either way so an endpoint's
+existence is not learnable by someone who does not own it.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 8: Browser module
+
+**Files:**
+- Create: `js.go`, `js/push.mjs`, `js/push.test.mjs`, `js_test.go`
+
+**Interfaces:**
+- 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.
+
+- [ ] **Step 1: Write js/push.mjs**
+
+```js
+// Browser half of aviso: enrol this device for push against the app's
+// server. The app supplies `save` and `remove` — same-origin fetches
+// to the Subscribe and Unsubscribe handlers — and owns the service
+// worker registration. Nothing here prompts except `enable`, and it
+// prompts synchronously inside the caller's gesture, because a prompt
+// after an await is denied by browsers and resented by people.
+
+function toBytes(base64url) {
+ const pad = "=".repeat((4 - (base64url.length % 4)) % 4);
+ const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/");
+ const raw = atob(b64);
+ const out = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+}
+
+function toBase64url(buf) {
+ let s = "";
+ const bytes = new Uint8Array(buf);
+ for (const b of bytes) s += String.fromCharCode(b);
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function sameKey(subscription, publicKey) {
+ const key = subscription.options && subscription.options.applicationServerKey;
+ if (!key) return false;
+ return toBase64url(key) === publicKey;
+}
+
+function body(subscription, publicKey, previousEndpoint) {
+ const json = subscription.toJSON();
+ const out = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey };
+ if (previousEndpoint) out.previousEndpoint = previousEndpoint;
+ return out;
+}
+
+async function persist(save, payload) {
+ const resp = await save(payload);
+ if (!resp || resp.ok !== true) {
+ throw new Error("aviso: save rejected: " + (resp && resp.status));
+ }
+}
+
+// capabilities reports what this browser can do. `standalone` is what
+// the installability recipe keys its coaching on: iOS delivers push
+// only to an installed app.
+export function capabilities(env = globalThis) {
+ const nav = env.navigator || {};
+ const standalone = nav.standalone === true ||
+ (env.matchMedia && env.matchMedia("(display-mode: standalone)").matches) || false;
+ return {
+ serviceWorker: !!nav.serviceWorker,
+ push: typeof env.PushManager !== "undefined",
+ notifications: typeof env.Notification !== "undefined",
+ standalone,
+ };
+}
+
+// status resolves the current permission and subscription, prompting
+// nothing.
+export async function status(registration, env = globalThis) {
+ const permission = env.Notification ? env.Notification.permission : "default";
+ const subscription = await registration.pushManager.getSubscription();
+ return { permission, subscription };
+}
+
+// enable asks permission (synchronously, first), subscribes, and
+// saves. Resolves the subscription, or null when permission was
+// denied. Anything else rejects.
+export async function enable({ registration, publicKey, save }, env = globalThis) {
+ const permission = await env.Notification.requestPermission();
+ if (permission !== "granted") return null;
+ const existing = await registration.pushManager.getSubscription();
+ let previous = "";
+ if (existing && !sameKey(existing, publicKey)) {
+ previous = existing.endpoint;
+ await existing.unsubscribe();
+ }
+ const sub = (existing && !previous) ? existing : await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: toBytes(publicKey),
+ });
+ await persist(save, body(sub, publicKey, previous));
+ return sub;
+}
+
+// reconcile repairs on every page load without prompting: with
+// permission granted it re-subscribes if the server key changed and
+// re-saves so the server's last_confirmed_at moves. Resolves the
+// subscription or null.
+export async function reconcile({ registration, publicKey, save }, env = globalThis) {
+ if (!env.Notification || env.Notification.permission !== "granted") return null;
+ const existing = await registration.pushManager.getSubscription();
+ if (existing && sameKey(existing, publicKey)) {
+ await persist(save, body(existing, publicKey, ""));
+ return existing;
+ }
+ let previous = "";
+ if (existing) {
+ previous = existing.endpoint;
+ await existing.unsubscribe();
+ }
+ const sub = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: toBytes(publicKey),
+ });
+ await persist(save, body(sub, publicKey, previous));
+ return sub;
+}
+
+// disable removes the server row first, then the browser subscription:
+// a crash between the two leaves a harmless orphan in the browser
+// rather than a server row that sends to nothing.
+export async function disable({ registration, remove }) {
+ const existing = await registration.pushManager.getSubscription();
+ if (!existing) return;
+ const resp = await remove({ endpoint: existing.endpoint });
+ if (!resp || resp.ok !== true) {
+ throw new Error("aviso: remove rejected: " + (resp && resp.status));
+ }
+ await existing.unsubscribe();
+}
+```
+
+- [ ] **Step 2: Write js/push.test.mjs**
+
+```js
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { capabilities, status, enable, reconcile, disable } from "./push.mjs";
+
+const KEY = "BAbcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu";
+
+function keyBytes(s) {
+ const pad = "=".repeat((4 - (s.length % 4)) % 4);
+ return Uint8Array.from(Buffer.from(s + pad, "base64"));
+}
+
+function fakeSub(endpoint, key) {
+ return {
+ endpoint,
+ options: { applicationServerKey: keyBytes(key) },
+ unsubscribed: false,
+ toJSON() { return { endpoint, keys: { p256dh: "P", auth: "A" } }; },
+ async unsubscribe() { this.unsubscribed = true; return true; },
+ };
+}
+
+function fakeRegistration(existing) {
+ const reg = {
+ subscribed: [],
+ pushManager: {
+ async getSubscription() { return existing; },
+ async subscribe(opts) {
+ assert.equal(opts.userVisibleOnly, true);
+ const s = fakeSub("https://push.example/new", KEY);
+ reg.subscribed.push(s);
+ return s;
+ },
+ },
+ };
+ return reg;
+}
+
+function env(permission, requested = permission) {
+ const calls = [];
+ return {
+ calls,
+ Notification: {
+ permission,
+ async requestPermission() { calls.push("prompt"); return requested; },
+ },
+ navigator: { serviceWorker: {} },
+ PushManager: function () {},
+ };
+}
+
+const okSave = () => { const saved = []; const save = async (b) => { saved.push(b); return { ok: true, status: 204 }; }; return { saved, save }; };
+
+test("capabilities reports the four booleans", () => {
+ const c = capabilities({ navigator: { serviceWorker: {}, standalone: true }, PushManager: function () {}, Notification: {} });
+ assert.deepEqual(c, { serviceWorker: true, push: true, notifications: true, standalone: true });
+});
+
+test("enable prompts, subscribes and saves", async () => {
+ const e = env("default", "granted");
+ const reg = fakeRegistration(null);
+ const { saved, save } = okSave();
+ const sub = await enable({ registration: reg, publicKey: KEY, save }, e);
+ assert.ok(sub);
+ assert.deepEqual(e.calls, ["prompt"]);
+ assert.equal(saved.length, 1);
+ assert.equal(saved[0].publicKey, KEY);
+ assert.equal(saved[0].subscription.endpoint, "https://push.example/new");
+ assert.equal("previousEndpoint" in saved[0], false);
+});
+
+test("enable resolves null when denied and never subscribes", async () => {
+ const e = env("default", "denied");
+ const reg = fakeRegistration(null);
+ const { saved, save } = okSave();
+ assert.equal(await enable({ registration: reg, publicKey: KEY, save }, e), null);
+ assert.equal(reg.subscribed.length, 0);
+ assert.equal(saved.length, 0);
+});
+
+test("enable rejects when save fails", async () => {
+ const e = env("granted");
+ const reg = fakeRegistration(null);
+ await assert.rejects(enable({ registration: reg, publicKey: KEY, save: async () => ({ ok: false, status: 500 }) }, e), /save rejected/);
+});
+
+test("reconcile never prompts and re-saves a matching subscription", async () => {
+ const e = env("granted");
+ const existing = fakeSub("https://push.example/old", KEY);
+ const reg = fakeRegistration(existing);
+ const { saved, save } = okSave();
+ const sub = await reconcile({ registration: reg, publicKey: KEY, save }, e);
+ assert.equal(sub, existing);
+ assert.deepEqual(e.calls, []);
+ assert.equal(saved.length, 1);
+ assert.equal(reg.subscribed.length, 0);
+});
+
+test("reconcile re-subscribes under a new key and names the old endpoint", async () => {
+ const e = env("granted");
+ const existing = fakeSub("https://push.example/old", "BOLDKEY");
+ const reg = fakeRegistration(existing);
+ const { saved, save } = okSave();
+ await reconcile({ registration: reg, publicKey: KEY, save }, e);
+ assert.equal(existing.unsubscribed, true);
+ assert.equal(reg.subscribed.length, 1);
+ assert.equal(saved[0].previousEndpoint, "https://push.example/old");
+});
+
+test("reconcile does nothing without permission", async () => {
+ const e = env("default");
+ const reg = fakeRegistration(null);
+ const { saved, save } = okSave();
+ assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null);
+ assert.equal(saved.length, 0);
+ assert.deepEqual(e.calls, []);
+});
+
+test("status reads permission and subscription", async () => {
+ const existing = fakeSub("https://push.example/x", KEY);
+ const s = await status(fakeRegistration(existing), env("granted"));
+ assert.equal(s.permission, "granted");
+ assert.equal(s.subscription, existing);
+});
+
+test("disable removes server-side first, then the browser subscription", async () => {
+ const existing = fakeSub("https://push.example/x", KEY);
+ const order = [];
+ const remove = async (b) => { order.push("remove:" + b.endpoint); return { ok: true }; };
+ existing.unsubscribe = async () => { order.push("unsubscribe"); return true; };
+ await disable({ registration: fakeRegistration(existing), remove });
+ assert.deepEqual(order, ["remove:https://push.example/x", "unsubscribe"]);
+});
+
+test("disable keeps the browser subscription when remove fails", async () => {
+ const existing = fakeSub("https://push.example/x", KEY);
+ await assert.rejects(disable({ registration: fakeRegistration(existing), remove: async () => ({ ok: false, status: 500 }) }), /remove rejected/);
+ assert.equal(existing.unsubscribed, false);
+});
+```
+
+- [ ] **Step 3: Write js.go and js_test.go**
+
+`js.go`:
+```go
+package aviso
+
+import _ "embed"
+
+//go:embed js/push.mjs
+var pushJS []byte
+
+//go:embed js/aviso-sw.js
+var workerJS []byte
+
+// JS is the browser module (js/push.mjs), for the app to serve as a
+// static asset and import from its page script.
+func JS() []byte { return pushJS }
+
+// WorkerJS is the classic service-worker helper (js/aviso-sw.js), for
+// the app to serve and load from its own sw.js via importScripts.
+func WorkerJS() []byte { return workerJS }
+```
+
+`js_test.go`:
+```go
+package aviso
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+func TestJSEmbedded(t *testing.T) {
+ if !bytes.Contains(JS(), []byte("export async function enable(")) {
+ t.Fatal("JS() does not look like push.mjs")
+ }
+ if !bytes.Contains(WorkerJS(), []byte("AvisoSW")) {
+ t.Fatal("WorkerJS() does not look like aviso-sw.js")
+ }
+}
+
+// runNodeTests materialises the embedded files beside the named test
+// in a temp dir and runs `node --test` there — so what is tested is
+// the bytes the binary serves, not a sibling file that could drift.
+func runNodeTests(t *testing.T, testFile string, files map[string][]byte) {
+ t.Helper()
+ node, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node not on PATH; JS half not exercised")
+ }
+ dir := t.TempDir()
+ src, err := os.ReadFile(filepath.Join("js", testFile))
+ if err != nil {
+ t.Fatal(err)
+ }
+ files[testFile] = src
+ for name, b := range files {
+ if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cmd := exec.Command(node, "--test", testFile)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("node --test %s failed: %v\n%s", testFile, err, out)
+ }
+}
+
+func TestPushModule(t *testing.T) {
+ runNodeTests(t, "push.test.mjs", map[string][]byte{"push.mjs": JS()})
+}
+```
+
+For 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:
+```js
+// aviso-sw.js — the classic service-worker helper; filled in by Task 9.
+(function (root) { root.AvisoSW = {}; })(typeof self !== "undefined" ? self : globalThis);
+```
+
+- [ ] **Step 4: Run**
+
+Run: `go test ./... -count=1 -run 'TestJS|TestPushModule' -v`
+Expected: PASS; node output shows 10 passing tests.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add js.go js js_test.go
+git commit -m "Add the browser module and its node tests
+
+enable prompts synchronously inside the gesture; reconcile never
+prompts and re-saves on every load so the server's confirmation moves;
+disable removes the server row before the browser subscription so a
+crash between the two cannot leave a row that sends to nothing. Tests
+run from go test against the embedded bytes.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 9: Worker helper
+
+**Files:**
+- Create: `js/aviso-sw.js` (replace placeholder), `js/aviso-sw.test.mjs`
+- Modify: `js_test.go` — add `TestWorkerHelper`
+
+**Interfaces:**
+- 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.
+
+- [ ] **Step 1: Write js/aviso-sw.js**
+
+```js
+// Service-worker half of aviso, a classic script the app's own sw.js
+// loads with importScripts. It owns nothing about the worker's
+// lifecycle — no skipWaiting, no clients.claim, no listeners of its
+// own; the app attaches these handlers inside its listeners and passes
+// the result to event.waitUntil.
+(function (root) {
+ "use strict";
+
+ // validateURL admits only a root-relative path on the app's own
+ // origin: no scheme, no protocol-relative "//", no backslashes, and
+ // the resolved URL must still be on origin. Applied to the default
+ // decoder, custom decoder output and fallbackURL alike — a click
+ // must never navigate off the app.
+ function validateURL(raw, origin) {
+ if (typeof raw !== "string" || raw === "" || raw[0] !== "/" || raw[1] === "/" || raw.indexOf("\\") !== -1) {
+ return null;
+ }
+ let u;
+ try { u = new URL(raw, origin); } catch (e) { return null; }
+ if (u.origin !== origin) return null;
+ return u.href;
+ }
+
+ // decodeDefault reads {title, body, url, tag}; title is required.
+ function decodeDefault(event, origin) {
+ if (!event.data) return null;
+ let p;
+ try { p = event.data.json(); } catch (e) { return null; }
+ if (!p || typeof p.title !== "string" || p.title === "") return null;
+ const options = { data: {} };
+ if (typeof p.body === "string") options.body = p.body;
+ if (typeof p.tag === "string") options.tag = p.tag;
+ if (p.url !== undefined) {
+ const href = validateURL(p.url, origin);
+ if (href) options.data.url = href;
+ }
+ return { title: p.title, options };
+ }
+
+ // handlePush always ends in a visible notification: WebKit revokes
+ // push for a worker that receives without showing, so a payload the
+ // decoder cannot read shows the app's fallback rather than nothing.
+ async function handlePush(event, opts) {
+ const origin = root.location.origin;
+ const reg = root.registration;
+ let n = null;
+ try {
+ n = opts && opts.decode ? await opts.decode(event) : decodeDefault(event, origin);
+ } catch (e) { n = null; }
+ if (!n || typeof n.title !== "string" || n.title === "") {
+ if (!opts || typeof opts.fallback !== "function") {
+ throw new Error("aviso: fallback is required");
+ }
+ n = await opts.fallback(event);
+ }
+ const options = Object.assign({}, n.options || {});
+ options.data = Object.assign({}, options.data || {});
+ if (options.data.url !== undefined) {
+ const href = validateURL(typeof options.data.url === "string" && options.data.url.indexOf(origin) === 0
+ ? options.data.url.slice(origin.length) : options.data.url, origin);
+ if (href) options.data.url = href; else delete options.data.url;
+ }
+ return reg.showNotification(n.title, options);
+ }
+
+ // handleClick closes the notification, focuses a window already at
+ // the destination, else opens it, else the fallback.
+ async function handleClick(event, opts) {
+ const origin = root.location.origin;
+ event.notification.close();
+ const data = event.notification.data || {};
+ let href = data.url ? validateURL(data.url.indexOf(origin) === 0 ? data.url.slice(origin.length) : data.url, origin) : null;
+ if (!href && opts && opts.fallbackURL) href = validateURL(opts.fallbackURL, origin);
+ if (!href) return;
+ const all = await root.clients.matchAll({ type: "window", includeUncontrolled: true });
+ for (const c of all) {
+ if (c.url === href && "focus" in c) return c.focus();
+ }
+ return root.clients.openWindow(href);
+ }
+
+ // handleSubscriptionChange renews and saves. Saving can fail — an
+ // installed app with no live session — and then it fails silently:
+ // reconcile() repairs on the next page open after sign-in. Nothing
+ // here retries and nothing here prompts.
+ async function handleSubscriptionChange(event, opts) {
+ let sub = event.newSubscription || null;
+ if (!sub && opts && typeof opts.renew === "function") {
+ try { sub = await opts.renew(event); } catch (e) { sub = null; }
+ }
+ if (!sub) return false;
+ try {
+ const resp = await opts.save(sub);
+ return !!(resp && resp.ok === true);
+ } catch (e) {
+ return false;
+ }
+ }
+
+ root.AvisoSW = { handlePush, handleClick, handleSubscriptionChange, validateURL };
+})(typeof self !== "undefined" ? self : globalThis);
+```
+
+- [ ] **Step 2: Write js/aviso-sw.test.mjs**
+
+```js
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+globalThis.self = globalThis;
+globalThis.location = { origin: "https://app.example" };
+await import("./aviso-sw.js");
+const { AvisoSW } = globalThis;
+
+function rig() {
+ const shown = [], opened = [], focused = [];
+ globalThis.registration = { async showNotification(title, options) { shown.push({ title, options }); } };
+ globalThis.clients = {
+ windows: [],
+ async matchAll() { return this.windows; },
+ async openWindow(u) { opened.push(u); },
+ };
+ return { shown, opened, focused };
+}
+
+function pushEvent(payload) {
+ return { data: payload === undefined ? null : { json() { return JSON.parse(payload); } } };
+}
+
+test("validateURL admits only root-relative same-origin paths", () => {
+ const o = "https://app.example";
+ assert.equal(AvisoSW.validateURL("/inbox?x=1", o), "https://app.example/inbox?x=1");
+ for (const bad of ["", "inbox", "//evil.example/x", "https://evil.example/x", "/a\\b", "javascript:alert(1)", 42, null]) {
+ assert.equal(AvisoSW.validateURL(bad, o), null, String(bad));
+ }
+});
+
+test("handlePush shows the default payload and validates url", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"title":"Hi","body":"b","url":"/inbox","tag":"t"}'), { fallback: () => ({ title: "fb" }) });
+ assert.equal(r.shown.length, 1);
+ assert.equal(r.shown[0].title, "Hi");
+ assert.equal(r.shown[0].options.body, "b");
+ assert.equal(r.shown[0].options.tag, "t");
+ assert.equal(r.shown[0].options.data.url, "https://app.example/inbox");
+});
+
+test("handlePush drops an off-origin url but still shows", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"title":"Hi","url":"https://evil.example/"}'), { fallback: () => ({ title: "fb" }) });
+ assert.equal(r.shown[0].title, "Hi");
+ assert.equal("url" in r.shown[0].options.data, false);
+});
+
+test("handlePush falls back on malformed or missing payload", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"nope":1}'), { fallback: () => ({ title: "fb", options: { data: { url: "/" } } }) });
+ await AvisoSW.handlePush(pushEvent(undefined), { fallback: () => ({ title: "fb2" }) });
+ assert.deepEqual(r.shown.map((s) => s.title), ["fb", "fb2"]);
+ assert.equal(r.shown[0].options.data.url, "https://app.example/");
+});
+
+test("handlePush requires a fallback", async () => {
+ rig();
+ await assert.rejects(AvisoSW.handlePush(pushEvent('{"x":1}'), {}), /fallback is required/);
+});
+
+test("handlePush uses a custom decoder and validates its url", async () => {
+ const r = rig();
+ await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
+ decode: async () => ({ title: "Custom", options: { data: { url: "//evil.example/" } } }),
+ fallback: () => ({ title: "fb" }),
+ });
+ assert.equal(r.shown[0].title, "Custom");
+ assert.equal("url" in r.shown[0].options.data, false);
+});
+
+test("handleClick focuses a matching window, else opens, else fallback", async () => {
+ const r = rig();
+ let focusedURL = null;
+ globalThis.clients.windows = [{ url: "https://app.example/inbox", async focus() { focusedURL = this.url; } }];
+ const ev = (url) => ({ notification: { close() {}, data: url === undefined ? {} : { url } } });
+ await AvisoSW.handleClick(ev("https://app.example/inbox"), {});
+ assert.equal(focusedURL, "https://app.example/inbox");
+ await AvisoSW.handleClick(ev("/other"), {});
+ assert.deepEqual(r.opened, ["https://app.example/other"]);
+ await AvisoSW.handleClick(ev(undefined), { fallbackURL: "/" });
+ assert.deepEqual(r.opened, ["https://app.example/other", "https://app.example/"]);
+ await AvisoSW.handleClick(ev("https://evil.example/"), {});
+ assert.equal(r.opened.length, 2);
+});
+
+test("handleSubscriptionChange renews then saves, and fails silently", async () => {
+ const saved = [];
+ const ok = await AvisoSW.handleSubscriptionChange({ newSubscription: { endpoint: "e" } }, { save: async (s) => { saved.push(s); return { ok: true }; } });
+ assert.equal(ok, true);
+ assert.equal(saved[0].endpoint, "e");
+ const renewed = await AvisoSW.handleSubscriptionChange({}, { renew: async () => ({ endpoint: "r" }), save: async () => ({ ok: true }) });
+ assert.equal(renewed, true);
+ const expired = await AvisoSW.handleSubscriptionChange({ newSubscription: { endpoint: "e" } }, { save: async () => ({ ok: false, status: 401 }) });
+ assert.equal(expired, false);
+ const threw = await AvisoSW.handleSubscriptionChange({ newSubscription: { endpoint: "e" } }, { save: async () => { throw new Error("net"); } });
+ assert.equal(threw, false);
+});
+```
+
+- [ ] **Step 3: Add to js_test.go**
+
+```go
+func TestWorkerHelper(t *testing.T) {
+ runNodeTests(t, "aviso-sw.test.mjs", map[string][]byte{"aviso-sw.js": WorkerJS()})
+}
+```
+
+- [ ] **Step 4: Run**
+
+Run: `go test ./... -count=1 -run 'TestWorkerHelper|TestJSEmbedded' -v`
+Expected: 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.
+
+- [ ] **Step 5: Commit, push, Codex review, advance task**
+
+```sh
+git add js js_test.go
+git commit -m "Add the service-worker helper
+
+Every push ends in a visible notification, with the app's required
+fallback covering payloads the decoder cannot read: WebKit revokes push
+for a worker that receives silently. Click URLs are validated to the
+app's origin wherever they come from, including custom decoders. A
+subscription change saves once and fails silently — the page repairs
+on the next open after sign-in — because a retry loop in a worker
+without a session has nothing to retry with.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 10: aviso-key
+
+**Files:**
+- Create: `cmd/aviso-key/main.go`, `cmd/aviso-key/main_test.go`
+
+- [ ] **Step 1: Write the test**
+
+```go
+package main
+
+import (
+ "bytes"
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+func TestRunPrintsOnePrivateKey(t *testing.T) {
+ var out bytes.Buffer
+ if err := run(&out); err != nil {
+ t.Fatal(err)
+ }
+ s := strings.TrimSpace(out.String())
+ if strings.Contains(s, "\n") {
+ t.Fatalf("more than one line: %q", s)
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil || len(raw) != 32 {
+ t.Fatalf("not a 32-byte base64url key: %q", s)
+ }
+}
+```
+
+- [ ] **Step 2: Write main.go**
+
+```go
+// Command aviso-key prints one VAPID private key, and nothing else, so
+// it can be captured straight into a secret store:
+//
+// APP_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)"
+//
+// The public key is derived by aviso.New; there is no second value to
+// keep.
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+func run(w io.Writer) error {
+ k, err := aviso.GenerateKey()
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintln(w, k)
+ return err
+}
+
+func main() {
+ if err := run(os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, "aviso-key:", err)
+ os.Exit(1)
+ }
+}
+```
+
+- [ ] **Step 3: Run, commit, push, Codex review, advance**
+
+Run: `go test ./cmd/... -count=1 && go run ./cmd/aviso-key | wc -c` (expect 44 bytes incl. newline).
+
+```sh
+git add cmd
+git commit -m "Add aviso-key: prints one private key
+
+Stdout carries the key alone so a shell substitution can capture it
+into a secret store without parsing.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 11: SKILL.md, README, installability recipe
+
+**Files:**
+- Create: `SKILL.md`, `docs/installable.md`, `skillmd_test.go`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write skillmd_test.go**
+
+```go
+package aviso
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// skillBudget is the byte ceiling for SKILL.md. It is what an agent
+// loads instead of reading the source, so it is reviewed like code and
+// kept short; raise it here, with a reason, rather than trimming a
+// load-bearing fact to fit.
+const skillBudget = 9000
+
+func TestSkillMDIsWithinBudgetAndNamesTheSurface(t *testing.T) {
+ b, err := os.ReadFile("SKILL.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(b) > skillBudget {
+ t.Fatalf("SKILL.md is %d bytes, budget %d", len(b), skillBudget)
+ }
+ s := string(b)
+ for _, want := range []string{
+ "aviso.New", "aviso.Schema", "BootSchema", "PrivateKey", "aviso-key",
+ "SendTo", "Send(", "Sweep", "DeleteSubject",
+ "PublicKey", "Subscribe", "Unsubscribe",
+ "JS()", "WorkerJS()", "importScripts", "enable(", "reconcile(", "disable(",
+ "handlePush", "handleClick", "handleSubscriptionChange", "fallback",
+ "docs/installable.md",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("SKILL.md does not mention %q", want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Write SKILL.md** (under 9000 bytes; the sections and facts below, in prose that says why)
+
+```markdown
+---
+name: aviso
+description: Web Push for a rastrillo app — enrol devices, sign with one VAPID key, fan a payload out to a subject's browsers. Load before wiring push into an app.
+---
+
+# aviso — Web Push for rastrillo apps
+
+Aviso moves bytes to devices a signed-in person enrolled. It never
+decides who is told what: recipient selection, payload meaning and the
+service worker's lifecycle are the app's.
+
+## Wire it
+
+1. Mint one key, once, into the app's secrets:
+ `APP_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)"`.
+ Empty is refused at boot (`aviso.ErrEmptyPrivateKey`); nothing
+ mints a key for you, because a key minted into local state is lost
+ at the next restore.
+2. `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)` —
+ BootSchema, never Schema, or `rastrillo migration check` proposes
+ dropping the addon's table.
+3. `svc, err := aviso.New(aviso.Config{DB: writer, PrivateKey: key,
+ Contact: "mailto:ops@example", Origin: origin})`. One per process.
+4. Mount, behind your session middleware:
+ `GET /aviso/public-key → svc.PublicKey`, `POST /aviso/subscribe →
+ svc.Subscribe`, `POST /aviso/unsubscribe → svc.Unsubscribe`.
+ Ownership is `sessions.Current(r).Subject`; the body never names one.
+5. Serve `aviso.JS()` as `/static/aviso/push.mjs` and `aviso.WorkerJS()`
+ as `/static/aviso/aviso-sw.js`; serve your own `sw.js` at the scope
+ it should control with `Cache-Control: no-cache`.
+
+## Send
+
+`svc.SendTo(ctx, subject, payload, aviso.Options{})` — every device the
+subject enrolled. `svc.Send(ctx, stored, payload, opts)` when you
+select devices yourself. Both return `([]Result, error)`: the error is
+what stopped the batch (query, bounds, cancellation), each Result one
+device's acceptance — not delivery. No retries; `Result.RetryAfter`
+is for your scheduler. Payload ≤ 3993 bytes. Default payload the
+worker helper understands: `{"title","body","url","tag"}` with `url` a
+root-relative path on your origin.
+
+## Browser
+
+```js
+import { enable, reconcile, disable, capabilities } from "/static/aviso/push.mjs";
+const save = (b) => fetch("/aviso/subscribe", { method: "POST", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
+const remove = (b) => fetch("/aviso/unsubscribe", { method: "POST", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
+const registration = await navigator.serviceWorker.register("/sw.js");
+const { publicKey } = await (await fetch("/aviso/public-key")).json();
+await reconcile({ registration, publicKey, save }); // every load, never prompts
+button.onclick = () => enable({ registration, publicKey, save }); // from the click, prompts
+```
+
+`enable` resolves null when denied. `disable({registration, remove})`
+removes the server row first. Call `disable` before sign-out.
+
+## Worker (your sw.js)
+
+```js
+importScripts("/static/aviso/aviso-sw.js");
+self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, {
+ fallback: () => ({ title: "New activity", options: { data: { url: "/" } } }),
+})));
+self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" })));
+self.addEventListener("pushsubscriptionchange", (e) => e.waitUntil(AvisoSW.handleSubscriptionChange(e, {
+ save: (sub) => fetch("/aviso/subscribe", { method: "POST", credentials: "same-origin", mode: "same-origin",
+ redirect: "error", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ subscription: sub.toJSON(), publicKey: PUBLIC_KEY }) }),
+})));
+```
+
+`fallback` is required: every push shows a notification, or WebKit
+revokes the subscription. Supply `decode(event)` for your own payload
+shape; its `options.data.url` is validated to your origin too. The
+helper never calls `skipWaiting` or `clients.claim`.
+
+## Retention and revocation
+
+`svc.Sweep(ctx, time.Now().AddDate(0,0,-90))` from a `carlos.Tick`
+handler removes subscriptions not confirmed in 90 days (confirmation
+moves on reconcile and on an accepted send). Session expiry does not
+revoke; call `disable` before sign-out and `svc.DeleteSubject` on
+account deletion. Re-check entitlement before every SendTo.
+
+## Installability
+
+iOS delivers push only to a Home Screen app. The manifest, head tags
+and coaching are yours: see `docs/installable.md`.
+
+## Rulings
+
+Endpoints https only, SSRF-guarded at dial. 409 on an endpoint another
+subject holds, and on a subscription made under a different server
+key. Logs carry subscription ids, never endpoints or keys.
+```
+
+- [ ] **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.
+
+- [ ] **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`.
+
+- [ ] **Step 5: Run `make ci`; commit, push, Codex review (ask Codex specifically whether SKILL.md contradicts the code), advance**
+
+```sh
+git add SKILL.md docs/installable.md README.md skillmd_test.go
+git commit -m "Add SKILL.md, the installability recipe and the README
+
+SKILL.md is what an agent loads instead of the source, so it is
+byte-budgeted and its facts are pinned by test.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 12: Example app
+
+**Files:**
+- 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`
+
+The 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.
+
+- [ ] **Step 1: Write example/main.go**
+
+```go
+// Command example is the smallest app that wires every aviso seam. It
+// signs everyone in as "dev" — an example, not a pattern — so the
+// enrol/send loop can be driven from one browser.
+package main
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+//go:embed index.html static/*
+var site embed.FS
+
+func handler(svc *aviso.Service) http.Handler {
+ mux := http.NewServeMux()
+ static, _ := fs.Sub(site, "static")
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ mux.HandleFunc("GET /static/aviso/push.mjs", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/javascript")
+ w.Write(aviso.JS())
+ })
+ mux.HandleFunc("GET /static/aviso/aviso-sw.js", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/javascript")
+ w.Write(aviso.WorkerJS())
+ })
+ mux.HandleFunc("GET /sw.js", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/javascript")
+ w.Header().Set("Cache-Control", "no-cache")
+ b, _ := site.ReadFile("static/sw.js")
+ w.Write(b)
+ })
+ mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
+ b, _ := site.ReadFile("index.html")
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(b)
+ })
+ mux.HandleFunc("GET /aviso/public-key", svc.PublicKey)
+ mux.HandleFunc("POST /aviso/subscribe", svc.Subscribe)
+ mux.HandleFunc("POST /aviso/unsubscribe", svc.Unsubscribe)
+ mux.HandleFunc("POST /notify", func(w http.ResponseWriter, r *http.Request) {
+ payload, _ := json.Marshal(map[string]string{"title": "Hello from aviso", "body": time.Now().Format(time.Kitchen), "url": "/"})
+ res, err := svc.SendTo(r.Context(), "dev", payload, aviso.Options{TTL: 60 * time.Second})
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ json.NewEncoder(w).Encode(res)
+ })
+ // Example-only: everyone is "dev". A real app runs sessions.Middleware.
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "dev"}))
+ })
+}
+
+func main() {
+ origin := os.Getenv("ORIGIN")
+ if origin == "" {
+ origin = "http://localhost:8080"
+ }
+ d, err := db.Open("example.db", nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil {
+ log.Fatal(err)
+ }
+ svc, err := aviso.New(aviso.Config{DB: d.Writer(), PrivateKey: os.Getenv("EXAMPLE_VAPID_PRIVATE_KEY"), Contact: "mailto:ops@example.test", Origin: origin})
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Println("listening on :8080 as", origin)
+ log.Fatal(http.ListenAndServe(":8080", handler(svc)))
+}
+```
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **Step 4: Run from the example dir; commit, push, Codex review, advance**
+
+```sh
+cd example && go mod tidy && go test ./... -count=1 && cd ..
+git add example
+git commit -m "Add the example app: every seam wired, everyone signed in as dev
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+Add to the Makefile's `test` target: `cd example && go test ./... -count=1` (the root `./...` does not cross module boundaries).
+
+---
+
+### Task 13: Browser test
+
+**Files:**
+- Create: `browser_test.go` (`//go:build browser`)
+
+Uses 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.
+
+- [ ] **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.
+
+- [ ] **Step 2: Commit, push, Codex review, advance**
+
+```sh
+git add browser_test.go
+git commit -m "Add the browser wiring test
+
+Proves the module, the worker import and the real handlers agree in
+Chromium, with a declared subscription double: Chromium's own push
+subscription cannot be pointed at a test service, and a test that
+pretended otherwise would prove less than it claimed.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 14: Land, tag, and list the addon in rastrillo's directory
+
+- [ ] **Step 1: Final gate and Codex whole-branch review**
+
+Run `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.
+
+- [ ] **Step 2: Update the branch description to its final state; mark all tasks Done**
+
+- [ ] **Step 3: Merge through the gate and tag v0.1.0**
+
+```sh
+amadan ci status rastrillo/aviso -branch build
+amadan branch merge rastrillo/aviso build -expect "$(git rev-parse HEAD)"
+git fetch origin main && git checkout main && git merge --ff-only origin/main
+git tag -a v0.1.0 -m "aviso v0.1.0: Web Push addon" && git push origin v0.1.0
+```
+
+- [ ] **Step 4: Directory entry in rastrillo**
+
+In 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`.
+
+---
+
+## Self-review
+
+**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.
+
+**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).
+
+**Known deviation to record in the branch discussion:** `Service.PublicKeyString()` is not in the spec's API list.
diff --git a/docs/superpowers/specs/2026-09-07-aviso-web-push-design.md b/docs/superpowers/specs/2026-09-07-aviso-web-push-design.md
new file mode 100644
index 0000000..8886e26
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-aviso-web-push-design.md
@@ -0,0 +1,426 @@
+# Aviso — Web Push as a rastrillo addon, extracted from Eleven
+
+**Date:** 2026-09-07 · **Status:** DRAFT for review ·
+**Source:** Eleven messenger (`github.com/elevenmessenger/messenger`):
+`push.go`, `web/static/push.js`, the notification half of
+`web/static/sw.js`, and their Go tests. **First consumer:**
+birthday-alarm (reminders go out by mail today). **Second, when it asks:**
+oficina/calendar.
+
+Brainstormed by Claude and Codex in two rounds on 2026-09-07; every
+ruling below was agreed by both, and the two points they argued are
+recorded in §14 with the position that won.
+
+## 0. What this is, and the answer to the question
+
+The question was whether rastrillo should have a "PWA library with push
+notifications" extracted from the Eleven PWA. The answer is **yes to
+push, as an addon, and no to a PWA library.**
+
+Rastrillo today has no web app manifest, no service worker support and no
+Web Push (`manifest.go` is the *resource* manifest, CRUD sugar; `icons.go`
+is an icon vocabulary). Eleven is the only app in the family that has
+push, and its code splits cleanly: a transport half — SSRF-guarded client,
+endpoint validation, subscription CRUD, VAPID keys, a bounded fan-out that
+prunes dead endpoints — that is generic; and a policy half — who gets
+told, what the payload means, how the worker decrypts and routes it —
+that is Eleven's message model and nothing else's. The transport half is
+about 230 lines of Go, 180 of browser JS and 40 of worker JS. That is
+what this spec extracts.
+
+There is no "PWA library" to extract. Eleven's service worker does no
+caching and has no offline page; its manifest handler is twenty lines of
+app identity. Installability is a recipe an app follows (§11), not code a
+package can own — and it is needed, because iOS only delivers push to an
+installed app.
+
+Two rulings bind everything, both inherited from the addons doctrine
+(`docs/site/addons.md`): **the arrow points one way** — aviso depends on
+rastrillo, rastrillo never learns aviso exists; and **the app owns
+policy** — aviso moves bytes to devices a subject enrolled, and never
+decides who should be told what.
+
+## 1. Rulings
+
+Each one line, with the failure it prevents.
+
+- **Addon, not core.** Push is real for many apps and wrong for every app
+ to carry; a dependency (`webpush-go`) in core would be paid by apps
+ that never push.
+- **Named `aviso`** ("notice"): it names the user outcome, in the family's
+ Spanish-word style, and collides with nothing in the family.
+- **Keep `github.com/SherClockHolmes/webpush-go` v1.4.0, behind hidden
+ types.** Extraction must not become a cryptography rewrite; RFC 8291's
+ aes128gcm record format and RFC 8292's VAPID JWT do not fall out of
+ rastrillo/crypto's envelope (which is P-256 ECDH/ECDSA, AES-256-GCM,
+ domain-separated — the right curve, the wrong construction). A stdlib
+ implementation is separately reviewed work, inside the addon, later or
+ never.
+- **The session subject owns a subscription.** Ownership is
+ `sessions.Current(r).Subject`, never a client-supplied field and never
+ `rastrillo.Actor` (which says human-or-agent, not who). Eleven's
+ per-subscription bearer token (`token_hash`) does not carry over: it
+ existed because Eleven has no server session.
+- **Reject cross-owner endpoint upserts.** Eleven's unconditional
+ `ON CONFLICT(endpoint) DO UPDATE` (`push.go:88`) would let a second
+ account on the same browser silently take over the first account's
+ device. Aviso answers 409.
+- **Revision-conditional pruning.** A 404/410 deletes a row only if the
+ revision the send captured is still current, so a slow send cannot
+ delete a subscription the browser refreshed meanwhile.
+- **Schema merges into `BootSchema`**, namespaced `aviso`, and has no
+ foreign key to any users table: apps own their identity schema.
+- **No boot-time key generation.** The VAPID private key is provisioned
+ like every other family secret (one env var, refused when empty, §8).
+ Eleven's mint-if-missing sidecar is the thing this rules out.
+- **`enable` needs a gesture; `reconcile` never prompts; the worker helper
+ never calls `skipWaiting` or `clients.claim`.** Permission prompts
+ outside a click are denied by browsers and resented by people; lifecycle
+ is the app's worker's business.
+- **Every push shows a notification.** WebKit revokes push for a worker
+ that receives without displaying; silent suppression is done
+ server-side by not sending.
+
+## 2. Package layout
+
+A separate repository and module, `amadan.net/rastrillo/aviso`, on the
+idear pattern (`docs/site/addons.md`; source `github.com/rastrillo/idear`):
+
+```
+aviso.go Config, New, Service, errors
+store.go List, DeleteSubject, Sweep, the CRUD the handlers use
+send.go Send, SendTo, fan-out, pruning
+http.go PublicKey, Subscribe, Unsubscribe
+vapid.go GenerateKey, key parsing, key id
+ssrf.go dial guard and endpoint validation (from Eleven's
+ push.go:25-70 and unfurl.go:160)
+migrations.go Schema = migrate.MustFromFS(migrationFS, "aviso")
+migrations/0001_init.sql
+js.go JS() and WorkerJS()
+js/push.mjs browser module (from push.js)
+js/aviso-sw.js classic worker helper, exposes AvisoSW
+js/*.test.mjs node tests, the vault/js precedent
+cmd/aviso-key prints one private key
+SKILL.md byte-budgeted, tested, like the framework's
+docs/installable.md the §11 recipe
+```
+
+Nothing in `github.com/carlosframework/rastrillo` imports aviso. The
+directory page in `docs/site/addons.md` gains one entry.
+
+## 3. Go API
+
+```go
+package aviso
+
+type Config struct {
+ DB *sql.DB
+ PrivateKey string // unpadded base64url, 32-byte P-256 scalar; required
+ Contact string // VAPID "sub": a mailto: or https: the push service may contact
+ Origin string // the app's origin, for csrf.SameOrigin and click-URL checks
+ Concurrency int // in-flight sends across the Service; 0 means 32
+ Logger *slog.Logger
+}
+
+type Subscription struct{ Endpoint, P256dh, Auth string }
+
+type Stored struct {
+ ID, Subject, VAPIDKeyID string
+ Revision int64
+ Subscription
+}
+
+type Options struct {
+ TTL time.Duration // whole seconds, >= 0; 0 means the push service's default
+ Urgency string // "very-low" | "low" | "normal" | "high"; "" means normal
+ Topic string // RFC 8030 topic, <= 32 URL-safe chars; "" means none
+}
+
+type Result struct {
+ ID string
+ Status int // push-service status; 0 when Err is transport-level
+ RetryAfter time.Duration // from a 429/503, else 0
+ Err error
+}
+
+var (
+ Schema *migrate.Set
+ ErrEmptyPrivateKey error
+ ErrInvalidPrivateKey error
+)
+
+func GenerateKey() (string, error)
+func New(cfg Config) (*Service, error)
+
+func (s *Service) List(ctx context.Context, subject string) ([]Stored, error)
+func (s *Service) DeleteSubject(ctx context.Context, subject string) error
+func (s *Service) Sweep(ctx context.Context, notConfirmedSince time.Time) error
+
+func (s *Service) Send(ctx context.Context, to []Stored, payload []byte, o Options) ([]Result, error)
+func (s *Service) SendTo(ctx context.Context, subject string, payload []byte, o Options) ([]Result, error)
+
+func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request)
+func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request)
+func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request)
+
+func JS() []byte
+func WorkerJS() []byte
+```
+
+`SendTo` is the common case — every device one subject enrolled — so
+birthday-alarm never touches `Stored`. `Send` is for apps that select
+devices. Both return a batch `error` for what stops the batch (selection
+query failed, payload over bound, invalid options, context cancelled) and
+a `Result` per attempted device; a database failure must not look like
+"zero devices". A `Result` reports the push service's *acceptance*, not
+delivery, and aviso never retries: `RetryAfter` is for the app's own
+scheduler. Rows whose `VAPIDKeyID` is not the Service's are skipped with
+a Result error, not sent with a key that cannot sign for them.
+
+Concurrency is bounded across the Service, not per call, and the
+semaphore wait honours `ctx`. Each request has a 30 s timeout.
+
+## 4. HTTP routes and their gating
+
+Handlers, not a router: the app mounts them where it likes (the SKILL.md
+suggests `/aviso/…`). Each enforces its method.
+
+| Route | Body | Reply |
+|---|---|---|
+| `GET …/public-key` | — | `{"publicKey": "<base64url>"}`; `Cache-Control: no-cache` |
+| `POST …/subscribe` | `{"subscription": {endpoint, keys:{p256dh, auth}}, "publicKey": "…", "previousEndpoint"?: "…"}` | 204 |
+| `POST …/unsubscribe` | `{"endpoint": "…"}` | 204, idempotent |
+
+The two mutations require the app's session middleware to have run, a
+non-empty `sessions.Current(r).Subject`, and `csrf.SameOrigin(r,
+cfg.Origin)`: 401 without a session, 403 on origin failure. `publicKey`
+must equal the Service's, else 409 — a browser subscribed under a rotated
+key must re-enrol, not be stored unsendable. An endpoint already owned by
+another subject is 409. `previousEndpoint` is deleted in the same
+transaction, and only if the same subject owns it; the subscribe body is
+capped at 8 KiB and the endpoint at 2048 bytes (Eleven's bound).
+Unsubscribe deletes only the caller's own row and answers 204 either way.
+
+A re-subscribe of an endpoint the same subject already owns replaces the
+keys, bumps `revision`, and sets `last_confirmed_at`.
+
+## 5. Schema
+
+`migrations/0001_init.sql`, immutable once released:
+
+```sql
+CREATE TABLE aviso_subscriptions (
+ id TEXT NOT NULL PRIMARY KEY,
+ endpoint TEXT NOT NULL UNIQUE,
+ subject TEXT NOT NULL CHECK (length(subject) > 0),
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ vapid_key_id TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+ created_at INTEGER NOT NULL,
+ last_confirmed_at INTEGER NOT NULL
+);
+CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject);
+CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at);
+```
+
+Times are UTC Unix seconds. `id` is 16 random bytes, base64url, never
+reused. `vapid_key_id` is base64url SHA-256 of the uncompressed public
+key, so a rotated key is visible per row. Pruning and the post-send
+confirmation bump both match `id` **and** the `revision` captured when the
+batch was selected.
+
+The app merges: `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)`.
+
+## 6. Browser module (`js/push.mjs`)
+
+```js
+export function capabilities() // {serviceWorker, push, notifications, standalone}
+export async function status(registration) // {permission, subscription|null}
+export async function enable({registration, publicKey, save}) // from a click
+export async function reconcile({registration, publicKey, save}) // on every load
+export async function disable({registration, remove})
+```
+
+`enable` calls `Notification.requestPermission()` **first**, synchronously
+inside the gesture, then awaits `registration.pushManager.subscribe`.
+`reconcile` never prompts: with permission already granted it compares
+the existing subscription's `applicationServerKey` to `publicKey`,
+re-subscribes if they differ (Eleven's self-heal, `push.js`
+`sameAppServerKey`), and calls `save` so the server's `last_confirmed_at`
+moves. `save(body)` is app-supplied — a same-origin `fetch` to the
+subscribe route — and must reject on non-2xx; Eleven's version ignores
+the response (`push.js:177`), which is how a failed save goes unnoticed
+until the first missed notification. `disable` removes the server row
+first, then unsubscribes the browser, so a crash between the two leaves a
+harmless orphan rather than a row that sends to nothing. Base64url
+conversion stays private. Worker registration is the app's.
+
+## 7. Worker helper (`js/aviso-sw.js`) and the payload contract
+
+A classic script (workers cannot `import` reliably everywhere), loaded by
+the app's own `sw.js` via `importScripts`, exposing:
+
+```js
+AvisoSW.handlePush(event, {decode, fallback}) // -> Promise
+AvisoSW.handleClick(event, {fallbackURL}) // -> Promise
+AvisoSW.handleSubscriptionChange(event, {renew, save})
+```
+
+The app attaches each to `event.waitUntil` in its own listener, so the
+helper never owns the worker's lifecycle.
+
+**Payload contract.** The default decoder accepts JSON
+`{"title","body","url","tag"}`; `title` is required, `url` must be a
+root-relative path on the app's origin (§10). It yields
+`{title, options}` with the validated URL in `options.data.url`. An app
+with its own shape (Eleven's encrypted blob, say) supplies `decode`,
+which may be async. `fallback` is **required** and produces the
+notification shown when the payload is missing or malformed — because
+every push shows a notification (§1), and the alternative is WebKit
+revoking the subscription.
+
+`handleClick` closes the notification, focuses a client whose URL matches
+exactly, else opens the validated destination, else `fallbackURL`.
+
+`handleSubscriptionChange` uses `event.newSubscription` or `renew(event)`
+(a `pushManager.subscribe` with the stored key), then `save` posts to the
+subscribe route with `mode: "same-origin"`, `credentials: "same-origin"`,
+`redirect: "error"`. Fetch's default credentials mode is already
+`same-origin`, so eligible cookies — HttpOnly included — go with the
+request; what nothing can guarantee is that an installed app still has a
+live session. When it does not, renewal fails silently, without
+prompting, and `reconcile` repairs it on the next page open after
+sign-in. The spec says this out loud so nobody builds a retry loop in a
+worker.
+
+## 8. VAPID custody
+
+`Config.PrivateKey` is the unpadded base64url encoding of a 32-byte P-256
+scalar (webpush-go's own format). `New` checks it is in range and derives
+the public key; empty is `ErrEmptyPrivateKey`, malformed
+`ErrInvalidPrivateKey`, and both stop boot.
+
+Provisioning is explicit and once:
+
+```sh
+go run amadan.net/rastrillo/aviso/cmd/aviso-key # prints the private key, nothing else
+```
+
+The app reads it the way birthday-alarm reads its instance key
+(`BIRTHDAYALARM_INSTANCE_KEY`, refused when empty): one env var,
+`<APP>_VAPID_PRIVATE_KEY`. Not a file (Eleven's `<db>.vapid` sidecar has
+no equivalent in a `STATE_DIRECTORY` that holds only the DB), not a table
+row (Eleven scrubbed exactly that, `push.go:208`, because DB snapshots
+travel), and not derived from `InstanceKey` (rotating one secret must not
+silently rotate another). Rotation means every browser re-enrols on its
+next `reconcile`; rows under the old key are skipped, never sent.
+
+## 9. Retention and revocation
+
+`Sweep(ctx, t)` deletes rows with `last_confirmed_at` before `t`. That
+time advances on `reconcile` (a page opened) and on a revision-matched
+2xx acceptance. It measures whether the *subscription* is alive, not
+whether a person still wants notifications: an accepted send keeps an
+unopened device forever, which is the push service's contract too. The
+SKILL.md recommends 90 days, run from a `carlos.Tick` handler.
+
+Session expiry does **not** revoke a subscription — the subject still
+owns the device. Apps call `disable` before sign-out or account switch,
+`DeleteSubject` on account deletion, and re-check that a recipient is
+still entitled before every `SendTo`. An accepted notification cannot be
+recalled.
+
+## 10. Security rulings
+
+- **SSRF.** Endpoints are https only, no userinfo, no fragment, ≤ 2048
+ bytes; the client follows no redirects, uses no environment proxy, and
+ its dialer rejects loopback, private, link-local and reserved ranges
+ **at connect time** (IPv4-mapped IPv6 included), so DNS rebinding after
+ validation still fails. This is Eleven's `guardDialControl`
+ (`push.go:25-45`, `unfurl.go:160`) and its `push_ssrf_test.go`, lifted.
+- **Bounds.** Plaintext ≤ 3993 bytes (RFC 8291 §4's one-record limit);
+ push-service response bodies read to a small cap.
+- **Click URLs.** Root-relative paths only, resolved against the origin
+ and required to stay under it; protocol-relative (`//`) and
+ backslash-bearing paths are rejected. Applied to the default decoder,
+ to custom decoder output, and to `fallbackURL`.
+- **Redaction.** Endpoints, `p256dh`, `auth`, payloads, `Authorization`
+ headers, push-service response bodies and URL-bearing transport errors
+ never reach logs; an endpoint is logged as its row `id`.
+- **Ownership.** Nothing in a request body names a subject; the session
+ does.
+
+## 11. Installability recipe (docs, not code)
+
+What an app does so that a phone can install it and iOS will deliver
+push (16.4+, Home Screen only):
+
+1. Serve `manifest.webmanifest` with a stable `id`, `name`, `short_name`,
+ `start_url`, `scope`, `display: standalone`, `theme_color`,
+ `background_color`, and 192 px and 512 px icons.
+2. In the layout `<head>`: `<link rel="manifest">`,
+ `<meta name="theme-color">`, a 180 px `apple-touch-icon`.
+3. Serve the worker at the scope it should control, `Cache-Control:
+ no-cache`, as Eleven's `serveSW` does (`main.go:4168`).
+4. Tell the person, in the app, to add it to the Home Screen and sign in
+ inside the installed copy before pressing the enable button
+ (`capabilities().standalone` decides whether to show that coaching).
+
+Eleven's `serveManifest` (`main.go:4181`) is the twenty-line model.
+Rastrillo's scaffold is not changed by this spec.
+
+## 12. Testing
+
+**Go.** A recording transport behind a private seam (production guards
+stay on): asserts `TTL`, `Urgency`, `Topic` and a VAPID `Authorization`
+header per send; 404/410 prunes only when the revision still matches;
+429/503 surface `RetryAfter`; cancellation stops the batch. Handlers:
+401/403/409 paths, cross-owner upsert refused, `previousEndpoint` only
+deleted when owned, body and endpoint bounds. SSRF: loopback and private
+endpoints refused at dial, redirect refused, rebinding case. Keys:
+`GenerateKey` round-trips through `New`, invalid scalars refused, the
+same key yields the same `VAPIDKeyID` after restart. Eleven's
+`push_ssrf_test.go` and `push_token_hash_test.go` are the seed corpus.
+
+**Node.** `js/push.test.mjs` against a fake `registration`/`PushManager`:
+permission denied, reconcile with matching and mismatched keys, failed
+`save` rejects, `disable` ordering. `js/aviso-sw.test.mjs` against a fake
+`self`/`clients`: default decoder, malformed payload falls back, click
+focuses vs opens, subscription change renews then saves, expired session
+fails silently.
+
+**Browser.** One chromedp test (the `webauthn/browser_test.go` precedent)
+that registers a worker, calls `enable` with a **declared subscription
+double** — a stubbed `pushManager` — and asserts the row lands through the
+real handlers. It proves the wiring, not Chromium's subscription service
+or encrypted delivery; nothing short of a real push service does, and
+CI must not depend on one. iOS is a manual smoke test, written down.
+
+**Gates.** The repo's own gate (`go vet`, `gofmt -l`, `go test`), the
+node tests, and the browser test, mirrored in `Makefile` `ci` and
+`.amadan/ci.d/`. The example app under `example/` is its own module and
+is tested from its own directory, as `AGENTS.md` requires.
+
+## 13. Out of scope, named
+
+Offline caching and app-shell workers; an installability package or
+scaffold change; Badging API and `beforeinstallprompt` UI; Eleven's
+native APNs relay (`docs/push-relay.md`); declarative push; payload
+encryption above RFC 8291 (Eleven's E2EE blob stays Eleven's `decode`);
+recipient policy, mute/block, presence ("skip active users"); durable
+queues, delivery receipts, automatic retries; replacing webpush-go.
+
+## 14. The two points argued, and who won
+
+- **Send returns `([]Result, error)`, not `[]Result`.** Claude proposed
+ results-only for `SendTo`; Codex objected that a selection failure
+ would then read as "no devices". Codex's version stands (§3).
+- **The browser test uses a subscription double, not a fake push
+ service.** Claude proposed a chromedp test round-tripping through a
+ local fake push endpoint; Codex objected that Chromium's own
+ subscription path cannot be pointed at it, so the test would prove
+ less than it claimed. Codex's version stands (§12).
+
+No open questions remain that the code cannot settle. The one decision
+that is Paul's: whether `aviso` is the name.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2c5e035
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module amadan.net/rastrillo/aviso
+
+go 1.25.0