diff --git a/.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md b/.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md new file mode 100644 index 0000000..b8ea7f9 --- /dev/null +++ b/.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md @@ -0,0 +1,233 @@ +# Phase 19.1: Chat satellites - Context + +**Gathered:** 2026-10-09 +**Status:** Ready for planning + + +## Phase Boundary + +The five remaining shared plugins that Wavepath's app plugin requires run on SummerCMS, each in its own `sm-*-plugin` repo under `git.golem15.com/golem15/`: + +| PHP plugin | Go repo / plugin ID | What it is | PHP size | +|---|---|---|---| +| `Golem15.ChatBots` | `sm-chatbots-plugin` / `golem15.chatbots` | Slash-command registry and dispatcher over chat, the `bot_flags` table, the `BotAuthResolver` seam | ~1.4k LOC | +| `Golem15.ChatVideo` | `sm-chatvideo-plugin` / `golem15.chatvideo` | LiveKit video sessions per conversation, call links (`join_code`), `video:*` realtime events | ~1.1k LOC | +| `Golem15.Forum` | `sm-forum-plugin` / `golem15.forum` | Context-scoped categories, threads and posts in Markdown, read marks, a deny-all access resolver | ~2.1k LOC | +| `Golem15.Herald` | `sm-herald-plugin` / `golem15.herald` | Web Push delivery core: subscriptions, payload, send engine, 404/410 pruning, `HeraldProfile` and `PresenceGate` seams | ~1.5k LOC | +| `Golem15.UserFriends` | `sm-userfriends-plugin` / `golem15.userfriends` | Friendships, friend groups (members, invites, bans), the friends chat context adapter | ~2.1k LOC | + +Each plugin provides its schema, models, a typed Go service API, a generic headless JSON API (D-05) and its events. + +Out of scope: +- Wavepath's own `/_wave/api/v1/community/...` and `/_wave/api/v1/push/...` routes, its controllers, and its host bindings: `WaveForumAccessResolver`, `WaveBotAuthResolver`, `WaveHeraldProfile`, `WavePresenceGate`, `WaveNotificationDispatcher`, `CommunityVideoService` and the group-lifecycle wiring in `wavepath/Plugin.php`. +- The Vue frontend and the Wavepath app port itself. + +**Repos touched:** +- The five new plugin repos. +- `sm-user-plugin`, only for additive user-lifecycle events that Phase 19 has not already added. +- `summercms.go`, for the framework Markdown module (D-17, delivered as a separate quick task), and only if `lighthouse`, `conga`, `fetchguard` or `festival` lack something. + +**Roadmap corrections** (make them in the first planning commit): +- Success criterion 2: drop "Chatbots is wired to `sm-golem-plugin` where the PHP original calls Golem". The PHP chatbots plugin never calls Golem; it requires only Chat and User. +- Success criterion 2: herald's decision note names `webpush-go` (D-09). Add decision notes for LiveKit (D-13) and bluemonday (D-16). +- Success criterion 3 is already met. Fonoteka's and Wavepath's userfriends are the same commit (`182b514`, no diff), fonoteka's PHP code never references it, and fonoteka.go has no port. Wavepath's copy is the single source. +- "Repos" line: add `summercms.go` for the Markdown module. + + + + +## Implementation Decisions + +### Carried forward from Phase 19 (not re-decided) +- **D-01:** **The frozen port contract is Wavepath's tree.** + - The commits are chatbots `1ecf8c8`, chatvideo `25135b4`, forum `ad10b54`, herald `a749f83` and userfriends `182b514`, read from `/media/nvme/dev/jz/wavepath.org/plugins/golem15/`. + - The P15/P18/P19 frozen-source rule applies. These names and shapes are binding: tables and columns, migration-equivalent schema, realtime event names, channels and payloads, serializer output, console command names, config keys, env names and lang keys. + - **Reversibility:** costly. The names are copied into the database and clients of every app. +- **D-02:** **One squashed, idempotent baseline per plugin,** equal to the final PHP schema, that adopts an existing Wavepath database unchanged (Phase 5, P18.1 D-04, P19 D-04). The researcher diffs each baseline against a real Wavepath database. + - The demo-seed migrations (chatvideo `seed_demo_users`, userfriends `seed_demo_friends`) are not ported as behavior (D-07). Their version IDs stay recorded so that adopted databases stay consistent, and fresh databases get no demo users. + - **Reversibility:** one-way. Migration IDs and table shapes become the shared contract. +- **D-03:** **The P19 seam rules apply to every satellite.** + - Contexts register through the build-time morph registry (P19 D-11). Stored PHP class names in morph columns are rewritten to aliases on adoption (P19 D-12). That covers `golem15_forum_categories.context_type` and any satellite morph column the researcher finds. + - Cross-plugin hooks are typed `festival` events, and no plugin patches another's model (P19 D-13). The GDPR user-delete cascades listen to `sm-user-plugin` events: + - chatbots deletes the `bot_flags` row; + - forum sets `user_id` to null on threads and posts and keeps the bodies; + - userfriends hands group ownership to the oldest admin, else the oldest member, else hard-deletes the empty group, then removes the user's member and ban rows. + - Every access seam is deny-all by default and has a setter, and Boot logs a Warn while the default is active (P19 D-14). That covers `ForumAccessResolver`, `BotAuthResolver` and herald's `PresenceGate`. + - Realtime goes through `lighthouse`, and user-targeted pushes go to the `user` namespace that `sm-user-plugin` registers (P19 D-16, D-17). + - Nothing names QuestStream, family, parent or child (P19 D-02), and READMEs never name a consuming app. +- **D-04:** **A test-only host per plugin repo** (P18 D-03, P19 D-21): testcontainers Postgres, the lighthouse memory driver, fakes for external services (a LiveKit Twirp fake and a push-service fake), neutral `acme` fixture contexts, and `sm-user-plugin`, plus `sm-chat-plugin` where the plugin requires it. Every PHP test method maps to a Go case or to an explicit "not applicable" note. Golden payloads and events are captured from Wavepath. The mapping is completed in the last plan. + +### HTTP surface +- **D-05:** **Every satellite ships a typed Go service API plus a generic headless JSON API under its own prefix,** following P19 D-06 to D-09: + - `/_forum/api/v1` mirrors `/_wave/api/v1/community/forums/{groupId}/...` (categories with create, rename, reorder and delete; threads, posts, edit and delete, read marks, unread count), re-rooted on context alias and id: `/_forum/api/v1/contexts/{type}/{id}/categories`. Access is decided by `ForumAccessResolver`. + - `/_chatvideo/api/v1` mirrors `/_wave/api/v1/community/groups/{id}/video` (start, join, leave, end, status) and `community/call-links/{code}`, addressed by conversation, or by context through chat's context registry. + - `/_userfriends/api/v1` mirrors `/_wave/api/v1/community/friends*` (list, requests, sent, request, accept, reject, cancel, unfriend) and the group routes (`community/groups*`: groups, members, invites, bans). + - `/_herald/api/v1/push/subscription` with POST, PATCH and DELETE, plus `GET /_herald/api/v1/push/vapid-public-key`, mirroring `/_wave/api/v1/push/*`. + - `/_chatbots/api/v1` covers the command catalog, filtered by `isAvailable`, and command dispatch, mirroring Wavepath's `CommunityCommandController`. The invoker is always the authenticated actor (PHP CMD-06). + - Auth is `jwt.auth`, mounted with the `lighthouse.Mount` surfaces pattern so the app picks the guard. PHP's named rate buckets carry over: `video-initiate` at 5 per minute per user, the friend-request bucket (`community-join`), and a general bucket per API. The actor id always comes from the token, never from the request body. + - One serializer set serves both the generic API and the future `/_wave` aliases, so payloads are identical. + - **Reversibility:** one-way. These are published APIs that apps will consume. +- **D-06:** **This reverses the PHP "herald ships no routes" rule** (herald README, D-78-04) for consistency with the other plugins. The herald README's reuse contract is rewritten around the generic API and the Go seams. +- **D-07:** **Dropped, as in P19 D-10:** + - the legacy plugin-owned routes `/api/video/*` and `/api/friends/*`, which no client calls; + - the WinterCMS components `VideoCall`, `FriendsList` and `FriendRequests` and their templates; + - chatvideo's bundled `livekit-client.umd.min.js`, `video-chat.js`, `video-vue.js` and CSS; + - the demo seeders (D-02). +- **D-08:** **Satellites couple through generic context events, never through each other's types.** + - userfriends fires typed festival events for group created, group deleted, member joined and member left. + - forum exposes a generic `DeleteContext(type, id)`, a soft-delete cascade over categories, threads and posts. It does not import userfriends. + - The app, or a small optional bridge in the app, wires group-deleted to `forum.DeleteContext`. This replaces PHP forum's `class_exists(FriendGroup)` hook. + - The other group hooks in `wavepath/Plugin.php` (spawning a chat or channel on group create, ending video on group delete) stay app wiring for the Wavepath port. + - userfriends registers its chat context adapter (the `FriendContext` provider) softly, only when chat is registered, through an optional-capability check rather than a hard requirement. This keeps PHP's "chat is the lower layer" rule. + +### Herald (Web Push) +- **D-09:** **Web Push uses `github.com/SherClockHolmes/webpush-go`,** which covers VAPID (RFC 8292) and aes128gcm (RFC 8291). + - The decision note required by the roadmap goes in `.planning/notes/` before any herald code lands. It records the version, maintenance state, transitive dependencies (it should already share `golang-jwt/jwt/v5`), and how its HTTP client is swapped for fetchguard's (D-10). + - The researcher verifies that the library's `Options.HTTPClient` accepts a `fetchguard.Client`-backed doer. If it doesn't, the note records the adapter. + - **Reversibility:** reversible. It sits behind the plugin's own sender. +- **D-10:** **Every push send goes through a `fetchguard` PublicOnly client** (HTTPS only, private and reserved IPs refused at dial time). + - There is no `EndpointGuard` seam. Dial-time checks close the subscribe-then-send DNS-rebind gap that PHP's IP pinning (`CURLOPT_RESOLVE`) and the host-bound `SsrfGuard` existed for. + - Subscribe time also validates the endpoint URL (HTTPS, public host). + - This is fail-closed by default, where PHP's `AllowAllEndpointGuard` was fail-open. +- **D-11:** **Sends run as a `conga` job, one per send.** + - `SendEvent` and `SendUnconditional` enqueue a River job inside the caller's transaction, so a rolled-back write pushes nothing. + - The job runs `PresenceGate.ShouldSend` (for `SendEvent` only), builds the payload with `HeraldProfile`, sends to every subscription the user has, and prunes 404 and 410 endpoints. + - River provides retries and backoff. PHP's rate-limited OBS-01 warning for other delivery failures carries over. + - The PHP defaults carry over: TTL 2419200 (4 weeks) and urgency `normal`, both overridable per send. +- **D-12:** **VAPID keys are config keys** `golem15.herald.vapid.{subject,public_key,private_key}` with the default subject `mailto:admin@example.com`. + - The PHP env names `VAPID_SUBJECT`, `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are mapped so an adopted `.env` works unchanged. + - `summer herald:vapid-generate` (the PHP `herald:vapid-generate`) prints a new keypair. The private key is never logged and never appears in a payload. + - Boot warns when the keys are missing. Keys are never generated automatically, because rotating them invalidates every subscription. + +### ChatVideo (LiveKit) +- **D-13:** **chatvideo uses the official `github.com/livekit/server-sdk-go`** for access tokens and RoomService (create, delete, remove participant, participant count, list rooms). + - A decision note in `.planning/notes/`, written before the code lands, records the version, the transitive dependency footprint (`livekit/protocol`, twirp, protobuf; flag anything heavy such as pion), and how tests fake it. + - The token claims must match PHP exactly, verified by a golden test: HS256, `iss` set to the API key, `sub` set to the user id as a string, `name`, `iat`/`nbf`/`exp` with `token_ttl` defaulting to 900, a `video` grant with `room`, `roomJoin`, `canPublish` and `canSubscribe`, and an optional JSON `metadata` string carrying the avatar. + - The admin URL keeps PHP's fallback: `livekit_admin_url`, else `livekit_url` with `wss://` or `ws://` rewritten to `https://` or `http://`. An error for a missing room is swallowed as in PHP (`participantCount` returns 0, `deleteRoom` is a no-op). + - Config keys and env names carry over: `livekit_url` and `LIVEKIT_URL`, the API key, the secret, `max_duration` (3600), `token_ttl` (900), `livekit_admin_url`, `max_participants` (8) and `screen_share`. + - **Reversibility:** reversible. It sits behind the plugin's LiveKit service. +- **D-14:** **The stale-session reconciler moves from the Wavepath app into chatvideo.** + - A generic reconcile service ends sessions whose LiveKit room is gone or empty, or that have run past `max_duration`. It is exposed as `summer chatvideo:reconcile-sessions` and as a `conga` periodic job (every 5 minutes, as Wavepath schedules it). + - The Wavepath port's `wave:reconcile-video-sessions` becomes a thin alias. The researcher reads `wavepath/console/ReconcileVideoSessions.php` and keeps only its generic part. +- **D-15:** **Realtime events are the PHP events, published through lighthouse.** + - `video:call:incoming` goes to recipients' `user:{id}` channels. + - `video:call:accepted`, `declined`, `ended`, `video:participant:joined` and `left` go to `video:session:{id}`. + - chatvideo registers the `video` namespace authorizer. It allows `video:session:{id}` only to participants of the session's conversation and grants capability `["prs"]`. It replaces `VideoChannelAuthorizer`. + - A publish failure is logged and not raised, as in PHP. + - The one-active-session-per-conversation unique index and the `card_message_id` and `join_code` columns carry over. + +### Forum (Markdown) +- **D-16:** **Sanitizing uses `github.com/microcosm-cc/bluemonday`,** recorded in a decision note. + - The policy is the PHP HTMLPurifier config, 1:1: + - Allowed tags: `p, br, strong, em, b, i, h1-h6, ul, ol, li, blockquote, pre, code, a[href|rel|target], table, thead, tbody, tr, th, td, hr, del, ins, sub, sup`. + - URL schemes: `http`, `https` and `mailto`. + - Links get `target=_blank` and `rel=nofollow`. + - Conversion uses `goldmark`, already a dependency, with GFM. That covers tables, strikethrough and autolinks (the external-link behavior), with raw HTML input stripped (PHP `html_input=strip`) and unsafe links disallowed. +- **D-17:** **The renderer is a framework module, not forum code.** + - It is a small shared package in `summercms.go` (or an extension of cabana's markdown support; the quick task decides) that exports `Render(markdown)` and `Purify(html)` with a named policy. + - The user's direction: "Framework module, done with gsd-quick by summer agent. We'll need that in multiple places." It is delivered as a separate `/msd-quick` task in `summercms.go` by the summer agent. It is not a plan of this phase. The forum plans consume it, and the Wavepath port's `purify()` callers (trainings) use it too. + - If the quick task hasn't landed when forum execution starts, the forum plan blocks on it. The forum plan does not grow its own renderer. + - The module's README and `docs/` page follow the Documentation rules in CLAUDE.md. +- **D-18:** **`content_html` parity is semantic, and stored rows are kept.** + - Adopted `content_html` rows are served as stored and never re-rendered on read. New and edited posts render in Go, on write, as in PHP. + - Golden tests compare a normalized DOM (the same tags, attributes and text) against PHP output for a corpus that covers the PHP `ForumMarkdownRenderTest` XSS cases (`