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