diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index cee97ef6..924d57c9 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1533,10 +1533,26 @@ Plans: 3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action. 4. The new code has unit tests, delivered in the phase's last plan. -**Plans:** 0 plans +**Plans:** 6 plans Plans: -- [ ] TBD (run /msd-plan-phase 20 to break down) +**Wave 1** +- [ ] 20-01-PLAN.md — shared plugin release: siblings fast-forwarded, chat and forum pre-save seams (D-18), new sm-moderation-plugin (D-17), go.mod fixes, eight tags after go-ahead + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 20-02-PLAN.md — demo backend: oauthserver bump (folded todo), eight plugins mounted by tag, demo context and channel adapter, bootstrap endpoints, realtime for members, slash commands, moderation wiring, EN/PL seed and reset, Centrifugo namespaces, local LiveKit + +**Wave 3** *(blocked on Wave 2 completion)* +- [ ] 20-03-PLAN.md — vue-chat-layer v0.1.0 (bm port, channels, threads, pins, command picker, link embeds) and /chat, floating panel, Chat tour card in vue-demo-app + +**Wave 4** *(blocked on Wave 3 completion)* +- [ ] 20-04-PLAN.md — vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer v0.1.0 (DM-only call button), their pages and tour cards, herald VAPID config without UI + +**Wave 5** *(blocked on Wave 4 completion)* +- [ ] 20-05-PLAN.md — rome deploy: the demo's own LiveKit, Vuurmuur rule (checkpoint), nginx, .env keys, Centrifugo, release, DEPLOY.md, check-deploy and check-live + +**Wave 6** *(blocked on Wave 5 completion)* +- [ ] 20-06-PLAN.md — unit tests (Go, layers, site), two-browser chat and DM call checks, phase gate and live verification ### Phase 30: Płytarium cutover diff --git a/.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-PLAN.md b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-PLAN.md new file mode 100644 index 00000000..1fc1add2 --- /dev/null +++ b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-PLAN.md @@ -0,0 +1,421 @@ +--- +phase: 20-demo-instance-with-every-shared-plugin +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - ../sm-chat-plugin/events/events.go + - ../sm-chat-plugin/contracts/rejection.go + - ../sm-chat-plugin/classes/service.go + - ../sm-chat-plugin/classes/writes.go + - ../sm-chat-plugin/api/errors.go + - ../sm-chat-plugin/saving_smoke_test.go + - ../sm-chat-plugin/README.md + - ../sm-forum-plugin/events/events.go + - ../sm-forum-plugin/contracts/rejection.go + - ../sm-forum-plugin/classes/forum_service.go + - ../sm-forum-plugin/classes/lifecycle.go + - ../sm-forum-plugin/api/errors.go + - ../sm-forum-plugin/saving_smoke_test.go + - ../sm-forum-plugin/README.md + - ../sm-chatvideo-plugin/go.mod + - ../sm-chatvideo-plugin/go.sum + - ../sm-chatvideo-plugin/README.md + - ../sm-herald-plugin/go.mod + - ../sm-herald-plugin/go.sum + - ../sm-herald-plugin/README.md + - ../sm-userfriends-plugin/go.mod + - ../sm-userfriends-plugin/go.sum + - ../sm-userfriends-plugin/README.md + - ../sm-moderation-plugin/.gitignore + - ../sm-moderation-plugin/go.mod + - ../sm-moderation-plugin/go.sum + - ../sm-moderation-plugin/go.work + - ../sm-moderation-plugin/plugin.go + - ../sm-moderation-plugin/admin.go + - ../sm-moderation-plugin/admin_permissions.go + - ../sm-moderation-plugin/logger.go + - ../sm-moderation-plugin/classes/filter.go + - ../sm-moderation-plugin/classes/filter_smoke_test.go + - ../sm-moderation-plugin/models/settings.go + - ../sm-moderation-plugin/models/registry.go + - ../sm-moderation-plugin/models/settings/fields.yaml + - ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go + - ../sm-moderation-plugin/updates/registry.go + - ../sm-moderation-plugin/lang/en/lang.yaml + - ../sm-moderation-plugin/lang/pl/lang.yaml + - ../sm-moderation-plugin/config/config.yaml + - ../sm-moderation-plugin/README.md +autonomous: false +requirements: [SC-1, D-17, D-18, D-19, D-20] +estimate: + tokens: 220000 + raw_tokens: 220000 + tasks: 6 + confidence: low +must_haves: + truths: + - "D-18 (chat): with a festival listener on events.MessageSaving that returns contracts.Rejection{Field: \"content\", Message: m}, POST /_chat/api/v1/conversations/{id}/messages, POST /_chat/api/v1/messages/{id}/replies and PUT /_chat/api/v1/messages/{id} answer 422 {\"error\":\"Validation failed\",\"errors\":{\"content\":[m]}}; no golem15_chat_messages row is inserted or changed, MessageCreated and MessageUpdated do not fire and nothing is published." + - "D-18 (forum): ForumService.CreateThread, Reply and EditPost fire events.PostSaving before breakwater.Render; a contracts.Rejection from a listener maps to the existing 422 envelope under errors.title or errors.body and nothing is persisted." + - "D-18 (parity): with no MessageSaving or PostSaving listener every existing route, payload, status, table, migration ID and config key of sm-chat-plugin and sm-forum-plugin is unchanged; their php_method_map_test.go and golden_test.go pass without edits; the seams name no application and ship as patch-level additions." + - "D-17: the new shared plugin golem15.moderation (module git.golem15.com/golem15/sm-moderation-plugin) publishes *classes.Filter at boot; Filter.Check returns a Violation for a banned word or phrase (case-insensitive, Unicode letter boundaries, Polish letters included) or a banned link (a listed domain, its subdomains, with or without scheme or www, and URL-prefix entries), and nil for clean text; the lists are the banned_words and banned_links textareas of its admin settings screen (singleton golem15_moderation_settings) behind the permission golem15.moderation.manage_settings; the plugin imports neither chat nor forum." + - "SC-1: sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin go.mod files keep only the framework replace and require sm-chat-plugin v0.1.3, sm-user-plugin v0.1.13 and summercms v0.5.1 where they depend on them; every one of the eight repos passes go vet and go test -short, and the five chat dependants build with GOWORK=off." + - "SC-1 (release, after the Task 5 go-ahead): origin of each repo carries its new tag on the pushed HEAD that passed the checks: sm-chat-plugin v0.1.4 (patch, the seam), sm-chatchannels-plugin, sm-chatbots-plugin, sm-chatvideo-plugin, sm-forum-plugin, sm-herald-plugin, sm-userfriends-plugin and sm-moderation-plugin v0.1.0 (or the next free number read from the remote); a scratch module requiring all eight tags and summercms v0.5.1 builds with GOWORK=off." + - "D-20 (D-19 superseded): this is plan 01 of the six plans confirmed at plan time; the chat family release precedes the demo backend (plan 02)." + artifacts: + - path: "../sm-chat-plugin/events/events.go" + provides: "MessageSaving pre-save event and MessageSavingEventName" + contains: "MessageSaving" + - path: "../sm-chat-plugin/contracts/rejection.go" + provides: "Rejection{Field, Message} error a pre-save listener returns" + contains: "type Rejection struct" + - path: "../sm-forum-plugin/events/events.go" + provides: "PostSaving pre-save event and PostSavingEventName" + contains: "PostSaving" + - path: "../sm-forum-plugin/contracts/rejection.go" + provides: "Rejection{Field, Message} for forum listeners" + contains: "type Rejection struct" + - path: "../sm-moderation-plugin/classes/filter.go" + provides: "Filter, NewFilter, Check, Match, ParseList, Violation" + contains: "func (f *Filter) Check" + - path: "../sm-moderation-plugin/admin.go" + provides: "moderation_settings settings item (HasSettings, HasLang)" + contains: "moderation_settings" + key_links: + - from: "../sm-chat-plugin/classes/service.go" + to: "../sm-chat-plugin/events/events.go" + via: "SendMessageWith fires MessageSaving on s.bus before lagoon.Transaction" + pattern: "MessageSaving\\{" + - from: "../sm-chat-plugin/api/errors.go" + to: "../sm-chat-plugin/contracts/rejection.go" + via: "writeChatError maps errors.As(err, &contracts.Rejection) to writeValidation" + pattern: "errors\\.As\\(err, &rej\\)" + - from: "../sm-forum-plugin/classes/forum_service.go" + to: "../sm-forum-plugin/events/events.go" + via: "CreateThread and Reply fire PostSaving on s.bus before breakwater.Render" + pattern: "PostSaving\\{" + - from: "../sm-moderation-plugin/plugin.go" + to: "../sm-moderation-plugin/classes/filter.go" + via: "Boot publishes classes.NewFilter(db) through app.Publish inside lagoon.OnDatabase" + pattern: "app\\.Publish" + prohibitions: + - statement: "No existing route, request or response field, status code, table, migration ID or config key of sm-chat-plugin or sm-forum-plugin changes; the seams are additive (WavePath parity, Phase 31)." + status: unresolved + verification: automated + - statement: "sm-moderation-plugin depends on neither sm-chat-plugin nor sm-forum-plugin." + status: unresolved + verification: automated + - statement: "No README, comment or identifier in the eight repos names a consuming application." + status: unresolved + verification: judgment + - statement: "Nothing is pushed, no remote repository is created and no tag is set before the Task 5 go-ahead." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented here, see the planning return): `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. Phase 19.4.1 deployed the demo early; this phase adds the chat family to that live instance. + +This plan's slice (plan 1 of 6, wave 1, per D-20): the shared plugin release the demo needs before it can require the chat family by tag. Fast-forward the stale sibling checkouts, add the generic pre-save seams to chat and forum (D-18), create `sm-moderation-plugin` (D-17), fix the go.mod files that break module mode, and, after the user's go-ahead, push and tag all eight repositories. + +## Decisions taken in this plan (Claude's discretion, recorded) + +- **Seam shape (D-18 discretion):** a festival "-ing" event fired before the write transaction, mirroring `user.Deleting` / `user.Deactivating` in sm-user-plugin. chat gets `events.MessageSaving`, forum a new `events` package with `events.PostSaving`. A listener refuses by returning the plugin's own `contracts.Rejection{Field, Message}`. Each plugin declares its own type, so forum does not import chat and neither imports moderation. The API maps `errors.As(err, &rej)` to the existing 422 envelope. Rationale: `festival.Bus.Fire` already returns the joined listener errors, chat already holds `s.bus`, forum holds an unused `Bus` field, and no service gains a new registration method. RESEARCH Pattern 2. +- **Event names:** `MessageSavingEventName = "golem15.chat.message.saving"` and `PostSavingEventName = "golem15.forum.post.saving"`. They are Go-only seams with no PHP counterpart; the doc comments say so. +- **Op values:** chat `OpCreate = "create"` and `OpUpdate = "update"`; forum `OpThread = "thread"`, `OpReply = "reply"` and `OpUpdate = "update"`. +- **Moderation lists:** two textareas, one entry per line; an `enabled` switch defaults to true; at most 500 entries of at most 200 characters per list (validated in `BeforeValidate`). The filter reads the singleton row on every `Check` and recompiles its matchers only when the stored text changed. RE2's `\b` is ASCII-only, so word boundaries are built from `[^\p{L}\p{N}_]` to cover Polish letters. +- **Violation message:** `Violation{Kind, Match, Message}`. Message comes from the plugin's lang files (`golem15.moderation::lang.violation.word` and `.link`, EN and PL) in the locale `towel.Locale(ctx)` carries, resolved through the framework's phrasebook. Without a locale it is English. The chat-family APIs answer in English today, so English is an acceptable fallback. +- **Dependants require existing tags:** chatvideo, herald and userfriends require `sm-chat-plugin v0.1.3` (the newest tag before this plan), `sm-user-plugin v0.1.13` and `summercms v0.5.1`, the set RESEARCH compiled. That keeps their module-mode check independent of the new chat tag; minimal version selection gives the demo chat v0.1.4. +- **Repository creation:** with the go-ahead, Claude creates `golem15/sm-moderation-plugin` on git.golem15.com with `tea` when `tea login list` shows a git.golem15.com login, using the same visibility as `sm-sitemap-plugin`. Otherwise the user creates it. +- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). + +## Artifacts this phase produces (this plan) + +- sm-chat-plugin (v0.1.4): `events.MessageSaving`, `events.MessageSavingEventName`, `events.OpCreate`, `events.OpUpdate`, `contracts.Rejection` (`Error()`), the 422 mapping in `writeChatError`, smoke test `TestMessageSavingTracer` (`saving_smoke_test.go`), a README changelog entry v0.1.4. +- sm-forum-plugin (v0.1.0): package `events` (`PostSaving`, `PostSavingEventName`, `OpThread`, `OpReply`, `OpUpdate`), `contracts.Rejection`, the 422 mapping in `writeForumError`, smoke test `TestPostSavingTracer`. +- sm-moderation-plugin (new repo, v0.1.0): plugin `golem15.moderation`, package `moderation`, `classes.Filter`, `classes.NewFilter(db *gorm.DB) *Filter`, `(*Filter).Check(ctx context.Context, text string) (*Violation, error)`, `classes.Match(words, links []string, text string) *Violation`, `classes.ParseList(text string) []string`, `classes.Violation{Kind, Match, Message}`, `classes.KindWord`, `classes.KindLink`, table `golem15_moderation_settings` (migration `202610280001_create_golem15_moderation_settings`), settings item `moderation_settings`, permission `golem15.moderation.manage_settings`, config keys `golem15.moderation.max_entries` and `golem15.moderation.max_entry_length`, smoke test `TestFilterSmoke`. +- go.mod fixes in sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin. +- Tags: chat v0.1.4; chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation v0.1.0 (numbers read from the remote at release time). +- Full unit tests come in plan 06 (project rule). + + +Release the chat family so the demo can require it by tag: the D-18 pre-save seams in chat and forum, the D-17 moderation plugin, go.mod hygiene, and eight tags after the user's go-ahead. + +Purpose: SC-1 needs `sm-demo-app` to build from tagged modules (19.4.1 D-03, `TestGoModRequiresTags`, `scripts/check-pins.sh`). D-17 and D-18 need the seams and the filter before plan 02 wires them. D-20 makes this plan 01 of six and supersedes D-19's earlier split. +Output: commits and, after the go-ahead, pushes and tags in eight plugin repositories. No change in sm-demo-app, vue-demo-app or summercms.go code. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md +@.planning/notes/core-plugins-own-repos.md + +Repos (siblings of summercms.go): ../sm-chat-plugin, ../sm-chatchannels-plugin, ../sm-chatbots-plugin (all three behind origin: fast-forward first), ../sm-chatvideo-plugin, ../sm-forum-plugin, ../sm-herald-plugin, ../sm-userfriends-plugin, and the new ../sm-moderation-plugin. Read-only references: ../sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots} (the origin-main trees, for reading only), ../sm-sitemap-plugin (plugin and settings analog), ../sm-demo-app/plugins/golem15/user/classes/events.go (the "-ing" event analog). + +Rules: no co-author tags in commits (user instruction overrides any attribution default); stage files by explicit path, never `git add -A`; one logical change per commit; nothing pushed, created or tagged before Task 5. Verify commands run from the framework root and address siblings with `go -C ../`. Plugin repos with a tracked go.work resolve their siblings through it (the seam-carrying ../sm-chat-plugin included). If the Docker socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md). + + +chat (origin main 51c9e17 = v0.1.3, same tree as ../sm-bm-app/plugins/golem15/chat): +- `classes.Service` fields `bus *festival.Bus` (from `Options.Bus`); `SendMessageWith(ctx, in SendInput) (*models.Message, error)` validates metadata, loads the conversation, checks `canSend`, trims content, returns `ErrEmptyMessage` when content and attachments are empty, then opens `lagoon.Transaction` and fires `events.MessageCreated` inside it (classes/service.go:287-345). `SendMessage` and `ReplyTo` (classes/attachments.go:218) funnel into it. +- `EditMessage(ctx, actorID, messageID uint, content string) (*models.Message, error)` in classes/writes.go:248, after `CanEditMessage` (writes.go:202). +- events/events.go: `MessageEvent{MessageID, ConversationID, ActorID uint; RootID *uint; Tx *gorm.DB}`, `MessageCreated`, `MessageUpdated`, `MessageDeleted`, consts `MessageCreatedEventName` etc.; the package doc says every event fires inside the transaction. +- api/errors.go: `writeValidation(w, map[string][]string)`, `writeChatError(w, what, err)` (switch over errors.Is, default 500). +- Test helpers: harness_test.go `bootChat`, `(*chatEnv).user`, `.count`, `.memory`, `.conversation(t, createdBy, typ, alias, contextID, participants...)`; http_tracer_test.go `bootWeb`, `(*webEnv).frontendToken`, `.call(t, method, path, token, body)`, `decodeObject`. +forum (../sm-forum-plugin, origin main 2779341): +- `classes.ForumService{bus *festival.Bus}` built in boot.go `bootService` with `Bus: app.Events`; `CreateThread(ctx, actorID, contextType, contextID, categoryID, title, body)` renders at forum_service.go:281, `Reply(...)` at :330, `EditPost(ctx, actorID, contextType, contextID, postID, body)` in classes/lifecycle.go:217. No `Fire(` call exists in the plugin today. +- api/errors.go: `writeValidation`, `writeForumError` (ErrForbidden/ErrNotFound 404, ErrTitleRequired/ErrBodyRequired 422). +- Test helpers: harness_test.go `bootForum`, `.user`, `.count`; http_tracer_test.go `bootWeb`, `.frontendToken`, `.call`. +festival (framework): `(*Bus).Listen[T any](owner string, fn func(context.Context, T) error)`, `(*Bus).Fire[T any](ctx, event T) error` returns errors.Join of listener errors (callers use errors.As). +sitemap analog: plugin.go (embed config, `pact.HasConfig/HasMigrations/HasModels`), admin.go (`Settings() []pact.SettingsItem` with Code, Label, Description, Category, Icon, Permissions, Form, NewModel; `AdminFS`, `LangFS`), admin_permissions.go (`Permissions() []pact.Permission`), models/settings.go (singleton row id 1, `*string` columns, `BeforeValidate` with a lang-key error), models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go (CREATE TABLE plus INSERT id 1), go.mod replace `git.golem15.com/golem15/summercms => ../summercms.go`, tracked go.work. +go.mod today: chatvideo requires chat v0.0.0 and replaces chat and user with sibling paths; herald replaces user with ../fonoteka.go/plugins/golem15/user; userfriends requires chat pseudo-version v0.0.0-20261017200243-4d0e764ca443. + + + + + + + Task 1: Tracer: a rejecting MessageSaving listener turns a chat send into the existing 422 envelope and nothing is stored + D-18: the seam becomes a public plugin contract other consumers can start using; changing its shape later touches every listener. + `git -C ../sm--plugin status --porcelain` prints nothing for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends; `docker info` exits 0. + ../sm-chat-plugin/events/events.go, ../sm-chat-plugin/contracts/rejection.go, ../sm-chat-plugin/classes/service.go, ../sm-chat-plugin/classes/writes.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/saving_smoke_test.go, ../sm-chat-plugin/README.md + ../sm-chat-plugin/classes/service.go (SendMessageWith, Options.Bus), ../sm-chat-plugin/classes/writes.go (EditMessage, CanEditMessage), ../sm-chat-plugin/classes/attachments.go (ReplyTo), ../sm-chat-plugin/events/events.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/api/messages.go and api/replies.go, ../sm-chat-plugin/http_tracer_test.go and harness_test.go, ../sm-chat-plugin/php_method_map_test.go, ../sm-chat-plugin/README.md, ../sm-demo-app/plugins/golem15/user/classes/events.go (DeactivatingEvent doc style), modules/festival/bus.go, 20-RESEARCH.md (Pattern 2, Pitfall 1), 20-PATTERNS.md (Chat / forum pre-save seam) + Per D-18 and D-20; wire one path end to end: listener, service, API, HTTP response, database unchanged. + +1. Fast-forward every sibling first (RESEARCH Pitfall 1): for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends run `git -C ../ pull --ff-only` and confirm `git -C ../ rev-parse HEAD` equals the hash `git -C ../ ls-remote origin refs/heads/main` prints (use the default branch each repo tracks). Record both hashes per repo for the SUMMARY. A non-fast-forward stops the task. +2. events/events.go: keep the three existing events untouched. Add `MessageSavingEventName = "golem15.chat.message.saving"` on its own line and a `MessageSaving` struct with `ConversationID`, `ActorID` and `MessageID uint` (MessageID is 0 on create), `RootID *uint`, `Content string` and `Op string`, plus the constants `OpCreate = "create"` and `OpUpdate = "update"`. MessageSaving carries no Tx because it fires before the transaction. Rewrite the package doc: the three message events fire inside the transaction after the save; MessageSaving fires before it, has no PHP counterpart, and a listener error aborts the write; a listener refuses with contracts.Rejection. +3. contracts/rejection.go: `type Rejection struct { Field, Message string }` with a value-receiver `Error() string` returning "golem15.chat: rejected " + Field + ": " + Message. The doc comment explains that listeners return the value (not a pointer) and callers match it with errors.As, because Fire joins listener errors. +4. classes/service.go `SendMessageWith`: after the ErrEmptyMessage check and before `lagoon.Transaction`, when `s.bus != nil`, fire `&events.MessageSaving{Op: events.OpCreate, ConversationID, ActorID: senderID, RootID: rootID, Content: content}` (the trimmed content) and return `nil, err` on error. classes/writes.go `EditMessage`: after CanEditMessage allows the edit and the new content is validated, before its transaction, fire the same event with `Op: events.OpUpdate` and the MessageID. No other line of either method changes. +5. api/errors.go `writeChatError`: before the existing switch, declare `var rej contracts.Rejection` and, when `errors.As(err, &rej)`, call `writeValidation(w, map[string][]string{field: {rej.Message}})` with field = rej.Field, or "content" when Field is empty, then return. The other branches stay byte-identical. +6. saving_smoke_test.go `TestMessageSavingTracer` (Docker; skips under -short like the other HTTP tests): boot with bootWeb; register on the app's event bus a `*events.MessageSaving` listener returning `contracts.Rejection{Field: "content", Message: "not allowed"}` when Content contains the word "blocked"; create a group conversation with two participants through the harness; then + - POST `/_chat/api/v1/conversations/{id}/messages` with content "this is blocked": 422, body errors.content equals ["not allowed"], and golem15_chat_messages has 0 rows; + - POST the content "hello": 201, 1 row; + - PUT `/_chat/api/v1/messages/{id}` with "blocked edit": 422, and the stored content is still "hello"; + - POST `/_chat/api/v1/messages/{id}/replies` with "blocked reply": 422, and the row count is still 1. +7. README.md: in Features and API reference, add MessageSaving (when it fires, no Tx, Op values), MessageSavingEventName, OpCreate, OpUpdate and contracts.Rejection, with a neutral example host listener (`acme`). Add a changelog entry "v0.1.4: pre-save MessageSaving event and Rejection (additive; no listener means no change)". Name no consuming application. + +Commit in ../sm-chat-plugin by path (`feat: pre-save MessageSaving event and Rejection`), README in the same commit. Do not push or tag. + + go -C ../sm-chat-plugin vet ./... && go -C ../sm-chat-plugin test -count=1 -short ./... && go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v . 2>&1 | grep -q -- '^--- PASS: TestMessageSavingTracer' + Non-zero exit: vet or the short suite red (php_method_map or golden tests broken by the change), or no "--- PASS: TestMessageSavingTracer" line (Docker unavailable so it skipped, a 201 where 422 was expected, or a stored row after a refusal). + + + - `go -C ../sm-chat-plugin test -count=1 ./...` (with Docker) passes, including the unchanged php_method_map_test.go and golden_test.go. + - `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go testdata` prints nothing. + - `grep -c 'MessageSavingEventName = "golem15.chat.message.saving"' ../sm-chat-plugin/events/events.go` prints 1. + - `grep -c 'errors.As(err, &rej)' ../sm-chat-plugin/api/errors.go` prints 1. + - `grep -c 'v0.1.4' ../sm-chat-plugin/README.md` prints at least 1. + - The SUMMARY records the before and after HEAD of all seven siblings, and each after-HEAD equals origin's default branch. + + A host can refuse a chat message before it is stored, the client gets the existing 422 envelope, and without a listener chat behaves byte-identically. + + + + Task 2: The forum gets the same seam: PostSaving before render on thread, reply and edit + D-18: a new public event package and Rejection type in a WavePath port-target plugin; later consumers depend on the field names. + ../sm-forum-plugin/events/events.go, ../sm-forum-plugin/contracts/rejection.go, ../sm-forum-plugin/classes/forum_service.go, ../sm-forum-plugin/classes/lifecycle.go, ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/saving_smoke_test.go, ../sm-forum-plugin/README.md + ../sm-forum-plugin/classes/forum_service.go (Options, New, CreateThread, Reply), ../sm-forum-plugin/classes/lifecycle.go (EditPost), ../sm-forum-plugin/boot.go (bootService passes app.Events), ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/api/threads.go and api/posts.go, ../sm-forum-plugin/http_tracer_test.go, harness_test.go and fixture_plugin_test.go (test access resolver), ../sm-forum-plugin/php_method_map_test.go, ../sm-forum-plugin/README.md, ../sm-chat-plugin/events/events.go and contracts/rejection.go (Task 1, for the shape) + Per D-18. + +- New package events (events/events.go): + - `PostSavingEventName = "golem15.forum.post.saving"`; + - `PostSaving` with `ContextType string`, `ContextID`, `ActorID`, `ThreadID` and `PostID uint`, `Title`, `Body` and `Op string`; + - constants `OpThread = "thread"` (ThreadID 0, Title set), `OpReply = "reply"` and `OpUpdate = "update"`. + - The package doc says forum fires no post-save events, PostSaving is a Go-only seam fired before render and persistence, and a listener error aborts the write. +- contracts/rejection.go: the same shape as chat's, with "golem15.forum: rejected " in Error(). +- ForumService, all guarded by `s.bus != nil`, firing after the access and required-field checks and before `breakwater.Render`, returning the error unchanged: + - CreateThread fires PostSaving with OpThread, the trimmed title and the body; + - Reply fires it with OpReply, the ThreadID and the body; + - EditPost (lifecycle.go) fires it with OpUpdate, the PostID, its ThreadID and the body. +- api/errors.go `writeForumError`: before the switch, `errors.As(err, &rej)` writes 422 `{rej.Field: [rej.Message]}`, with field "body" when Field is empty. +- saving_smoke_test.go `TestPostSavingTracer` (Docker): boot through bootWeb with the fixture resolver that allows posting; register a PostSaving listener rejecting Title or Body containing "blocked" (Field "title" or "body"). Then: + - POST a thread whose title holds the word: 422 errors.title, no thread and no post row; + - a clean thread: 201; + - a reply whose body holds the word: 422 errors.body; + - PUT the first post with the word: 422, and content_html is unchanged. +- README: add Features "Pre-save seam" and the API reference rows (events.PostSaving, PostSavingEventName, Op constants, contracts.Rejection), and correct the Overview so it says forum fires only this pre-save event. Add a changelog line "v0.1.0: first tag; adds the pre-save PostSaving seam". Name no application. + +Commit in ../sm-forum-plugin by path (`feat: pre-save PostSaving event and Rejection`). Do not push or tag. + + go -C ../sm-forum-plugin vet ./... && go -C ../sm-forum-plugin test -count=1 -short ./... && go -C ../sm-forum-plugin test -count=1 -run '^TestPostSavingTracer$' -v . 2>&1 | grep -q -- '^--- PASS: TestPostSavingTracer' + Non-zero exit: vet or the short suite red, or no "--- PASS: TestPostSavingTracer" line (skipped without Docker, a refused write that still stored a row, or the wrong 422 field). + + + - `go -C ../sm-forum-plugin test -count=1 ./...` passes with Docker, php_method_map_test.go and golden_test.go unedited (`git -C ../sm-forum-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` prints nothing). + - `grep -rn 'sm-chat-plugin' ../sm-forum-plugin/go.mod ../sm-forum-plugin/events ../sm-forum-plugin/contracts` prints nothing. + - `grep -c 'PostSaving{' ../sm-forum-plugin/classes/forum_service.go` prints 2 and `grep -c 'PostSaving{' ../sm-forum-plugin/classes/lifecycle.go` prints 1. + + Forum threads, replies and edits can be refused before render and storage with the existing 422 envelope; without a listener nothing changes. + + + + Task 3: New shared plugin sm-moderation-plugin: banned words and links, editable in admin settings, published as a Filter + ../sm-moderation-plugin/.gitignore, ../sm-moderation-plugin/go.mod, ../sm-moderation-plugin/go.sum, ../sm-moderation-plugin/go.work, ../sm-moderation-plugin/plugin.go, ../sm-moderation-plugin/admin.go, ../sm-moderation-plugin/admin_permissions.go, ../sm-moderation-plugin/logger.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/filter_smoke_test.go, ../sm-moderation-plugin/models/settings.go, ../sm-moderation-plugin/models/registry.go, ../sm-moderation-plugin/models/settings/fields.yaml, ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go, ../sm-moderation-plugin/updates/registry.go, ../sm-moderation-plugin/lang/en/lang.yaml, ../sm-moderation-plugin/lang/pl/lang.yaml, ../sm-moderation-plugin/config/config.yaml, ../sm-moderation-plugin/README.md + ../sm-sitemap-plugin/plugin.go, admin.go, admin_permissions.go, logger.go, models/settings.go, models/registry.go, models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go, updates/registry.go, lang/en/lang.yaml, config/config.yaml, go.mod, go.work, README.md (the whole plugin layout), modules/phrasebook/README.md and modules/towel/README.md (locale lookup), `summer help make:plugin` (scaffold option), .planning/notes/core-plugins-own-repos.md, 20-PATTERNS.md (sm-moderation-plugin) + Per D-17. Create the repository at /media/nvme/dev/golem15/summercms.io/summercms/sm-moderation-plugin with `git init -b main`. Add no remote yet. Start from `summer make:plugin golem15.moderation` if it emits a standalone module layout, otherwise copy the sitemap layout. Module `git.golem15.com/golem15/sm-moderation-plugin`, package `moderation`, plugin ID `golem15.moderation`, `Requires()` nil, display name Moderation / Moderacja. Standard library plus gorm, gormigrate and framework modules only. + +- go.mod: go 1.27.0, require `git.golem15.com/golem15/summercms v0.5.1` plus gorm and gormigrate at the versions sitemap's go.mod uses, and `replace git.golem15.com/golem15/summercms => ../summercms.go`. Run `go mod tidy`. Track a go.work with `use .` and the same replace, as sitemap does. +- plugin.go: the interface assertions (party.Plugin, pact.HasConfig, pact.HasMigrations, pact.HasModels) and `//go:embed config`. Register keeps the app. Boot calls lagoon.OnDatabase and publishes `classes.NewFilter(db)` with `app.Publish`. The package doc says the plugin is a content filter other plugins' hosts wire into their pre-save seams, and that it imports no content plugin. +- config/config.yaml: `max_entries: 500`, `max_entry_length: 200`. +- models/settings.go: `Settings` singleton on `golem15_moderation_settings` (`ID uint`, `Enabled bool`, `BannedWords *string`, `BannedLinks *string`). `BeforeValidate` refuses: + - a list longer than max_entries (lang key `golem15.moderation::lang.errors.too_many`); + - an entry longer than max_entry_length (`errors.too_long`); + - a link entry that is neither a host name nor an http(s) URL (`errors.link`). + models/registry.go `All()`. +- models/settings/fields.yaml: `enabled` (switch), `banned_words` (textarea, comment "one word or phrase per line"), `banned_links` (textarea, comment "one domain or URL per line; subdomains match"), with the sitemap header comment block. +- updates/202610280001_create_golem15_moderation_settings.go: `CREATE TABLE IF NOT EXISTS golem15_moderation_settings (id INTEGER PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT TRUE, banned_words TEXT NULL, banned_links TEXT NULL)` and `INSERT INTO golem15_moderation_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`. Rollback drops the table. updates/registry.go `All()`. +- admin.go: `Settings()` returns item Code `moderation_settings` (lang keys for label, description and category, Icon "shield", Permissions [`golem15.moderation.manage_settings`], Form `models/settings/fields.yaml`, NewModel), plus AdminFS and LangFS, with the sitemap assertions (AdminAssets, HasSettings, HasLang). admin_permissions.go declares the permission on the role list sitemap uses. +- lang/en and lang/pl: plugin name, settings label, description and category, field labels and comments, `errors.*`, `violation.word` ("The text contains a word that is not allowed." / "Tekst zawiera niedozwolone słowo.") and `violation.link` ("The text contains a link that is not allowed." / "Tekst zawiera niedozwolony link."). +- classes/filter.go: + - `KindWord = "word"`, `KindLink = "link"`; `Violation{Kind, Match, Message string}`; + - `ParseList(text string) []string` splits lines, trims them, lowercases them and drops empty lines and duplicates; + - `Match(words, links []string, text string) *Violation` is pure and returns the first violation, words before links: + - a word or phrase matches case-insensitively between non-letter, non-digit boundaries (built from `[^\p{L}\p{N}_]`, start or end of text) with regexp.QuoteMeta; + - a link entry that is a host (example.com) matches any host in the text equal to it or ending in "." plus it: http(s) URLs, `www.` prefixes and bare domains (labels of letters, digits and hyphens with at least one dot); + - an entry with a path matches a URL whose host and path start with it. + - `NewFilter(db) *Filter`. `(*Filter).Check(ctx, text) (*Violation, error)` reads the singleton row, returns nil when Enabled is false, recompiles the matchers only when the stored text differs from the cached text (guard the cache with a sync.Mutex), and fills Message from the lang phrase for `towel.Locale(ctx)` through the framework phrasebook, falling back to the English text. +- classes/filter_smoke_test.go `TestFilterSmoke` (pure, no Docker): "Spam here" against the word spam gives a word violation; "spamming" gives none; "Kup TANIO teraz" against the phrase "kup tanio" gives a word violation; "visit https://www.bad.example/x" and "bad.example" against the link bad.example give link violations; "good.example" gives none. +- README.md in the standard plugin structure: + - H1, one-sentence summary, import line, a `**Name:**` beach mnemonic, Overview, Features, Usage, API reference, Configuration (the two keys), Dependencies, Testing; + - Usage shows a neutral host (`acme`) wiring Check into another plugin's pre-save event without importing it here. + +Commit in ../sm-moderation-plugin (`feat: golem15.moderation banned word and link filter`). Do not create a remote, push or tag. + + go -C ../sm-moderation-plugin vet ./... && go -C ../sm-moderation-plugin test -count=1 -short ./... && go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterSmoke$' -v ./classes 2>&1 | grep -q -- '^--- PASS: TestFilterSmoke' + Non-zero exit: the module does not build or vet, the short suite fails, or no "--- PASS: TestFilterSmoke" line (a boundary or host rule wrong). + + + - `go -C ../sm-moderation-plugin list -deps ./... | grep -cE 'sm-(chat|forum)-plugin'` prints 0. + - `grep -c 'golem15_moderation_settings' ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go` prints at least 2. + - `grep -c 'golem15.moderation.manage_settings' ../sm-moderation-plugin/admin_permissions.go` prints at least 1. + - `grep -c '^\*\*Name:\*\*' ../sm-moderation-plugin/README.md` prints 1. + - `git -C ../sm-moderation-plugin remote` prints nothing. + + A shared, chat-agnostic moderation plugin exists locally: admins edit banned words and links in settings, and any host can call Filter.Check from a pre-save seam. + + + + Task 4: Module-mode hygiene: chatvideo, herald and userfriends require tags, and the whole family builds and tests + ../sm-chatvideo-plugin/go.mod, ../sm-chatvideo-plugin/go.sum, ../sm-chatvideo-plugin/README.md, ../sm-herald-plugin/go.mod, ../sm-herald-plugin/go.sum, ../sm-herald-plugin/README.md, ../sm-userfriends-plugin/go.mod, ../sm-userfriends-plugin/go.sum, ../sm-userfriends-plugin/README.md + ../sm-chatvideo-plugin/go.mod and go.work, ../sm-herald-plugin/go.mod and go.work, ../sm-userfriends-plugin/go.mod and go.work, ../sm-chatchannels-plugin/go.mod and ../sm-chatbots-plugin/go.mod (after the Task 1 fast-forward), ../sm-bm-app/plugins/golem15/chatbots/go.mod (the "drop sibling replaces" analog), each README's Dependencies section, 20-RESEARCH.md (Repo State and Tagging, Pitfall 2) + Per SC-1 and RESEARCH Pitfall 2. With `GOWORK=off GOPRIVATE=git.golem15.com/*`, edit each go.mod: +- sm-chatvideo-plugin: delete the sm-chat-plugin and sm-user-plugin replace lines. Require `git.golem15.com/golem15/sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`. Keep `replace git.golem15.com/golem15/summercms => ../summercms.go`. +- sm-herald-plugin: delete the sm-user-plugin replace. Require sm-user-plugin v0.1.13 and summercms v0.5.1. +- sm-userfriends-plugin: replace the chat pseudo-version with v0.1.3. Require sm-user-plugin v0.1.13 and summercms v0.5.1. +- sm-chatchannels-plugin and sm-chatbots-plugin (fast-forwarded in Task 1): confirm their go.mod holds no replace other than the framework one and only tag requires. If either does not, fix it the same way and add it to this task's commits. +Run `go mod tidy` in each changed repo (GOWORK=off). The tracked go.work files stay: they are development conveniences and module consumers ignore them. Update each changed README's Dependencies section with the required versions and add a changelog line "v0.1.0: first tag; requires tagged chat, user and SummerCMS modules" where the README keeps a changelog. + +Then verify the family: +- all eight repos (chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation) pass `go vet ./...` and `go test -count=1 -short ./...` through their go.work; +- chatchannels, chatbots, chatvideo, herald and userfriends pass `GOWORK=off go build ./...`, which resolves chat v0.1.3 and user v0.1.13 from git.golem15.com and proves no sibling path is needed. +Grep the changed files of all eight repos for consuming application names (the sm-*-app repository names next to summercms.go, and their vendor prefixes) and remove any hit in the files this plan changed. + +Commit per repo by path (`chore: require tagged chat, user and SummerCMS modules; drop sibling replaces`). Do not push or tag. + + for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... || exit 1; done && for r in sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-herald-plugin sm-userfriends-plugin; do GOWORK=off GOPRIVATE='git.golem15.com/*' go -C ../$r build ./... || exit 1; done + Non-zero exit: any repo fails vet or its short suite, or a GOWORK=off build fails (for example "unknown revision v0.0.0" or a sibling replace path that no longer resolves). + + + - `grep -cE '^replace .*=> \.\./(sm-|fonoteka)' ../sm-chatvideo-plugin/go.mod ../sm-herald-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file. + - `grep -c 'v0.0.0' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file. + - `grep -c 'sm-chat-plugin v0.1.3' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 1 for each file. + - `git -C ../sm- status --porcelain` prints nothing for all eight repos after the commits. + + Every chat-family module resolves its dependencies by tag, so a demo build without go.work can require them. + + + + Task 5: Go-ahead for the one-way release: create sm-moderation-plugin on git.golem15.com, push eight repositories and tag them + The release table Claude prints before asking, one row per repository: current remote tags (`git ls-remote --tags origin`), the next free tag, the commit it will sit on, the commits it adds since the last tag, and why (seam, first tag, go.mod fix, new repo). Also whether `tea login list` shows a git.golem15.com login, and the visibility of sm-sitemap-plugin. + + - The user chose one option and it is recorded in the SUMMARY. With "release", git.golem15.com/golem15/sm-moderation-plugin exists (`git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0) before Task 6 pushes. + + Create the sm-moderation-plugin repository, push the commits of this plan and set the eight tags now? + A pushed Go module tag is cached by module proxies and can never point anywhere else (one-way, as 19.4.1 D-03). Tags freeze exactly the commits Tasks 1-4 verified. chat v0.1.4 is a patch: the seam is additive and WavePath's parity tests stay green (D-18). Plan 02 cannot mount the chat family by tag until this runs. Creating a repository on git.golem15.com and pushing need your go-ahead (CLAUDE.md). + + + + + For "release": if Claude reported no tea login for git.golem15.com, create the repository golem15/sm-moderation-plugin (empty, same visibility as sm-sitemap-plugin, default branch main) in the git.golem15.com web UI first, then reply. Do not paste tokens into the chat. + Reply "release" (add "repo ready" if you created the repository yourself) or "hold". + + + + Task 6: Release: push, tag and prove the eight tags resolve in module mode + Pushed module tags are cached by proxies and every consumer's go.sum; they can only be superseded, never moved. + Task 5 resolved with "release"; `git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0 or `tea login list` lists git.golem15.com. + ../sm-moderation-plugin/README.md + The Task 5 release table, each repository's README changelog, .planning/notes/core-plugins-own-repos.md + Per SC-1 and D-17/D-18. +1. If the repository does not exist yet, create it with `tea repo create --name sm-moderation-plugin --owner golem15` at sm-sitemap-plugin's visibility. Then `git -C ../sm-moderation-plugin remote add origin git@git.golem15.com:golem15/sm-moderation-plugin.git` and push main with upstream. +2. Push the default branch of chat, forum, chatvideo, herald and userfriends (and chatchannels or chatbots only if Task 4 committed there). No force-push. +3. Read the next free tag from `git ls-remote --tags origin` per repository: chat the next v0.1.x patch (v0.1.4 expected), the others v0.1.0 unless taken. Create annotated tags (`git tag -a -m ""`) on the HEADs Tasks 1-4 verified, chat first. Push each tag. Confirm `git ls-remote origin refs/tags/^{}` equals the local HEAD. +4. If the moderation README says no tag exists yet, fix it in a commit before tagging that repository, never after. +5. Module-mode proof: in a fresh `mktemp -d` directory, `go mod init example.invalid/familycheck`, require all eight modules at their new tags plus `git.golem15.com/golem15/summercms v0.5.1`, write a main.go that blank-imports the eight plugin packages, then run `GOWORK=off GOPRIVATE=git.golem15.com/* GOFLAGS=-mod=mod go build ./...`. Delete the directory afterwards. The SUMMARY records every tag with its commit hash for plan 02. + + for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do t=$(git -C ../$r describe --tags --exact-match HEAD) && [ "$(git -C ../$r ls-remote origin "refs/tags/$t^{}" | cut -f1)" = "$(git -C ../$r rev-parse HEAD)" ] || exit 1; done + Non-zero exit: a repository HEAD carries no tag (describe fails), or the remote tag is missing or points at a different commit than the local HEAD. + + + - `git -C ../sm-chat-plugin describe --tags --exact-match HEAD` prints a v0.1.x tag at least v0.1.4. + - The scratch module build printed no error, and the SUMMARY lists the eight tags with commit hashes. + - `git -C ../sm- status --porcelain` prints nothing in all eight repositories. + + All eight chat-family modules are on git.golem15.com at verified tags and resolve together without go.work. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| chat/forum HTTP clients to the plugin APIs | message and post bodies are untrusted input; the seam runs on them before storage | +| host listeners to plugin services | host code decides refusals; a listener error must not leak internals | +| admin settings to the filter | administrators edit the lists that every message is matched against | +| build machine to git.golem15.com and module proxies | tags become immutable inputs of every consumer | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-20-01 | Tampering | chat write paths that skip the seam | high | mitigate | MessageSaving fires in SendMessageWith (SendMessage, replies and ReplyTo funnel into it) and EditMessage; TestMessageSavingTracer covers send, reply and edit; plan 06 adds the full matrix | +| T-20-02 | Information disclosure | listener errors surfacing in API bodies | medium | mitigate | only contracts.Rejection maps to 422 with its own Field and Message; any other listener error keeps the existing opaque 500 path of writeChatError and writeForumError | +| T-20-03 | Denial of service | filter matching cost | medium | mitigate | RE2 linear-time regexps from QuoteMeta'd entries; at most 500 entries of at most 200 characters per list (BeforeValidate); matchers compiled once per list version | +| T-20-04 | Elevation of privilege | moderation settings screen | medium | mitigate | settings item behind the permission golem15.moderation.manage_settings, granted to the same role as sitemap settings | +| T-20-05 | Tampering | one-way module tags | medium | mitigate | tags only on HEADs that passed vet and tests in Tasks 1-4; next free number read from the remote; go-ahead at Task 5; ls-remote check and a GOWORK=off scratch build after pushing | +| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install: Go modules are the project's own tagged repositories plus gorm and gormigrate at versions already in sm-sitemap-plugin's go.sum (GOPRIVATE, go.sum committed) | + + + +- Task 1: chat seam tracer green, chat parity tests unchanged, seven siblings at origin HEAD. +- Task 2: forum seam tracer green, forum parity tests unchanged. +- Task 3: moderation plugin builds, filter smoke green, no dependency on chat or forum. +- Task 4: all eight repositories vet and test; five chat dependants build with GOWORK=off. +- Task 5: go-ahead recorded. +- Task 6: eight tags on origin equal to the verified HEADs; the scratch module builds them together. + + + +The chat family and the moderation plugin are released by tag (SC-1 prerequisite), with the D-18 seams additive and parity-safe and the D-17 filter shared and chat-agnostic. + + + +Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md` when done: per repository the before and after HEAD, the tag and its commit, the go.mod changes, how the moderation repository was created, and the checkpoint answer. + diff --git a/.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-PLAN.md b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-PLAN.md new file mode 100644 index 00000000..fe139761 --- /dev/null +++ b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-PLAN.md @@ -0,0 +1,400 @@ +--- +phase: 20-demo-instance-with-every-shared-plugin +plan: 02 +type: execute +wave: 2 +depends_on: ["20-01"] +files_modified: + - ../sm-demo-app/.gitmodules + - ../sm-demo-app/go.work + - ../sm-demo-app/go.mod + - ../sm-demo-app/go.sum + - ../sm-demo-app/summer.yaml + - ../sm-demo-app/plugins.gen.go + - ../sm-demo-app/plugins/golem15/oauthserver + - ../sm-demo-app/plugins/golem15/chat + - ../sm-demo-app/plugins/golem15/chatchannels + - ../sm-demo-app/plugins/golem15/chatbots + - ../sm-demo-app/plugins/golem15/chatvideo + - ../sm-demo-app/plugins/golem15/forum + - ../sm-demo-app/plugins/golem15/herald + - ../sm-demo-app/plugins/golem15/userfriends + - ../sm-demo-app/plugins/golem15/moderation + - ../sm-demo-app/config/golem15.chatvideo.yaml + - ../sm-demo-app/plugins/demo/plugin.go + - ../sm-demo-app/plugins/demo/config/config.yaml + - ../sm-demo-app/plugins/demo/chat.go + - ../sm-demo-app/plugins/demo/chat_api.go + - ../sm-demo-app/plugins/demo/chatbots.go + - ../sm-demo-app/plugins/demo/moderation.go + - ../sm-demo-app/plugins/demo/realtime_access.go + - ../sm-demo-app/plugins/demo/routes.go + - ../sm-demo-app/plugins/demo/classes/chat_context.go + - ../sm-demo-app/plugins/demo/classes/forum_access.go + - ../sm-demo-app/plugins/demo/console/seed.go + - ../sm-demo-app/plugins/demo/console/seed_chat.go + - ../sm-demo-app/plugins/demo/console/seed_forum.go + - ../sm-demo-app/plugins/demo/console/seed_friends.go + - ../sm-demo-app/plugins/demo/console/reset.go + - ../sm-demo-app/chat_smoke_test.go + - ../sm-demo-app/boot_test.go + - ../sm-demo-app/realtime_test.go + - ../sm-demo-app/deploy/centrifugo.json.example + - ../sm-demo-app/deploy/livekit.yaml.example + - ../sm-demo-app/deploy/env.example + - ../sm-demo-app/scripts/dev.sh + - ../sm-demo-app/README.md + - .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md + - .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md +autonomous: true +requirements: [SC-1, D-05, D-06, D-07, D-08, D-09, D-10, D-12, D-14, D-17, D-20, D-22, D-23] +estimate: + tokens: 320000 + raw_tokens: 320000 + tasks: 4 + confidence: low +must_haves: + truths: + - "SC-1: sm-demo-app requires chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation at the plan 01 tags, mounts them as submodules at plugins/golem15/ checked out on those tags, lists them in summer.yaml before golem15.demo, and passes TestGoModRequiresTags, TestBootAllPlugins and scripts/check-pins.sh; scripts/build.sh dev builds." + - "Folded todo: plugins/golem15/oauthserver sits on tag v0.1.3 (which contains b6a9586), go.mod requires sm-oauthserver-plugin v0.1.3, the change is one commit `chore: bump sm-oauthserver-plugin to v0.1.3`, and the todo file moves to .planning/todos/done with g15office task ssuyq2os set to Live." + - "D-06: golem15.demo registers the chat context alias demo (id 1, broadcast chat:context:1) whose members are every activated, non-deleted, non-banned user, and the alias channel (chatchannels' ChannelAlias) that resolves a seeded channel's backing conversation to the same membership with broadcast chat:channel:{id}; both require live membership." + - "D-07: channels exist only through demo:seed; the chatchannels access resolver answers CanManageContext false, so POST /_chatchannels/api/v1/contexts/demo/1/channels is refused for every member; a member can join and leave seeded channels and post in them." + - "D-08: any member can open a DM with any other member through POST /_chat/api/v1/contexts/demo/1/dm; userfriends does not gate it." + - "D-09: demo:seed creates persona users (no usable password) with EN and PL channel history, threads, reactions and pins, forum categories, threads and posts in EN and PL, friendships and a friend group, the demo-moderators user group and default moderation lists; a second run reports exists; demo:reset wipes every chat-family table in foreign-key order and reseeds, keeping golem15_moderation_settings and the userfriends context singleton." + - "D-17: a banned word or link in a chat message (create, reply, edit) or a forum thread, reply or edit is refused with 422 through the demo's listeners on events.MessageSaving and events.PostSaving calling the published moderation Filter." + - "D-22: members of the user group named by golem15.demo.chat.moderator_group (default demo-moderators) can delete other members' messages in channel conversations (CanModerateMessage) and manage forum posts (forum CanManage); the bootstrap reports moderator true for them; banning stays in the user admin." + - "D-05: chatbots lists the demo's ephemeral slash commands in GET /_chatbots/api/v1/commands and POST /_chatbots/api/v1/dispatch answers them in ephemeral mode; there is no bot persona." + - "D-12, D-23: config/golem15.chatvideo.yaml sets max_duration 900, max_participants 4, token_ttl 900 and screen_share false; the server does not restrict calls by conversation type (calls are limited to DMs by the UI, plan 04)." + - "GET /_demo/api/v1/chat answers a member with {member, moderator, context, channels[], conversations[], limits} and GET /_demo/api/v1/chat/members with {members[]}; both need a JWT." + - "GET /api/realtime/token answers 200 for any member (guard demo.realtime-access) and 401 without a JWT; Centrifugo's committed config has the namespaces presentations, chat, user and video with the subscribe proxy; scripts/dev.sh up starts a local LiveKit on 127.0.0.1:7890 (TCP 7891, UDP 7892) with keys written to .env only (D-10 locally)." + - "D-14: the forum resolver lets every member read and post in context demo/1 and the seeded EN and PL categories are listed by GET /_forum/api/v1/contexts/demo/1/categories." + artifacts: + - path: "../sm-demo-app/plugins/demo/classes/chat_context.go" + provides: "DemoContext, ChannelContext adapter, ChatAccess resolver, membership query" + contains: "ChatContextType" + - path: "../sm-demo-app/plugins/demo/chat.go" + provides: "registerChat: registry aliases demo and channel, resolvers, limits" + contains: "ChannelAlias" + - path: "../sm-demo-app/plugins/demo/chat_api.go" + provides: "GET /_demo/api/v1/chat and /chat/members (host bootstrap contract)" + contains: "/chat/members" + - path: "../sm-demo-app/plugins/demo/moderation.go" + provides: "MessageSaving and PostSaving listeners calling the moderation Filter" + contains: "MessageSaving" + - path: "../sm-demo-app/plugins/demo/realtime_access.go" + provides: "demo.realtime-access middleware" + contains: "demo.realtime-access" + - path: "../sm-demo-app/plugins/demo/console/seed_chat.go" + provides: "persona users, channels, messages, threads, reactions, pins (EN/PL)" + contains: "persona" + - path: "../sm-demo-app/deploy/livekit.yaml.example" + provides: "LiveKit config template: port 7890, tcp 7891, udp mux 7892, room caps" + contains: "udp_port: 7892" + - path: "../sm-demo-app/chat_smoke_test.go" + provides: "TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke" + contains: "TestDemoChatTracer" + key_links: + - from: "../sm-demo-app/plugins/demo/chat.go" + to: "../sm-demo-app/plugins/golem15/chat/contracts/registry.go" + via: "reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, ...})" + pattern: "Alias: +chanmodels\\.ChannelAlias" + - from: "../sm-demo-app/plugins/demo/moderation.go" + to: "../sm-demo-app/plugins/golem15/moderation/classes/filter.go" + via: "app.Lookup[*modclasses.Filter]() then Check(ctx, content)" + pattern: "Check\\(ctx" + - from: "../sm-demo-app/plugins/demo/routes.go" + to: "../sm-demo-app/plugins/demo/realtime_access.go" + via: "lighthouse.Mount UserAuth surf.Use(\"jwt.auth\", \"demo.realtime-access\", bucketRealtime)" + pattern: "demo\\.realtime-access" + - from: "../sm-demo-app/plugins/demo/console/reset.go" + to: "../sm-demo-app/plugins/demo/console/seed_chat.go" + via: "resetTables lists the chat-family tables before users; runSeed reseeds them" + pattern: "golem15_chat_messages" + prohibitions: + - statement: "No file inside a plugin submodule is edited from sm-demo-app; shared plugin fixes go to the plugin repos (plan 01 pattern) and need a new tag." + status: unresolved + verification: judgment + - statement: "go.mod carries no replace directive and no pseudo-version for a golem15 module." + status: unresolved + verification: automated + - statement: "No LiveKit key, VAPID key or persona password appears in a committed file or in command output; .env holds them." + status: unresolved + verification: judgment + - statement: "Nothing is pushed in this plan (plan 05 pushes with the deploy go-ahead)." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented here): `sm-demo-app` runs every shared Golem15 plugin in one binary, and its public Nuxt site shows them working; this phase adds the chat family to the live demo from Phase 19.4.1. + +This plan's slice (plan 2 of 6, wave 2, per D-20): the demo backend. After it, a registered demo member can read and post in seeded channels, DM anyone, use slash commands, read and post in the forum and manage friends through the plugins' APIs. Moderation refuses banned content, and a local LiveKit can mint calls. Nothing in the Nuxt site changes yet (plans 03 and 04). + +## Decisions taken in this plan (Claude's discretion, recorded) + +- **Contexts (D-06):** alias `demo`, id 1, group name "General", broadcast `chat:context:1`. Alias `channel` (chatchannels' `models.ChannelAlias`, legacy class `models.ChannelMorphName`) loads the channel by id, with broadcast `chat:channel:{id}`. Neither broadcast is subscribable, because AuthorizeChat admits only `chat:conversation:{id}`. Both implement `contracts.LiveMembership` (true) and `contracts.MessageModerator` (moderator group, non-DM conversations only), as bm does. +- **Joining a channel (RESEARCH assumption A3):** the layer opens a channel with `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, whose GetGroupChat lazy-joins a context member. The tracer test proves it. If it does not join, the bootstrap adds the caller as participant of each joined channel with chat's `AddUserToGroupChat`, and the SUMMARY records which path holds. +- **Personas (discretion):** four, Ada and Ben (English) and Ola and Tomek (Polish). Emails are `@personas.demo.invalid` (RFC 2606, undeliverable). Each gets a random bcrypt hash that is never printed, so nobody can log in as a persona. Channels: `general` (#general), `announcements` (#announcements), `ogolny` (#ogólny, Polish). About 15 to 25 messages per language with two threads, reactions and one pin per channel. Persona Ada sends a welcome DM to the visitor account when golem15.demo.visitor.email is set. +- **Seeding writes:** raw SQL in the existing seed style (`insertID`, `exists`), or a ChannelService instance built with a seed-only resolver and no bus or realtime, for the channel rows. The demo's own resolver refuses CanManageContext (RESEARCH Anti-Patterns), so it cannot seed. Forum posts store content_html from `breakwater.Render`, the plugin's own renderer. +- **Moderator group (D-22):** user group code `demo-moderators` (config `golem15.demo.chat.moderator_group`), created by the seed with no members. demo:reset wipes memberships with the users, so moderators are reassigned after a reset. +- **Slash commands (D-05 discretion):** `/demo` explains what the demo shows and `/tour` lists the site sections with their paths. Both are ephemeral, English and Polish chosen from the actor's locale when known (else English). `/help` is chatbots' built-in. +- **Bootstrap contract:** bm's `/_bm/api/v1/chat` shape with `general` replaced by `channels[]`. Each channel is `{id, slug, name, conversation_id, joined, unread, mentions}`. vue-chat-layer documents this contract under a neutral name (plan 03). +- **Local LiveKit (D-10 locally):** ports 7890/7891/7892, the same as rome (RESEARCH Pitfall 7), so dev and production configs differ only in keys and external IP. `livekit_url` locally is `ws://127.0.0.1:7890`. +- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). + +## Artifacts this phase produces (this plan) + +- Submodules `plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends,moderation}`; summer.yaml entries; go.mod requires at the plan 01 tags; sm-oauthserver-plugin v0.1.3. +- golem15.demo: `classes.DemoContext`, `classes.ChannelContext`, `classes.ChatAccess`, `classes.ForumAccess`, `classes.ChatContextType = "demo"`, `classes.ChatContextID = 1`, `classes.ChatBroadcastChannel = "chat:context:1"`, `classes.ChannelBroadcastChannel(id uint) string`, `classes.MemberIDs`; `(*Plugin).registerChat`; `MiddlewareRealtimeAccess = "demo.realtime-access"` with `pact.HasMiddleware`; routes `GET /_demo/api/v1/chat` and `GET /_demo/api/v1/chat/members`; slash commands `/demo` and `/tour`; seam listeners for `chatevents.MessageSaving` and `forumevents.PostSaving`. +- Config keys: `golem15.demo.chat.moderator_group` (default `demo-moderators`), `golem15.demo.chat.max_embeds` (default 3); `config/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`, `max_duration: 900`, `max_participants: 4`, `token_ttl: 900`, `screen_share: false`). +- demo:seed steps: `chat personas`, `chat channels and history (en)`, `chat channels and history (pl)`, `forum categories and threads (en, pl)`, `friends and group`, `moderator group demo-moderators`, `moderation lists`; demo:reset table list extended. +- Deploy and dev: `deploy/livekit.yaml.example`, Centrifugo namespaces `chat`, `user`, `video`; `scripts/dev.sh` LiveKit start and stop (pid name `livekit`); env keys `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY` and `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET` (names only in env.example). +- Smoke tests: `TestDemoChatTracer`, `TestDemoModerationSmoke`, `TestSeedChatSmoke` (chat_smoke_test.go). Full unit tests come in plan 06. + + +Mount the released chat family in sm-demo-app and give it a demo host: community context, channel adapter, resolvers, bootstrap endpoints, realtime for every member, moderation wiring, slash commands, EN/PL showcase seed with reset, and a local LiveKit. + +Purpose: SC-1 (builds with every shared plugin; demo:seed fills each), D-05 to D-09, D-12, D-14, D-17, D-22, D-23 on the server side, and the folded oauthserver todo. Plans 03 and 04 build the site UI on top of these endpoints. +Output: commits in ../sm-demo-app (code) and one planning commit in summercms.go (the todo move). Nothing pushed. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md +@.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md +@.planning/G15OFFICE.md + +Repo: ../sm-demo-app (all code edits). Read-only: the mounted plugin submodules, ../sm-bm-app/plugins/jz/bm (host analog), ../sm-bm-app/deploy/centrifugo/bm-config.json (namespace options). + +Rules: no co-author tags; stage by explicit path; one `feat: mount golem15. ` commit per plugin (precedent 9451b31: submodule, .gitmodules, go.work, go.mod, go.sum, summer.yaml, plugins.gen.go, boot_test.go); planning files in their own commit in summercms.go. Other sessions (Phase 33) may have uncommitted edits in sm-demo-app or vue-demo-app: check `git status --porcelain` before each commit, never stage or revert files this plan did not change, and stop if a file this plan must change carries foreign edits. Verify commands run from the framework root with `go -C ../sm-demo-app` and scripts by path (they cd to their own root). Docker tests: if the socket is denied, run through `sudo -u golem`. + + +Host analog ../sm-bm-app/plugins/jz/bm: +- chat.go: `registerChat` (HasPlugin guard, `app.Lookup[*chatcontracts.Registry]()`, `reg.Add(p.ID(), chatcontracts.Registration{Alias, Loader})`, `lagoon.OnDatabase` then `app.Lookup[*chanclasses.ChannelService]()` and `SetAccessResolver`; `installChatLimits` via `svc.SetMaxMessageBytes`). +- classes/chat_context.go: assertions `chatcontracts.Context`, `MessageModerator`, `LiveMembership`, `chancontracts.AccessResolver`; methods ID, Type, ChatEnabled, GroupName, BroadcastChannel, CanActFor (`chatcontracts.DefaultCanActFor`), CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members; `ChatContextLoader`; member query joins users_groups and user_groups. +- realtime_access.go: `MiddlewareRealtimeAccess`, `Middlewares() map[string]pact.Middleware`, `realtimeAccess`, `writeRealtimeGuard`. +- controllers/api/chat.go: bootstrap JSON structs (member, moderator, context, general, conversations, limits; members with id, name, surname, handle, is_bot). +chatchannels: `models.ChannelAlias = "channel"`, `models.ChannelMorphName`, `models.Channel{ContextType, Name, Slug, Visibility...}`; `chancontracts.AccessResolver{CanAccessContext, CanManageContext}`; `ChannelService.CreateChannel` gated by CanManageContext (channel_service.go:136); `JoinChannel` only upserts a pref row (:959). +chat: `Service.GetGroupChat(ctx, actorID, alias, contextID)`, `AddUserToGroupChat(ctx, userID, alias, contextID)`, `GetConversationsForUser`, `GetAvailableDmRecipients`, `SetMaxMessageBytes`, `MaxMessageBytes`; events `MessageSaving` and `contracts.Rejection` (plan 01). +forum: `forum.Plugin.SetAccessResolver`, `contracts.AccessResolver` (CanRead, CanPost, CanManage, CanManageContext), `ForumService.RegisterContext(alias, legacyPHPClass)`, events `PostSaving`, `contracts.Rejection` (plan 01). +chatbots: `contracts.SlashCommand{Trigger, Description, IsAvailable, Handle}`, `classes.Ephemeral(payload)`, `(*classes.Registry).Register` (help is reserved), Registry published in chatbots' OnDatabase hook. +moderation: `*classes.Filter` published at boot, `Check(ctx, text) (*Violation, error)` (plan 01). +chatvideo config keys (config/config.yaml of the plugin): livekit_url, livekit_api_key, livekit_api_secret, max_duration, token_ttl, livekit_admin_url, max_participants, screen_share; env `SUMMER_GOLEM15__CHATVIDEO__*` or PHP names `LIVEKIT_*`. +sm-demo-app today: plugins/demo/plugin.go (Requires, Boot calls wireRealtime), routes.go (`APIPrefix`, `mountRealtime` limited to presentations with `presentations.realtime-owner`), console/seed.go (`runSeed` step table, `created`, `insertID`, `exists`), console/reset.go (`resetTables`, children first), boot_test.go (`wantPlugins`, routed prefixes), realtime_test.go (`TestRealtimeRoutes`, `wantNamespaces`), testmain_test.go (`bootDemo`), go_mod_test.go, scripts/dev.sh (`ensure_env`, `start_centrifugo`, `claim_port`, `up`/`down`/`status`), deploy/centrifugo.json.example (namespaces array with `subscribe_proxy_enabled`). + + + + + + + Task 1: Tracer: a registered member reads the chat bootstrap and posts in the seeded #general channel through the chat API + The plan 01 SUMMARY lists the eight tags and `git ls-remote --tags git@git.golem15.com:golem15/sm-chat-plugin.git` shows the chat tag; `git -C ../sm-demo-app status --porcelain` shows nothing except ` M plugins/golem15/oauthserver`; `docker info` exits 0. + ../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chat, ../sm-demo-app/plugins/golem15/chatchannels, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/classes/chat_context.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go, .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md, .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md + ../sm-demo-app/summer.yaml, go.mod, go.work, .gitmodules, plugins.gen.go, `git -C ../sm-demo-app show 9451b31 --stat` (mount commit shape), ../sm-demo-app/plugins/demo/plugin.go, routes.go, console/seed.go, ../sm-demo-app/boot_test.go, testmain_test.go, internal/demotest/postgres.go, ../sm-bm-app/plugins/jz/bm/chat.go, classes/chat_context.go, controllers/api/chat.go, ../sm-demo-app/plugins/golem15/chat/contracts/*.go and classes/writes.go (GetGroupChat, AddUserToGroupChat) after mounting, ../sm-demo-app/plugins/golem15/chatchannels/models/channel.go, classes/channel_service.go (CreateChannel, JoinChannel), contracts/*.go, ../sm-demo-app/scripts/check-pins.sh, the folded todo, .planning/G15OFFICE.md + Per D-06, D-07, SC-1 and the folded todo. + +1. Folded todo first: + - `git -C ../sm-demo-app/plugins/golem15/oauthserver fetch --tags`, confirm `git merge-base --is-ancestor b6a9586 v0.1.3`, then check out v0.1.3; + - in ../sm-demo-app run `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` and `go mod tidy`; + - commit the gitlink with go.mod and go.sum as `chore: bump sm-oauthserver-plugin to v0.1.3`. + In summercms.go, `git mv` the todo to .planning/todos/done/, add a "Done in Phase 20 plan 02" line, and commit it as a planning commit. Move g15office task ssuyq2os to Live with a one-line comment, as .planning/G15OFFICE.md describes. +2. Mount chat and chatchannels at their plan 01 tags: + - `git submodule add -b main git@git.golem15.com:golem15/sm--plugin.git plugins/golem15/`, then check out the tag; + - add both to go.work `use`, and run `GOWORK=off go get @` plus `go mod tidy`; + - add summer.yaml entries after golem15.user (chat, then chatchannels) and before golem15.demo; + - regenerate plugins.gen.go the way 9451b31 did (the summer build generator); + - append the IDs to boot_test.go wantPlugins and `/_chat/api/v1` and `/_chatchannels/api/v1` to its routed prefixes. + One `feat: mount golem15. ` commit per plugin. +3. plugins/demo/classes/chat_context.go, ported from bm's chat_context.go: + - constants ChatContextType "demo", ChatContextID 1, ChatGroupName "General", ChatBroadcastChannel "chat:context:1", and `ChannelBroadcastChannel(id)` returning "chat:channel:{id}"; + - `MemberIDs(ctx, db)` and `IsMember(ctx, db, userID)`: users that are activated, have no deleted_at and are not banned (use the user plugin's own ban column; read its models); + - DemoContext and ChannelContext implement chatcontracts.Context, MessageModerator and LiveMembership. CanModerateMessage is true only for a member of the moderator group in a non-DM conversation. RequireLiveMembership returns true; + - ChatAccess (chancontracts.AccessResolver): CanAccessContext is true for a member when the type is "demo" and the id is 1; CanManageContext is always false (D-07); + - keep bm's `WithMembership` and `MemberFunc` injection so tests can stub membership. +4. plugins/demo/chat.go `registerChat(app)`, called from Boot after wireRealtime: + - with the HasPlugin guard, register alias demo (loader returns DemoContext for id 1 and the unknown-context error otherwise) and alias `chanmodels.ChannelAlias` with `LegacyPHPClass: chanmodels.ChannelMorphName` (loader reads the channel row by id and returns ChannelContext); + - in lagoon.OnDatabase, install ChatAccess on the ChannelService. + Requires() gains golem15.chat and golem15.chatchannels. Config keys go into plugins/demo/config/config.yaml in Task 2. +5. console/seed_chat.go: a step `chat channels and history (en)` that, in this task, creates only #general in context demo/1 (channel row plus backing conversation, exactly the rows CreateChannel writes, through a seed-only resolver or raw SQL) and one welcome message from the first admin or a persona. It is idempotent by slug. Add it to the runSeed step table. +6. plugins/demo/chat_api.go: `GET /_demo/api/v1/chat` behind `jwt.auth` and `throttle:120,1`, mounted from Routes. For a member it answers `{member: true, moderator, context: {type: "demo", id: 1}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [DMs as bm's chatConversationJSON], limits: {max_files, max_bytes, max_embeds}}`. For a non-member it answers `{member: false}`. Errors use `wire.WriteJSON` with `{"error": ...}`. +7. chat_smoke_test.go `TestDemoChatTracer` (Docker): bootDemo, run demo:seed, create and activate a user, then with its JWT: + - GET /_demo/api/v1/chat shows #general with a conversation_id; + - GET /_chat/api/v1/contexts/channel/{channelId}/conversation answers 200; + - POST /_chat/api/v1/conversations/{conversation_id}/messages answers 201; + - GET .../messages contains the text; + - POST /_chatchannels/api/v1/contexts/demo/1/channels is refused (403 or 404). + If the lazy join does not make the user a participant, implement the AddUserToGroupChat fallback in the bootstrap and record it. + +Commit the host wiring and the test as `feat: demo chat context, channel adapter and bootstrap`. + + go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer|TestGoModRequiresTags|TestBootAllPlugins)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && ../sm-demo-app/scripts/check-pins.sh + Non-zero exit: vet or the short suite red; fewer than three "--- PASS" lines (a replace or pseudo-version in go.mod, a plugin not activated, a missing route prefix, the member cannot post, or the channel create was allowed); or check-pins prints a mismatch line instead of "check-pins: ok". + + + - `git -C ../sm-demo-app log --oneline -6` shows `chore: bump sm-oauthserver-plugin to v0.1.3`, `feat: mount golem15.chat`, `feat: mount golem15.chatchannels` and the host commit, each separate. + - `git -C ../sm-demo-app submodule status plugins/golem15/oauthserver plugins/golem15/chat plugins/golem15/chatchannels` shows no leading `+` and each describes as its tag. + - `test -f .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md && ! test -e .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md` exits 0. + - `grep -c 'Alias: *chanmodels.ChannelAlias' ../sm-demo-app/plugins/demo/chat.go` prints 1. + + A demo member can open the seeded #general channel and post in it through the shared chat API, from a build that requires every module by tag. + + + + Task 2: The rest of the family is mounted and wired: forum, friends, slash commands, video config, herald, and moderation refusing banned content + ../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chatbots, ../sm-demo-app/plugins/golem15/chatvideo, ../sm-demo-app/plugins/golem15/forum, ../sm-demo-app/plugins/golem15/herald, ../sm-demo-app/plugins/golem15/userfriends, ../sm-demo-app/plugins/golem15/moderation, ../sm-demo-app/config/golem15.chatvideo.yaml, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/config/config.yaml, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/chatbots.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go + ../sm-demo-app/plugins/golem15/forum/README.md, boot.go, contracts/access_resolver.go, ../sm-demo-app/plugins/golem15/chatbots/README.md, contracts/slash_command.go, classes/registry.go, classes/result.go, boot.go, ../sm-demo-app/plugins/golem15/chatvideo/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/herald/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/userfriends/README.md, ../sm-demo-app/plugins/golem15/moderation/README.md and classes/filter.go, ../sm-demo-app/plugins/golem15/chat/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/golem15/forum/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/demo/chat.go (Task 1), ../sm-bm-app/plugins/jz/bm/chat.go (config key block) + Per D-05, D-08, D-12, D-14, D-17, D-22, D-23, SC-1. + +1. Mount chatbots, chatvideo, forum, herald, userfriends and moderation at their plan 01 tags as in Task 1, one `feat: mount golem15. ` commit each. Order in summer.yaml: chatbots and chatvideo after chatchannels; forum, herald and userfriends after them (userfriends after chat, so it sees the chat registry at Boot); moderation; all before golem15.demo. Extend boot_test.go wantPlugins and its prefixes: /_chatbots/api/v1, /_chatvideo/api/v1, /_forum/api/v1, /_herald/api/v1, /_userfriends/api/v1 and /_demo/api/v1/chat. +2. config/golem15.chatvideo.yaml (D-12, D-23): livekit_url "ws://127.0.0.1:7890", livekit_admin_url "http://127.0.0.1:7890", max_duration 900, max_participants 4, token_ttl 900, screen_share false. API key and secret come only from the environment. Herald gets no committed config: its keys come from .env (plan 04). +3. plugins/demo/config/config.yaml: `chat: {moderator_group: demo-moderators, max_embeds: 3}`. chat.go reads them as in bm's config key block and reports the limits in the bootstrap (max_bytes from the chat service's MaxMessageBytes). +4. Forum (D-14, D-22): classes/forum_access.go `ForumAccess`. CanRead and CanPost are true for a member when the context is demo/1; CanManage and CanManageContext are true for members of the moderator group. registerChat looks up `*forum.Plugin` and calls SetAccessResolver, and registers the context alias demo with the forum service when its API requires registration (check ListCategories on an unregistered alias). +5. DMs (D-08): nothing to add beyond DemoContext.HasMember. Verify in the smoke test that two members open a DM with POST /_chat/api/v1/contexts/demo/1/dm. +6. chatbots.go (D-05): `/demo` and `/tour` implement contracts.SlashCommand with IsAvailable true for any non-zero actor and Handle returning classes.Ephemeral with a short text (/tour lists /blog, /presentations, /quizzes, /subscriptions, /faq, /chat, /forum, /friends). Register them in lagoon.OnDatabase after chatbots published its Registry; a missing registry is a boot error naming the summer.yaml order. +7. moderation.go (D-17): in Boot, `app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)` looks up `*modclasses.Filter` per event and returns `chatcontracts.Rejection{Field: "content", Message: v.Message}` when Check finds a violation. `app.Events.Listen[*forumevents.PostSaving]` checks Title (Field "title"), then Body (Field "body"), returning `forumcontracts.Rejection`. A Check error is returned as is (500). A missing filter is a boot error, because golem15.demo requires golem15.moderation. +8. plugin.go Requires() lists every plugin whose tables the seed or reset writes or that the demo wires: chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation, plus the existing ones. The package doc mentions the chat host. +9. chat_smoke_test.go `TestDemoModerationSmoke` (Docker): seed, then set golem15_moderation_settings banned_words to "blockedword" and banned_links to "bad.example". A member's message "this has blockedword" in #general answers 422 errors.content; a forum thread titled "visit bad.example" in a seeded category answers 422 errors.title; a clean message answers 201. Also assert the DM open from step 5 and that GET /_chatbots/api/v1/commands lists demo and tour. + +Commit wiring (`feat: demo hosts forum, friends, slash commands and moderation`) separately from the six mount commits. + + go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoModerationSmoke|TestBootAllPlugins|TestGoModRequiresTags|TestDemoChatTracer)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && ../sm-demo-app/scripts/check-pins.sh && ../sm-demo-app/scripts/build.sh dev + Non-zero exit: vet or short suite red; fewer than four "--- PASS" lines (banned content stored, a plugin not activated, a prefix not routed, a go.mod rule broken); check-pins reports a mismatch; or the dev build fails. + + + - `grep -cE '^ - id: golem15\.(chat|chatchannels|chatbots|chatvideo|forum|herald|userfriends|moderation)$' ../sm-demo-app/summer.yaml` prints 8. + - `grep -c 'max_participants: 4' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1 and `grep -c 'max_duration: 900' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1. + - `grep -rn 'livekit_api_secret: *"[^"]' ../sm-demo-app/config` prints nothing (no committed secret). + - `git -C ../sm-demo-app status --porcelain -- plugins/golem15` prints nothing (submodules clean, on tags). + + Every chat-family plugin boots in the demo with its host policy; banned words and links are refused in chat and forum; slash commands answer; video is configured with the D-12 caps. + + + + Task 3: demo:seed fills chat, forum and friends with EN and PL showcase data, and demo:reset wipes and reseeds all of it + ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/plugins/demo/console/seed_forum.go, ../sm-demo-app/plugins/demo/console/seed_friends.go, ../sm-demo-app/plugins/demo/console/reset.go, ../sm-demo-app/chat_smoke_test.go + ../sm-demo-app/plugins/demo/console/seed.go (step table, created, insertID, exists, visitor), seed_locales.go, reset.go (resetTables order and comments), ../sm-demo-app/reset_test.go and seed_test.go, the baseline migrations of every mounted chat-family plugin (../sm-demo-app/plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends}/updates/*.go) for table names and foreign keys, ../sm-demo-app/plugins/golem15/chatchannels/models (pins, mentions, members, prefs), ../sm-demo-app/plugins/golem15/chat/models (message, reaction, thread summary), modules/breakwater/README.md, 20-RESEARCH.md (Pitfall 10, Anti-Patterns) + Per D-09, D-14, D-15 (data), D-22, D-17. + +- seed_chat.go: + - `chat personas`: Ada, Ben, Ola and Tomek, as decided above. Each is activated, has a random password hash that is never printed, and is skipped when the email exists. + - `chat channels and history (en)`: #general and #announcements, with English history from the personas, two threads (root plus 2 to 4 replies, keeping chat's thread summary rows consistent the way the service writes them), reactions on a few messages using the keys chat allows (GET /reactions catalog in the plugin), and one pin per channel in chatchannels' pins table. + - `chat channels and history (pl)`: #ogólny (slug ogolny) with Polish history, a thread, reactions and a pin. + - A welcome DM from Ada to the visitor when golem15.demo.visitor.email is set (skipped otherwise). + - Rows are written in created order with timestamps spread over the last few days. Messages are inserted directly: no realtime, no jobs, no events. Steps are idempotent by channel slug and persona email. +- seed_forum.go `forum categories and threads (en, pl)`: categories "General discussion" and "Show and tell" (EN) and "Rozmowy" (PL) in context demo/1, two or three threads each with replies by personas. content is Markdown and content_html comes from `breakwater.Render`. Idempotent by category slug. +- seed_friends.go `friends and group`: accepted friendships among the personas, one pending request from Ola to the visitor when set, and a friend group "Demo crew" owned by Ada with Ben and Tomek as members. Use userfriends' tables and the context singleton id 1. Idempotent. +- seed.go: steps `moderator group demo-moderators` (a user_groups row with the configured code and name "Demo moderators", no members) and `moderation lists` (when banned_words and banned_links are both empty, fill a short, mild default list of common spam words and one link-shortener domain; otherwise "exists"). Order the new steps after the visitor and before the roadmap decks. +- reset.go `resetTables` (RESEARCH Pitfall 10), children first, all before `users`: + - chatvideo participants, then sessions; + - chat reactions, thread mutes, thread summaries, messages, participants, conversations; + - chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels, categories; + - forum thread reads, posts, threads, categories; + - userfriends group bans, invites, group members, groups, friendships; + - herald push subscriptions; + - chatbots bot flags. + Use the exact table names from the migrations. Keep golem15_moderation_settings and golem15_userfriends_contexts. Update the comment and the command description. Remove the chat-family uploads (chat attachments in system_files) the same way the reset already removes other owners' attachments, keeping resetKeptAttachments. +- chat_smoke_test.go `TestSeedChatSmoke` (Docker): seed with a visitor email. + - Three channels exist, #ogólny holds Polish text, at least one thread root has replies, at least one reaction and one pin exist, the forum has three categories with posts whose content_html is non-empty, there are at least three accepted friendships and the group "Demo crew", and the visitor has a DM. + - A second seed run reports every new step as "exists". + - demo:reset --force empties golem15_chat_messages before reseeding and leaves golem15_moderation_settings unchanged. + +Commit `feat: seed and reset the chat family (EN/PL)`. + + go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke|TestResetKeepsPlans|TestSeedRoadmapUpsert)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 + Non-zero exit or fewer than three "--- PASS" lines: seed not idempotent, a missing EN/PL item, a foreign-key violation in the reset order, moderation settings wiped, or the existing reset and roadmap tests broken. + + + - `grep -c 'golem15_chat_messages' ../sm-demo-app/plugins/demo/console/reset.go` prints at least 1, and TestSeedChatSmoke asserts the moderation settings row is identical before and after demo:reset --force. + - `grep -c 'personas.demo.invalid' ../sm-demo-app/plugins/demo/console/seed_chat.go` prints at least 1. + - Running `../sm-demo-app/scripts/dev.sh seed` twice against the local stack prints no "created" for the new steps on the second run. + + A fresh or reset demo shows lived-in EN and PL channels, threads, reactions, pins, forum threads, friendships and a friend group, and the reset job keeps it that way. + + + + Task 4: Every member gets realtime, the layers get their members endpoint, and dev runs Centrifugo namespaces and a local LiveKit + ../sm-demo-app/plugins/demo/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/scripts/dev.sh, ../sm-demo-app/README.md + ../sm-bm-app/plugins/jz/bm/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go (mountRealtime), realtime.go, ../sm-demo-app/realtime_test.go (TestRealtimeRoutes, wantNamespaces, realtimeApp), ../sm-bm-app/deploy/centrifugo/bm-config.json (chat, user and video namespace options), ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/scripts/dev.sh (ensure_env, start_centrifugo, claim_port, up, down, status, the usage header), ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/plugins/golem15/chatvideo/README.md (keys, admin URL, TrustedMode), 20-RESEARCH.md (Pitfalls 4, 5, 7; LiveKit config block) + Per D-10 (locally), D-06, SC-1. + +1. realtime_access.go: `MiddlewareRealtimeAccess = "demo.realtime-access"`, exported through `Middlewares() map[string]pact.Middleware` (assert pact.HasMiddleware in plugin.go). The guard reads `bouncer.User(r.Context())`: no user gives 401, a database not yet published gives 503, and a user for whom classes.IsMember is true passes. Otherwise it falls back to the presentations owner guard looked up once (sync.Once) by name, as bm does. routes.go mountRealtime mounts when golem15.presentations or golem15.chat is active, with `UserAuth: surf.Use("jwt.auth", MiddlewareRealtimeAccess, bucketRealtime)`. Update its doc comment. +2. realtime_test.go: the token route's expected middleware becomes ["jwt.auth", "demo.realtime-access", "throttle:golem15.demo.realtime"], and wantNamespaces gains the namespaces the chat, chatvideo and user plugins register. Plan 06 adds the member/non-member cases. +3. chat_api.go `GET /_demo/api/v1/chat/members` (jwt.auth, throttle:120,1) answers `{members: [{id, name, surname, handle, is_bot}]}` for every member except the caller, as bm's ChatMembers does (handle from the user plugin's username or a slug of the name; is_bot false). A non-member gets 403. +4. deploy/centrifugo.json.example: add namespaces `chat` (presence, join_leave, force_push_join_leave, allow_presence_for_subscriber and subscribe_proxy_enabled, with the option names bm's config uses translated to this file's v6 format), `user` (subscribe_proxy_enabled) and `video` (subscribe_proxy_enabled). Run `centrifugo checkconfig -c deploy/centrifugo.json.example`, then diff the option names against `centrifugo defaultconfig`, because checkconfig misses misspelt names. Update the _comment lines. +5. deploy/livekit.yaml.example (RESEARCH LiveKit block): + - port 7890, bind_addresses [127.0.0.1]; + - rtc tcp_port 7891, udp_port 7892 (UDP mux only, no port-range keys), use_external_ip true and a comment that local runs set it false; + - room max_participants 4, empty_timeout 120, departure_timeout 20; + - keys with one placeholder pair (CHANGE_ME_KEY: CHANGE_ME_SECRET); + - a header comment naming rome's path /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo), the supervisor program plan 05 adds, and the env keys. +6. scripts/dev.sh: + - LIVEKIT=${LIVEKIT:-$(command -v livekit-server || true)}; + - ensure_env appends SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY and SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET (random, umask 077, never echoed) when missing; + - `livekit_config` writes logs/livekit.yaml from the example with the .env keys and use_external_ip false; + - `start_livekit` claims ports 7890 and 7891, starts `livekit-server --config logs/livekit.yaml` under the pid name livekit with output to logs/livekit.log, waits for http://127.0.0.1:7890/ and warns and continues without video when the binary is missing; + - up starts it after Centrifugo, down, destroy and status include livekit, and the header usage lines mention it. +7. deploy/env.example: the two LiveKit key names with empty values and a comment. README.md: a "Chat family" section covering the mounted plugins, the contexts, the bootstrap contract of /_demo/api/v1/chat and /chat/members, the seed data, the moderator group, slash commands and local LiveKit. + +Commit `feat: realtime for every demo member, members endpoint, Centrifugo namespaces and local LiveKit`. + + go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && centrifugo checkconfig -c ../sm-demo-app/deploy/centrifugo.json.example && ../sm-demo-app/scripts/dev.sh up && curl -fsS http://127.0.0.1:7890/ >/dev/null && ../sm-demo-app/scripts/check-local.sh realtime + Non-zero exit: vet or the short suite red (TestRealtimeRoutes middleware or namespaces), checkconfig refuses the Centrifugo file, dev.sh up fails to start a service, LiveKit does not answer on 127.0.0.1:7890, or check-local realtime reports a non-200 token for the visitor or a token without a JWT. + + + - `grep -c '"name": "chat"' ../sm-demo-app/deploy/centrifugo.json.example` prints 1, and the same for "user" and "video". + - `grep -v '^\s*#' ../sm-demo-app/deploy/livekit.yaml.example | grep -cE 'port_range_(start|end)'` prints 0, and `grep -c 'udp_port: 7892' ../sm-demo-app/deploy/livekit.yaml.example` prints 1. + - With the local stack up, `curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $VISITOR_JWT" http://127.0.0.1:8097/api/realtime/token` prints 200 (JWT from the login API). + - `grep -c 'LIVEKIT_API_SECRET=' ../sm-demo-app/deploy/env.example` prints 1 and that line has no value after the equals sign. + + Every demo member gets a realtime token and the member list, Centrifugo knows the chat namespaces, and a local LiveKit runs with the dev stack. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser to /_demo, /_chat, /_chatchannels, /_forum, /_userfriends, /_chatbots, /_chatvideo | every request untrusted; actor only from the JWT | +| Centrifugo subscribe proxy to Go | channel names come from clients; authorizers decide | +| seed and reset commands to the database | trusted operator commands; persona accounts must stay unusable | +| Go to LiveKit RoomService on loopback | admin calls with the API secret | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-20-06 | Elevation of privilege | channel creation by members | high | mitigate | ChatAccess.CanManageContext is always false; TestDemoChatTracer asserts POST channels is refused (D-07) | +| T-20-07 | Information disclosure | subscribing to other conversations | high | mitigate | broadcast channels chat:context:1 and chat:channel:{id} are not subscribable (AuthorizeChat admits only chat:conversation:{id}); user:{id} only to itself (user plugin authorizer); video:session:{id} only to session participants | +| T-20-08 | Spoofing | persona accounts | medium | mitigate | random password hashes never printed, emails under .invalid, so no login and no mail; reset wipes and recreates them | +| T-20-09 | Elevation of privilege | moderator rights | medium | mitigate | moderator group membership only through the user admin (D-22); CanModerateMessage limited to non-DM conversations; banned users fail HasMember with live membership on | +| T-20-10 | Tampering | spam and abuse in public chat and forum | high | mitigate | moderation listeners on both seams (TestDemoModerationSmoke), plugin buckets (chat-api, chat-dm, forum-api, video-initiate), Turnstile on register (19.4.1), reset job | +| T-20-11 | Denial of service | realtime token minting for every member | low | mitigate | guard needs a JWT of a live member, per-user bucket golem15.demo.realtime 120/min unchanged | +| T-20-12 | Information disclosure | LiveKit and VAPID secrets | high | mitigate | values only in .env (dev.sh writes them with umask 077, never echoed); env.example holds names; config files hold no secret (acceptance grep) | +| T-20-13 | Denial of service | stale chat-family rows after reset | low | mitigate | reset table list in FK order covering every chat-family table; TestSeedChatSmoke checks messages are emptied | +| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install in this plan; Go modules are the project's tagged plugins from plan 01 (go.sum committed, GOPRIVATE) | + + + +- Task 1: tracer test, go.mod tags, boot test, check-pins. +- Task 2: moderation smoke, boot and go.mod tests, check-pins, dev build. +- Task 3: seed smoke, reset and roadmap tests. +- Task 4: short suite, Centrifugo checkconfig, dev stack up with LiveKit, check-local realtime. + + + +sm-demo-app builds with every shared plugin including the chat family, and demo:seed fills each with EN/PL showcase data (SC-1). The demo hosts one community where every member can chat, DM, post in the forum and befriend others, with moderation, slash commands and video configured (D-05 to D-09, D-12, D-14, D-17, D-22, D-23). + + + +Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md` when done: mount commits with tags, which channel join path holds (lazy join or bootstrap), persona and channel names, the reset table order, the Centrifugo option names used, and any boot warnings left by plugins (for example herald's presence gate). + diff --git a/.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-PLAN.md b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-PLAN.md new file mode 100644 index 00000000..33811aa3 --- /dev/null +++ b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-PLAN.md @@ -0,0 +1,446 @@ +--- +phase: 20-demo-instance-with-every-shared-plugin +plan: 03 +type: execute +wave: 3 +depends_on: ["20-02"] +files_modified: + - ../vue-chat-layer/.gitignore + - ../vue-chat-layer/.nvmrc + - ../vue-chat-layer/package.json + - ../vue-chat-layer/pnpm-lock.yaml + - ../vue-chat-layer/pnpm-workspace.yaml + - ../vue-chat-layer/tsconfig.json + - ../vue-chat-layer/nuxt.config.ts + - ../vue-chat-layer/vitest.config.ts + - ../vue-chat-layer/README.md + - ../vue-chat-layer/.playground/nuxt.config.ts + - ../vue-chat-layer/.playground/app/app.vue + - ../vue-chat-layer/app/lib/client.ts + - ../vue-chat-layer/app/lib/types.ts + - ../vue-chat-layer/app/lib/realtime.ts + - ../vue-chat-layer/app/lib/chatEvents.ts + - ../vue-chat-layer/app/lib/chatFormat.ts + - ../vue-chat-layer/app/lib/chatText.ts + - ../vue-chat-layer/app/lib/chatUnread.ts + - ../vue-chat-layer/app/lib/linkEmbeds.ts + - ../vue-chat-layer/app/lib/format.ts + - ../vue-chat-layer/app/lib/media.ts + - ../vue-chat-layer/app/lib/tabs.ts + - ../vue-chat-layer/app/composables/useChatApi.ts + - ../vue-chat-layer/app/composables/useChat.ts + - ../vue-chat-layer/app/composables/useChatNotice.ts + - ../vue-chat-layer/app/components/Chat/Page.vue + - ../vue-chat-layer/app/components/Chat/ChannelList.vue + - ../vue-chat-layer/app/components/Chat/Panel.vue + - ../vue-chat-layer/app/components/Chat/MessageList.vue + - ../vue-chat-layer/app/components/Chat/Message.vue + - ../vue-chat-layer/app/components/Chat/Composer.vue + - ../vue-chat-layer/app/components/Chat/Header.vue + - ../vue-chat-layer/app/components/Chat/DmList.vue + - ../vue-chat-layer/app/components/Chat/MemberPicker.vue + - ../vue-chat-layer/app/components/Chat/MentionPopup.vue + - ../vue-chat-layer/app/components/Chat/ReactionBar.vue + - ../vue-chat-layer/app/components/Chat/LinkEmbed.vue + - ../vue-chat-layer/app/components/Chat/StagingChips.vue + - ../vue-chat-layer/app/components/Chat/DeleteDialog.vue + - ../vue-chat-layer/app/components/Chat/Avatar.vue + - ../vue-chat-layer/app/components/Chat/ThreadPane.vue + - ../vue-chat-layer/app/components/Chat/PinsPanel.vue + - ../vue-chat-layer/app/components/Chat/CommandPicker.vue + - ../vue-chat-layer/app/components/Chat/FloatingPanel.vue + - ../vue-chat-layer/app/components/Chat/Icon.vue + - ../vue-chat-layer/app/components/Chat/Notices.vue + - ../vue-chat-layer/app/assets/css/chat-tokens.css + - ../vue-chat-layer/app/assets/css/chat-base.css + - ../vue-chat-layer/i18n/locales/en.json + - ../vue-chat-layer/i18n/locales/pl.json + - ../vue-chat-layer/tests/smoke.test.ts + - ../sm-demo-app/vue-demo-app/package.json + - ../sm-demo-app/vue-demo-app/pnpm-lock.yaml + - ../sm-demo-app/vue-demo-app/nuxt.config.ts + - ../sm-demo-app/vue-demo-app/app/plugins/auth.ts + - ../sm-demo-app/vue-demo-app/app/layouts/default.vue + - ../sm-demo-app/vue-demo-app/app/pages/chat.vue + - ../sm-demo-app/vue-demo-app/app/pages/index.vue + - ../sm-demo-app/vue-demo-app/app/components/TourCard.vue + - ../sm-demo-app/vue-demo-app/app/lib/realtime.ts + - ../sm-demo-app/vue-demo-app/i18n/locales/en.json + - ../sm-demo-app/vue-demo-app/i18n/locales/pl.json + - ../sm-demo-app/scripts/check-chat.cjs + - ../sm-demo-app/scripts/check-local.sh + - ../sm-demo-app/scripts/smoke.sh +autonomous: false +requirements: [SC-2, D-01, D-02, D-03, D-04, D-05, D-07, D-20, D-22, D-23] +estimate: + tokens: 380000 + raw_tokens: 380000 + tasks: 6 + confidence: low +must_haves: + truths: + - "D-01: vue-chat-layer is a new shared Nuxt layer (own repository, tag v0.1.0) ported from the bm SPA's chat components, store, API client and chat libraries, with no Pinia (state is useState composables), no bm entity embeds and no name of a consuming application; host specifics enter only through runtime config (public.chat.hostApiBase), the $chatAuth hook, slots and the link-embed renderer registry." + - "D-02: on /chat a logged-in member sees the seeded channels in a sidebar, opens one, reads history, sends and edits messages, replies in threads (thread pane), sees typing, unread and mention counts, adds and removes reactions, attaches files, mentions members with @, picks a DM partner from the member picker, deletes own messages (moderators also others' in channels, D-22) through the delete dialog, sees channel pins and sees URLs as links with a generic link embed." + - "D-03: every page of vue-demo-app shows a floating chat panel (DM list and unread badge) for logged-in users (not on /chat and not on the full-screen deck pages); the homepage tour has a Chat card; the site is EN and PL." + - "D-04: an anonymous visitor on /chat sees a description and a register-to-chat link to /register?next=/chat; there is no read-only preview, and the floating panel is not rendered." + - "D-05: typing / at the start of the composer opens a command picker filled from GET /_chatbots/api/v1/commands; choosing or sending a command calls POST /_chatbots/api/v1/dispatch and shows the ephemeral answer only to the sender; there is no chatbots layer." + - "D-07: the layer offers no channel creation UI; members join and leave seeded channels." + - "D-23 (extension point): the conversation header exposes a slot that the layer renders only for DM conversations, so a call button placed there (plan 04) appears only in DMs." + - "SC-2: messages from another member arrive without reload through the shared Centrifuge client (one connection per page, shared with the presentations remote through vue-demo-app's app/lib/realtime.ts re-export)." + - "After the Task 5 go-ahead, git.golem15.com/golem15/vue-chat-layer exists with tag v0.1.0 and vue-demo-app pins git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0 in a committed package.json and pnpm-lock.yaml." + artifacts: + - path: "../vue-chat-layer/nuxt.config.ts" + provides: "layer config: runtimeConfig chat.apiBase, public.chat.hostApiBase, css, i18n registration (no routes, no route rules)" + contains: "hostApiBase" + - path: "../vue-chat-layer/app/composables/useChat.ts" + provides: "useState-based chat store (channels, DMs, threads, unread, typing, realtime)" + contains: "useState" + - path: "../vue-chat-layer/app/lib/realtime.ts" + provides: "one shared Centrifuge client with connect, subscribe, presence, openChannel and onUserEvent" + contains: "onUserEvent" + - path: "../vue-chat-layer/app/lib/linkEmbeds.ts" + provides: "linkify and the link-embed renderer registry (registerEmbedRenderer)" + contains: "registerEmbedRenderer" + - path: "../vue-chat-layer/app/components/Chat/CommandPicker.vue" + provides: "slash command picker over the chatbots catalog and dispatch" + contains: "dispatch" + - path: "../sm-demo-app/vue-demo-app/app/pages/chat.vue" + provides: "/chat: ChatPage for members, register prompt for anonymous visitors" + contains: "registerToChat" + - path: "../sm-demo-app/scripts/check-chat.cjs" + provides: "Playwright check: the visitor sends a message in #general on /chat" + contains: "check-chat: ok" + key_links: + - from: "../vue-chat-layer/app/composables/useChatApi.ts" + to: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts" + via: "the layer reads useNuxtApp().$chatAuth {token, refresh, unauthorized}; the app provides it" + pattern: "chatAuth" + - from: "../vue-chat-layer/app/composables/useChat.ts" + to: "../sm-demo-app/plugins/demo/chat_api.go" + via: "bootstrap GET {public.chat.hostApiBase}/chat and /chat/members" + pattern: "hostApiBase" + - from: "../sm-demo-app/vue-demo-app/app/lib/realtime.ts" + to: "../vue-chat-layer/app/lib/realtime.ts" + via: "re-export so presentations and chat share one connection" + pattern: "vue-chat-layer/app/lib/realtime" + - from: "../sm-demo-app/vue-demo-app/app/layouts/default.vue" + to: "../vue-chat-layer/app/components/Chat/FloatingPanel.vue" + via: "ClientOnly ChatFloatingPanel when logged in" + pattern: "ChatFloatingPanel" + prohibitions: + - statement: "vue-chat-layer names no consuming application and has no default host bootstrap path." + status: unresolved + verification: automated + - statement: "No message, name or attachment field is rendered with v-html except the escaped output of the ported chatText renderer." + status: unresolved + verification: judgment + - statement: "No Pinia and no new npm package except those already pinned in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3)." + status: unresolved + verification: automated + - statement: "Nothing is pushed and no repository is created before the Task 5 go-ahead; vue-demo-app is not pushed in this plan (plan 05)." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working, chat and channels with realtime included, through the shared layers. + +This plan's slice (plan 3 of 6, wave 3, per D-20): `vue-chat-layer` v0.1.0, ported from bm's chat SPA and widened to several channels, threads, pins, a command picker and generic link embeds, then used in vue-demo-app as `/chat`, a floating panel and a tour card, in EN and PL. + +## Decisions taken in this plan (Claude's discretion, recorded) + +- **Layout of the layer:** pure modules in `app/lib/` (imported explicitly, never auto-imported, so generic names like `initials` cannot clash with an app's utils). Composables `useChat`, `useChatApi` and `useChatNotice` in `app/composables/`. Components in `app/components/Chat/`, auto-registered with the `Chat` prefix (`ChatPage`, `ChatFloatingPanel`). The layer ships components; the app owns routes, page copy and the anonymous prompt (as vue-paymentgateway-layer does). +- **Host contract:** `runtimeConfig.public.chat.hostApiBase` defaults to an empty string, and the layer refuses to bootstrap without it, with a console error naming the key. vue-demo-app sets `/_demo/api/v1`. The README documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the contract any host implements, under neutral names. +- **Auth hook:** `$chatAuth` with `token(): string | null`, `refresh(): Promise` and `unauthorized(): void`. One refresh-and-retry on 401 (bm's client renews through `/_user/api/v1/fetch`), then `unauthorized()`. +- **Store:** bm's Pinia setup store becomes a module-level composable over `useState` keys prefixed `chat:` (19.4.1 no-Pinia rule). `general`/`generalId` become `channels[]` and `activeId`. +- **Realtime:** bm's current `lib/realtime.ts` (shared client) is ported with `onUserEvent(handler)`, so other layers (chatvideo, plan 04) get `user:{me}` events they do not own without a second subscription. vue-demo-app's `app/lib/realtime.ts` becomes a re-export, so the presentations remote and chat share one WebSocket. +- **Embeds:** bm's entity embeds and picker are dropped. URLs in text are linkified (escaped, `rel="noopener noreferrer nofollow"`, `target="_blank"`). A message with a URL shows a `ChatLinkEmbed` card with host and path; there is no unfurl backend. `registerEmbedRenderer(match, component)` lets a host render its own entity links (D-01 extension point). +- **Extension slots:** `conversation-actions` in ChatHeader, rendered only for `type === 'dm'` (D-23), and `sidebar-footer` in ChannelList (the friends link, plan 04). +- **Floating panel:** hidden on `/chat`, `/p/**` and `/presentations/**` (full-screen decks) and for anonymous visitors. +- **Development pin:** while the layer has no remote, vue-demo-app depends on `link:../../vue-chat-layer`, committed with the first vue-demo-app commit so every commit builds on this machine. Task 6 replaces it with the tag pin; vue-demo-app is pushed only in plan 05, after the swap, so no pushed commit needs the local path. +- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). + +## Artifacts this phase produces (this plan) + +- Repository `vue-chat-layer` (tag v0.1.0): + - components `ChatPage`, `ChatChannelList`, `ChatPanel`, `ChatMessageList`, `ChatMessage`, `ChatComposer`, `ChatHeader`, `ChatDmList`, `ChatMemberPicker`, `ChatMentionPopup`, `ChatReactionBar`, `ChatLinkEmbed`, `ChatStagingChips`, `ChatDeleteDialog`, `ChatAvatar`, `ChatThreadPane`, `ChatPinsPanel`, `ChatCommandPicker`, `ChatFloatingPanel`, `ChatIcon`, `ChatNotices`; + - composables `useChat`, `useChatApi`, `useChatNotice`; + - lib exports: `createChatClient`, `REALTIME_WS_PATH`, `connect`, `subscribe`, `presence`, `openChannel`, `onUserEvent`, `disconnect`, `linkify`, `registerEmbedRenderer`, `embedRendererFor`, and the ported chatEvents, chatFormat, chatText and chatUnread functions; + - runtime config `chat.apiBase` (`NUXT_CHAT_API_BASE`), `public.chat.apiBase`, `public.chat.channelsApiBase`, `public.chat.botsApiBase`, `public.chat.hostApiBase`; + - Nuxt plugin hook `$chatAuth`; slots `conversation-actions` and `sidebar-footer`; i18n keys under `chat.*` (EN and PL); + - smoke test `tests/smoke.test.ts`. +- vue-demo-app: page `/chat`, the floating panel in the default layout, Chat tour card (TourCard icon `chat`), i18n keys `chat.*` and `tour.chat.*`, goPaths `/_chat`, `/_chatchannels`, `/_chatbots`, ChatPage inside ClientOnly (the page itself is server-rendered so the banner and the anonymous prompt are in the HTML), `chatAuth` provided in app/plugins/auth.ts, app/lib/realtime.ts re-exporting the layer client. +- sm-demo-app: `scripts/check-chat.cjs`, check-local check `chat`, check-local `tour` expecting the Chat card, smoke.sh covering `/chat`. + + +Build and release vue-chat-layer v0.1.0 and put chat on the demo site: `/chat`, the floating panel, the Chat tour card and the anonymous prompt, in EN and PL. + +Purpose: SC-2 (chat and channels with realtime on the public site), D-01 to D-05, D-07 (no channel creation), D-22 (moderator delete) and the D-23 extension point plan 04 uses. +Output: a new repository vue-chat-layer (pushed and tagged after the go-ahead), commits in ../sm-demo-app/vue-demo-app (the submodule; not pushed) and ../sm-demo-app (scripts; not pushed). + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md + +Repos: new ../vue-chat-layer; ../sm-demo-app/vue-demo-app (the submodule, origin golem15/vue-demo-app; never the stale ../vue-demo-app sibling); ../sm-demo-app (scripts). Read-only port source: ../sm-bm-app/frontend/src (components/chat/*, stores/chat.ts and chat.test.ts, api/chat.ts, api/types.ts, lib/chat*.ts, lib/realtime.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue). Layer conventions: ../vue-faq-layer and ../vue-paymentgateway-layer. + +Rules: no co-author tags; stage by explicit path; the layer repository gets no remote before Task 5; vue-demo-app is committed in its own repository (never staged from sm-demo-app) and not pushed here; check `git -C ../sm-demo-app/vue-demo-app status --porcelain` before each commit and leave other sessions' edits alone (Phase 33 also edits nuxt.config.ts). The local dev stack runs with `../sm-demo-app/scripts/dev.sh up` (Go 127.0.0.1:8097, Nuxt 127.0.0.1:3020, Centrifugo, LiveKit). Playwright is the global install (`NODE_PATH="$(npm root -g)"`, scripts/lib/browser.cjs). + + +Host bootstrap (plan 02, ../sm-demo-app/plugins/demo/chat_api.go): GET /_demo/api/v1/chat gives {member, moderator, context: {type, id}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [{id, type: "dm", other_user_id, unread, last_message: {id, sender_id, content, created_at}}], limits: {max_files, max_bytes, max_embeds}}; GET /_demo/api/v1/chat/members gives {members: [{id, name, surname, handle, is_bot}]}. +chat API /_chat/api/v1: GET|POST /contexts/{type}/{id}/conversation; GET|POST /conversations/{id}/messages; POST /conversations/{id}/read, /typing, /mute; GET|PUT|DELETE /messages/{id}; GET /reactions; POST /messages/{id}/reactions; DELETE /messages/{id}/reactions/{key}; GET|POST /messages/{id}/replies; PUT /threads/{rootId}/mute; POST /attachments (multipart, body limit); GET /attachments/{messageId}/{fileId}; POST /contexts/{type}/{id}/dm. 422 envelope {"error":"Validation failed","errors":{field:[msg]}}, other refusals {"error": msg}, IDOR 404. +chatchannels /_chatchannels/api/v1/contexts/demo/1: GET channels, POST channels/{id}/join and /leave, GET|POST channels/{id}/pins, DELETE channels/{id}/pins/{messageId}, GET channels/{id}/mentions, GET mentions. +chatbots /_chatbots/api/v1: GET /commands gives {commands: [{trigger, description}]}; POST /dispatch {input} gives {mode, payload}; unknown gives 422 errors.input. +Realtime: token GET /api/realtime/token; WebSocket /ws; channels user:{me} (chat:message, typing, read, unread, deleted, reacted, updated events per bm's chatEvents.ts; video:* from chatvideo), chat:conversation:{id}. +vue-demo-app today: nuxt.config.ts (`goPaths`, `extends`, `runtimeConfig`, `routeRules`, `robots.disallow: goPaths`), app/plugins/auth.ts (`paymentGatewayAuth`), app/utils/authCookie.ts (`DEMO_TOKEN_COOKIE`), app/composables/useDemoAuth.ts (`loggedIn`), app/pages/quizzes/index.vue (anonymous prompt with `registerLink` and `quiz.registerToTake`), app/pages/index.vue (`stops`, TourCard), app/components/TourCard.vue (`icon` union, `ICONS` paths), app/lib/realtime.ts (openChannel subset of bm's file), vitest.config.ts (app/**/*.test.ts), package.json (centrifuge 5.7.3, layers pinned by git+https tag). +Layer analogs: ../vue-faq-layer/nuxt.config.ts (currentDir, absolute paths, runtimeConfig), ../vue-paymentgateway-layer/nuxt.config.ts (i18n:registerModule for EN/PL), app/composables/usePaymentGatewayApi.ts (auth hook through useNuxtApp), package.json (main nuxt.config.ts, peerDependencies nuxt 4.4.8, engines node >=22.18). + + + + + + + Task 1: Tracer: a logged-in visitor opens /chat, picks #general from the seeded channels and sends a message that appears; an anonymous visitor gets the register prompt + D-01: a public layer with tagged releases that later sites pin; its runtime-config keys, auth hook and bootstrap contract become a contract. + The plan 02 SUMMARY exists; `../sm-demo-app/scripts/dev.sh up` brings the stack up and `curl -fsS http://127.0.0.1:8097/_demo/api/v1/roadmap` answers; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes. + ../vue-chat-layer/.gitignore, ../vue-chat-layer/.nvmrc, ../vue-chat-layer/package.json, ../vue-chat-layer/pnpm-lock.yaml, ../vue-chat-layer/pnpm-workspace.yaml, ../vue-chat-layer/tsconfig.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/.playground/nuxt.config.ts, ../vue-chat-layer/.playground/app/app.vue, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/lib/types.ts, ../vue-chat-layer/app/composables/useChatApi.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/components/Chat/Page.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/tests/smoke.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh + ../vue-faq-layer/{nuxt.config.ts,package.json,pnpm-workspace.yaml,tsconfig.json,.gitignore,.nvmrc,.playground/nuxt.config.ts}, ../vue-paymentgateway-layer/nuxt.config.ts and app/composables/usePaymentGatewayApi.ts and app/utils/client.ts, ../sm-bm-app/frontend/src/api/chat.ts and api/types.ts, ../sm-bm-app/frontend/src/stores/chat.ts (bootstrap, open, send), ../sm-bm-app/frontend/src/components/chat/{ChatPanel,MessageList,ChatComposer}.vue, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts,app/pages/quizzes/index.vue,app/composables/useDemoAuth.ts,app/utils/authCookie.ts,i18n/locales/en.json}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, check_remote, ALL_CHECKS), ../sm-demo-app/scripts/check-remote.cjs and scripts/lib/browser.cjs, 20-PATTERNS.md (vue-chat-layer) + Per D-01, D-04, D-07, SC-2. One path through every layer: app page, layer component, store, client, host bootstrap, chat API, database, back to the list. + +1. Create the repository: `git init -b main` at /media/nvme/dev/golem15/summercms.io/summercms/vue-chat-layer with the vue-faq-layer files (.gitignore, .nvmrc, tsconfig.json, pnpm-workspace.yaml allowing the same builds plus `protobufjs: false`). + - package.json: name vue-chat-layer, version 0.1.0, private, type module, main ./nuxt.config.ts, engines node >=22.18, peerDependencies nuxt 4.4.8; + - dependencies centrifuge 5.7.3 and @nuxtjs/i18n 10.4.0; devDependencies nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vitest 3.2.7; + - scripts test (`vitest run`), dev (`nuxi dev .playground`), build:playground and dev:prepare. + Run pnpm install. +2. nuxt.config.ts: + - the header comment says an app consumes it with `extends: ['vue-chat-layer']`, provides `$chatAuth` and sets `public.chat.hostApiBase`; currentDir with absolute paths; + - runtimeConfig.chat.apiBase for SSR (`http://127.0.0.1:8096/_chat/api/v1`, NUXT_CHAT_API_BASE); + - public.chat: apiBase `/_chat/api/v1`, channelsApiBase `/_chatchannels/api/v1`, botsApiBase `/_chatbots/api/v1`, hostApiBase empty; + - no pages and no route rules: the app owns its routes and wraps the chat components in ClientOnly (the components touch window and WebSocket, RESEARCH Pitfall 9); + - the i18n:registerModule block of vue-paymentgateway-layer for en and pl (strings come in Task 3; create empty-object locale files now if the module needs them). +3. app/lib/client.ts: `createChatClient({ bases, auth, fetch })`, pure and Nuxt-free so tests import it. It builds URLs, sends Bearer from `auth.token()`, retries once after `auth.refresh()` on 401 and then calls `auth.unauthorized()`, and turns the 422 envelope into a typed `ChatApiError` with field errors. Methods for the tracer: `bootstrap()`, `members()`, `openConversation(type, id)`, `messages(conversationId, opts)` and `send(conversationId, body)`. app/lib/types.ts holds the bootstrap and message types ported from bm's api/types.ts. app/composables/useChatApi.ts binds the client to runtime config and `useNuxtApp().$chatAuth`, following usePaymentGatewayApi. +4. app/composables/useChat.ts, minimal for now, over `useState('chat:…')`: `start()` loads the bootstrap (and logs a console error, then stops, when hostApiBase is empty); `channels`; `activeId`; `open(channel)` calls openConversation('channel', channel.id) then messages; `send(text)`. +5. Components ChatPage (sidebar plus pane), ChatChannelList (channel names, no create button), ChatMessageList (plain text in this task) and ChatComposer (textarea plus send). No v-html. +6. vue-demo-app: + - run `pnpm --dir ../sm-demo-app/vue-demo-app add vue-chat-layer@link:../../vue-chat-layer` (development pin, committed with this task's vue-demo-app commit; Task 6 swaps it for the tag before any push); + - nuxt.config.ts: append 'vue-chat-layer' to extends; add '/_chat', '/_chatchannels' and '/_chatbots' to goPaths; set `public.chat.hostApiBase: '/_demo/api/v1'` and `chat.apiBase` from `api`; no route rule for /chat (the page is server-rendered with the chat inside ClientOnly, so smoke.sh still sees the banner and the prompt); + - app/plugins/auth.ts provides `chatAuth: { token, refresh, unauthorized }`. token reads DEMO_TOKEN_COOKIE. refresh calls the user plugin's refresh endpoint that the login flow uses (read useDemoAuth) and stores the new token. unauthorized clears the cookie; + - app/pages/chat.vue: for loggedIn, ``; otherwise a description and a `NuxtLink class="button"` to `{ path: '/register', query: { next: '/chat' } }` labelled `t('chat.registerToChat')`, as the quizzes page does. useSeoMeta with title and description; + - i18n en and pl: chat.title, chat.lead and chat.registerToChat. +7. sm-demo-app/scripts/check-chat.cjs (CommonJS, Playwright, modeled on check-remote.cjs): + - `--base` (Nuxt origin) and DEMO_JWT from the environment; + - sets the DEMO_TOKEN_COOKIE cookie with the JWT, opens {base}/chat, clicks the channel whose name is general and sends a unique text; + - waits up to 10 s for the text in the message list, then fails on a console error or a 4xx/5xx response from /_chat; + - opens {base}/chat in a fresh context without the cookie and asserts the register link to /register?next=/chat; + - prints `check-chat: ok`. + check-local.sh gets `check_chat`, which takes visitor_jwt and runs the script as check_remote does, and `chat` in ALL_CHECKS plus the header list. +8. tests/smoke.test.ts (vitest): createChatClient builds `/_chat/api/v1/conversations/7/messages`, sends the Bearer header, retries once on 401 after refresh and maps a 422 body to field errors. + +Commit vue-chat-layer (`feat: chat layer tracer: bootstrap, channel list, messages, composer`). Commit vue-demo-app package.json, pnpm-lock.yaml (link pin), nuxt.config.ts, auth.ts, chat.vue and locales by path (`feat: /chat page on vue-chat-layer`). Commit sm-demo-app scripts (`test: check-chat browser check`). + + pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && ../sm-demo-app/scripts/check-local.sh chat + Non-zero exit: the layer smoke test fails; the Nuxt build fails (layer not resolved, type or template error); the stack does not come up; or check-local prints "check-local: chat failed" (the sent text does not appear, /_chat answered 4xx/5xx, or the anonymous page lacks the register link). + + + - `git -C ../vue-chat-layer remote` prints nothing. + - `grep -c "hostApiBase: ''" ../vue-chat-layer/nuxt.config.ts` prints 1. + - `grep -rniE 'sm-bm|jz\.bm|_demo/api' ../vue-chat-layer/app ../vue-chat-layer/nuxt.config.ts ../vue-chat-layer/README.md 2>/dev/null | wc -l` prints 0. + - `grep -c "'/_chat'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1. + - `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `link:../../vue-chat-layer` (the development pin, swapped in Task 6). + + A visitor can chat in #general on the local demo site through the new layer, and an anonymous visitor is invited to register. + + + + Task 2: The bm chat experience on the layer: realtime, DMs, member picker, mentions, unread counts and typing + ../vue-chat-layer/app/lib/realtime.ts, ../vue-chat-layer/app/lib/chatEvents.ts, ../vue-chat-layer/app/lib/chatFormat.ts, ../vue-chat-layer/app/lib/chatText.ts, ../vue-chat-layer/app/lib/chatUnread.ts, ../vue-chat-layer/app/lib/format.ts, ../vue-chat-layer/app/lib/media.ts, ../vue-chat-layer/app/lib/tabs.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/composables/useChatNotice.ts, ../vue-chat-layer/app/components/Chat/Panel.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/DmList.vue, ../vue-chat-layer/app/components/Chat/MemberPicker.vue, ../vue-chat-layer/app/components/Chat/MentionPopup.vue, ../vue-chat-layer/app/components/Chat/Avatar.vue, ../vue-chat-layer/app/components/Chat/Icon.vue, ../vue-chat-layer/app/components/Chat/Notices.vue, ../vue-chat-layer/app/components/Chat/Page.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts + ../sm-bm-app/frontend/src/stores/chat.ts (whole store), stores/chat.test.ts, lib/realtime.ts, lib/chatEvents.ts, lib/chatFormat.ts, lib/chatText.ts, lib/chatUnread.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue, components/chat/{ChatPanel,MessageList,ChatMessage,ChatComposer,ChatHeader,DmList,MemberPicker,MentionPopup,Avatar}.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts and realtime.test.ts, app/pages/presentations/[slug]/index.vue and pilot.vue (openChannel callers), ../vue-chat-layer/app/composables/useChat.ts (Task 1), 20-RESEARCH.md (Pattern 3, Pitfall 9) + Per D-01, D-02, SC-2. + +- Port bm's lib/realtime.ts into app/lib/realtime.ts. It keeps the same exports (REALTIME_WS_PATH, tokenFailureIsFinal, wsURL, connect, subscribe, presence, onConnection, onUnavailable, realtimeState, realtimeUnavailable, disconnect, openChannel). The token is fetched from `/api/realtime/token` with the `$chatAuth` token through a setter `setRealtimeAuth(getToken)`, so the module stays Nuxt-free. `centrifuge` is loaded with a dynamic import. Add `onUserEvent(handler): () => void`: the chat store's `user:{me}` subscription hands every event it does not handle to the registered handlers, so a second layer never subscribes to `user:{me}` itself. +- Port chatEvents, chatFormat, chatText and chatUnread unchanged except for import paths, and the helpers they need from format, media and tabs. The bm tests come in plan 06. chatText's renderer escapes before it marks mentions; keep it the only raw-HTML binding in the layer. +- useChat: port bm's store as a composable over useState. Replace `general` and `generalId` with `channels`, `activeId` and a `kind` ('channel' or 'dm') for the open conversation. Keep: + - DMs (`conversations`; `openDm(userId)` through POST /contexts/demo/1/dm, using the context from the bootstrap); + - the member list from `/chat/members`; + - unread and mention counts, read marks and typing, with bm's polling fallback when realtime is unavailable (15 s open conversation, 60 s counts); + - realtime start for a member session, edits and deletions from events. + Channel join and leave call chatchannels join and leave and refresh the bootstrap. Errors go to useChatNotice (a small toast state rendered by ChatNotices inside ChatPage and the floating panel), replacing bm's toast store. +- Port the components ChatPanel, ChatMessageList, ChatMessage (text through chatText, attachment rendering in Task 3), ChatComposer (mention popup, Enter to send, Shift+Enter newline), ChatHeader (unread and mention badges), ChatDmList, ChatMemberPicker, ChatMentionPopup and ChatAvatar, plus ChatIcon with the icons they use. ChatPage composes sidebar (channels, DMs, a new-DM button opening the member picker) and pane. Strip every bm-specific element (entity embeds, deadline pins, the student app's tabs) or leave a named slot in its place. +- vue-demo-app app/lib/realtime.ts becomes a re-export of `vue-chat-layer/app/lib/realtime` (with a comment), and the app plugin calls `setRealtimeAuth` with the DEMO_TOKEN_COOKIE reader. If the existing app/lib/realtime.test.ts fails against the shared client, replace it with bm's current realtime.test.ts, adjusted only in import paths. + +Commit vue-chat-layer (`feat: port the chat store, realtime client and components`) and vue-demo-app (`refactor: share the chat layer's realtime client`). + + pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh chat realtime remote + Non-zero exit: layer smoke or vue-demo-app vitest red (the realtime re-export broke the presentations tests), the build fails, or check-local reports chat, realtime or remote failed (the presentations remote no longer works through the shared client). + + + - `grep -c 'export function onUserEvent' ../vue-chat-layer/app/lib/realtime.ts` prints 1. + - `grep -rn "from 'pinia'" ../vue-chat-layer/app | wc -l` prints 0. + - `grep -c 'vue-chat-layer/app/lib/realtime' ../sm-demo-app/vue-demo-app/app/lib/realtime.ts` prints 1. + - `grep -rn 'v-html' ../vue-chat-layer/app/components | grep -v 'renderChatText\|rendered' | wc -l` prints 0. + + The layer behaves like bm's chat across several channels and DMs, live, with one shared realtime connection on every page. + + + + Task 3: Threads, pins, reactions, attachments, moderator delete, link embeds and the slash command picker + ../vue-chat-layer/app/components/Chat/ThreadPane.vue, ../vue-chat-layer/app/components/Chat/PinsPanel.vue, ../vue-chat-layer/app/components/Chat/ReactionBar.vue, ../vue-chat-layer/app/components/Chat/StagingChips.vue, ../vue-chat-layer/app/components/Chat/DeleteDialog.vue, ../vue-chat-layer/app/components/Chat/LinkEmbed.vue, ../vue-chat-layer/app/components/Chat/CommandPicker.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/lib/linkEmbeds.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/assets/css/chat-tokens.css, ../vue-chat-layer/app/assets/css/chat-base.css, ../vue-chat-layer/i18n/locales/en.json, ../vue-chat-layer/i18n/locales/pl.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/README.md + ../sm-bm-app/frontend/src/components/chat/{ReactionBar,StagingChips,DeleteDialog,ChatMessage,ChatComposer,EmbedCard}.vue, ../sm-bm-app/frontend/src/lib/embeds.ts (what to drop), ../sm-demo-app/plugins/golem15/chat/README.md (replies, thread mute, reactions catalog, attachments and limits), ../sm-demo-app/plugins/golem15/chatchannels/README.md (pins, mentions), ../sm-demo-app/plugins/golem15/chatbots/README.md and api/commands.go, ../vue-paymentgateway-layer/{README.md,app/assets/css/pg-tokens.css}, ../vue-faq-layer/README.md (layer README structure), 20-RESEARCH.md (Open Question 4, Anti-Patterns) + Per D-02, D-05, D-22, D-23, D-01. + +- Threads: a reply count on root messages opens ChatThreadPane, which lists replies (GET /messages/{id}/replies), posts replies (POST /messages/{id}/replies) and mutes the thread (PUT /threads/{rootId}/mute). Realtime reply events update the count. +- Pins: ChatPinsPanel, opened from the header of a channel, lists GET .../channels/{id}/pins. Pin and unpin show only when the API allows it: a refused pin shows a notice and hides the action for the session. +- Reactions: ChatReactionBar ported, with the keys from GET /reactions. +- Attachments: ChatStagingChips ported. Uploads go to POST /attachments within the bootstrap limits (max_files, max_bytes), and the message shows a download link (GET /attachments/{messageId}/{fileId} with the auth header, fetched as a blob). +- Delete: ChatDeleteDialog ported. Own messages always; others' only when the bootstrap's moderator is true and the conversation is a channel (D-22). +- Edits: an Edit action on own messages through PUT /messages/{id}. +- Link embeds: app/lib/linkEmbeds.ts with `linkify(escapedHtml)`, `registerEmbedRenderer(match: (url: URL) => boolean, component)` and `embedRendererFor(url)`. ChatMessage renders the first URL of a message with a registered renderer or with ChatLinkEmbed (host and path, no fetch). max_embeds caps the cards. +- Commands (D-05): typing "/" at the start of the composer opens ChatCommandPicker, filled from GET /commands (cached per session) and filtered by prefix. Sending a "/" message calls POST /dispatch with {input} instead of posting. An ephemeral answer renders as a local, dismissable, sender-only system row with the payload text (escaped); a 422 shows the error. +- Slots: ChatHeader renders `` only when the open conversation is a DM (D-23). ChatChannelList renders ``. ChatPage forwards both slots. +- Styling: chat-tokens.css and chat-base.css (neutral tokens an app overrides, as pg-tokens.css does), registered in nuxt.config.ts css with absolute paths. +- i18n: every UI string of the layer in en and pl under `chat.*`. The app's own copy stays in the app. +- README.md in the layer structure of vue-faq-layer and vue-paymentgateway-layer: + - install by tag; extends; + - the `$chatAuth` hook; the runtime-config keys; + - the host bootstrap contract (the two JSON shapes with a neutral `acme` host); + - slots, `registerEmbedRenderer`, `onUserEvent`; + - client-only rendering (wrap ChatPage and ChatFloatingPanel in ClientOnly); testing. + Name no consuming application. + +Commit vue-chat-layer (`feat: threads, pins, reactions, attachments, link embeds and the command picker`). + + pnpm --dir ../vue-chat-layer test && pnpm --dir ../vue-chat-layer run build:playground && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh chat + Non-zero exit: the layer smoke test, the playground build or the app build fails, or check-local prints "check-local: chat failed". + + + - `grep -c 'conversation-actions' ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1, and the slot sits inside a condition on the DM type (`grep -c "type === 'dm'" ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1). + - `grep -c 'export function registerEmbedRenderer' ../vue-chat-layer/app/lib/linkEmbeds.ts` prints 1. + - `grep -c '/dispatch' ../vue-chat-layer/app/lib/client.ts` prints at least 1. + - `node -e "const a=require('../vue-chat-layer/i18n/locales/en.json'),b=require('../vue-chat-layer/i18n/locales/pl.json');const k=o=>Object.entries(o).flatMap(([x,v])=>typeof v==='object'?k(v).map(y=>x+'.'+y):[x]);const d=k(a).filter(x=>!k(b).includes(x));if(d.length){console.error(d);process.exit(1)}"` exits 0 (PL covers every EN key). + - In a manual run on the local stack, a "/demo" command in the composer shows the ephemeral answer and nothing is posted to the channel (recorded in the SUMMARY). + + The layer has the full D-02 feature set plus the slash command picker and the extension points other layers and hosts plug into. + + + + Task 4: Chat everywhere on the demo site: floating panel, Chat tour card, EN/PL copy and updated checks + ../vue-chat-layer/app/components/Chat/FloatingPanel.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh + ../sm-demo-app/vue-demo-app/app/layouts/default.vue, app/pages/index.vue (stops, TourCard), app/components/TourCard.vue (icon union, ICONS), app/composables/useDemoAuth.ts, i18n/locales/en.json and pl.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour, card_titles), ../sm-demo-app/scripts/smoke.sh (page loop at line 237), ../sm-bm-app/frontend/src/components/chat/DmList.vue + Per D-03, D-04. +- Layer ChatFloatingPanel: a corner button with the total unread badge (DMs plus channel mentions). It opens a compact panel with ChatDmList and the open DM's message list and composer, and has a link to the full chat page whose path comes from a `chatPath` prop (default '/chat'). It starts the chat store only for a member and renders nothing otherwise. +- vue-demo-app default.vue: inside ``, render `` when loggedIn and the route is not /chat, /p/** or /presentations/**. +- index.vue: a Chat stop `{ key: 'chat', to: '/chat' }` after quizzes. TourCard gets an outline chat icon in ICONS and 'chat' in the icon union. en and pl get tour.chat.title and tour.chat.text, and the tour heading text matches the new count ("Seven things to try" / the Polish equivalent). +- chat.vue: final copy (EN/PL) explaining channels, DMs, threads and slash commands, plus the register prompt. +- check-local.sh check_tour expects Chat (EN) and Czat (PL) among the cards and seven cards. smoke.sh adds /chat to the page loop (banner present; the anonymous prompt link to /register?next=%2Fchat in the HTML). + +Commit the layer (`feat: floating chat panel`), vue-demo-app (`feat: floating chat panel, Chat tour card`) and sm-demo-app scripts separately. + + pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh tour chat + Non-zero exit: build or tests fail, or check-local reports tour failed (missing Chat or Czat card, wrong card count) or chat failed. + + + - `grep -c 'ChatFloatingPanel' ../sm-demo-app/vue-demo-app/app/layouts/default.vue` prints 1 and the element sits inside ClientOnly. + - `grep -c "key: 'chat'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 1. + - `grep -c '/chat' ../sm-demo-app/scripts/smoke.sh` prints at least 1. + + Chat is reachable from every page for members and from the tour for everyone, in EN and PL. + + + + Task 5: Go-ahead to create vue-chat-layer on git.golem15.com, push it and tag v0.1.0 + Claude's summary before asking: `git -C ../vue-chat-layer log --oneline`, the README's install line, the visibility of vue-faq-layer on git.golem15.com (consumers install layers over git+https), whether `tea login list` shows a git.golem15.com login, and the result of the Task 4 checks. + + - The user chose one option and it is recorded in the SUMMARY; with "release", `git ls-remote https://git.golem15.com/golem15/vue-chat-layer.git` exits 0 before Task 6 pushes, or Task 6 creates it with tea. + + Create the vue-chat-layer repository, push main and tag v0.1.0 now? + D-01 rates the layer costly: later sites pin it by tag, so v0.1.0 freezes the hook, runtime-config and bootstrap contract shown in the README. A pushed tag must not move. vue-demo-app can only pin the layer by tag after this; until then it builds from a local link that cannot be pushed. Repository creation and pushes need your go-ahead (CLAUDE.md). + + + + + For "release" without a tea login: create golem15/vue-chat-layer (empty, default branch main, same visibility as vue-faq-layer) in the git.golem15.com web UI, then reply. Do not paste tokens. + Reply "release" (add "repo ready" if you created it) or "hold". + + + + Task 6: Release vue-chat-layer v0.1.0 and pin it in vue-demo-app + A pushed layer tag is installed by consumers' lockfiles; it can be superseded but must never move. + Task 5 resolved with "release". + ../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml + ../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json (other layer pins), the Task 5 answer + Per D-01. +1. If the repository is missing, create it with `tea repo create --name vue-chat-layer --owner golem15` at vue-faq-layer's visibility. +2. Add the remote `git@git.golem15.com:golem15/vue-chat-layer.git` and push main. +3. If the README's install line says no tag exists yet, fix it before tagging. Create the annotated tag v0.1.0, push it, and confirm `git ls-remote origin refs/tags/v0.1.0^{}` equals HEAD. +4. In vue-demo-app run `pnpm add "vue-chat-layer@git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0"` (replacing the link pin), then build and test. +5. Commit package.json and pnpm-lock.yaml (`feat: pin vue-chat-layer v0.1.0`). Do not push vue-demo-app (plan 05). + + [ "$(git -C ../vue-chat-layer ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../vue-chat-layer rev-parse HEAD)" ] && grep -q 'vue-chat-layer.git#v0.1.0' ../sm-demo-app/vue-demo-app/package.json && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build + Non-zero exit: the remote tag is missing or differs from HEAD, package.json still holds the link pin, or the vue-demo-app tests or build fail against the tagged layer. + + + - `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0`. + - `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing. + - `../sm-demo-app/scripts/check-local.sh chat tour` prints `check-local: ok chat tour`. + + vue-chat-layer v0.1.0 is published and the demo site builds against the tag. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| other members' messages, names and URLs to the browser | untrusted content rendered in every member's page | +| browser to the chat-family APIs | JWT bearer from the demo cookie; actor fixed server-side | +| browser to Centrifugo | connection token per member; subscriptions authorized server-side | +| pnpm install from git.golem15.com | the layer tag becomes an input of every consuming site | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-20-14 | Tampering | stored XSS through message text, names or URLs | high | mitigate | text rendered through the ported chatText renderer that escapes before marking mentions; linkify runs on escaped HTML and only emits http(s) anchors with rel noopener noreferrer nofollow; names and payloads are text bindings; acceptance grep limits v-html to the renderer output | +| T-20-15 | Information disclosure | JWT handling in the layer | medium | mitigate | the token comes only from the app's $chatAuth hook (cookie reader), is sent as a Bearer header to same-origin paths, never stored by the layer, and cleared through unauthorized() after a failed refresh | +| T-20-16 | Information disclosure | attachment downloads | medium | mitigate | downloads go through GET /attachments/{messageId}/{fileId} with the Bearer header (participants only server-side); no public URL is built | +| T-20-17 | Spoofing | slash command answers | low | mitigate | ephemeral answers are rendered locally as escaped text marked as a system row and never posted as the user | +| T-20-18 | Tampering | layer supply chain | medium | mitigate | the layer pins exact versions already audited in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0); vue-demo-app pins the layer by tag with a committed lockfile | +| T-20-19 | Denial of service | realtime connections | low | mitigate | one shared Centrifuge client per page (re-export in vue-demo-app); polling fallback intervals of 15 s and 60 s from bm | +| T-20-SC | Tampering | npm installs | high | mitigate | no new package: centrifuge 5.7.3 was audited in 19.4.1 (protobufjs postinstall disabled in pnpm-workspace.yaml); @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt, vue and typescript are the pins already in the workspace lockfiles | + + + +- Task 1: layer smoke, app build, check-local chat (browser send, anonymous prompt). +- Task 2: layer and app vitest, build, check-local chat, realtime and remote. +- Task 3: layer smoke, playground build, app build, check-local chat, PL key coverage. +- Task 4: build, check-local tour and chat, smoke.sh page list. +- Task 5: go-ahead recorded. +- Task 6: remote tag equals HEAD, vue-demo-app builds against the tag. + + + +The public demo site has `/chat` with channels, DMs, threads, pins, reactions, attachments, mentions, typing, unread counts, link embeds and slash commands working live (SC-2, D-01 to D-05), a floating panel for members and an invitation for visitors. vue-chat-layer v0.1.0 is a reusable, app-neutral layer. + + + +Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md` when done: the layer's public API (runtime keys, hook, slots, exports), what was stripped from bm and why, the refresh endpoint used by chatAuth, which realtime test file is used in vue-demo-app, the tag and commit, and the checkpoint answer. + diff --git a/.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-PLAN.md b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-PLAN.md new file mode 100644 index 00000000..32c73c55 --- /dev/null +++ b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-PLAN.md @@ -0,0 +1,517 @@ +--- +phase: 20-demo-instance-with-every-shared-plugin +plan: 04 +type: execute +wave: 4 +depends_on: ["20-03"] +files_modified: + - ../vue-forum-layer/.gitignore + - ../vue-forum-layer/.nvmrc + - ../vue-forum-layer/package.json + - ../vue-forum-layer/pnpm-lock.yaml + - ../vue-forum-layer/pnpm-workspace.yaml + - ../vue-forum-layer/tsconfig.json + - ../vue-forum-layer/nuxt.config.ts + - ../vue-forum-layer/README.md + - ../vue-forum-layer/.playground/nuxt.config.ts + - ../vue-forum-layer/.playground/app/app.vue + - ../vue-forum-layer/app/utils/forumClient.ts + - ../vue-forum-layer/app/composables/useForumApi.ts + - ../vue-forum-layer/app/composables/useForum.ts + - ../vue-forum-layer/app/components/Forum/Page.vue + - ../vue-forum-layer/app/components/Forum/CategoryList.vue + - ../vue-forum-layer/app/components/Forum/ThreadList.vue + - ../vue-forum-layer/app/components/Forum/Thread.vue + - ../vue-forum-layer/app/components/Forum/PostEditor.vue + - ../vue-forum-layer/app/assets/css/forum-base.css + - ../vue-forum-layer/i18n/locales/en.json + - ../vue-forum-layer/i18n/locales/pl.json + - ../vue-forum-layer/tests/client.test.ts + - ../vue-userfriends-layer/.gitignore + - ../vue-userfriends-layer/.nvmrc + - ../vue-userfriends-layer/package.json + - ../vue-userfriends-layer/pnpm-lock.yaml + - ../vue-userfriends-layer/pnpm-workspace.yaml + - ../vue-userfriends-layer/tsconfig.json + - ../vue-userfriends-layer/nuxt.config.ts + - ../vue-userfriends-layer/README.md + - ../vue-userfriends-layer/.playground/nuxt.config.ts + - ../vue-userfriends-layer/.playground/app/app.vue + - ../vue-userfriends-layer/app/utils/friendsClient.ts + - ../vue-userfriends-layer/app/composables/useFriendsApi.ts + - ../vue-userfriends-layer/app/composables/useFriends.ts + - ../vue-userfriends-layer/app/components/Friends/Page.vue + - ../vue-userfriends-layer/app/components/Friends/List.vue + - ../vue-userfriends-layer/app/components/Friends/Requests.vue + - ../vue-userfriends-layer/app/components/Friends/Groups.vue + - ../vue-userfriends-layer/app/components/Friends/AddFriend.vue + - ../vue-userfriends-layer/app/assets/css/friends-base.css + - ../vue-userfriends-layer/i18n/locales/en.json + - ../vue-userfriends-layer/i18n/locales/pl.json + - ../vue-userfriends-layer/tests/client.test.ts + - ../vue-chatvideo-layer/.gitignore + - ../vue-chatvideo-layer/.nvmrc + - ../vue-chatvideo-layer/package.json + - ../vue-chatvideo-layer/pnpm-lock.yaml + - ../vue-chatvideo-layer/pnpm-workspace.yaml + - ../vue-chatvideo-layer/tsconfig.json + - ../vue-chatvideo-layer/nuxt.config.ts + - ../vue-chatvideo-layer/README.md + - ../vue-chatvideo-layer/.playground/nuxt.config.ts + - ../vue-chatvideo-layer/.playground/app/app.vue + - ../vue-chatvideo-layer/app/lib/videoClient.ts + - ../vue-chatvideo-layer/app/lib/callMachine.ts + - ../vue-chatvideo-layer/app/composables/useCall.ts + - ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts + - ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue + - ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue + - ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue + - ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue + - ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css + - ../vue-chatvideo-layer/i18n/locales/en.json + - ../vue-chatvideo-layer/i18n/locales/pl.json + - ../vue-chatvideo-layer/tests/callMachine.test.ts + - ../sm-demo-app/vue-demo-app/package.json + - ../sm-demo-app/vue-demo-app/pnpm-lock.yaml + - ../sm-demo-app/vue-demo-app/nuxt.config.ts + - ../sm-demo-app/vue-demo-app/app/plugins/auth.ts + - ../sm-demo-app/vue-demo-app/app/layouts/default.vue + - ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue + - ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue + - ../sm-demo-app/vue-demo-app/app/pages/friends.vue + - ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue + - ../sm-demo-app/vue-demo-app/app/pages/chat.vue + - ../sm-demo-app/vue-demo-app/app/pages/index.vue + - ../sm-demo-app/vue-demo-app/app/pages/account/security.vue + - ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue + - ../sm-demo-app/vue-demo-app/app/components/TourCard.vue + - ../sm-demo-app/vue-demo-app/i18n/locales/en.json + - ../sm-demo-app/vue-demo-app/i18n/locales/pl.json + - ../sm-demo-app/scripts/check-local.sh + - ../sm-demo-app/scripts/smoke.sh + - ../sm-demo-app/scripts/dev.sh + - ../sm-demo-app/deploy/env.example + - ../sm-demo-app/README.md + - .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md +autonomous: false +requirements: [SC-2, D-11, D-12, D-13, D-14, D-15, D-16, D-20, D-21, D-22, D-23] +estimate: + tokens: 380000 + raw_tokens: 380000 + tasks: 7 + confidence: low +must_haves: + truths: + - "D-14, D-21: /forum shows a member the seeded EN and PL categories and their threads; a thread page lists posts as the server's content_html; a member creates a thread, replies, edits own posts and deletes own posts (moderators any post, D-22); an anonymous visitor on /forum and on a thread URL sees a description and a register-to-join link (/register?next=...), and no forum API call is made without a JWT." + - "D-15: /friends shows a member their friends, incoming and sent requests (accept, reject, cancel, unfriend) and friend groups (list, create, join, leave, members); a member sends a request to another member chosen from the app-provided directory; /friends is linked from the chat sidebar (sidebar-footer slot) and from the account area (header link and /account/security)." + - "D-11, D-23: the call button (ChatvideoCallButton) appears only in DM conversations through vue-chat-layer's conversation-actions slot; there is no call button in channels; the server is not changed to refuse channel calls." + - "D-13: /call/{conversation} is a minimal call page: own camera, remote tiles, mute, camera toggle and leave, built on livekit-client loaded with a dynamic import; an incoming video:call:incoming event (received through vue-chat-layer's onUserEvent, no second user:{me} subscription) shows an accept/decline prompt on every page for members." + - "D-12: the call page shows the D-12 caps' effects (a started call ends at the server's max_duration; a fifth participant is refused by LiveKit's room cap) without client-side limits of its own." + - "D-16: herald runs with VAPID keys from .env (SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, __PRIVATE_KEY, __SUBJECT; dev.sh generates them with herald:vapid-generate, never printing them) and there is no subscribe UI; /_herald is not added to vue-demo-app goPaths." + - "The homepage tour has Forum, Friends and Calls cards next to Chat (ten cards, EN and PL)." + - "livekit-client is installed only after the blocking human-verify checkpoint, at an exact version the user confirmed." + - "After the Task 6 go-ahead, vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer exist on git.golem15.com with tag v0.1.0 each, and vue-demo-app pins all three by git+https tag in a committed package.json and pnpm-lock.yaml." + artifacts: + - path: "../vue-forum-layer/app/components/Forum/Page.vue" + provides: "categories and thread list of one forum context" + contains: "contextType" + - path: "../vue-userfriends-layer/app/components/Friends/Page.vue" + provides: "friends, requests and groups" + contains: "Requests" + - path: "../vue-chatvideo-layer/app/lib/callMachine.ts" + provides: "pure call state machine (idle, starting, connecting, connected, ended, failed)" + contains: "connected" + - path: "../vue-chatvideo-layer/app/composables/useCall.ts" + provides: "LiveKit room lifecycle over the chatvideo API, dynamic import of livekit-client" + contains: "import('livekit-client')" + - path: "../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue" + provides: "/call/{conversation} page" + contains: "ChatvideoCall" + - path: ".planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md" + provides: "API coverage decisions for livekit-client, LiveKit server, Centrifugo and Web Push" + contains: "livekit-client" + key_links: + - from: "../sm-demo-app/vue-demo-app/app/pages/chat.vue" + to: "../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue" + via: "template #conversation-actions renders ChatvideoCallButton (DM only, D-23)" + pattern: "conversation-actions" + - from: "../vue-chatvideo-layer/app/composables/useIncomingCalls.ts" + to: "../vue-chat-layer/app/lib/realtime.ts" + via: "onUserEvent receives video:call:incoming from the chat store's user:{me} subscription" + pattern: "onUserEvent" + - from: "../vue-chatvideo-layer/app/composables/useCall.ts" + to: "../sm-demo-app/plugins/golem15/chatvideo/api/video.go" + via: "POST /_chatvideo/api/v1/conversations/{id}/video/start or /join gives {livekit_url, token}" + pattern: "video/(start|join)" + - from: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts" + to: "../vue-forum-layer/app/composables/useForumApi.ts" + via: "provide forumAuth and userfriendsAuth token getters (chatvideo reuses chatAuth)" + pattern: "forumAuth" + prohibitions: + - statement: "None of the three layers names a consuming application or carries a default host path." + status: unresolved + verification: automated + - statement: "Forum post HTML is rendered only from the server's content_html; user Markdown is never rendered client-side." + status: unresolved + verification: judgment + - statement: "livekit-client is not added to any package.json before the Task 3 checkpoint is approved." + status: unresolved + verification: judgment + - statement: "No push, repository creation or tag before the Task 6 go-ahead; vue-demo-app and sm-demo-app are not pushed (plan 05)." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working through shared layers, video calls included. + +This plan's slice (plan 4 of 6, wave 4, per D-20): three new shared layers at v0.1.0 (`vue-forum-layer`, `vue-userfriends-layer`, `vue-chatvideo-layer`), their pages and tour cards in vue-demo-app, and herald's VAPID configuration without UI. + +**Wave note (deviation from D-20, recorded):** D-20 lets plans 03 and 04 run in parallel after plan 02. This plan still depends on plan 03 and runs after it, for two reasons: +1. It consumes plan 03's vue-chat-layer outputs: vue-chatvideo-layer imports the shared realtime client (`onUserEvent`, `subscribe`) and renders its call button in vue-chat-layer's DM-only `conversation-actions` slot (D-23), and the friends link goes into its `sidebar-footer` slot. +2. Both plans edit the same vue-demo-app files (package.json, pnpm-lock.yaml, nuxt.config.ts, app/plugins/auth.ts, app/pages/index.vue, the locales) in one checkout. Parallel execution runs without worktrees, so the two plans would collide in the same wave. + +## Decisions taken in this plan (Claude's discretion, recorded) + +- **Layer shape:** like vue-faq-layer and vue-paymentgateway-layer. A Nuxt-free client in `app/utils/*Client.ts` (tested with `node --test`), an auth-hook composable, components with a directory prefix (`Forum*`, `Friends*`, `Chatvideo*`), EN/PL strings through `i18n:registerModule`, and no pages or route rules: the app owns routes and wraps client-side parts in ClientOnly. +- **Auth hooks:** `$forumAuth` and `$userfriendsAuth` are token getters, the vue-paymentgateway-layer shape. vue-chatvideo-layer reuses vue-chat-layer's `$chatAuth`, because it has a peer dependency on that layer. +- **Forum context:** props `contextType` and `contextId`; vue-demo-app passes `demo` and `1`. Thread URLs are built by an app-supplied `threadPath(id)` prop. Markdown is written in a textarea with the server preview endpoint (`POST /contexts/{type}/{id}/preview`); nothing is rendered client-side. +- **Friend directory:** the layer takes an optional `$userfriendsDirectory(): Promise<{id, name}[]>` hook for the add-friend picker and falls back to a username field (the API accepts user_id, username or email). vue-demo-app provides the directory from `GET /_demo/api/v1/chat/members`. +- **chatvideo and chat:** vue-chatvideo-layer lists vue-chat-layer v0.1.0 as a peer dependency and dev dependency, without `extends`. The app extends both, so the chat layer is not loaded twice. It imports `onUserEvent` and `subscribe` from `vue-chat-layer/app/lib/realtime`. +- **Call flow:** the call button starts (`video/start`) and navigates to `/call/{conversation}`. The call page joins (`video/join`) when it was not the starter and connects with the returned `livekit_url` and `token`. Leave calls `video/leave` then `room.disconnect()`. An incoming call prompt offers Accept (navigate) and Decline (`video/leave`; the service publishes video:call:declined for a participant who never joined; Task 4 confirms this in video_service.go). Screen share stays off (D-13 minimal, `screen_share: false`). +- **Profile link (D-15):** the demo has no profile page. The account area stands for it: a "Friends" header link for members next to "Security", and a link on /account/security. +- **Tour:** ten cards (Journal, Presentations, Quizzes, Chat, Forum, Friends, Calls, Payments, FAQ, Accounts). The Calls card links to /chat with text saying calls start from a DM. +- **Development pins:** `link:` dependencies to the three local checkouts until Task 7 swaps them for tags, as in plan 03. +- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). + +## Artifacts this phase produces (this plan) + +- Repository `vue-forum-layer` v0.1.0: + - components `ForumPage`, `ForumCategoryList`, `ForumThreadList`, `ForumThread`, `ForumPostEditor`; + - composables `useForum`, `useForumApi`; client `createForumClient`; + - runtime keys `forum.apiBase` (`NUXT_FORUM_API_BASE`) and `public.forum.apiBase`; hook `$forumAuth`; i18n `forum.*`. +- Repository `vue-userfriends-layer` v0.1.0: + - components `FriendsPage`, `FriendsList`, `FriendsRequests`, `FriendsGroups`, `FriendsAddFriend`; + - composables `useFriends`, `useFriendsApi`; client `createFriendsClient`; + - runtime keys `userfriends.apiBase` and `public.userfriends.apiBase`; hooks `$userfriendsAuth` and `$userfriendsDirectory`; i18n `friends.*`. +- Repository `vue-chatvideo-layer` v0.1.0: + - components `ChatvideoCall`, `ChatvideoTile`, `ChatvideoCallButton`, `ChatvideoIncomingCall`; + - composables `useCall`, `useIncomingCalls`; lib `createVideoClient`, `callMachine` (`initialCallState`, `transition`); + - runtime key `public.chatvideo.apiBase`; prop `callPath`; i18n `chatvideo.*`; dependency `livekit-client` at the confirmed exact version. +- vue-demo-app: + - pages `/forum`, `/forum/t/[id]`, `/friends`, `/call/[conversation]`; + - ChatvideoIncomingCall in the default layout; the call button in /chat's conversation-actions slot; the friends link in /chat's sidebar-footer slot, the header and /account/security; + - tour cards forum, friends and calls; goPaths `/_forum`, `/_userfriends`, `/_chatvideo`; hooks `forumAuth`, `userfriendsAuth`, `userfriendsDirectory`. +- sm-demo-app: check-local checks `forum`, `friends`, `call`, `herald`; tour expects ten cards; smoke.sh pages /forum and /friends; dev.sh VAPID generation; env keys `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in env.example). +- `.planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` (API coverage matrix). + + +Build and release the forum, friends and video-call layers, put their pages and tour cards on the demo site, and configure herald's VAPID keys without UI. + +Purpose: SC-2 (video calls and the other plugins visible through shared layers), D-11, D-12, D-13, D-14, D-15, D-16, D-21, D-22 (forum moderation), D-23. +Output: three new repositories (pushed and tagged after the go-ahead), commits in vue-demo-app and sm-demo-app (not pushed), and the phase COVERAGE.md. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md +@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md + +Repos: new ../vue-forum-layer, ../vue-userfriends-layer and ../vue-chatvideo-layer; ../sm-demo-app/vue-demo-app (submodule); ../sm-demo-app. Layer analogs: ../vue-faq-layer (structure, node --test, client in app/utils), ../vue-paymentgateway-layer (auth hook, i18n registration), ../vue-chat-layer (plan 03: slots, onUserEvent, $chatAuth). + +Rules: as in plan 03 (no co-author tags, stage by path, no remote before the go-ahead, vue-demo-app committed in its own repository and not pushed, check for other sessions' edits before each commit). The local stack: `../sm-demo-app/scripts/dev.sh up` with LiveKit on 127.0.0.1:7890. + +Size (accepted under D-20): seven tasks over three new layer repositories are above the usual per-plan budget, but the user confirmed exactly six plans (D-20, the lean-planning rule in CLAUDE.md), so this plan is not split. After Task 4 is committed, write a progress note into a draft `20-04-SUMMARY.md` (Tasks 1 to 4 with their commits, the approved livekit-client version, the decline endpoint, open points) so a context reset resumes from Task 5. + + +forum API /_forum/api/v1 (JWT, forum-api 120/min): GET /contexts/{type}/{id}/categories; GET /contexts/{type}/{id}/categories/{categoryId}/threads; POST same (create thread {title, body}); GET /contexts/{type}/{id}/threads; GET /contexts/{type}/{id}/threads/{threadId}/posts; POST same (reply {body}); PUT /contexts/{type}/{id}/posts/{postId} ({body}); DELETE /contexts/{type}/{id}/posts/{postId}; DELETE /contexts/{type}/{id}/threads/{threadId}; POST /contexts/{type}/{id}/threads/{threadId}/read; GET /contexts/{type}/{id}/unread; POST /contexts/{type}/{id}/preview. Posts carry content_html (server-rendered with breakwater). 422 envelope as chat. +userfriends API /_userfriends/api/v1 (JWT; userfriends-api 120/min, community-join 10/min): GET /friends, /friends/requests, /friends/sent; POST /friends/request ({user_id} or {username} or {email}); POST /friends/requests/{id}/accept; DELETE /friends/requests/{id}; DELETE /friends/sent/{id}; DELETE /friends/{userId}; GET|POST /groups; GET|PUT|DELETE /groups/{id}; POST /groups/{id}/join, /leave; GET|POST /groups/{id}/members; DELETE /groups/{id}/members/{userId}; GET|POST|DELETE /groups/{id}/invite; GET|POST /groups/join/{code}. +chatvideo API /_chatvideo/api/v1 (JWT): POST /conversations/{id}/video/start (video-initiate 5/min) and /join give {livekit_url, token, ...}; POST /leave, /end; GET /status. Realtime: video:call:incoming on user:{id}; video:call:accepted, declined, ended and participant events on video:session:{id}. +vue-chat-layer v0.1.0 (plan 03): slots conversation-actions (DM only) and sidebar-footer on ChatPage; `onUserEvent(handler)`, `subscribe(channel, hooks)` in `vue-chat-layer/app/lib/realtime`; `$chatAuth {token, refresh, unauthorized}`. +herald: keys golem15.herald.vapid.{subject, public_key, private_key}; env SUMMER_GOLEM15__HERALD__VAPID__*; command herald:vapid-generate (--json prints {"public_key", "private_key"}; -q quiet). +vue-demo-app (after plan 03): app/pages/chat.vue (ChatPage in ClientOnly), app/layouts/default.vue (ChatFloatingPanel), app/pages/index.vue (stops with chat), app/components/TourCard.vue, app/components/SiteHeader.vue (links mine and security), app/pages/account/security.vue, app/plugins/auth.ts (paymentGatewayAuth, chatAuth). + + + + + + + Task 1: Tracer: a member opens /forum, sees the seeded categories, opens a thread and posts a reply that the server renders; an anonymous visitor gets the register prompt + The plan 03 SUMMARY records vue-chat-layer v0.1.0 on git.golem15.com; `../sm-demo-app/scripts/dev.sh up` is running; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes. + ../vue-forum-layer/.gitignore, ../vue-forum-layer/.nvmrc, ../vue-forum-layer/package.json, ../vue-forum-layer/pnpm-lock.yaml, ../vue-forum-layer/pnpm-workspace.yaml, ../vue-forum-layer/tsconfig.json, ../vue-forum-layer/nuxt.config.ts, ../vue-forum-layer/README.md, ../vue-forum-layer/.playground/nuxt.config.ts, ../vue-forum-layer/.playground/app/app.vue, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-forum-layer/app/composables/useForumApi.ts, ../vue-forum-layer/app/composables/useForum.ts, ../vue-forum-layer/app/components/Forum/Page.vue, ../vue-forum-layer/app/components/Forum/CategoryList.vue, ../vue-forum-layer/app/components/Forum/ThreadList.vue, ../vue-forum-layer/app/components/Forum/Thread.vue, ../vue-forum-layer/app/components/Forum/PostEditor.vue, ../vue-forum-layer/app/assets/css/forum-base.css, ../vue-forum-layer/i18n/locales/en.json, ../vue-forum-layer/i18n/locales/pl.json, ../vue-forum-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue, ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh + ../vue-faq-layer/{nuxt.config.ts,package.json,README.md,app/utils/client.ts,app/composables/useFaqApi.ts,tests/client.test.ts}, ../vue-paymentgateway-layer/{nuxt.config.ts,app/composables/usePaymentGatewayApi.ts}, ../sm-demo-app/plugins/golem15/forum/README.md and api/*.go (payloads), ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,app/plugins/auth.ts,app/pages/chat.vue,app/pages/quizzes/index.vue}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, json_has, ALL_CHECKS) and smoke.sh (page loop) + Per D-14, D-21, D-22, SC-2. + +1. Create ../vue-forum-layer (`git init -b main`) with the vue-faq-layer skeleton: + - package.json: name vue-forum-layer, version 0.1.0, main nuxt.config.ts, peer nuxt 4.4.8, dependency @nuxtjs/i18n 10.4.0, dev deps nuxt, vue and typescript at the workspace pins, test `node --test --test-reporter=tap tests/*.test.ts`; + - nuxt.config.ts: header comment, currentDir, runtimeConfig `forum.apiBase` (NUXT_FORUM_API_BASE) and `public.forum.apiBase: '/_forum/api/v1'`, the EN/PL i18n registration, absolute css path. +2. app/utils/forumClient.ts `createForumClient({ base, token, fetch })`: categories, threads (per category and all), posts, createThread, reply, editPost, deletePost, deleteThread, markRead, unread and preview, each building `/contexts/{type}/{id}/...` paths, plus the 422 field-error mapping. app/composables/useForumApi.ts binds it to runtime config and `$forumAuth`. useForum keeps the selected category, the threads, and the open thread and posts. +3. Components: + - ForumPage (props contextType, contextId, threadPath(id)): ForumCategoryList plus ForumThreadList with a "new thread" ForumPostEditor; + - ForumThread (props contextType, contextId, threadId): each post renders the server's content_html as raw HTML (the only raw-HTML binding in the layer), with author and date, reply editor, Edit and Delete on own posts and on all posts when the API reports manage rights (a refused delete hides the action); + - ForumPostEditor: textarea plus a server preview button. + Mark the thread read on open. +4. vue-demo-app: + - run `pnpm add vue-forum-layer@link:../../vue-forum-layer` (development pin, committed; Task 7 swaps it for the tag before any push); + - nuxt.config.ts: extends gains 'vue-forum-layer'; goPaths gains '/_forum'; `forum.apiBase` comes from `api`; + - auth.ts provides `forumAuth`; + - app/pages/forum/index.vue and forum/t/[id].vue: for members, ClientOnly ForumPage and ForumThread with context demo/1 and `threadPath: id => '/forum/t/' + id`; for anonymous visitors, a server-rendered description and a register link with next set to the current path (D-21, the chat page pattern). useSeoMeta on both; + - EN/PL copy: forum.title, forum.lead and forum.registerToJoin. +5. check-local.sh `check_forum`: + - with the visitor JWT, GET /_forum/api/v1/contexts/demo/1/categories answers 200 with at least three categories; + - POST a reply to the first thread answers 201 and its content_html holds a rendered paragraph; + - GET $NUXT/forum without a cookie holds the register link with next=%2Fforum. + Add it to ALL_CHECKS. smoke.sh adds /forum to the page loop. +6. tests/client.test.ts: URL building for every method, the Bearer header, and the 422 mapping. + +Commit the layer (`feat: forum layer: categories, threads and Markdown posts`), vue-demo-app (package.json and pnpm-lock.yaml with the link pin, pages, config, auth and locales) and sm-demo-app scripts separately. + + pnpm --dir ../vue-forum-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && ../sm-demo-app/scripts/check-local.sh forum + Non-zero exit: the forum client tests fail, the Nuxt build fails, the stack does not start, or check-local prints "check-local: forum failed" (categories missing, the reply not rendered, or no register link for anonymous visitors). + + + - `grep -rniE 'golem15\.demo|sm-demo|demo/1' ../vue-forum-layer/app ../vue-forum-layer/nuxt.config.ts ../vue-forum-layer/README.md | wc -l` prints 0. + - `grep -rn 'v-html' ../vue-forum-layer/app/components | grep -vc 'content_html'` prints 0. + - `grep -c "'/_forum'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1. + + Members read and post in the seeded forum on the demo site through a new shared layer; anonymous visitors are invited to register. + + + + Task 2: Friends: list, requests and groups on /friends, linked from the chat sidebar and the account area + ../vue-userfriends-layer/.gitignore, ../vue-userfriends-layer/.nvmrc, ../vue-userfriends-layer/package.json, ../vue-userfriends-layer/pnpm-lock.yaml, ../vue-userfriends-layer/pnpm-workspace.yaml, ../vue-userfriends-layer/tsconfig.json, ../vue-userfriends-layer/nuxt.config.ts, ../vue-userfriends-layer/README.md, ../vue-userfriends-layer/.playground/nuxt.config.ts, ../vue-userfriends-layer/.playground/app/app.vue, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../vue-userfriends-layer/app/composables/useFriendsApi.ts, ../vue-userfriends-layer/app/composables/useFriends.ts, ../vue-userfriends-layer/app/components/Friends/Page.vue, ../vue-userfriends-layer/app/components/Friends/List.vue, ../vue-userfriends-layer/app/components/Friends/Requests.vue, ../vue-userfriends-layer/app/components/Friends/Groups.vue, ../vue-userfriends-layer/app/components/Friends/AddFriend.vue, ../vue-userfriends-layer/app/assets/css/friends-base.css, ../vue-userfriends-layer/i18n/locales/en.json, ../vue-userfriends-layer/i18n/locales/pl.json, ../vue-userfriends-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/friends.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/pages/account/security.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh + ../sm-demo-app/plugins/golem15/userfriends/README.md and api/friends.go and api/groups.go (bodies, 422 behaviour), ../vue-forum-layer (Task 1, same skeleton), ../vue-chat-layer/README.md (sidebar-footer slot), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/components/SiteHeader.vue,app/pages/account/security.vue,app/plugins/auth.ts}, ../sm-demo-app/plugins/demo/chat_api.go (members endpoint) + Per D-15. +- Create ../vue-userfriends-layer with the Task 1 skeleton (runtime `userfriends.apiBase`, `public.userfriends.apiBase: '/_userfriends/api/v1'`). +- app/utils/friendsClient.ts `createFriendsClient`: friends, incoming, sent, request({user_id} or {username}), accept, reject, cancel, unfriend, groups, group, createGroup, updateGroup, deleteGroup, join, leave, members, kick, invite, joinByCode. useFriendsApi binds `$userfriendsAuth`. +- Components: + - FriendsList: friends with Unfriend; + - FriendsRequests: incoming with Accept and Reject, sent with Cancel; + - FriendsAddFriend: picks from `$userfriendsDirectory()` when provided, else a username field; the generic 422 shows the server message; + - FriendsGroups: my groups, create (name, visibility as the API allows), join by code, leave, member list; + - FriendsPage: all four in sections. +- vue-demo-app: + - pnpm link dependency; extends 'vue-userfriends-layer'; goPaths '/_userfriends'; runtime config; + - auth.ts provides `userfriendsAuth` and `userfriendsDirectory` (GET /_demo/api/v1/chat/members with the JWT, mapped to {id, name: name plus surname}); + - app/pages/friends.vue: ClientOnly FriendsPage for members, the register prompt for anonymous visitors; + - chat.vue passes `