# 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.

---

## Review amendments (Codex, 2026-09-07, before Task 2)

Codex reviewed this plan against the spec, rastrillo's source and
webpush-go v1.4.0. Eleven findings; every one is applied to the task
it names, and the task text below is superseded where they conflict.

1. **Task 6, Send.** The sketch raced (`countSends` read results the
   goroutines were writing) and leaked goroutines on cancellation.
   Send now decides up front which rows it will attempt, uses a
   `sync.WaitGroup`, marks rows it never started with `ctx.Err()` when
   cancelled while waiting for a slot, always waits for started
   goroutines, and returns `ctx.Err()` as the batch error whenever the
   context was cancelled at any point.
2. **Task 5, guard.** `guardedIP` admitted `0.0.0.0/8`, `192.0.0.0/24`,
   `192.0.2.0/24`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`,
   `240.0.0.0/4`, `2001:db8::/32`, `64:ff9b::/96`. It now carries an
   explicit reserved-CIDR list on top of the net.IP predicates, and the
   test corpus names each range.
3. **Task 6.** `sendOne` calls `validateEndpoint` before sending: a
   caller can hand `Send` a `Stored` it edited.
4. **Task 6.** webpush-go prefixes `mailto:` to any subscriber not
   starting with `https:` (`vapid.go:74`). `New` keeps `Contact` as
   given for validation and stores the mailto-stripped form for the
   wire; the header test decodes the JWT and checks `sub`.
5. **Task 4/6 tests.** `sub()` generates a real P-256 point for
   `P256dh` and 16 random bytes for `Auth`; webpush-go decodes both
   before any HTTP happens.
6. **Task 7/9/11.** Browser `PushSubscription.toJSON()` carries
   `expirationTime`. The handler no longer uses `DisallowUnknownFields`
   (the byte cap and field validation are the defence), and every
   client path projects to `{endpoint, keys}` anyway.
7. **Task 8 test.** `KEY` is a real 65-byte point, base64url'd in the
   test from bytes, so `sameKey` round-trips.
8. **Task 6, TTL.** webpush-go always sends the `TTL` header and `0`
   means "deliver now or drop", not "service default". Ruling:
   `Options.TTL == 0` means 24 hours. Recorded as a spec deviation in
   the branch discussion.
9. **Task 8/9, callbacks.** `save`/`remove` may resolve to nothing (a
   callback that validated its own response); only an explicit
   `{ok: false}`-shaped return, or a rejection, counts as failure.
10. **Task 9/11/12, worker key.** A worker forgets a module-level
    variable when it is terminated. `handleSubscriptionChange` takes
    `{publicKey, save}` where `publicKey()` fetches the key on demand;
    the helper performs the renewal subscribe itself (from
    `event.newSubscription`, else `pushManager.subscribe` with the
    fetched key) and builds the full subscribe body, so the app's
    `save` only posts.
11. **Task 13/1, CI.** A `browser` make target
    (`go test -tags browser -count=1 -run Browser .`) joins `ci` and
    gets `.amadan/ci.d/50-browser`; it fails, not skips, without
    Chromium — rastrillo's own rule (`RASTRILLO_BROWSER_OPTIONAL`
    unset).

---

## 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.
