Files
summercms/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md

20 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, plan_head_before, plan_head_after, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects actuals plan_head_before plan_head_after tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
11-jobs-realtime-and-search-infrastructure 04 realtime
web-push
vapid
rfc8291
rfc8292
aes128gcm
centrifugo
cli
bonfire
phase provides
11-jobs-realtime-and-search-infrastructure 11-03 centrifugo.Config/LoadConfig, Client (Enabled, DebugInfo, post with apikey header), ErrNotConfigured
phase provides
11-jobs-realtime-and-search-infrastructure 11-05 fonoteka plugin.go and README layout (edited after it)
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 <user_id> [--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
11-07 unit tests
a future app plugin that stores push subscriptions
15 cutover env mapping
tokens tasks commits
22995 2 3
a8305a015d f55cb444ab
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 '<command>: 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
created modified
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
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
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 <n>'; the PHP notification-icon block is not ported because it reads another plugin's config
Command failures print their lines through Output and return '<command>: 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:
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
RT-01
id description requirement verification human_judgment
D1 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 RT-01
kind ref status
unit modules/flare/encrypt_test.go#TestRFC8291AppendixA pass
false
id description requirement verification human_judgment
D2 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 RT-01
kind ref status
integration modules/flare/send_test.go#TestVAPIDSendRoundTrip pass
false
id description requirement verification human_judgment
D3 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 RT-01
kind ref status
integration modules/flare/send_test.go#TestSendRefusesDisallowedEndpoint pass
false
id description requirement verification human_judgment
D4 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) RT-01
kind ref status
integration modules/flare/commands_test.go#TestGenerateVAPIDKeysCommand pass
kind ref status
unit modules/compass/persist_test.go#TestPersistKeepsEarlierOverrides pass
false
id description requirement verification human_judgment
D5 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 RT-01
kind ref status
integration modules/flare/commands_test.go#TestTestPushCommand pass
false
id description requirement verification human_judgment
D6 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 RT-01
kind ref status
integration modules/lighthouse/centrifugo/commands_test.go#TestHealthCommand pass
false
id description requirement verification human_judgment
D7 fonoteka binary registers the three commands and, with the committed empty api_key, websockets:health prints the not-configured line and exits 1 RT-01
kind ref status
e2e 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)' pass
false
id description verification human_judgment rationale
D8 A real browser subscription receives a push from the VAPID driver (FCM or Mozilla autopush)
true 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
17min 2026-09-30 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 <command>: 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.