233 lines
25 KiB
Markdown
233 lines
25 KiB
Markdown
---
|
|
phase: 11-jobs-realtime-and-search-infrastructure
|
|
plan: 04
|
|
type: execute
|
|
wave: 4
|
|
depends_on: ["11-03", "11-05"]
|
|
files_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/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
|
|
autonomous: true
|
|
requirements: [RT-01]
|
|
estimate:
|
|
tokens: 110000
|
|
raw_tokens: 110000
|
|
tasks: 2
|
|
confidence: low
|
|
must_haves:
|
|
truths:
|
|
- "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."
|
|
artifacts:
|
|
- path: "modules/flare/encrypt.go"
|
|
provides: "RFC 8291 aes128gcm encryption"
|
|
contains: "aes128gcm"
|
|
- path: "modules/flare/vapid.go"
|
|
provides: "VAPID key generation, parsing and RFC 8292 header"
|
|
contains: "vapid t="
|
|
- path: "modules/flare/flare.go"
|
|
provides: "Pusher, Subscription, SendOptions, SubscriptionSource, Service, From"
|
|
contains: "type Pusher interface"
|
|
- path: "modules/flare/commands.go"
|
|
provides: "websockets:generate-vapid-keys and websockets:test-push"
|
|
contains: "websockets:generate-vapid-keys"
|
|
- path: "modules/lighthouse/centrifugo/commands.go"
|
|
provides: "websockets:health"
|
|
contains: "websockets:health"
|
|
key_links:
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
|
|
to: "modules/flare/commands.go"
|
|
via: "Commands() appends flare.Commands(app) and centrifugo.Commands(app)"
|
|
pattern: "flare\\.Commands|centrifugo\\.Commands"
|
|
- from: "modules/flare/flare.go"
|
|
to: "modules/flare/encrypt.go"
|
|
via: "VAPID driver encrypts every payload before POSTing"
|
|
pattern: "encrypt"
|
|
- from: "modules/lighthouse/centrifugo/commands.go"
|
|
to: "modules/lighthouse/centrifugo/client.go"
|
|
via: "health probes Client.Info"
|
|
pattern: "\\.Info\\("
|
|
prohibitions:
|
|
- requirement_id: RT-01
|
|
category: privacy
|
|
statement: "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"
|
|
status: resolved
|
|
verification: test
|
|
- requirement_id: RT-01
|
|
category: safety
|
|
statement: "websockets:test-push MUST NOT send a push when no SubscriptionSource is registered or push.enabled is false"
|
|
status: resolved
|
|
verification: 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).
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
@~/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
- compass: `(*Config).Set(path string, value any) error` (runtime override), `(*Config).Persist() error` (writes config/env/<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 <key>`, `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).
|
|
</interfaces>
|
|
</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`.
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: A payload encrypted and signed by the VAPID driver is accepted and decrypted by a push service endpoint</name>
|
|
<files>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</files>
|
|
<read_first>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)</read_first>
|
|
<action>(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>`.</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/flare -run '^(TestRFC8291AppendixA|TestVAPIDSendRoundTrip|TestSendRefusesDisallowedEndpoint)$' -count=1 -v</automated>
|
|
<fails_when>Non-zero exit; the output lacks "--- PASS" for TestRFC8291AppendixA, TestVAPIDSendRoundTrip or TestSendRefusesDisallowedEndpoint, or prints "no tests to run" or "--- SKIP".</fails_when>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Operators run websockets:health, websockets:generate-vapid-keys and websockets:test-push from the app binary</name>
|
|
<files>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</files>
|
|
<read_first>/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)</read_first>
|
|
<behavior>
|
|
- 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.
|
|
</behavior>
|
|
<action>(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: <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: <err>` 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.</action>
|
|
<verify>
|
|
<automated>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)')</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/11-jobs-realtime-and-search-infrastructure/11-04-SUMMARY.md` when done.
|
|
</output>
|