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 |
|
|
|
|
|
a8305a015d |
f55cb444ab |
|
|
|
|
|
|
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,SubscriptionSourceandSubscriptionInfo. - Errors:
ErrPushDisabled,ErrEndpointNotAllowed,ErrSubscriptionGone,ErrUserNotFoundandStatusError. From(app)builds the service frompush.*and publishes it. It readsenabled,public_key,private_keyandsubject, plusttl(default 2419200 s) andallowed_hosts(default FCM, Mozilla autopush,*.push.apple.comand*.notify.windows.com).
- Types:
- RFC 8291.
Encryptuses an ephemeral P-256 key throughcrypto/ecdhand the HKDF-SHA-256 key schedule throughcrypto/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.TestRFC8291AppendixAcompares 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.
GenerateVAPIDKeysreturns an unpadded base64url pair (87 and 43 characters).ParseVAPIDKeysaccepts padded or unpadded input and checks that the public key matches the private key.VAPIDHeadersigns an ES256 JWT (golang-jwt/v5) withaudset to the endpoint origin,exp12 h ahead and asubthat 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: aes128gcmandContent-Type: application/octet-stream, the optionalUrgencyandTopic, and the VAPIDAuthorization. The request times out after 10 s. - 2xx is success. 404 and 410 return
ErrSubscriptionGone, and any other status returnsStatusErrorwithout the body. - It never follows redirects, and its errors name the host, never the endpoint path.
- Commands.
websockets:healthports CentrifugoHealthCheck. It now calls the realinfoAPI through the newClient.Info, because PHP'sgetDebugInfonever contacted the server.websockets:generate-vapid-keysports GenerateVapidKeys. Configured keys are truncated.--show-currentstops after showing them.--updatesaves through compassSet/Persistto a 0600 overrides file. Without it, the command prints manualSUMMER_PUSH__*lines.websockets:test-pushports TestPushNotifications. It reads subscriptions through the app-publishedSubscriptionSourceand 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()appendscentrifugo.Commands(p.app)andflare.Commands(p.app).config/push.yamlships with push disabled.- The README maps
PUSH_*toSUMMER_PUSH__*and notes that Płytarium registers noSubscriptionSource.
- Docs.
- A new
modules/flare/README.mdfollows the standard structure and gets a root modules row with the same summary sentence. - The lighthouse README gains
Client.Info,Commandsand a CLI commands section. - The compass README describes the new Persist merge.
- A new
Task Commits
summercms.go:
- Task 1: VAPID driver, RFC 8291/8292, flare README and root row (tracer):
a9af0d7(feat) - Task 2 (prerequisite fix): compass Persist keeps earlier overrides:
5fb22c2(fix) - Task 2: websockets:health, generate-vapid-keys and test-push:
f55cb44(feat)
fonoteka.go:
- 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,HostAllowedmodules/flare/encrypt.go:Encrypt, the RFC 8291 key schedule, base64url decodingmodules/flare/vapid.go:VAPIDKeys,GenerateVAPIDKeys,ParseVAPIDKeys,VAPIDHeader, origin serializationmodules/flare/commands.go:Commands, the two push commands and their name constantsmodules/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 listmodules/lighthouse/centrifugo/client.go:Client.Infomodules/lighthouse/centrifugo/commands.go,commands_test.go:Commands,HealthCommandName,websockets:healthand its testsmodules/compass/persist.go,persist_test.go,README.md: Persist merges over the saved fileREADME.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.Inforeturned an empty map. Every subtest ofTestGenerateVAPIDKeysCommand,TestTestPushCommandandTestHealthCommandfailed on its assertions, with no build or load errors. The compass testTestPersistKeepsEarlierOverridesfailed withapp.name = "base", want the earlier persisted value.gsd-tools check tdd-red-evidencereturnedRED_EVIDENCE_OK(target_test_failed) for all three records (TestTestPushCommand, TestHealthCommand, TestPersistKeepsEarlierOverrides). - GREEN:
5fb22c2andf55cb44. All subtests pass, including under-race. - Gate note: there are no separate
test(11-04)commits. The project requiresgo vetandgo 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.gobefore wiring--update - Issue:
Persistwrote only this process's runtime values.websockets:generate-vapid-keys --updatewould therefore replaceoverrides.yamlwith the two push keys and silently delete every other persisted override. - Fix:
Persistloads 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--updatesubtest asserts that an earlierapp.nameoverride 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:
NewVAPIDPushercopies any given client and setsCheckRedirecttohttp.ErrUseLastResponse. A 3xx answer is returned as aStatusError. - 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.Configandflare.VAPIDKeysredact the private key inString,GoStringandLogValue.- 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/%#vnever 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) andLoadConfigService.Config,Service.Enabled,Service.SetHTTPClient(test injection) andService.LoggerVAPIDPusherandNewVAPIDPusherHostAllowedandDefaultAllowedHostsStatusError,ErrPayloadTooLarge,ErrInvalidVAPIDKeysandErrInvalidSubject- the constants
ContentEncoding,MaxPayloadSize,DefaultTTL,DefaultTimeout,VAPIDTokenLifetime,PublicKeyLengthandPrivateKeyLength GenerateVAPIDKeysCommandNameandTestPushCommandName
- 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>: failedline, which the binary prints before it exits 1. websockets:healthhints atSUMMER_REALTIME__CENTRIFUGO__API_KEYinstead 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:
- Run
fonoteka websockets:generate-vapid-keys. - Set
SUMMER_PUSH__PUBLIC_KEY,SUMMER_PUSH__PRIVATE_KEY,SUMMER_PUSH__SUBJECTandSUMMER_PUSH__ENABLED=true. - Have an app plugin publish a
flare.SubscriptionSource.
Next Phase Readiness
- Plan 11-07 (unit tests) can add:
HostAllowededge cases beyond the smoke tableLoadConfigparsing (ttlas a duration string,allowed_hostsas a comma string)ParseVAPIDKeysmismatch and padded inputVAPIDHeadersubject and origin rules (default port stripped, IPv6)- the
agoformatting Service.SetHTTPClient
- An application that stores push subscriptions only needs to publish a
flare.SubscriptionSourceand callflare.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,5fb22c2andf55cb44exist, and so does fonoteka.go commitcdb87d9. 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:healthprintsCentrifugo not configured (API key missing)and exits 1. - Every acceptance-criteria grep and
go doccheck passed in both tasks, including the exact counts of 1.