Files
summercms/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-PLAN.md
2026-09-29 14:34:06 +02:00

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
11-03
11-05
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/client.go
modules/lighthouse/centrifugo/commands.go
modules/lighthouse/centrifugo/commands_test.go
modules/lighthouse/README.md
README.md
../fonoteka.go/config/push.yaml
../fonoteka.go/README.md
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
true
RT-01
tokens raw_tokens tasks confidence
110000 110000 2 low
truths artifacts key_links prohibitions
Per D-15 and user decision 3, Web Push is a separate framework package `modules/flare` (not a realtime driver) with a `Pusher` interface and a stdlib VAPID driver: RFC 8291 aes128gcm payload encryption built from crypto/ecdh, crypto/hkdf, crypto/aes and crypto/cipher, reproducing the RFC 8291 Appendix A test vector byte for byte, and an RFC 8292 `Authorization: vapid t=<ES256 JWT>, k=<public key>` header whose JWT carries aud (the endpoint origin), exp (at most 24h ahead) and sub (push.subject).
Per D-15, config keys follow the PHP push block: `push.enabled` (default false), `push.public_key`, `push.private_key`, `push.subject`, plus `push.ttl` (default 2419200 seconds) and `push.allowed_hosts`; no push is sent while push.enabled is false.
Per D-15 and user decision 3, `websockets:generate-vapid-keys` generates a P-256 key pair as unpadded base64url (65-byte public key, 87 chars; 32-byte private key, 43 chars), validates and prints them with manual SUMMER_PUSH__* instructions, `--show-current` shows only the configured keys truncated to first 8 + `...` + last 4 with their lengths, and `--update` writes the keys through compass Set/Persist into the 0600 environment overrides file.
Per D-15 and user decision 3, `websockets:test-push <user_id> [--show-config]` shows the push configuration without key values, reads subscriptions only through an app-provided `flare.SubscriptionSource` and reports `no subscription source registered` (exit 1) when none is published; fonoteka publishes none because Płytarium has no subscription store.
Per D-15 and D-12, `websockets:health` exits 1 with `Centrifugo not configured (API key missing)` when realtime.centrifugo.api_key is empty, and otherwise prints the API URL, probes Centrifugo's `info` API method and prints the PHP settings table (API URL, Enabled, API Key Set) with exit 0, or `Connection check failed: ...` with exit 1.
Push endpoints are user-supplied URLs, so the VAPID driver sends only to https endpoints whose host matches `push.allowed_hosts` (default: the FCM, Mozilla autopush, Apple and Windows push services); any other endpoint is refused before a connection is made.
fonoteka registers the three websockets:* commands through its plugin Commands() and ships config/push.yaml with push disabled.
path provides contains
modules/flare/encrypt.go RFC 8291 aes128gcm encryption aes128gcm
path provides contains
modules/flare/vapid.go VAPID key generation, parsing and RFC 8292 header vapid t=
path provides contains
modules/flare/flare.go Pusher, Subscription, SendOptions, SubscriptionSource, Service, From type Pusher interface
path provides contains
modules/flare/commands.go websockets:generate-vapid-keys and websockets:test-push websockets:generate-vapid-keys
path provides contains
modules/lighthouse/centrifugo/commands.go websockets:health websockets:health
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/plugin.go modules/flare/commands.go Commands() appends flare.Commands(app) and centrifugo.Commands(app) flare.Commands|centrifugo.Commands
from to via pattern
modules/flare/flare.go modules/flare/encrypt.go VAPID driver encrypts every payload before POSTing encrypt
from to via pattern
modules/lighthouse/centrifugo/commands.go modules/lighthouse/centrifugo/client.go health probes Client.Info .Info(
requirement_id category statement status verification
RT-01 privacy websockets:test-push, websockets:health and --show-current MUST NOT print or log a configured VAPID private key or the Centrifugo API key; only newly generated keys are printed, and configured keys appear truncated or as set/unset resolved test
requirement_id category statement status verification
RT-01 safety websockets:test-push MUST NOT send a push when no SubscriptionSource is registered or push.enabled is false resolved test

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>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-CONTEXT.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-03-SUMMARY.md @modules/lighthouse/centrifugo/client.go @modules/compass/persist.go @modules/lagoon/keygen.go @modules/bonfire/command.go @modules/bonfire/prompts.go @../fonoteka.go/plugins/golem15/fonoteka/plugin.go - compass: `(*Config).Set(path string, value any) error` (runtime override), `(*Config).Persist() error` (writes config/env//overrides.yaml atomically with restrictive permissions), `String/Int/Bool/Lookup`. - lagoon/keygen.go `KeyGenerateCommand()` is the key-printing command precedent. - bonfire: `Command{Name, Description, Flags, Args, Run}`, `Flag{Bare}`, `Output` (Info, Success, Printf, Table, confirm prompts in prompts.go with non-TTY defaults). - From plan 11-03: `centrifugo.Config` (realtime.centrifugo.*), `centrifugo.NewClient`, `(*Client).Enabled()`, `(*Client).DebugInfo() DebugInfo{APIURL, Enabled, APIKeySet}`, header `Authorization: apikey `, `ErrNotConfigured`. - Go 1.27 stdlib: `crypto/ecdh` (P256 GenerateKey, NewPrivateKey, NewPublicKey, ECDH), `crypto/hkdf` (Extract, Expand), `crypto/aes` + `crypto/cipher` (GCM), `crypto/ecdsa` (`ParseRawPrivateKey(elliptic.P256(), b)` to sign ES256), `encoding/base64.RawURLEncoding`; golang-jwt/v5 `SigningMethodES256`. - PHP: /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/console/{GenerateVapidKeys.php, TestPushNotifications.php, CentrifugoHealthCheck.php}, config/config.php push block, classes/CentrifugoClient.php (isEnabled, getDebugInfo). - RFC 8291 (https://www.rfc-editor.org/rfc/rfc8291) Section 3.4 key derivation and Appendix A vector; RFC 8292 (https://www.rfc-editor.org/rfc/rfc8292) Sections 2 and 3; RFC 8188 record layout (salt 16 bytes, rs uint32, idlen, keyid).

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.
Task 1: A payload encrypted and signed by the VAPID driver is accepted and decrypted by a push service endpoint modules/flare/flare.go, modules/flare/vapid.go, modules/flare/encrypt.go, modules/flare/encrypt_test.go, modules/flare/send_test.go, modules/flare/README.md, README.md modules/postcard/mailer.go (From/driver selection pattern), modules/lagoon/keygen.go, modules/lagoon/encrypted.go (stdlib crypto style, hkdf use), /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/config/config.php, RFC 8291 Section 3.4 and Appendix A, RFC 8292 Sections 2-3 (fetch the RFC text; copy vector values verbatim), .planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md (Alternatives "Hand-rolled RFC 8291", Pitfall 14, T-11-11) (1) encrypt.go (RFC 8291/8188): `func Encrypt(payload []byte, sub Subscription) ([]byte, error)` decodes `P256dh` (65-byte uncompressed P-256 point) and `Auth` (16 bytes) from unpadded base64url, generates an ephemeral P-256 key and a 16-byte random salt through unexported injectable sources (so the Appendix A vector can fix both), computes ecdh_secret, `PRK_key = HKDF-Extract(auth_secret, ecdh_secret)`, `IKM = HKDF-Expand(PRK_key, "WebPush: info" 0x00 || ua_public || as_public, 32)`, `PRK = HKDF-Extract(salt, IKM)`, `CEK = HKDF-Expand(PRK, "Content-Encoding: aes128gcm" 0x00, 16)`, `NONCE = HKDF-Expand(PRK, "Content-Encoding: nonce" 0x00, 12)`, encrypts `payload || 0x02` with AES-128-GCM as a single record, and returns `salt || uint32be(4096) || 0x41 || as_public || ciphertext`; payloads above 3993 bytes are an error.

(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.

Task 2: Operators run websockets:health, websockets:generate-vapid-keys and websockets:test-push from the app binary modules/flare/commands.go, modules/flare/commands_test.go, modules/flare/README.md, modules/lighthouse/centrifugo/client.go, modules/lighthouse/centrifugo/commands.go, modules/lighthouse/centrifugo/commands_test.go, modules/lighthouse/README.md, ../fonoteka.go/config/push.yaml, ../fonoteka.go/README.md, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/console/GenerateVapidKeys.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/console/TestPushNotifications.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/console/CentrifugoHealthCheck.php, modules/compass/persist.go, modules/bonfire/prompts.go, modules/bonfire/output.go, modules/lighthouse/centrifugo/client.go, modules/lighthouse/README.md, modules/flare/flare.go (Task 1), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Commands), ../fonoteka.go/README.md (Configuration section) - websockets:health with an empty api_key prints `Centrifugo not configured (API key missing)` and exits 1 without a request. - websockets:health against a fake Centrifugo answering `/info` 200 prints `Configuration OK` and the table rows API URL, Enabled Yes, API Key Set Yes and exits 0; answering 500 prints `Connection check failed:` and exits 1; the api key never appears in output. - websockets:generate-vapid-keys prints an 87-char public key and a 43-char private key that ParseVAPIDKeys accepts; `--show-current` prints only `first8...last4` forms with lengths; `--update` persists both keys so a reloaded config returns them and the overrides file mode is 0600. - websockets:test-push 5 with no SubscriptionSource prints `no subscription source registered` and exits 1; with a source returning ErrUserNotFound prints `User 5 not found`; with no subscriptions prints `No push subscriptions found for this user`; with push.enabled false it lists subscriptions but refuses to send; with a working source and an allowlisted TLS endpoint it sends one encrypted push per subscription. - No command output contains a configured private key or api key value. (1) centrifugo: add `(*Client).Info(ctx) (map[string]any, error)` POSTing `{}` to `{api_url}/info` with the same headers and timeout; new commands.go `Commands(app) []bonfire.Command` with `websockets:health` (description "Check Centrifugo connection health") porting CentrifugoHealthCheck: not enabled → error lines `Centrifugo not configured (API key missing)` and `Set SUMMER_REALTIME__CENTRIFUGO__API_KEY in the environment`, exit 1; else `Checking Centrifugo connection...`, `API URL: `, Info probe (PHP's comment calls this the basic connectivity check; PHP's getDebugInfo never reached the server, so the probe is the real check), then `Configuration OK` and the Setting/Value table from DebugInfo, or `Connection check failed: ` and exit 1.

(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>
After Task 2: `go vet ./... && go test ./...` in summercms.go and the fonoteka.go full vet/test command pass; TestRFC8291AppendixA matches the RFC; `fonoteka websockets:health` with the committed config exits 1 with the not-configured message.

<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>
Create `.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md` when done.