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