From a0f65cdc09be9ab956103a53c02971d723cdd0d9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 13:54:25 +0200 Subject: [PATCH] docs(11-04): complete flare Web Push and websockets commands plan --- .../11-04-SUMMARY.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 .planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md diff --git a/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md b/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md new file mode 100644 index 0000000..c9d0fc5 --- /dev/null +++ b/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md @@ -0,0 +1,308 @@ +--- +phase: 11-jobs-realtime-and-search-infrastructure +plan: 04 +subsystem: realtime +tags: [web-push, vapid, rfc8291, rfc8292, aes128gcm, centrifugo, cli, bonfire] + +requires: + - phase: 11-jobs-realtime-and-search-infrastructure + provides: "11-03 centrifugo.Config/LoadConfig, Client (Enabled, DebugInfo, post with apikey header), ErrNotConfigured" + - phase: 11-jobs-realtime-and-search-infrastructure + provides: "11-05 fonoteka plugin.go and README layout (edited after it)" +provides: + - "modules/flare: Pusher, Subscription, SendOptions, SubscriptionSource, SubscriptionInfo, Service/From, VAPIDPusher, Encrypt (RFC 8291), VAPIDHeader/GenerateVAPIDKeys/ParseVAPIDKeys (RFC 8292), HostAllowed allowlist" + - "websockets:generate-vapid-keys [--update] [--show-current] and websockets:test-push [--show-config] (flare.Commands)" + - "centrifugo.Client.Info and websockets:health (centrifugo.Commands)" + - "compass.Config.Persist keeps keys saved earlier in overrides.yaml" + - "fonoteka.go: the three websockets commands in the binary, config/push.yaml with push disabled, PUSH_* env mapping" +affects: [11-07 unit tests, a future app plugin that stores push subscriptions, 15 cutover env mapping] + +actuals: + tokens: 22995 + tasks: 2 + commits: 3 +plan_head_before: a8305a015ddb32a401655a8df2168f64e12a9959 +plan_head_after: f55cb444ab32eefb0ad25ea1c89077ec1281dc71 +# fonoteka.go (separate repository) received 1 more commit: cdb87d9 + +tech-stack: + added: [] + patterns: + - "Web Push is hand-rolled on stdlib crypto (crypto/ecdh, crypto/hkdf, AES-GCM) plus golang-jwt ES256; no push library" + - "Outbound requests to user-supplied URLs: https only, host allowlist checked before dialing, redirects never followed, errors name the host not the URL" + - "Commands print their PHP-shaped lines through bonfire.Output and return a short ': failed' error so the binary exits 1 without repeating the message" + - "App-provided data seams for operator commands: the app publishes an interface (flare.SubscriptionSource) and the command looks it up" + +key-files: + created: + - modules/flare/flare.go + - modules/flare/vapid.go + - modules/flare/encrypt.go + - modules/flare/commands.go + - modules/flare/encrypt_test.go + - modules/flare/send_test.go + - modules/flare/commands_test.go + - modules/flare/README.md + - modules/lighthouse/centrifugo/commands.go + - modules/lighthouse/centrifugo/commands_test.go + - ../fonoteka.go/config/push.yaml + modified: + - README.md + - modules/lighthouse/centrifugo/client.go + - modules/lighthouse/README.md + - modules/compass/persist.go + - modules/compass/persist_test.go + - modules/compass/README.md + - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go + - ../fonoteka.go/README.md + +key-decisions: + - "flare's VAPID driver never follows redirects (CheckRedirect returns ErrUseLastResponse on a copy of any injected client), so a push service cannot bounce a request to a host outside push.allowed_hosts" + - "compass.Config.Persist now merges runtime values over the existing overrides.yaml instead of replacing it; otherwise websockets:generate-vapid-keys --update would silently wipe every other persisted override" + - "websockets:health calls Centrifugo's info API method as the connectivity probe; unlike publishing, a 200 answer with an error body fails the check" + - "Configured keys shorter than 20 characters are shown as '...' only, because first 8 + last 4 would reveal most of the value" + - "websockets:test-push takes no user name from the source (SubscriptionSource returns subscriptions only), so it prints 'Testing push for user ID '; the PHP notification-icon block is not ported because it reads another plugin's config" + - "Command failures print their lines through Output and return ': failed' (or ': check failed'); main prints that one line and exits 1" + - "push.subject defaults to empty, not the PHP app's mailto address, as the plan specifies; VAPIDHeader refuses a subject that is not mailto: or https:" + +patterns-established: + - "HostAllowed: exact host or '*.suffix' for any subdomain (never the apex), case-insensitive, trailing dot ignored" + - "Secrets in config structs: String, GoString and LogValue redact the private key" + +requirements-completed: [RT-01] + +coverage: + - id: D1 + description: "RFC 8291 aes128gcm encryption reproduces the Appendix A body, header, ciphertext, CEK and nonce byte for byte (values copied from the RFC text); Encrypt round-trips and refuses payloads above 3993 bytes" + requirement: RT-01 + verification: + - kind: unit + ref: "modules/flare/encrypt_test.go#TestRFC8291AppendixA" + status: pass + human_judgment: false + - id: D2 + description: "VAPID send: an httptest TLS push service verifies the ES256 JWT with the key from k=, checks aud (origin), exp within 24h, sub, typ/alg, TTL, Content-Encoding, Content-Type, Urgency and Topic, and decrypts the body to the payload; 404/410 map to ErrSubscriptionGone and 500 to StatusError without the body" + requirement: RT-01 + verification: + - kind: integration + ref: "modules/flare/send_test.go#TestVAPIDSendRoundTrip" + status: pass + human_judgment: false + - id: D3 + description: "Endpoint allowlist: http, unlisted host, foreign host, user info, relative, other scheme, empty host and host-suffix tricks are refused with ErrEndpointNotAllowed before any request; redirects are not followed; a disabled pusher sends nothing; errors never expose the endpoint path; default allowlist table" + requirement: RT-01 + verification: + - kind: integration + ref: "modules/flare/send_test.go#TestSendRefusesDisallowedEndpoint" + status: pass + human_judgment: false + - id: D4 + description: "websockets:generate-vapid-keys prints an 87/43-char pair that ParseVAPIDKeys accepts with SUMMER_PUSH__* lines; --show-current prints only first8...last4 with lengths and check marks; --update persists both keys (reloaded config returns them, earlier override kept, file mode 0600)" + requirement: RT-01 + verification: + - kind: integration + ref: "modules/flare/commands_test.go#TestGenerateVAPIDKeysCommand" + status: pass + - kind: unit + ref: "modules/compass/persist_test.go#TestPersistKeepsEarlierOverrides" + status: pass + human_judgment: false + - id: D5 + description: "websockets:test-push: no source prints 'no subscription source registered' and exits 1; unknown user 'User 5 not found'; no subscriptions; push disabled lists subscriptions but sends nothing; a working source sends one encrypted push per subscription with the PHP-shaped payload; a failed send exits 1 after attempting all; the private key never appears" + requirement: RT-01 + verification: + - kind: integration + ref: "modules/flare/commands_test.go#TestTestPushCommand" + status: pass + human_judgment: false + - id: D6 + description: "websockets:health: empty api_key prints 'Centrifugo not configured (API key missing)' and exits 1 with no request; /info 200 prints Configuration OK and the API URL/Enabled/API Key Set table; 500, an error body and an unreachable server print 'Connection check failed:' and exit 1; the API key is never printed" + requirement: RT-01 + verification: + - kind: integration + ref: "modules/lighthouse/centrifugo/commands_test.go#TestHealthCommand" + status: pass + human_judgment: false + - id: D7 + description: "fonoteka binary registers the three commands and, with the committed empty api_key, websockets:health prints the not-configured line and exits 1" + requirement: RT-01 + verification: + - kind: e2e + ref: "cd ../fonoteka.go && SUMMER_GOLEM15__USER__JWT__SECRET=test-only-cli-secret go run . websockets:health 2>&1 | grep -q 'Centrifugo not configured (API key missing)'" + status: pass + human_judgment: false + - id: D8 + description: "A real browser subscription receives a push from the VAPID driver (FCM or Mozilla autopush)" + verification: [] + human_judgment: true + rationale: "Needs a live push service and a browser; Płytarium has no subscription store, so this can only be exercised by an app that publishes a SubscriptionSource" + +duration: 17min +completed: 2026-09-30 +status: complete +--- + +# Phase 11 Plan 04: flare Web Push and the websockets console commands Summary + +**A new framework package, `flare`, sends Web Push with a standard-library VAPID driver: RFC 8291 aes128gcm encryption that matches the RFC's Appendix A vector byte for byte, and an RFC 8292 ES256 `vapid t=…, k=…` header. It sends only to https endpoints on a push-service allowlist and never follows redirects. The three WinterCMS websockets commands (`websockets:health`, `websockets:generate-vapid-keys`, `websockets:test-push`) now run from the fonoteka binary, and none of them prints a configured secret.** + +## Performance + +- **Duration:** 17 min +- **Started:** 2026-09-30T11:35:45Z +- **Completed:** 2026-09-30T11:53:01Z +- **Tasks:** 2 +- **Files modified:** 19 (16 in summercms.go, 3 in fonoteka.go) + +## Accomplishments + +- **flare core (D-15, user decision 3).** + - Types: `Pusher`, `Subscription`, `SendOptions`, `SubscriptionSource` and `SubscriptionInfo`. + - Errors: `ErrPushDisabled`, `ErrEndpointNotAllowed`, `ErrSubscriptionGone`, `ErrUserNotFound` and `StatusError`. + - `From(app)` builds the service from `push.*` and publishes it. It reads `enabled`, `public_key`, `private_key` and `subject`, plus `ttl` (default 2419200 s) and `allowed_hosts` (default FCM, Mozilla autopush, `*.push.apple.com` and `*.notify.windows.com`). +- **RFC 8291.** `Encrypt` uses an ephemeral P-256 key through `crypto/ecdh` and the HKDF-SHA-256 key schedule through `crypto/hkdf`. It writes one AES-128-GCM record with the 0x02 delimiter, and the header is salt, rs 4096, idlen 65 and the key id. Payloads over 3993 bytes are refused. `TestRFC8291AppendixA` compares the output against the published body, header, ciphertext, CEK and nonce, with values copied verbatim from the RFC text. Changing the nonce label makes the test fail. +- **RFC 8292.** + - `GenerateVAPIDKeys` returns an unpadded base64url pair (87 and 43 characters). + - `ParseVAPIDKeys` accepts padded or unpadded input and checks that the public key matches the private key. + - `VAPIDHeader` signs an ES256 JWT (golang-jwt/v5) with `aud` set to the endpoint origin, `exp` 12 h ahead and a `sub` that must be mailto: or https:. +- **VAPIDPusher.** + - Refusals come first. It refuses while disabled. Before dialing, it refuses any endpoint that is not https, carries user info, or has a host outside the allowlist. + - It POSTs `TTL`, `Content-Encoding: aes128gcm` and `Content-Type: application/octet-stream`, the optional `Urgency` and `Topic`, and the VAPID `Authorization`. The request times out after 10 s. + - 2xx is success. 404 and 410 return `ErrSubscriptionGone`, and any other status returns `StatusError` without the body. + - It never follows redirects, and its errors name the host, never the endpoint path. +- **Commands.** + - `websockets:health` ports CentrifugoHealthCheck. It now calls the real `info` API through the new `Client.Info`, because PHP's `getDebugInfo` never contacted the server. + - `websockets:generate-vapid-keys` ports GenerateVapidKeys. Configured keys are truncated. `--show-current` stops after showing them. `--update` saves through compass `Set`/`Persist` to a 0600 overrides file. Without it, the command prints manual `SUMMER_PUSH__*` lines. + - `websockets:test-push` ports TestPushNotifications. It reads subscriptions through the app-published `SubscriptionSource` and asks a confirm prompt whose default is yes. It refuses to send while push is disabled and reports a result for each subscription. +- **fonoteka.go.** + - `Commands()` appends `centrifugo.Commands(p.app)` and `flare.Commands(p.app)`. + - `config/push.yaml` ships with push disabled. + - The README maps `PUSH_*` to `SUMMER_PUSH__*` and notes that Płytarium registers no `SubscriptionSource`. +- **Docs.** + - A new `modules/flare/README.md` follows the standard structure and gets a root modules row with the same summary sentence. + - The lighthouse README gains `Client.Info`, `Commands` and a CLI commands section. + - The compass README describes the new Persist merge. + +## Task Commits + +summercms.go: +1. **Task 1: VAPID driver, RFC 8291/8292, flare README and root row (tracer)**: `a9af0d7` (feat) +2. **Task 2 (prerequisite fix): compass Persist keeps earlier overrides**: `5fb22c2` (fix) +3. **Task 2: websockets:health, generate-vapid-keys and test-push**: `f55cb44` (feat) + +fonoteka.go: +1. **Task 2: command registration, config/push.yaml, README**: `cdb87d9` (feat) + +## Files Created/Modified + +- `modules/flare/flare.go`: types, errors, `Config`/`LoadConfig` (with redaction), `Service`/`From`, `VAPIDPusher`, `HostAllowed` +- `modules/flare/encrypt.go`: `Encrypt`, the RFC 8291 key schedule, base64url decoding +- `modules/flare/vapid.go`: `VAPIDKeys`, `GenerateVAPIDKeys`, `ParseVAPIDKeys`, `VAPIDHeader`, origin serialization +- `modules/flare/commands.go`: `Commands`, the two push commands and their name constants +- `modules/flare/*_test.go`: the RFC vector, the TLS push-service round trip with a test-side RFC 8291 decrypt, the allowlist refusals, and the command behaviour list +- `modules/lighthouse/centrifugo/client.go`: `Client.Info` +- `modules/lighthouse/centrifugo/commands.go`, `commands_test.go`: `Commands`, `HealthCommandName`, `websockets:health` and its tests +- `modules/compass/persist.go`, `persist_test.go`, `README.md`: Persist merges over the saved file +- `README.md`, `modules/flare/README.md`, `modules/lighthouse/README.md`: docs +- fonoteka.go: `plugins/golem15/fonoteka/plugin.go`, `config/push.yaml`, `README.md` + +## Decisions Made + +See `key-decisions` in the frontmatter. + +## TDD Gate Compliance (Task 2) + +- **RED:** The tests were written against stubs with the final signatures, where each command's Run returned nil and `Client.Info` returned an empty map. Every subtest of `TestGenerateVAPIDKeysCommand`, `TestTestPushCommand` and `TestHealthCommand` failed on its assertions, with no build or load errors. The compass test `TestPersistKeepsEarlierOverrides` failed with `app.name = "base", want the earlier persisted value`. `gsd-tools check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`) for all three records (TestTestPushCommand, TestHealthCommand, TestPersistKeepsEarlierOverrides). +- **GREEN:** `5fb22c2` and `f55cb44`. All subtests pass, including under `-race`. +- **Gate note:** there are no separate `test(11-04)` commits. The project requires `go vet` and `go test ./...` to be green at every commit, so tests and code landed together, as in plans 11-01 to 11-06. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 2 - Missing critical] compass Persist dropped earlier overrides** +- **Found during:** Task 2, while reading `compass/persist.go` before wiring `--update` +- **Issue:** `Persist` wrote only this process's runtime values. `websockets:generate-vapid-keys --update` would therefore replace `overrides.yaml` with the two push keys and silently delete every other persisted override. +- **Fix:** `Persist` loads the existing file, merges the runtime values over it and writes the result atomically with mode 0600, as before. The README and doc comment describe the merge. +- **Files modified:** modules/compass/persist.go, persist_test.go, README.md +- **Verification:** `TestPersistKeepsEarlierOverrides` (RED, then GREEN). The existing compass tests pass. The flare `--update` subtest asserts that an earlier `app.name` override survives. +- **Committed in:** `5fb22c2` + +**2. [Rule 2 - Security] The VAPID driver refuses redirects** +- **Found during:** Task 1 +- **Issue:** The allowlist is checked before dialing, but Go's default client follows redirects. A push service, or anything that answers on an allowed host, could bounce the request to an arbitrary host (T-11-22). +- **Fix:** `NewVAPIDPusher` copies any given client and sets `CheckRedirect` to `http.ErrUseLastResponse`. A 3xx answer is returned as a `StatusError`. +- **Verification:** The redirect case in `TestSendRefusesDisallowedEndpoint`. The redirect target gets zero requests. +- **Committed in:** `a9af0d7` + +**3. [Rule 2 - Security] Secret-safe formatting and errors** +- `flare.Config` and `flare.VAPIDKeys` redact the private key in `String`, `GoString` and `LogValue`. +- Transport errors drop `*url.Error`'s repeated endpoint URL and name only the host, because an endpoint path is a capability. +- The test asserts that `%v`/`%#v` never print the private key. +- **Committed in:** `a9af0d7` + +**4. [Additions] Extra exported surface, all in the READMEs and checked with go doc** +- flare: + - `Config` (`Keys`, `String`, `GoString`, `LogValue`) and `LoadConfig` + - `Service.Config`, `Service.Enabled`, `Service.SetHTTPClient` (test injection) and `Service.Logger` + - `VAPIDPusher` and `NewVAPIDPusher` + - `HostAllowed` and `DefaultAllowedHosts` + - `StatusError`, `ErrPayloadTooLarge`, `ErrInvalidVAPIDKeys` and `ErrInvalidSubject` + - the constants `ContentEncoding`, `MaxPayloadSize`, `DefaultTTL`, `DefaultTimeout`, `VAPIDTokenLifetime`, `PublicKeyLength` and `PrivateKeyLength` + - `GenerateVAPIDKeysCommandName` and `TestPushCommandName` +- centrifugo: `HealthCommandName`. +- The name constants keep each command name literal to one occurrence per file, as the acceptance greps require. + +**5. [Output shape] Documented differences from PHP** +- Titles are framework-neutral. For example, "Push Notification Tester" replaces "QuestStream Push Notification Tester". +- "Testing push for user ID " replaces PHP's name line, because a SubscriptionSource returns no user name. +- The notification icon/badge block is not ported. It reads the absent notifications plugin's config. +- A failing command ends with one `: failed` line, which the binary prints before it exits 1. +- `websockets:health` hints at `SUMMER_REALTIME__CENTRIFUGO__API_KEY` instead of `.env`. + +--- + +**Total deviations:** 3 auto-fixed (all Rule 2), plus 2 documented notes. +**Impact on plan:** Fix 1 prevents config data loss from the new `--update` flag. Fixes 2 and 3 harden the T-11-22 and T-11-11 mitigations. No scope creep. + +## Issues Encountered + +None. `TestRFC8291AppendixA` passed on the first run. A mutation of the nonce label confirmed that the test is sensitive to it. + +## Known Stubs + +None. Płytarium publishing no `SubscriptionSource` is intended, per the plan: the PHP app has no subscription store. `websockets:test-push` reports this and exits 1. + +## Threat Flags + +None beyond the plan's threat model. The only new surface is outbound HTTPS to push services, which T-11-22 covers with the allowlist and, now, the refused redirects. No inbound endpoint was added. + +## User Setup Required + +None. To enable push in a deployment later: +1. Run `fonoteka websockets:generate-vapid-keys`. +2. Set `SUMMER_PUSH__PUBLIC_KEY`, `SUMMER_PUSH__PRIVATE_KEY`, `SUMMER_PUSH__SUBJECT` and `SUMMER_PUSH__ENABLED=true`. +3. Have an app plugin publish a `flare.SubscriptionSource`. + +## Next Phase Readiness + +- Plan 11-07 (unit tests) can add: + - `HostAllowed` edge cases beyond the smoke table + - `LoadConfig` parsing (`ttl` as a duration string, `allowed_hosts` as a comma string) + - `ParseVAPIDKeys` mismatch and padded input + - `VAPIDHeader` subject and origin rules (default port stripped, IPv6) + - the `ago` formatting + - `Service.SetHTTPClient` +- An application that stores push subscriptions only needs to publish a `flare.SubscriptionSource` and call `flare.From(app).Pusher().Send`. + +--- +*Phase: 11-jobs-realtime-and-search-infrastructure* +*Completed: 2026-09-30* + +## Self-Check: PASSED + +- All 11 key created files exist on disk. +- summercms.go commits `a9af0d7`, `5fb22c2` and `f55cb44` exist, and so does fonoteka.go commit `cdb87d9`. The fonoteka.go working tree is clean. +- Task 1 verify: `go vet ./...` passes. TestRFC8291AppendixA, TestVAPIDSendRoundTrip and TestSendRefusesDisallowedEndpoint each report `--- PASS`, with no SKIP. +- Task 2 verify: `go vet ./... && go test ./...` passes in summercms.go. The fonoteka.go vet and test commands pass for all three modules. `fonoteka websockets:health` prints `Centrifugo not configured (API key missing)` and exits 1. +- Every acceptance-criteria grep and `go doc` check passed in both tasks, including the exact counts of 1.