25 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-jobs-realtime-and-search-infrastructure | 04 | execute | 4 |
|
|
true |
|
|
|
Phase Goal
ROADMAP Phase 11 goal (verbatim, not in user-story form): River jobs run on the correct dual-driver split, Centrifugo publishing and channel authorization match the existing server, and Typesense sync stays a re-gated pre-filter — all brought up before the API phases that depend on them.
This plan's slice: an operator can check Centrifugo connectivity, generate VAPID keys and test Web Push from the app binary, with the push channel ready for any app that supplies subscriptions (D-15, RT-01's websockets plugin surface).
Port the PHP websockets console commands and the Web Push seams: a `flare` package with a stdlib VAPID driver, `websockets:generate-vapid-keys`, `websockets:test-push` over an app-provided SubscriptionSource, and `websockets:health` on the Centrifugo client.Purpose: D-15 keeps the full websockets command surface even though Płytarium's PHP push code is dead (minishlink/web-push is not installed and TestPushNotifications imports a plugin that is not in the repo; RESEARCH Pitfall 14). Decisions implemented: D-15, D-12 (health on the hand-rolled client); user decision 3. Output: modules/flare with README and root row, centrifugo commands and Info probe, fonoteka config and command registration, smoke tests including the RFC 8291 vector.
Repos: summercms.go (framework) and fonoteka.go (config, Commands, README). This plan runs after 11-05 because both edit fonoteka's plugin.go and the root and fonoteka READMEs. Planning docs and code in separate commits. Never add co-author tags.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
- flare:
Pusher(Send(ctx, sub Subscription, payload []byte, opts SendOptions) error),Subscription{Endpoint, P256dh, Auth string},SendOptions{TTL time.Duration; Urgency, Topic string},SubscriptionSource(Subscriptions(ctx, userID uint) ([]SubscriptionInfo, error)),SubscriptionInfo{Subscription; ID uint; UserAgent string; SubscribedAt, LastUsedAt *time.Time},ErrUserNotFound,ErrPushDisabled,ErrEndpointNotAllowed,ErrSubscriptionGone,Service,From(app) (*Service, error),(*Service).Pusher() Pusher,VAPIDKeys{PublicKey, PrivateKey string},GenerateVAPIDKeys() (VAPIDKeys, error),ParseVAPIDKeys(public, private string) (*ecdh.PrivateKey, error),VAPIDHeader(endpoint, subject string, keys VAPIDKeys, now time.Time) (string, error),Encrypt(payload []byte, sub Subscription) ([]byte, error),Commands(app) []bonfire.Command. - lighthouse/centrifugo:
(*Client).Info(ctx) (map[string]any, error),Commands(app) []bonfire.Command. - CLI:
websockets:generate-vapid-keys [--update] [--show-current],websockets:test-push <user_id> [--show-config],websockets:health. - Config keys:
push.enabled,push.public_key,push.private_key,push.subject,push.ttl,push.allowed_hosts. - Files:
modules/flare/*,../fonoteka.go/config/push.yaml.
(2) vapid.go (RFC 8292): VAPIDKeys{PublicKey, PrivateKey string}; GenerateVAPIDKeys() via ecdh.P256().GenerateKey(rand.Reader) with the public key as the unpadded base64url of the 65-byte uncompressed point and the private key as the unpadded base64url of the 32-byte scalar; ParseVAPIDKeys(public, private) accepts padded or unpadded input and checks the pair matches; VAPIDHeader(endpoint, subject, keys, now) builds an ES256 JWT (golang-jwt/v5, header typ JWT) with aud = scheme://host of the endpoint, exp = now + 12h, sub = subject (must start with mailto: or https:), and returns vapid t=<jwt>, k=<public key>.
(3) flare.go (D-15): Pusher, Subscription, SendOptions, SubscriptionSource, SubscriptionInfo, the error values; Service and From(app) (lookup-or-publish) reading push.enabled, push.public_key, push.private_key, push.subject, push.ttl (default 2419200s) and push.allowed_hosts (default fcm.googleapis.com, updates.push.services.mozilla.com, *.push.apple.com, *.notify.windows.com; *. means any subdomain); (*Service).Pusher() returns the VAPID driver whose Send refuses when disabled (ErrPushDisabled), refuses non-https endpoints or hosts outside the allowlist (ErrEndpointNotAllowed, before dialing), encrypts, POSTs with headers TTL, Content-Encoding: aes128gcm, Content-Type: application/octet-stream, optional Urgency/Topic and Authorization from VAPIDHeader, uses an injectable *http.Client with a 10s timeout, treats 2xx as success, maps 404 and 410 to ErrSubscriptionGone and other statuses to an error with the status code; the private key is never logged.
(4) Smoke tests: encrypt_test.go TestRFC8291AppendixA fixes the Appendix A salt and application-server key pair and asserts the exact output bytes for the Appendix A plaintext and receiver keys; send_test.go TestVAPIDSendRoundTrip runs an httptest.NewTLSServer push endpoint (allowlisted host in the test config) that verifies the VAPID JWT with the public key from k=, checks aud and the headers, and decrypts the body with the subscription's private key (a test-side RFC 8291 decrypt helper) back to the original payload; plus TestSendRefusesDisallowedEndpoint for an http URL and an unlisted host.
(5) Docs: new modules/flare/README.md in the standard structure (H1, summary "Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface.", import line, Overview stating push is a separate channel from realtime, Features, Usage with an acme SubscriptionSource, API reference, Configuration, CLI commands (filled in Task 2), Dependencies (stdlib plus golang-jwt/v5), Testing), and the root README.md row with the same sentence; identifiers checked with go doc ./modules/flare <Identifier>.
go vet ./... && go test ./modules/flare -run '^(TestRFC8291AppendixA|TestVAPIDSendRoundTrip|TestSendRefusesDisallowedEndpoint)$' -count=1 -v
<fails_when>Non-zero exit; the output lacks "--- PASS" for TestRFC8291AppendixA, TestVAPIDSendRoundTrip or TestSendRefusesDisallowedEndpoint, or prints "no tests to run" or "--- SKIP".</fails_when>
<acceptance_criteria>
- go doc ./modules/flare Pusher, go doc ./modules/flare Encrypt, go doc ./modules/flare VAPIDHeader and go doc ./modules/flare SubscriptionSource exit 0.
- grep -c 'crypto/hkdf' modules/flare/encrypt.go prints 1 and go list -m all | grep -ci 'webpush' prints 0.
- grep -c 'Content-Encoding: aes128gcm' modules/flare/encrypt.go prints at least 1.
- grep -c '\[flare\](modules/flare/README.md)' README.md prints 1.
- TestRFC8291AppendixA compares against the RFC's published output string, not a value produced by the implementation.
</acceptance_criteria>
The VAPID driver produces RFC 8291 ciphertext matching the RFC vector and a push endpoint can verify and decrypt its requests; disallowed endpoints are refused before any connection.
(2) flare commands.go Commands(app) []bonfire.Command: websockets:generate-vapid-keys (bare flags update, show-current) porting GenerateVapidKeys: title lines, current keys shown truncated (first 8 + ... + last 4, char count, check mark when the decoded public key is 65 bytes and the private key 32 bytes), --show-current stops there; otherwise generate, validate lengths and base64url charset, print both new keys, then with --update call app.Config.Set("push.public_key", ...), Set("push.private_key", ...) and Persist() and print the overrides path, or print manual lines SUMMER_PUSH__PUBLIC_KEY=<key> and SUMMER_PUSH__PRIVATE_KEY=<key>; framework-neutral next steps. websockets:test-push (required arg user_id, bare flag show-config, accepted for PHP compatibility since the configuration is always shown) porting TestPushNotifications: configuration block (enabled, public/private key set with char counts only, subject with mailto/https check), key length checks (public 87 or 88, private 43), SubscriptionSource lookup via app.Lookup[flare.SubscriptionSource]() (missing → no subscription source registered, exit 1), user and subscription reporting as in PHP (endpoint first 60 chars plus ..., user agent, subscribed/last used), a confirm prompt Send test notification? defaulting to yes (non-TTY takes the default), refusal when push is disabled, and a JSON payload {"title": "<app.name> test", "body": "This is a test push notification sent at HH:MM:SS", "data": {"test": true, "timestamp": <unix>}} sent to each subscription with per-subscription results; exit 1 when any send fails.
(3) fonoteka.go: plugin.go Commands() returns the existing oauth-client command plus centrifugo.Commands(p.app)... and flare.Commands(p.app)...; new config/push.yaml with enabled: false, empty public_key/private_key/subject and a comment on SUMMER_PUSH__* names; README Configuration gains PUSH_ENABLED, PUSH_VAPID_PUBLIC_KEY, PUSH_VAPID_PRIVATE_KEY and PUSH_VAPID_SUBJECT mapped to SUMMER_PUSH__ENABLED, SUMMER_PUSH__PUBLIC_KEY, SUMMER_PUSH__PRIVATE_KEY and SUMMER_PUSH__SUBJECT, and a note that Płytarium registers no SubscriptionSource yet.
(4) Tests: modules/flare/commands_test.go and modules/lighthouse/centrifugo/commands_test.go for the behavior list, running commands through bonfire.NewRootIO with captured output and temp config directories. Docs: modules/flare/README.md and modules/lighthouse/README.md CLI commands sections updated in the same commit.
go vet ./... && go test ./modules/flare ./modules/lighthouse/... -count=1 && go test ./... && (cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... && go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... && SUMMER_GOLEM15__USER__JWT__SECRET=test-only-cli-secret go run . websockets:health 2>&1 | grep -q 'Centrifugo not configured (API key missing)')
<fails_when>Any command exits non-zero or reports FAIL; the final grep finds no "Centrifugo not configured (API key missing)" line in the output of fonoteka websockets:health run with the committed empty api_key.</fails_when>
<acceptance_criteria>
- grep -c 'websockets:health' modules/lighthouse/centrifugo/commands.go prints 1; grep -c 'websockets:generate-vapid-keys' modules/flare/commands.go and grep -c 'websockets:test-push' modules/flare/commands.go each print 1.
- grep -c 'no subscription source registered' modules/flare/commands.go prints 1.
- grep -c 'flare.Commands(p.app)' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go prints 1 and grep -c 'centrifugo.Commands(p.app)' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go prints 1.
- grep -c 'enabled: false' ../fonoteka.go/config/push.yaml prints 1 and grep -c 'SUMMER_PUSH__PRIVATE_KEY' ../fonoteka.go/README.md prints at least 1.
- go doc ./modules/lighthouse/centrifugo Commands and go doc ./modules/flare Commands exit 0.
</acceptance_criteria>
All three PHP websockets commands exist in the app binary with PHP output shapes, never print configured secrets, and push sends only through an app-provided subscription source; both repositories pass their full suites.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| App-provided subscriptions (browser-supplied endpoint URLs) → VAPID driver | Outbound HTTPS requests to URLs that originated in browsers |
| Operator CLI → key material | Commands generate, show and persist VAPID keys and read the Centrifugo key |
| Config overrides file on disk | Persisted private key |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-11-11 | Information Disclosure | VAPID private key | medium | mitigate | Printed only when newly generated; --show-current and test-push show truncated values or lengths; never logged (Task 2 tests assert output). |
| T-11-22 | Spoofing / SSRF | push endpoint requests | high | mitigate | https only and host allowlist (push.allowed_hosts, known push services by default) checked before dialing; unlisted endpoints fail with ErrEndpointNotAllowed (Task 1). |
| T-11-23 | Information Disclosure | websockets:health output | low | mitigate | Only "API Key Set: Yes/No" is shown; the key is never printed or logged (Task 2). |
| T-11-24 | Information Disclosure | persisted overrides file | medium | mitigate | --update uses compass Persist, which writes atomically with restrictive (0600) permissions; test asserts the mode (Task 2). |
| T-11-SC | Tampering | Go module installs | high | mitigate | No push library is installed (the webpush-go alternative in RESEARCH was not recommended and is not added); only stdlib crypto and the already-pinned golang-jwt/v5. |
| </threat_model> |
<success_criteria>
- flare exists with README and root row; the VAPID driver matches RFC 8291's vector and RFC 8292's header format.
- websockets:health, websockets:generate-vapid-keys and websockets:test-push run from the app binary with PHP-shaped output and no secret leakage.
- fonoteka registers the commands and ships push disabled. </success_criteria>