docs(20): gap-closure plans for the code review findings
Claude-Session: https://claude.ai/code/session_01BK6kckmLyJ4a91Vm9rqaDP
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: ["20-06"]
|
||||
gap_closure: true
|
||||
review_findings: [CR-02, CR-03]
|
||||
files_modified:
|
||||
- ../sm-moderation-plugin/classes/filter.go
|
||||
- ../sm-moderation-plugin/classes/normalize.go
|
||||
- ../sm-moderation-plugin/classes/html.go
|
||||
- ../sm-moderation-plugin/classes/bypass_test.go
|
||||
- ../sm-moderation-plugin/classes/html_test.go
|
||||
- ../sm-moderation-plugin/classes/filter_test.go
|
||||
- ../sm-moderation-plugin/models/settings.go
|
||||
- ../sm-moderation-plugin/models/settings_test.go
|
||||
- ../sm-moderation-plugin/plugin_test.go
|
||||
- ../sm-moderation-plugin/go.mod
|
||||
- ../sm-moderation-plugin/go.sum
|
||||
- ../sm-moderation-plugin/README.md
|
||||
- ../sm-forum-plugin/events/events.go
|
||||
- ../sm-forum-plugin/classes/forum_service.go
|
||||
- ../sm-forum-plugin/classes/lifecycle.go
|
||||
- ../sm-forum-plugin/saving_test.go
|
||||
- ../sm-forum-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [D-17, D-18, SC-2]
|
||||
estimate:
|
||||
tokens: 170000
|
||||
raw_tokens: 170000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CR-03, D-17: classes.Match and Filter.Check refuse every word bypass the reviewer listed: a zero-width space or soft hyphen inside a banned word (and every other format character), a no-break, thin or ideographic space between the words of a banned phrase, decomposed (NFD) Polish diacritics against an NFC entry, fullwidth letters, HTML entities (viagra) and inline markup inside a word (via**gra**, via`gra`, _viagra_). Matching is diacritic-insensitive (zolw matches the entry żółw) and reports the entry as listed."
|
||||
- "CR-02, D-17: classes.Match and Filter.Check refuse every link bypass the reviewer listed: a percent-encoded host (bit%2Ely, bit.l%79), fullwidth letters and ideographic, fullwidth or halfwidth dots in the host, a port in front of a path rule (example.com:443/promo), a percent-encoded or doubled path (/%70romo, //promo), a trailing dot (example.com./promo), a subdomain of a path rule (sub.example.com/promo), userinfo (user@bit.ly), an entity in a link destination ([x](https://bit.ly/a)), and either spelling of an IDN (punycode against Unicode, both ways). The http(s) scheme with no or mixed slashes and backslashes after the colon (https:bit.ly) counts as the host it opens."
|
||||
- "Clean text stays clean: every existing case of TestMatchWords, TestMatchWordsFirstAndEmpty, TestMatchLinks, TestMatchLinksInvalidEntries and TestParseList keeps its expectation, except `a.shop.example/promo`, which the review's subdomain rule now flags."
|
||||
- "CR-03, D-18: forum's events.PostSaving carries HTML, the content_html breakwater renders from Body, on thread, reply and edit; the value equals what is stored. With no listener the forum's routes, payloads, stored rows and status codes are unchanged, and the existing seam, golden and method-map tests pass unedited."
|
||||
- "CR-03: classes.MatchHTML and Filter.CheckHTML read rendered HTML as a member sees it (text across inline tags joined, entities decoded, href and src values as links), so the forum's Markdown forms the raw check cannot see (bit\\.ly, via<!-- x -->gra, [x](https:bit.ly), a reference link with an entity in its destination) are refused once a host checks PostSaving.HTML."
|
||||
- "No new module enters the build: golang.org/x/text v0.42.0 and golang.org/x/net v0.58.0 become direct requires of sm-moderation-plugin at the versions the framework already requires directly (summercms.go go.mod), resolved offline."
|
||||
artifacts:
|
||||
- path: "../sm-moderation-plugin/classes/normalize.go"
|
||||
provides: "text normaliser (entities, NFKC, format characters, Unicode spaces, diacritics, inline markup) and the link-candidate canonicaliser (percent-decoding, dots, IDNA, port, userinfo, path)"
|
||||
contains: "norm.NFKC"
|
||||
- path: "../sm-moderation-plugin/classes/html.go"
|
||||
provides: "MatchHTML and Filter.CheckHTML over x/net/html tokens"
|
||||
contains: "func MatchHTML"
|
||||
- path: "../sm-moderation-plugin/classes/bypass_test.go"
|
||||
provides: "the reviewer's probes as regression tests"
|
||||
contains: "TestMatchBypassLinks"
|
||||
- path: "../sm-forum-plugin/events/events.go"
|
||||
provides: "PostSaving.HTML"
|
||||
contains: "HTML"
|
||||
key_links:
|
||||
- from: "../sm-forum-plugin/classes/forum_service.go"
|
||||
to: "../sm-forum-plugin/events/events.go"
|
||||
via: "CreateThread and Reply render first and fire PostSaving with HTML set to the rendered body"
|
||||
pattern: "HTML: *html"
|
||||
- from: "../sm-forum-plugin/classes/lifecycle.go"
|
||||
to: "../sm-forum-plugin/events/events.go"
|
||||
via: "EditPost renders first and fires PostSaving with HTML"
|
||||
pattern: "HTML: *html"
|
||||
- from: "../sm-moderation-plugin/classes/filter.go"
|
||||
to: "../sm-moderation-plugin/classes/normalize.go"
|
||||
via: "compile folds every entry and match runs on the normalised text, Check and Match alike"
|
||||
pattern: "normali[sz]e"
|
||||
prohibitions:
|
||||
- statement: "sm-moderation-plugin imports neither sm-chat-plugin nor sm-forum-plugin."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No push, tag or deploy in this plan; sm-demo-app is not edited here (its listener change needs the new tags and lands in 20-09)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Forum's HTTP API, stored columns, migrations and config keys do not change; PostSaving gains a field and nothing else changes shape."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): sm-demo-app runs every shared plugin and its public site shows them working, deployed publicly.
|
||||
|
||||
This plan's slice (gap round after the code review, plan 7 of 9, wave 7): the moderation filter refuses what members actually see, not only the literal text they type. It closes CR-02 (banned-link bypass through URL forms browsers normalise) and CR-03 (banned-word bypass through invisible characters, Unicode spaces, decomposed diacritics and forum Markdown) from 20-REVIEW.md, in the two repositories that own the code: sm-moderation-plugin (the filter) and sm-forum-plugin (the pre-save seam passes the rendered post).
|
||||
|
||||
**Unit tests rule for this gap round:** the project rule "unit tests are the last plan of every phase" is met because every fix in this plan ships with its own regression tests in the repository that owns the code, and plan 20-09 runs every touched repository's full suite before anything is tagged.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Where the rendered text comes from.** Forum renders the body before it fires PostSaving and passes the result in a new field `HTML` (additive; the review's second option, "so every host gets it"). The moderation plugin offers `MatchHTML` and `Filter.CheckHTML` to read it. Moderation stays free of chat and forum imports; HTML is generic, so a later host (journal posts, a rich editor) can use the same call.
|
||||
- **The raw check stays and gets stronger.** `Check` and `Match` normalise the text themselves (entities, NFKC, format characters, Unicode spaces, diacritics, inline markup inside words), so chat messages, which the browser renders with vue-chat-layer's inline renderer, and forum titles are covered without any host change. The three forum forms the reviewer reproduced (via**gra**, viagra, an entity in a link destination) are already refused on the raw body by this normalisation; `CheckHTML` adds the forms only the renderer resolves.
|
||||
- **The demo listener.** `plugins/demo/moderation.go` `checkMessage` needs no change (Check normalises). `checkPost` needs one more check, `CheckHTML` on `ev.HTML`, which compiles only against the new moderation and forum tags. sm-demo-app's go.work points at the submodule checkouts pinned to the current tags, so that edit is made in 20-09 right after the dependency bumps, keeping every sm-demo-app commit buildable. This plan records the finding in its SUMMARY.
|
||||
- **Dependencies (CLAUDE.md rule).** `golang.org/x/text` (unicode/norm) and `golang.org/x/net` (idna for punycode, html for the tokenizer) move from indirect to direct requires of sm-moderation-plugin. Both are already direct requires of the framework at v0.42.0 and v0.58.0 (summercms.go go.mod), already in the plugin's go.sum and in the module cache, and sm-journal-plugin imports `x/net/html` directly (.planning/notes/journal-html-sanitizing-bluemonday.md). The standard library has no NFKC, IDNA or HTML tokenizer. This plan is the phase note that names them; the README's Dependencies section says so too.
|
||||
- **Diacritic-insensitive matching** folds both the entries and the text: NFD, drop nonspacing marks, recompose, plus a small map for the letters that have no decomposition (ł, đ, ø, ħ, ŧ and their capitals). An entry is reported as listed, so `Match` keeps answering "żółw", not its folded form.
|
||||
- **Path rules match subdomains** as host rules do (the review's fix). This flips one existing expectation, `a.shop.example/promo`, from clean to flagged; the plan updates that case and no other.
|
||||
- **Versions.** The next patch tags are v0.1.1 for sm-moderation-plugin (its latest tag is v0.1.0; the brief's v0.1.2 assumed a v0.1.1 that does not exist) and v0.1.2 for sm-forum-plugin (latest v0.1.1). The README changelog entries name those versions; 20-09 re-checks the tags before the release gate.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
<objective>
|
||||
Make the banned-word and banned-link filter match what a member sees: normalise the text and every link candidate before matching, read rendered forum HTML, and give the forum seam the rendered post.
|
||||
|
||||
Purpose: D-17 (banned words and links refused in chat and forum) holds against anyone who tries more than once; D-18's seam carries what the forum stores. Closes CR-02 and CR-03 server-side.
|
||||
Output: commits in ../sm-moderation-plugin and ../sm-forum-plugin (not pushed, not tagged), with regression tests for every bypass the reviewer listed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md
|
||||
|
||||
Repos: ../sm-moderation-plugin (main at 313d117, tag v0.1.0) and ../sm-forum-plugin (main at 13aa642, tag v0.1.1). Both have a tracked go.work (`use .`, framework replaced by ../summercms.go). Read-only reference: ../sm-demo-app/plugins/demo/moderation.go (the listener), ../summercms.go/modules/breakwater (Render), ../sm-demo-app/plugins/demo/console/seed_friends.go (the demo's default lists: buy followers, casino bonus, free crypto, viagra; bit.ly).
|
||||
|
||||
Rules: no co-author tags; stage files by explicit path; plugin code and planning docs in separate commits; no push or tag (20-09 does both after a go-ahead); framework code and these plugins name no consuming application (README examples use `acme`); check `git status` in each repo before editing and leave edits that are not this plan's alone. Docker tests skip under `-short`; if the Docker socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md). Resolve modules offline: `GOFLAGS=-mod=mod GOPROXY=off` for `go mod tidy`.
|
||||
|
||||
The reviewer's probe: /tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/0fc9bf8a-bc2a-4cab-8faa-51763a879541/scratchpad/modtest/main_test.go (a module that replaces sm-moderation-plugin with the sibling checkout; run it with `GOWORK=off GOFLAGS=-mod=mod GOPROXY=off go test -v` in that directory). It may be gone after a reboot; its cases are copied into bypass_test.go in Task 1 and Task 2, which are the record.
|
||||
|
||||
<interfaces>
|
||||
sm-moderation-plugin today (classes/filter.go): consts KindWord, KindLink; type Violation {Kind, Match, Message}; `boundary` = non-letter, non-digit, non-underscore; `hostPattern` regexp (labels of letters, digits, inner hyphens, a dot; optional http(s) scheme; optional path); `ParseList(text) []string` (trim, lower-case, collapse whitespace, dedupe); unexported `compile(words, links) (*matcher, error)` and `(*matcher).match(text) *Violation` (words regexp `(?i)(?:^|boundary)(alt|alt)(?:$|boundary)` with `\s+` between phrase words; link rules {entry, host, path}, host rules match the host and its subdomains, path rules the same host and a path prefix); `Match(words, links, text) *Violation` (pure); `Filter` {db, tr, cache} with `NewFilter(db)`, `SetTranslator(tr)`, `Check(ctx, text) (*Violation, error)` (reads row 1, enabled switch, recompiles on change, translated message).
|
||||
models/settings.go: `LinkEntry(entry) (host, path string, ok bool)` (host entry or http(s) URL, lower-cased, www. and trailing dot dropped, path unescaped, port and userinfo refused), `ValidHost(h)`, `Lines`, `Text`, `Settings.BeforeValidate` (uses LinkEntry to refuse bad link entries).
|
||||
sm-forum-plugin: events.PostSaving {ContextType, ContextID, ActorID, ThreadID, PostID, Title, Body, Op}; fire sites CreateThread (classes/forum_service.go, Fire at ~284, Render at ~290), Reply (~341, Render ~347), EditPost (classes/lifecycle.go ~235, Render ~241), each guarded by `s.bus != nil`; tests saving_test.go TestPostSavingNoListener, TestPostSavingRejects (Docker harness).
|
||||
breakwater.Render output for the forum probes (checked during planning): via**gra** → `<p>via<strong>gra</strong></p>`; viagra → `<p>viagra</p>`; [x](https://bit.ly/a) → an anchor with href https://bit.ly/a; bit\.ly → `<p>bit.ly</p>`; via<!-- -->gra → `<p>viagra</p>`; [x](https:bit.ly) → href "https:bit.ly"; _viagra_ → `<em>viagra</em>`; a reference link `[x][1]` with `[1]: https://bit.ly/r` → href https://bit.ly/r.
|
||||
Go facts (checked during planning): net/url refuses a percent-escape in the host ("invalid URL escape %2E"), so the authority is percent-decoded before parsing; `idna.Lookup.ToASCII` maps fullwidth letters and the dots U+3002, U+FF0E and U+FF61 and gives punycode for an IDN (żółw.pl → xn--w-uga1v8h.pl); NFKC maps fullwidth letters and U+FF0E but not U+3002.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a banned word split by a zero-width space and a banned host written as bit%2Ely are refused by Match and by Filter.Check, through one normaliser and one link canonicaliser, under test</name>
|
||||
<precondition>`git -C ../sm-moderation-plugin status --porcelain` prints nothing and `git -C ../sm-moderation-plugin rev-parse HEAD` equals `git -C ../sm-moderation-plugin ls-remote origin refs/heads/main` (fast-forward base).</precondition>
|
||||
<files>../sm-moderation-plugin/classes/normalize.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/bypass_test.go, ../sm-moderation-plugin/plugin_test.go, ../sm-moderation-plugin/go.mod, ../sm-moderation-plugin/go.sum</files>
|
||||
<read_first>../sm-moderation-plugin/classes/filter.go (whole file), classes/filter_test.go (runMatch helper and the existing cases), classes/filter_internal_test.go, plugin_test.go (TestMain, the testcontainers harness, TestFilterCheckDisabled), models/settings.go (LinkEntry), go.mod, 20-REVIEW.md CR-02 and CR-03, the scratchpad probe main_test.go</read_first>
|
||||
<action>Per D-17, closing the first case of CR-03 and of CR-02 end to end.
|
||||
1. Red first: create classes/bypass_test.go (package classes_test, reusing the runMatch shape of filter_test.go) with `TestMatchBypassWords` holding one case, the word "viagra" with a U+200B zero-width space after "via", and `TestMatchBypassLinks` holding one case, "https://bit%2Ely/x" against the host entry bit.ly. Lists come from `classes.ParseList` of the demo defaults (words "buy followers", "casino bonus", "free crypto", "viagra"; links "bit.ly" and "example.com/promo"). Run them and see both fail.
|
||||
2. classes/normalize.go (new, unexported helpers, doc comments in the file's style):
|
||||
- a text normaliser used by both Match and Filter.Check: decode HTML entities once with the standard library's `html.UnescapeString`, apply `norm.NFKC` (golang.org/x/text/unicode/norm), drop every rune of category Cf (zero-width space, joiners, word joiner, BOM, soft hyphen, bidi controls), and turn every rune that `unicode.IsSpace` or the Z categories accept into a plain space;
|
||||
- a link-candidate canonicaliser: given one candidate (with or without an http(s) scheme), percent-decode its authority once (the WHATWG parser does; net/url refuses an escape in the host), parse it with net/url, take `Hostname()` (this drops the port and userinfo), lower-case it, drop a trailing dot and a leading "www.", and convert it with `idna.Lookup.ToASCII` (golang.org/x/net/idna), falling back to the lower-cased NFKC form when ToASCII refuses the label.
|
||||
3. classes/filter.go: `compile` folds each word entry with the same normaliser before quoting it, and remembers the entry as listed; `match` normalises the text first; link candidates found in the text go through the canonicaliser, and rule hosts are converted to their ASCII form once in compile. Keep `Violation.Match` the entry as listed (ParseList's form). This task only needs candidate extraction to see "bit%2Ely" as one host: widen candidate extraction so a host may contain percent-escapes (Task 2 replaces it with full tokenising).
|
||||
4. go.mod: `GOFLAGS=-mod=mod GOPROXY=off go -C ../sm-moderation-plugin mod tidy`; golang.org/x/text and golang.org/x/net move to the direct block at v0.42.0 and v0.58.0 (no version change, no new module).
|
||||
5. plugin_test.go: add `TestFilterCheckNormalises` (Docker, the existing harness): with the settings row holding the demo default lists, `Filter.Check` on the zero-width-space word answers KindWord "viagra" and on "https://bit%2Ely/x" KindLink "bit.ly", and clean text answers nil.
|
||||
6. Make both bypass tests and every existing test green. Commit in sm-moderation-plugin: `fix: normalise text and link hosts before matching banned entries`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-moderation-plugin vet ./... && go -C ../sm-moderation-plugin test -count=1 -short ./... && go -C ../sm-moderation-plugin test -count=1 -short -run '^(TestMatchBypassWords|TestMatchBypassLinks)$' -v ./classes 2>&1 | grep -c -- '^--- PASS: TestMatchBypass' | grep -qx 2 && go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterCheckNormalises$' .</automated>
|
||||
<fails_when>Non-zero exit: vet fails, an existing short test breaks, either bypass test is missing or red, or the Docker test TestFilterCheckNormalises fails (the stored lists do not reach the normalised matcher).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^\s+golang.org/x/(text|net) v0\.(42|58)\.0$' ../sm-moderation-plugin/go.mod` prints 2 (both direct, without the indirect comment).
|
||||
- `go -C ../sm-moderation-plugin list -deps ./... | grep -cE 'sm-(chat|forum)-plugin'` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>One invisible-character word and one percent-encoded host are refused by the pure matcher and by the stored-list filter, and the tracer's normaliser and canonicaliser are the single path every later case goes through.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Every word and link bypass the reviewer listed is refused, entries are normalised the same way, and clean text stays clean</name>
|
||||
<files>../sm-moderation-plugin/classes/normalize.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/bypass_test.go, ../sm-moderation-plugin/classes/filter_test.go, ../sm-moderation-plugin/models/settings.go, ../sm-moderation-plugin/models/settings_test.go, ../sm-moderation-plugin/README.md</files>
|
||||
<read_first>The Task 1 code, classes/filter_test.go (all cases), models/settings_test.go (TestLinkEntry), README.md (Features, Usage, API reference, Dependencies, Testing, Changelog), 20-REVIEW.md CR-02 table and CR-03 list, ../vue-chat-layer/app/lib/markdown.ts formatSpans and ../vue-chat-layer/app/lib/chatText.ts CODE_RE (the inline markup chat renders)</read_first>
|
||||
<behavior>
|
||||
- Words (TestMatchBypassWords): viagra split by U+200B, U+00AD, U+200D, U+2060 or U+FEFF; "buy followers" with U+00A0, U+2009, U+202F or U+3000 between the words; fullwidth viagra; viagra and viagra; via**gra**, via`gra`, via~~gra~~ and _viagra_; with the entry żółw, its NFD spelling (z plus U+0307, o plus U+0301, ł, w) and the folded spelling zolw both flag and report "żółw".
|
||||
- Words that must stay clean: spam_bot, _spam, spamming (with spam listed), and every existing TestMatchWords case.
|
||||
- Links (TestMatchBypassLinks), against bit.ly and example.com/promo: https://bit%2Ely/x, https://bit.l%79/x, bit.l%79/x, https://bit.ly/x, https://bit。ly/x, https://bit.ly/x, https://bit。ly/x, https://user@bit.ly/x, https://example.com@bit.ly/, [x](https://bit.ly/a), https:bit.ly, https:/\bit.ly/x, https://bit.ly./x, https://example.com:443/promo, https://example.com/%70romo, https://example.com//promo, https://example.com/./promo, https://example.com./promo, https://sub.example.com/promo; with the entry żółw.example, https://xn--w-uga1v8h.example/ flags, and with the entry xn--w-uga1v8h.example, https://żółw.example/ flags.
|
||||
- Links that must stay clean: https://notbit.ly/x, https://example.com/about, https://example.com/x/promo, https://bit.lyx/, and every existing TestMatchLinks case except a.shop.example/promo, which now flags (path rules cover subdomains).
|
||||
</behavior>
|
||||
<action>Per D-17, closing CR-02 and the text half of CR-03.
|
||||
- Extend bypass_test.go with every case of the behavior block (one table per test, each case commented with the bypass it pins), then make it green:
|
||||
- normaliser: after the Task 1 steps, fold diacritics (NFD, drop category Mn, recompose with NFC, then map ł Ł đ Đ ø Ø ħ Ħ ŧ Ŧ to their base letters); the same function folds the entries in compile, and a map from folded entry to listed entry keeps `Violation.Match` as listed. Join the words of a phrase with `[\p{Z}\s]+`.
|
||||
- markup: `match` runs the words and links on two forms of the text, first the normalised text, then the same text with inline markup removed: every asterisk, backtick and tilde dropped, and an underscore pair around a word (`_word_` between boundaries, the rule of vue-chat-layer's formatSpans) unwrapped. A lone underscore stays a word character, so spam_bot and _spam stay clean.
|
||||
- links: replace the regexp candidate extraction with tokenising: split the normalised text on whitespace and on the delimiters `<>"'()[]{}`, trim trailing sentence punctuation (.,;:!?), and treat a token as a candidate when it has an http(s) scheme or contains a dot after the dot mapping. For http and https, any run of slashes and backslashes after the colon (none included) counts as `//`, as the WHATWG parser reads special schemes. The candidate's path is percent-decoded and cleaned with path.Clean after a leading slash (collapses //, /./ and dot segments) and compared lower-cased. A path rule matches its host and every subdomain, like a host rule.
|
||||
- models/settings.go `LinkEntry`: apply NFKC and the dot mapping to the entry, and return the host in its IDNA ASCII form (`idna.Lookup.ToASCII`); an entry whose host ToASCII refuses is not ok, so the admin form refuses it. Existing ASCII cases of TestLinkEntry keep their results; change the żółw.example case to expect its punycode host and add cases for a fullwidth entry and an entry with U+3002.
|
||||
- filter_test.go: change only the a.shop.example/promo case to expect a KindLink violation for https://shop.example/promo, with a comment that path rules cover subdomains (CR-02).
|
||||
- README.md: Features (normalisation and link canonicalisation, in plain words, with the list of forms), Usage unchanged, API reference (LinkEntry's ASCII host), Dependencies (golang.org/x/text and golang.org/x/net, why, at the framework's versions), Testing (the new test names), Changelog `### v0.1.1` (banned entries match what members see: invisible characters, Unicode spaces, diacritics, entities, inline markup, URL forms browsers normalise; path rules cover subdomains; no schema, config or API change other than LinkEntry's ASCII host). Name no application.
|
||||
- Commit in sm-moderation-plugin: `fix: match banned words and links in the forms browsers show`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-moderation-plugin vet ./... && go -C ../sm-moderation-plugin test -count=1 -short ./... && go -C ../sm-moderation-plugin test -count=1 ./... && P=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/0fc9bf8a-bc2a-4cab-8faa-51763a879541/scratchpad/modtest; if [ -f "$P/main_test.go" ]; then (cd "$P" && GOWORK=off GOFLAGS=-mod=mod GOPROXY=off go test -count=1 -v . 2>&1 | grep -c 'flagged=false' | grep -qx 0); fi</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a short or Docker test fails (a bypass case still passes, a clean case is now flagged, an existing expectation other than a.shop.example/promo changed, LinkEntry refuses a valid entry); or the reviewer's probe, when it still exists, prints any flagged=false line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-moderation-plugin test -count=1 -short -run '^TestMatchBypass(Words|Links)$' -v ./classes 2>&1 | grep -c -- '^--- PASS'` prints 2.
|
||||
- `grep -F 'a.shop.example/promo' ../sm-moderation-plugin/classes/filter_test.go | grep -c 'KindLink'` prints 1 (the one existing case that changed), and `git -C ../sm-moderation-plugin diff --stat 313d117 -- classes/filter_test.go` reports 1 deletion (the old case line; the insertions are the new case and its comment).
|
||||
- `grep -c '### v0.1.1' ../sm-moderation-plugin/README.md` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>No word or link form from the review gets past the filter, an admin's entry matches in every spelling a browser accepts, and every clean sentence the earlier tests pinned still passes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: The forum seam carries the rendered post, and the filter reads rendered HTML the way a member sees it</name>
|
||||
<files>../sm-moderation-plugin/classes/html.go, ../sm-moderation-plugin/classes/html_test.go, ../sm-moderation-plugin/README.md, ../sm-forum-plugin/events/events.go, ../sm-forum-plugin/classes/forum_service.go, ../sm-forum-plugin/classes/lifecycle.go, ../sm-forum-plugin/saving_test.go, ../sm-forum-plugin/README.md</files>
|
||||
<read_first>../sm-forum-plugin/events/events.go, classes/forum_service.go (CreateThread, Reply), classes/lifecycle.go (EditPost, Preview), saving_test.go (harness, TestPostSavingNoListener, TestPostSavingRejects), README.md (Features "Pre-save seam", API reference, Testing, Changelog), ../summercms.go/modules/breakwater/breakwater.go (Render, the sanitiser policy: which tags and attributes survive), ../sm-demo-app/plugins/demo/moderation.go (checkPost, read only), the Task 2 normaliser</read_first>
|
||||
<behavior>
|
||||
- TestMatchHTML (moderation, -short): for each forum probe, MatchHTML(defaults, breakwater.Render(probe)) is a violation: via**gra**, viagra, _viagra_, via`gra`, via<!-- x -->gra (word "viagra"); [x](https://bit.ly/a), [x](https:bit.ly), bit\.ly, a reference link [x][1] with [1]: https://bit.ly/r, and an autolink <https://bit.ly/x> (link "bit.ly"). Clean Markdown (a heading, a list, a link to https://example.org/about, code with "via" in one span and "gra" in another paragraph) is not.
|
||||
- Text across inline tags joins without a boundary (via<strong>gra</strong> is "viagra"); block tags and <br> separate (<p>via</p><p>gra</p> is clean); href and src values are link candidates; alt and title text is checked as words.
|
||||
- TestPostSavingHTML (forum, Docker): a recording listener sees, for a thread, a reply and an edit, HTML equal to the content_html stored for that post; a body of viagra gives HTML that contains "viagra"; TestPostSavingNoListener and TestPostSavingRejects pass unchanged.
|
||||
</behavior>
|
||||
<action>Per D-17 and D-18, closing the forum half of CR-03.
|
||||
sm-forum-plugin:
|
||||
- events/events.go: add the field `HTML` to PostSaving, documented as the content_html that would be stored, rendered by breakwater from Body (sanitised), set on every Op. Update the package and type comments: the event fires after the Markdown is rendered and before anything is persisted.
|
||||
- classes/forum_service.go (CreateThread, Reply) and classes/lifecycle.go (EditPost): call `breakwater.Render(body)` before the Fire and pass the result as `HTML`; the transaction stores the same value. Behaviour without a listener is byte-identical (the render only moves up). Update the doc comments of the three methods.
|
||||
- saving_test.go: add TestPostSavingHTML per the behavior block.
|
||||
- README.md: Features "Pre-save seam" (fires after render, carries HTML), API reference row for PostSaving with HTML, the acme listener example also checking `ev.HTML`, Testing (TestPostSavingHTML), Changelog `### v0.1.2` (PostSaving carries the rendered HTML; additive, no route, payload, table, migration or config change). Commit in sm-forum-plugin: `feat: PostSaving carries the rendered post HTML`.
|
||||
sm-moderation-plugin:
|
||||
- classes/html.go: `MatchHTML(words, links []string, html string) *Violation` (pure) and `(*Filter).CheckHTML(ctx, html string) (*Violation, error)` (the same settings row, enabled switch, cache and translated message as Check). Walk the HTML with the golang.org/x/net/html tokenizer: text tokens (the tokenizer decodes entities) appended as they come, with no separator for inline elements and a newline for block elements and br; the values of href and src checked as link candidates on their own; alt and title appended as text. Then run the Task 2 matcher (both forms) on the joined text, words before links, and on each attribute URL.
|
||||
- classes/html_test.go: TestMatchHTML per the behavior block, rendering with breakwater (a framework module the plugin already requires).
|
||||
- README.md: Usage gets the host pattern for rendered content (check the source text with Check and the rendered HTML with CheckHTML, as in the acme example); API reference rows for MatchHTML and Filter.CheckHTML; Testing; the v0.1.1 changelog entry gains "MatchHTML and Filter.CheckHTML read rendered HTML". Commit in sm-moderation-plugin: `feat: MatchHTML and Filter.CheckHTML for rendered content`.
|
||||
- Read ../sm-demo-app/plugins/demo/moderation.go and write into the SUMMARY: checkMessage needs no change; checkPost needs `CheckHTML(ctx, ev.HTML)` after title and body (field body), which 20-09 adds after bumping the demo to these tags. Do not edit sm-demo-app here.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-forum-plugin vet ./... && go -C ../sm-forum-plugin test -count=1 -short ./... && go -C ../sm-forum-plugin test -count=1 -run '^(TestPostSavingNoListener|TestPostSavingRejects|TestPostSavingHTML|TestPostSavingTracer)$' . && go -C ../sm-moderation-plugin vet ./... && go -C ../sm-moderation-plugin test -count=1 -short -run '^TestMatchHTML$' -v ./classes 2>&1 | grep -q -- '^--- PASS: TestMatchHTML' && go -C ../sm-moderation-plugin test -count=1 ./...</automated>
|
||||
<fails_when>Non-zero exit: forum vet or tests fail (the HTML field differs from the stored content_html, the no-listener path changed, a refusal stopped working), TestMatchHTML is missing or red (a rendered probe passes, clean Markdown is flagged), or any moderation test fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 'HTML: *html' ../sm-forum-plugin/classes/forum_service.go` prints 2 and `grep -cE 'HTML: *html' ../sm-forum-plugin/classes/lifecycle.go` prints 1.
|
||||
- `grep -c '### v0.1.2' ../sm-forum-plugin/README.md` prints 1.
|
||||
- `grep -cE 'MatchHTML|CheckHTML' ../sm-moderation-plugin/README.md` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>A host can refuse a forum post by what its readers will see, and the two plugins are ready for their patch tags with every bypass pinned by a test.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| member text to the filter | untrusted chat messages, forum titles and Markdown bodies, any Unicode |
|
||||
| filter to the member's browser | what passes is rendered as text, links and link cards by the chat renderer, goldmark and the WHATWG URL parser |
|
||||
| admin settings to the filter | banned entries typed by an administrator, any Unicode |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-38 | Spoofing | banned-link matching (classes/normalize.go, filter.go) | high | mitigate | candidates are canonicalised the way browsers resolve them (percent-decoded authority, IDNA ASCII, dots mapped, port and userinfo dropped, special-scheme slashes, cleaned path) and compared with entries in the same form; TestMatchBypassLinks pins every form from CR-02 |
|
||||
| T-20-39 | Tampering | banned-word matching | high | mitigate | NFKC, format characters dropped, Unicode spaces folded, diacritics folded on both sides, entities decoded, inline markup removed in a second pass; TestMatchBypassWords pins every form from CR-03 |
|
||||
| T-20-40 | Tampering | forum Markdown rendered after the check | high | mitigate | PostSaving carries the rendered HTML and CheckHTML reads it as a member sees it; TestPostSavingHTML and TestMatchHTML pin the rendered forms (wired in the demo in 20-09) |
|
||||
| T-20-41 | Denial of service | normalising and matching large texts | low | accept | RE2 is linear, lists are bounded by max_entries and max_entry_length, the work is two passes over a message the plugins already cap in size |
|
||||
| T-20-SC | Tampering | Go module installs | high | mitigate | no new module: golang.org/x/text v0.42.0 and golang.org/x/net v0.58.0 are already direct requires of the framework and in go.sum; tidy runs with GOPROXY=off, so nothing is fetched |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vet, short tests, the two tracer bypass tests and the Docker TestFilterCheckNormalises.
|
||||
- Task 2: the full moderation suite with Docker; every reviewer probe flagged; one existing expectation flipped on purpose.
|
||||
- Task 3: forum suite with the HTML seam test and the unchanged seam tests; TestMatchHTML on breakwater output; the demo listener finding recorded for 20-09.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Every bypass in CR-02 and CR-03 is refused by the filter and pinned by a regression test, the forum seam passes the rendered post (D-18, additive), clean text is still accepted, and both plugins are committed, untagged, ready for 20-09's release gate.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-07-SUMMARY.md` when done: the commits per repo, the probe table (form, before, after), the one changed expectation, the dependency note, and the listener finding for 20-09 (checkMessage unchanged, checkPost adds CheckHTML on ev.HTML).
|
||||
</output>
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: ["20-06"]
|
||||
gap_closure: true
|
||||
review_findings: [CR-01, WR-01, WR-04, WR-05, CR-02]
|
||||
files_modified:
|
||||
- ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- ../vue-chatvideo-layer/app/lib/videoClient.ts
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.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/useCall.test.ts
|
||||
- ../vue-chatvideo-layer/tests/videoClient.test.ts
|
||||
- ../vue-chatvideo-layer/README.md
|
||||
- ../vue-chat-layer/app/lib/urlGuard.ts
|
||||
- ../vue-chat-layer/app/lib/urlGuard.test.ts
|
||||
- ../vue-chat-layer/app/lib/markdown.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
|
||||
- ../vue-chat-layer/README.md
|
||||
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
|
||||
autonomous: true
|
||||
requirements: [SC-2, D-02, D-04, D-11, D-13, D-17, D-23]
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CR-01, D-13: a call the user leaves, or whose page unmounts, while start or join is pending, while livekit-client loads, while LiveKit connects or between enabling the camera and the microphone never publishes media after the leave: no Room connects after it, setCameraEnabled and setMicrophoneEnabled are not called after it, a Room already created is disconnected, and once the server session exists the starter posts end and the callee posts leave. Leave works in the starting and connecting states. A second begin while one is pending creates no second Room."
|
||||
- "WR-05: a failed LiveKit connect posts end when the session has nobody joined besides this user (GET status), and leave otherwise; when status cannot be read, the starter posts end and the callee leave."
|
||||
- "WR-04, D-11, D-13, D-23: ChatvideoCall starts media on mount only when an in-memory handoff younger than a minute shows the user just clicked: the DM call button's start answer (start) or the incoming prompt's Accept (join). A direct load or a chat link to /call/{id}, with or without ?start=1, shows a confirmation with a Start call or Join call button and calls neither video/start nor video/join and opens no LiveKit connection until it is clicked."
|
||||
- "CR-02 (client side), D-02, D-17: the chat renders as text, with no link and no link card, any http(s) URL whose host as written is not the host the browser opens: a percent-escape, userinfo or a backslash in the authority, ideographic, fullwidth or halfwidth dots, fullwidth letters, a trailing dot, or a scheme not followed by exactly two slashes. Honest URLs, IDN hosts written in Unicode, ports, queries and fragments still link and embed. bm's copied chat tests pass unchanged."
|
||||
- "WR-01, D-04: logging out (header or login page) and the chat's unauthorized() reset the chat store, close the shared realtime socket and dismiss a ringing call prompt in the same tab; one failing part does not stop the others; the next login starts from empty chat state with a fresh connection token."
|
||||
- "Every fix has vitest regression tests in the repository that owns it; vue-demo-app builds and passes its tests on its current layer pins (v0.1.0 already exports useChat().reset, the realtime disconnect and useIncomingCalls().dismiss); nothing is pushed or tagged here."
|
||||
artifacts:
|
||||
- path: "../vue-chatvideo-layer/app/composables/useCall.ts"
|
||||
provides: "per-attempt token, stale checks after every await, leave from the pre-room states, failed-connect end rule"
|
||||
contains: "attempt"
|
||||
- path: "../vue-chatvideo-layer/app/lib/videoClient.ts"
|
||||
provides: "hasStart, rememberAccept, takeAccept and callAutostart (the click handoffs)"
|
||||
contains: "callAutostart"
|
||||
- path: "../vue-chatvideo-layer/app/components/Chatvideo/Call.vue"
|
||||
provides: "the join confirmation"
|
||||
contains: "chatvideo-confirm"
|
||||
- path: "../vue-chat-layer/app/lib/urlGuard.ts"
|
||||
provides: "honestHttpUrl"
|
||||
contains: "honestHttpUrl"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts"
|
||||
provides: "createCommunitySignOut and endSession"
|
||||
contains: "createCommunitySignOut"
|
||||
key_links:
|
||||
- from: "../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue"
|
||||
to: "../vue-chatvideo-layer/app/lib/videoClient.ts"
|
||||
via: "Accept leaves the join handoff before it navigates"
|
||||
pattern: "rememberAccept"
|
||||
- from: "../vue-chatvideo-layer/app/components/Chatvideo/Call.vue"
|
||||
to: "../vue-chatvideo-layer/app/lib/videoClient.ts"
|
||||
via: "mount begins only when callAutostart answers true"
|
||||
pattern: "callAutostart"
|
||||
- from: "../vue-chat-layer/app/lib/markdown.ts"
|
||||
to: "../vue-chat-layer/app/lib/urlGuard.ts"
|
||||
via: "safeUrl allows an http(s) target only through honestHttpUrl"
|
||||
pattern: "honestHttpUrl"
|
||||
- from: "../vue-chat-layer/app/lib/linkEmbeds.ts"
|
||||
to: "../vue-chat-layer/app/lib/urlGuard.ts"
|
||||
via: "linkify and messageUrls parse through honestHttpUrl"
|
||||
pattern: "honestHttpUrl"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
|
||||
to: "../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts"
|
||||
via: "the plugin builds the sign-out and hands it to createChatAuth and to logout"
|
||||
pattern: "createCommunitySignOut"
|
||||
prohibitions:
|
||||
- statement: "No push, tag or pin change in this plan; vue-demo-app keeps its v0.1.0 layer pins and the sm-demo-app gitlink is not bumped (20-09)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new package in any package.json or lockfile; tests use vitest, vi.mock and pure helpers (no @vue/test-utils, which no lockfile has)."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "Neither layer names a consuming application or a host path."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): sm-demo-app runs every shared plugin and its public site shows them working, video calls included.
|
||||
|
||||
This plan's slice (gap round after the code review, plan 8 of 9, wave 7, parallel with 20-07: no shared file): the call and session fixes from 20-REVIEW.md in the repositories that own the code.
|
||||
- CR-01: leaving or unmounting during start or join still connected and published the camera and microphone.
|
||||
- WR-05: a failed connect posted leave and left an empty ringing session.
|
||||
- WR-04: a link opened a call and turned media on with no confirmation.
|
||||
- WR-01: logout left the previous user's chat store, realtime socket and call prompt running.
|
||||
- The client half of CR-02: the chat's link-embed rendering gets the guard the brief asked for; the server half is 20-07.
|
||||
|
||||
**Unit tests rule for this gap round:** the project rule "unit tests are the last plan of every phase" is met because every fix here ships with vitest regression tests in the repository that owns it, and 20-09 runs every touched repository's full suite before anything is tagged.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **One attempt token** (the review's fix shape). `begin` increments an attempt counter and dispatches `start` before its first await, for the callee too, so the state leaves idle at once and Leave and unmount always see a live attempt. After every await (the start or join answer, the livekit-client import, connect, each media toggle) it bails out when the attempt is stale. `leave()` invalidates the attempt. The call machine already moves starting → connecting on `joined`, so it needs no change.
|
||||
- **Who posts what after a stale answer.** When the start or join answer arrives after a leave, the server session exists only from that moment, so `begin`'s stale branch posts `end` (starter) or `leave` (callee) itself. `leave()` posts only when an answer has already arrived for the attempt. This avoids the 404 that the current code's premature `end` produced.
|
||||
- **"Alone" after a failed connect (WR-05).** The review's formula (`start || remoteParticipants.size === 0`) would end the call for a waiting caller whenever a callee's connect fails, because a room that never connected has no remote participants. The plan uses the server's view instead: GET status, alone when at most one participant has status `joined` (chatvideo marks the starter joined at start and the callee at join). Fallback on a status error: the starter ends, the callee leaves.
|
||||
- **Confirmation (WR-04).** The autostart decision is a pure helper, `callAutostart(conversationId, start)`, over in-memory handoffs that a link cannot create: the call button's start answer (existing `rememberStart`, now readable with `hasStart` without taking it) and a new Accept marker (`rememberAccept`, `takeAccept`), each valid for a minute. `?start=1` stays in the demo's call path but only chooses Start over Join on the confirmation. The rule is tested as a helper because @vue/test-utils is in no lockfile (20-06 deviation 1). The demo's call page needs no code change. Its doc comment is updated together with the pin bump in 20-09, so it never describes behaviour that the pinned layer lacks.
|
||||
- **Link guard (CR-02 client side).** It lives in a new `app/lib/urlGuard.ts` with `honestHttpUrl(raw)`, used by `safeUrl` (markdown.ts) and by `parseHttp` (linkEmbeds.ts). A deceptive URL stays text: no anchor and no card. The server now refuses banned ones (20-07). The client guard covers any host, so a card never shows a different host than the text. bm's copied tests (chatText.test.ts and the other four) stay byte-identical, so the new cases go into urlGuard.test.ts and linkEmbeds.test.ts.
|
||||
- **Sign-out (WR-01).** The review's explicit-call option, not a `watch(loggedIn)`: the token store is document.cookie, which a useCookie watcher would not see when `unauthorized()` clears it. `createCommunitySignOut` and `endSession` are Nuxt-free factories in `app/utils/communityAuth.ts`, tested with vitest. The plugin builds the sign-out with `nuxtApp.runWithContext` and hands it to `createChatAuth` (unauthorized) and, through `$communitySignOut`, to `useDemoAuth().logout()`. SiteHeader.vue and login.vue already go through `logout()` and need no change. Clearing `chatvideo:incoming` uses `useIncomingCalls().dismiss()`, which also stops the ring timer and closes the session handle. The handle leak between two pending subscribes is IN-07 and stays deferred.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
<objective>
|
||||
Make leaving a call final at any moment, end empty sessions after a failed connect, ask before a call page starts the camera and microphone, keep deceptive link hosts out of chat links and cards, and reset the previous user's chat, socket and call prompt on logout.
|
||||
|
||||
Purpose: D-13 (the minimal call page) and D-11/D-23 (DM calls from the call button) behave as the user expects. D-02 and D-17 hold in the chat renderer. D-04 holds after logout (no chat for a signed-out visitor).
|
||||
Output: commits in ../vue-chatvideo-layer, ../vue-chat-layer and ../sm-demo-app/vue-demo-app (not pushed, not tagged, pins unchanged), each with vitest regression tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md
|
||||
|
||||
Repos: ../vue-chatvideo-layer (main at 8222255, tag v0.1.0), ../vue-chat-layer (main at b6d3389, tag v0.1.0; 764cb42 and b6d3389 are already on main after the tag), ../sm-demo-app/vue-demo-app (the submodule, master at deb809f; pins vue-chat-layer and vue-chatvideo-layer v0.1.0 by git+https tag). Test runners: `pnpm --dir <repo> test` (vitest 3.2.7); the layers' SFCs compile with `pnpm --dir <layer> run build:playground`; vue-demo-app builds with `pnpm --dir ../sm-demo-app/vue-demo-app run build`.
|
||||
|
||||
Rules: no co-author tags; stage files by explicit path; no push, tag or pin change (20-09); a layer's README changes in the same commit as its API; the layers name no application; check `git status` in each repo before editing and leave edits that are not this plan's alone (vue-demo-app often has a stale `nuxt dev` from 2026-10-22 running, pids 212159 and 212194, leave it alone). Do not edit sm-demo-app itself; its browser checks for these fixes are written in 20-09 Task 1, where they run against the unreleased layers.
|
||||
|
||||
<interfaces>
|
||||
vue-chatvideo-layer today:
|
||||
- app/composables/useCall.ts: `useCall(conversationId)` returns {state, tiles, declined, audioBlocked, reconnecting, mediaError, begin(start), leave(), toggleMute(), toggleCamera(), startAudio()}; module-level `room`, `lk`, `channel`, `leaving`; begin: guard on `status !== 'idle' && isLive`, `dispatch start` only for the starter, `await client.start/join` (or takeStart), `joined`, pagehide listener, `openChannel(session_id)`, `await import('livekit-client')`, `room = new Room(...)`, `await r.connect`, a check that room is still r, `connected`, `await setCameraEnabled(true)`, `await setMicrophoneEnabled(true)`; on connect failure it posts leave. `leave()` returns early on idle; it posts end when alone (no room or no remote participants) else leave, then disconnects. onBeforeUnmount calls leave.
|
||||
- app/lib/callMachine.ts: states idle, starting, connecting, connected, ended, failed; `joined` accepted from idle or starting; `isLive`; unchanged by this plan.
|
||||
- app/lib/videoClient.ts: `createVideoClient` {start, join, leave, end, status → VideoStatus {session_id, conversation_id, status, started_by, participants [{user_id, status}]} or null}; `rememberStart`/`takeStart` (in-memory, a minute, taken once); `videoSessionChannel`; `parseIncomingCall`.
|
||||
- Call.vue: props conversationId, start, backPath; `onMounted(() => call.begin(props.start))`; Leave enabled while starting, connecting or connected; retry calls begin. IncomingCall.vue: `accept()` takes the pending call and navigates to callPath; decline posts leave. Locale keys chatvideo.state.*, chatvideo.action.*, chatvideo.reason.*.
|
||||
- tests/useCall.test.ts: vi.mock of livekit-client (fake Room with connect, disconnect, localParticipant setters as vi.fn, `Room.instances`, `failConnect`, `onCreate`), vi.mock of vue-chat-layer realtime (`realtime.subs`), unmount hooks captured, a stub client (start, join, leave, end, status as vi.fn), `connected(start)` helper. The test "a failed LiveKit connect fails the call and leaves the session" expects leave; WR-05 changes it.
|
||||
- chatvideo server facts: Start creates the session with the starter's participant row `joined` and the others `invited`; Join marks the actor `joined`; Leave marks `left`.
|
||||
vue-chat-layer today: app/lib/markdown.ts `safeUrl(raw)` (http(s) with `//` and a non-slash, mailto, site paths, anchors), `escapeHtml`, `formatSpans`; app/lib/chatText.ts `renderChatText` (LINK_RE, URL_RE through safeUrl); app/lib/linkEmbeds.ts `parseHttp(raw)` (new URL, http(s) only), `linkify`, `messageUrls`, `registerEmbedRenderer`, `embedRendererFor`; Chat/LinkEmbed.vue shows url.host and links url.href; useChat `reset()` (exported in v0.1.0); app/lib/realtime.ts `disconnect()` (exported in v0.1.0).
|
||||
vue-demo-app today: app/utils/communityAuth.ts {CommunityTokens, CommunityFetch, tokenGetter, createChatAuth(store, fetchJson) → {token, refresh, unauthorized}, friendsDirectory}; app/plugins/auth.ts provides paymentGatewayAuth, chatAuth, forumAuth, userfriendsAuth, userfriendsDirectory and calls setRealtimeAuth; app/composables/useDemoAuth.ts `logout()` posts /_user/api/v1/logout with the token, then `token.value = null`; app/lib/realtime.ts re-exports vue-chat-layer's realtime (disconnect included); app/layouts/default.vue renders ChatFloatingPanel and ChatvideoIncomingCall for members.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the caller clicks Leave while the call is still starting, and nothing connects or turns on afterwards, under test</name>
|
||||
<precondition>`git -C ../vue-chatvideo-layer status --porcelain` prints nothing; `pnpm --dir ../vue-chatvideo-layer test` passes before the change.</precondition>
|
||||
<files>../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/tests/useCall.test.ts</files>
|
||||
<read_first>../vue-chatvideo-layer/app/composables/useCall.ts (whole file), app/lib/callMachine.ts, tests/useCall.test.ts (whole file: the fakes, beforeEach, the connected helper, every existing test), 20-REVIEW.md CR-01</read_first>
|
||||
<action>Per D-13, closing the caller path of CR-01 end to end.
|
||||
1. Red first: in tests/useCall.test.ts add a small deferred-promise helper and the test "leave while start is pending: no room, no media, end once the session exists". `client.start` answers a deferred promise; call `begin(true)` without awaiting it, `await leave()`, then resolve the start answer and await the begin promise. Expect: no fake Room was connected (`Room.instances` empty, or none of them had connect called), setCameraEnabled and setMicrophoneEnabled never called, `client.end` called once with the conversation id after the answer arrived, the state ended with reason left, no open session channel, no pagehide listener. Run it and see it fail.
|
||||
2. useCall.ts: add the per-attempt counter. begin increments it and keeps its own number, resets `leaving`, dispatches `start` before the first await (for both roles), and after the start or join answer checks whether the attempt is stale (another attempt started or leave ran); if so it posts `end` for a start or `leave` for a join (errors swallowed) and returns before subscribing, adding the pagehide listener or loading livekit-client. leave() no longer returns early while an attempt is in flight in the starting state: it marks leaving, invalidates the attempt, dispatches leave, posts end or leave only when this attempt already has its answer, and disconnects.
|
||||
3. Keep every existing test green; update the module header comment (begin, leave and unmount semantics).
|
||||
4. Commit in vue-chatvideo-layer: `fix: leaving while a call starts never connects it`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chatvideo-layer test</automated>
|
||||
<fails_when>Non-zero exit: the new test fails (a Room connects, a media setter runs, end is posted before or never, the state is not ended) or an existing useCall, callMachine or videoClient test breaks.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'leave while start is pending' ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
|
||||
- `git -C ../vue-chatvideo-layer diff --stat 8222255 -- package.json pnpm-lock.yaml` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A caller who hangs up during "Connecting…" is out of the call for good: the late start answer ends the session and no camera or microphone turns on.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Every other moment of leaving is final too (callee join, unmount, livekit-client load, connect, between camera and microphone, a second begin), and a failed connect ends an empty session</name>
|
||||
<files>../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/tests/useCall.test.ts, ../vue-chatvideo-layer/README.md</files>
|
||||
<read_first>The Task 1 code and test, tests/useCall.test.ts (the fake Room's connect and setters, failConnect, onCreate, the unmount hooks), app/lib/videoClient.ts (status, VideoStatus), README.md (Usage, "The call flow", API reference, Testing)</read_first>
|
||||
<behavior>
|
||||
- "unmount while join is pending": begin(false) with a deferred join, run the captured unmount hook, resolve the join; no Room connects, no media setter runs, client.leave is posted once after the answer, state ended.
|
||||
- "leave while connect is pending": connect answers a deferred promise; leave; resolve connect; setCameraEnabled and setMicrophoneEnabled never called, the Room disconnected, end posted once (alone).
|
||||
- "leave between camera and microphone": setCameraEnabled answers a deferred promise; leave; resolve it; setMicrophoneEnabled never called, the Room disconnected.
|
||||
- "leave while livekit-client loads": the import is awaited after the answer; stall it with a hoisted deferred gate in the mocked module if vitest allows it reliably, otherwise call leave in the same tick the answer resolves (the stale check right after the answer) and say which in the SUMMARY; no Room created after the leave, end posted once.
|
||||
- "a second begin while the first join is pending": two begin(false) calls before the answer; join called once, at most one Room ever created and connected.
|
||||
- WR-05 "a failed connect ends a session nobody else joined": failConnect set, client.status answers participants [{user_id: 7, status: 'joined'}, {user_id: 9, status: 'invited'}]; client.end posted, client.leave not. "a failed connect leaves when the other side joined": status answers two joined participants; client.leave posted, client.end not. "status unreadable": status rejects; the starter posts end, the callee posts leave.
|
||||
- The existing test "a failed LiveKit connect fails the call and leaves the session" is renamed and changed to the WR-05 rule: the stub's status answers undefined, which counts as unreadable (as do null and a rejection), so the starter ends; every other existing expectation stays.
|
||||
</behavior>
|
||||
<action>Per D-13 and D-12 (the reconcile job and ringing behave as designed), closing the rest of CR-01 and WR-05.
|
||||
- useCall.ts: after the livekit-client import, return when stale (no Room is created); after `connect` resolves, when stale disconnect that Room (disconnect(true), errors swallowed) and return; check staleness before each of setCameraEnabled and setMicrophoneEnabled, and after the camera, so a leave between them disconnects and skips the microphone; the Disconnected handler and finish() keep working for the current attempt only. On a connect failure: dispatch error, disconnect, then ask `client.status(conversationId)`; post end when at most one participant has status joined, else leave; on a status error post end for a start and leave for a join. Remove nothing else (tiles, declined, audio, reconnecting stay as they are).
|
||||
- tests/useCall.test.ts: the behavior block's tests; keep the fakes, extend them only where a deferred is needed.
|
||||
- README.md: "The call flow" says Leave works at any moment and what is posted when, the WR-05 rule, and Testing lists the new tests.
|
||||
- Commit in vue-chatvideo-layer: `fix: a call left at any moment stays left, and a failed connect ends an empty session`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chatvideo-layer test</automated>
|
||||
<fails_when>Non-zero exit: any behavior-block test fails (media published after a leave, a second Room, a stale branch posting the wrong call, WR-05 posting leave for an empty session) or an existing test breaks.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE "test\('(unmount while join is pending|leave while connect is pending|leave between camera and microphone|a second begin while the first join is pending)" ../vue-chatvideo-layer/tests/useCall.test.ts` prints 4.
|
||||
- `grep -c 'a failed connect ends a session nobody else joined' ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>Whatever the moment a user leaves or navigates away, the camera and microphone stay off and the session is closed the right way, and a call that could not connect does not keep ringing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: The call page asks before it starts media, unless the user just clicked the call button or Accept</name>
|
||||
<files>../vue-chatvideo-layer/app/lib/videoClient.ts, ../vue-chatvideo-layer/app/components/Chatvideo/Call.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/videoClient.test.ts, ../vue-chatvideo-layer/README.md</files>
|
||||
<read_first>app/lib/videoClient.ts (the handoff block: HANDOFF_MS, rememberStart, takeStart), app/components/Chatvideo/Call.vue, CallButton.vue, IncomingCall.vue, app/assets/css/chatvideo-base.css (button and note styles), i18n/locales/en.json and pl.json, tests/videoClient.test.ts (the handoff tests), README.md (Usage, Components, "The call flow"), ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue and app/pages/chat.vue (how the demo passes start and callPath; read only), 20-REVIEW.md WR-04</read_first>
|
||||
<behavior>
|
||||
- callAutostart(id, true) is false on a direct load; true right after rememberStart(id, …) within a minute, and begin's takeStart still receives that answer (hasStart does not take it); false after a minute; false for another conversation.
|
||||
- callAutostart(id, false) is false on a direct load; true once right after rememberAccept(id) within a minute, then false (taken); false after a minute; false for another conversation; a start handoff does not make a join autostart.
|
||||
- takeStart behaves as before (the existing handoff tests stay).
|
||||
</behavior>
|
||||
<action>Per D-11, D-13 and D-23, closing WR-04.
|
||||
- app/lib/videoClient.ts: add `hasStart(conversationId, now?)` (a start answer younger than HANDOFF_MS is waiting; not taken), `rememberAccept(conversationId, now?)` and `takeAccept(conversationId, now?)` (an Accept marker, a minute, taken once; in memory, never in a URL or storage), and `callAutostart(conversationId, start, now?)` (start: hasStart; join: takeAccept). Doc comments say why: a link can open the call route but cannot create a handoff.
|
||||
- IncomingCall.vue: accept() calls rememberAccept for the conversation before it navigates.
|
||||
- Call.vue: on mount, begin only when callAutostart answers true; otherwise the idle state renders a confirmation block (class `chatvideo-confirm`) with the text chatvideo.confirm.start or chatvideo.confirm.join and one button (classes `chatvideo-button chatvideo-confirm__go`, label chatvideo.action.startCall or chatvideo.action.joinCall) that calls begin(start), plus the back link when backPath is set. Live, ended and failed states render as today; Retry stays an explicit click. Update the component's header comment (media starts only after a click on this page, the call button or Accept).
|
||||
- Locales en and pl: confirm.start, confirm.join (each says the camera and microphone turn on after the click), action.startCall, action.joinCall. chatvideo-base.css: the confirm block in the existing card and button style.
|
||||
- tests/videoClient.test.ts: the behavior block.
|
||||
- README.md: Components (ChatvideoCall's confirmation; `start` picks Start or Join), "The call flow" (autostart only after the call button or Accept; a direct load or a link asks), API reference (hasStart, rememberAccept, takeAccept, callAutostart), Usage (the ?start=1 example now only selects Start), Testing.
|
||||
- Commit in vue-chatvideo-layer: `feat: the call page asks before it starts the camera and microphone`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chatvideo-layer test && pnpm --dir ../vue-chatvideo-layer run build:playground</automated>
|
||||
<fails_when>Non-zero exit: a handoff test fails (a direct load autostarts, Accept does not, hasStart takes the answer, a marker outlives a minute or crosses conversations), an earlier test breaks, or the playground build fails to compile Call.vue or IncomingCall.vue.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'callAutostart' ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue` prints at least 1 and `grep -c 'rememberAccept' ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue` prints at least 1.
|
||||
- `node -e "for (const l of ['en','pl']) { const c = require('../vue-chatvideo-layer/i18n/locales/' + l + '.json').chatvideo; if (!c.confirm?.start || !c.confirm?.join || !c.action?.startCall || !c.action?.joinCall) process.exit(1) }"` exits 0.
|
||||
- `grep -rniE 'golem15\.demo|sm-demo|demo\.summercms' ../vue-chatvideo-layer/app ../vue-chatvideo-layer/README.md | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Opening a call link shows a clear Start call or Join call button and nothing turns on until it is clicked, while the call button and Accept still go straight into the call.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: The chat renders deceptive link hosts as text, never as a link or a link card</name>
|
||||
<files>../vue-chat-layer/app/lib/urlGuard.ts, ../vue-chat-layer/app/lib/urlGuard.test.ts, ../vue-chat-layer/app/lib/markdown.ts, ../vue-chat-layer/app/lib/linkEmbeds.ts, ../vue-chat-layer/app/lib/linkEmbeds.test.ts, ../vue-chat-layer/README.md</files>
|
||||
<read_first>../vue-chat-layer/app/lib/markdown.ts, app/lib/chatText.ts (LINK_RE, URL_RE, how safeUrl is used), app/lib/linkEmbeds.ts, app/lib/linkEmbeds.test.ts, app/components/Chat/LinkEmbed.vue, README.md (link embeds section, Testing), 20-REVIEW.md CR-02 (the table and the rendering paragraph)</read_first>
|
||||
<behavior>
|
||||
- honestHttpUrl refuses (returns null): https://bit%2Ely/x, https://bit.l%79/x, https://bit.ly/x, https://bit。ly/x, https://bit.ly/x, https://bit。ly/x, https://user@bit.ly/x, https://example.com@bit.ly/, https:/\bit.ly/x, https://\bit.ly/x, https://bit.ly./x, javascript: and data: URLs, and anything new URL cannot parse.
|
||||
- honestHttpUrl accepts: https://bit.ly/x, http://example.com:8080/a?b=c#d, https://żółw.example/ (an IDN written in Unicode), https://xn--w-uga1v8h.example/, https://sub.example.org/path.
|
||||
- renderChatText of each refused URL, bare and as [label](url), contains no anchor (the text shows as written, escaped); messageUrls of a text holding them returns none; linkify leaves them as text. The accepted URLs still render as links and still yield cards.
|
||||
- bm's copied tests (chatEvents, chatFormat, chatText, chatUnread, realtime) pass byte-identical.
|
||||
</behavior>
|
||||
<action>Per D-02 (generic link embeds) and D-17, the client half of CR-02.
|
||||
- app/lib/urlGuard.ts (new): `honestHttpUrl(raw: string): URL | null`. The URL must parse with new URL as http or https, and the scheme must be followed by exactly two slashes. The authority as written, up to the first slash, question mark or hash, must hold no percent sign, no at sign and no backslash. The host as written must hold none of U+3002, U+FF0E and U+FF61, must be unchanged by NFKC (no fullwidth or compatibility characters), and must not end with a dot. Its lower-cased form must equal url.hostname, or, for a host with non-ASCII letters, it must be in NFC with only U+002E as the separator. Doc comment: the chat links and embeds only URLs whose written host is the host the browser opens, so a card never shows a host the text does not.
|
||||
- markdown.ts: safeUrl allows an http(s) target only when honestHttpUrl accepts it (mailto, site paths and anchors unchanged). linkEmbeds.ts: parseHttp delegates to honestHttpUrl, so linkify and messageUrls skip deceptive URLs.
|
||||
- app/lib/urlGuard.test.ts (new): the behavior block, including renderChatText cases (import it from ./chatText). linkEmbeds.test.ts: messageUrls and linkify cases for two refused and two accepted URLs.
|
||||
- README.md: the link embeds section describes the guard; Testing lists urlGuard.test.ts.
|
||||
- Commit in vue-chat-layer: `fix: deceptive link hosts render as text, not links or cards`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && git -C ../vue-chat-layer diff --quiet b6d3389 -- app/lib/chatEvents.test.ts app/lib/chatFormat.test.ts app/lib/chatText.test.ts app/lib/chatUnread.test.ts app/lib/realtime.test.ts</automated>
|
||||
<fails_when>Non-zero exit: a guard test fails (a deceptive URL becomes an anchor or a card, an honest or IDN URL stops linking), a store, client or embed test breaks, or one of bm's copied test files was edited.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'honestHttpUrl' ../vue-chat-layer/app/lib/markdown.ts` and `grep -c 'honestHttpUrl' ../vue-chat-layer/app/lib/linkEmbeds.ts` each print at least 1.
|
||||
- `grep -rniE 'golem15\.demo|sm-demo|demo\.summercms' ../vue-chat-layer/app ../vue-chat-layer/README.md | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>A message can no longer show a working link or a link card to a host it hides behind escapes, dots or userinfo; honest links work as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: Logging out resets the chat, closes the realtime socket and clears a ringing call in the same tab</name>
|
||||
<files>../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts, ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts</files>
|
||||
<read_first>../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts and communityAuth.test.ts (whole files), app/plugins/auth.ts, app/composables/useDemoAuth.ts (whole file), app/components/SiteHeader.vue and app/pages/login.vue (their logout calls), app/lib/realtime.ts (the re-export), app/layouts/default.vue, vitest.config.ts, ../vue-chat-layer/app/composables/useChat.ts (reset, lines around 1514) and app/lib/realtime.ts (disconnect), ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts (dismiss), 20-REVIEW.md WR-01</read_first>
|
||||
<behavior>
|
||||
- createCommunitySignOut({resetChat, disconnectRealtime, dismissCall}) runs the three in that order; a part that throws does not stop the next ones; calling it twice is safe.
|
||||
- createChatAuth(store, fetch, onSignedOut): unauthorized() clears the token, then calls onSignedOut once; without onSignedOut it behaves as before (the existing tests pass unchanged).
|
||||
- endSession(token, postLogout, clearToken, signOut): with a token it posts logout with that token, clears the token even when the post rejects, then signs out once; without a token it posts nothing and still clears and signs out.
|
||||
</behavior>
|
||||
<action>Per D-04 (no chat for a signed-out visitor), closing WR-01.
|
||||
- app/utils/communityAuth.ts: add `createCommunitySignOut(parts)` and `endSession(token, postLogout, clearToken, signOut)` (Nuxt-free, doc comments), and an optional third parameter `onSignedOut` on `createChatAuth`, called by unauthorized() after the token is cleared. Update the file header.
|
||||
- app/plugins/auth.ts: take the nuxtApp argument; build the sign-out with createCommunitySignOut whose parts run only in the browser: resetChat runs useChat().reset() inside nuxtApp.runWithContext, disconnectRealtime calls disconnect from ~/lib/realtime, dismissCall runs useIncomingCalls().dismiss() inside nuxtApp.runWithContext. Pass it to createChatAuth and provide it as `communitySignOut`. Update the header comment.
|
||||
- app/composables/useDemoAuth.ts: read `$communitySignOut` from useNuxtApp() when the composable is created (before any await, so the Nuxt context is still there), and make logout() delegate to endSession with the current token, the existing logout POST, a clear that sets the token to null, and that sign-out. SiteHeader.vue and login.vue keep calling logout() unchanged.
|
||||
- app/utils/communityAuth.test.ts: the behavior block, next to the existing tests.
|
||||
- Commit in vue-demo-app only (not pushed; the sm-demo-app gitlink is bumped in 20-09): `fix: logging out resets the chat, closes the realtime socket and clears a ringing call`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
|
||||
<fails_when>Non-zero exit: a sign-out or endSession test fails, an existing auth hook test breaks, or the site build fails (a missing auto-import or a layer API that v0.1.0 does not export).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'createCommunitySignOut' ../sm-demo-app/vue-demo-app/app/plugins/auth.ts` prints at least 1 and `grep -c 'endSession' ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts` prints at least 1.
|
||||
- `git -C ../sm-demo-app/vue-demo-app diff --stat deb809f -- package.json pnpm-lock.yaml app/components/SiteHeader.vue` prints nothing.
|
||||
- `git -C ../sm-demo-app status --porcelain -- vue-demo-app` prints ` M vue-demo-app` (the gitlink moved, not staged; 20-09 commits the bump).
|
||||
</acceptance_criteria>
|
||||
<done>After logout nothing of the previous member stays in the tab: no chat state, no open socket, no ringing prompt, and the next member starts clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser camera and microphone to LiveKit | media leaves the device once a Room is connected and tracks are published |
|
||||
| chat links to the call route | any member can post a same-site link to /call/{id} |
|
||||
| chat text to rendered links and cards | member-written URLs become anchors and cards |
|
||||
| one browser tab, two accounts | the previous member's state must not reach the next one |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-42 | Information disclosure | useCall (camera and microphone after leave) | critical | mitigate | per-attempt token checked after every await; leave invalidates it; a stale branch disconnects any Room and never calls the media setters; useCall tests pin each moment (start, join, import, connect, between camera and microphone, unmount, second begin) |
|
||||
| T-20-43 | Elevation of privilege | /call/{id} opened from a link | high | mitigate | media starts on mount only with an in-memory handoff a link cannot create (call button start answer, Accept marker, a minute, taken once); otherwise the confirmation waits for a click; callAutostart tests |
|
||||
| T-20-44 | Information disclosure | chat store, realtime socket and call prompt after logout | high | mitigate | logout and unauthorized run the community sign-out (reset, disconnect, dismiss), each part isolated; communityAuth tests; 20-09 adds a browser check that the socket closes |
|
||||
| T-20-45 | Denial of service | empty ringing session after a failed connect | medium | mitigate | the failed-connect branch ends the session when GET status shows nobody else joined; tests for both outcomes and for an unreadable status |
|
||||
| T-20-46 | Spoofing | deceptive hosts in chat anchors and link cards | high | mitigate | honestHttpUrl refuses escapes, userinfo, backslashes, compatibility dots and letters, trailing dots and malformed scheme separators before safeUrl, linkify or messageUrls produce a link or card; urlGuard tests |
|
||||
| T-20-SC | Tampering | npm installs | high | mitigate | no package or lockfile change in this plan; acceptance checks the layer manifests are untouched |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vue-chatvideo-layer tests with the caller leave-during-start test.
|
||||
- Task 2: the remaining CR-01 moments and WR-05 under test.
|
||||
- Task 3: handoff tests and the playground build compiling the changed components.
|
||||
- Task 4: vue-chat-layer tests with the guard, bm's copied tests untouched.
|
||||
- Task 5: vue-demo-app tests and build on the v0.1.0 pins.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
CR-01, WR-01, WR-04, WR-05 and the client half of CR-02 are fixed in the repositories that own the code, each with regression tests; nothing is pushed, tagged or re-pinned, so 20-09 can run the full suites, integrate the unreleased layers locally and release them.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-08-SUMMARY.md` when done: the commits per repo, the new tests per finding, the changed expectation of the failed-connect test, the new layer exports (hasStart, rememberAccept, takeAccept, callAutostart, honestHttpUrl) and class names (chatvideo-confirm, chatvideo-confirm__go) that 20-09's browser checks use.
|
||||
</output>
|
||||
@@ -0,0 +1,552 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 09
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["20-07", "20-08"]
|
||||
gap_closure: true
|
||||
review_findings: [CR-01, CR-02, CR-03, WR-01, WR-04, WR-05]
|
||||
files_modified:
|
||||
- ../vue-chat-layer/package.json
|
||||
- ../vue-chatvideo-layer/package.json
|
||||
- ../sm-demo-app/scripts/check-moderation.sh
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/check-chat.cjs
|
||||
- ../sm-demo-app/scripts/check-all.sh
|
||||
- ../sm-demo-app/README.md
|
||||
- ../sm-demo-app/DEPLOY.md
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/moderation
|
||||
- ../sm-demo-app/plugins/golem15/forum
|
||||
- ../sm-demo-app/plugins/demo/moderation.go
|
||||
- ../sm-demo-app/moderation_test.go
|
||||
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
|
||||
- ../sm-demo-app/vue-demo-app
|
||||
- ../sm-demo-app/vue-demo-app/package.json
|
||||
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
|
||||
- .planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/pending/chatchannels-pin-policy.md
|
||||
- .planning/todos/pending/moderation-profile-group-and-filenames.md
|
||||
- .planning/todos/pending/forum-friends-client-401-refresh.md
|
||||
- .planning/todos/pending/chatvideo-dm-only-host-hook.md
|
||||
- .planning/todos/pending/chatvideo-live-membership.md
|
||||
- .planning/todos/pending/check-local-jwt-out-of-argv.md
|
||||
- .planning/todos/pending/chat-slash-text-escape.md
|
||||
- .planning/todos/pending/demo-seed-moderation-lists-once.md
|
||||
- .planning/todos/pending/demo-nginx-livekit-rtc-only.md
|
||||
- .planning/todos/pending/chatvideo-incoming-handle-leak.md
|
||||
- .planning/todos/pending/demo-vapid-runbook-pipefail.md
|
||||
- .planning/todos/pending/demo-chat-members-read-only.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, SC-4, D-10, D-13, D-17, D-18]
|
||||
estimate:
|
||||
tokens: 340000
|
||||
raw_tokens: 340000
|
||||
tasks: 12
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: before anything is pushed, the full suites pass in every repository the gap round touched: sm-moderation-plugin and sm-forum-plugin (go vet, go test ./... with Docker), vue-chat-layer and vue-chatvideo-layer (vitest and the playground build), vue-demo-app (vitest and the Nuxt build) and sm-demo-app (go vet, go test ./... with Docker)."
|
||||
- "Before any tag, the unreleased fixes run together in the local demo (the sibling plugin checkouts through a scratch go.work, the local layers as packed tarballs, both temporary and reverted): check-local moderation, chat, chatlive and callmedia pass, so a one-way tag never ships an untested combination."
|
||||
- "After the Task 3 go-ahead: annotated tags exist on origin at the default branch's head, each containing the earlier tags. sm-moderation-plugin gets the next patch after v0.1.0 (v0.1.1), sm-forum-plugin after v0.1.1 (v0.1.2), and vue-chat-layer and vue-chatvideo-layer after v0.1.0 (v0.1.1 each). For each, `git ls-remote origin refs/tags/<tag>^{}` equals the pushed HEAD."
|
||||
- "sm-demo-app requires the new moderation and forum tags (go.mod and go.sum, submodules checked out at the tags, check-pins ok, one `chore: bump` commit per plugin), its forum listener also checks the rendered post (PostSaving.HTML through CheckHTML), and TestDemoModeration refuses every reviewer probe through HTTP on chat send, reply and edit and on forum thread, reply and edit, with nothing stored. vue-demo-app pins both layers at their new tags."
|
||||
- "check-moderation.sh refuses every reviewer probe in chat and forum with 422 under the right field, after a plain control word is refused (so changed lists cannot pass silently), and leaves nothing behind. check-chat.cjs --call shows the confirmation on a direct /call load with no LiveKit connection before the click, then completes the DM call. check-chat.cjs's default mode shows that logout closes the realtime socket."
|
||||
- "SC-3, after the Task 7 go-ahead: rome runs the new binary and site through DEPLOY.md's Upload and Release. Only summercms-demo and summercms-demo-site restart, check-live prints `check-live: ok`, and bin/demo.prev and site/.output.prev are kept for a rollback."
|
||||
- "SC-2, D-17 on the live site: with the user's live tokens, the bypass probe passes against https://demo.summercms.io and the call probe completes a DM call there with the confirmation step. Without tokens the user's own UAT of the same steps is recorded. smoke.sh passes on the deployed artifact."
|
||||
- "After the Task 11 go-ahead, one post per plugin tag (sm-moderation-plugin, sm-forum-plugin) is on https://summercms.io/changelog/plugins, written per CLAUDE.md (user-facing, no planning IDs, no H1). The journal token is never printed or copied."
|
||||
- "20-REVIEW-DISPOSITION.md: CR-01, CR-02, CR-03, WR-01, WR-04 and WR-05 are fixed, with commit refs. WR-02, WR-03, WR-06 and IN-01 to IN-09 are deferred, each pointing at its todo in .planning/todos/pending/ (g15office task id in the frontmatter when the g15office tools answer)."
|
||||
artifacts:
|
||||
- path: "../sm-demo-app/scripts/check-moderation.sh"
|
||||
provides: "the bypass probe for a local or the live demo"
|
||||
contains: "check-moderation: ok"
|
||||
- path: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
provides: "forum listener checking the rendered post"
|
||||
contains: "CheckHTML"
|
||||
- path: ".planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md"
|
||||
provides: "the closed ledger"
|
||||
contains: "fixed"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
to: "../sm-forum-plugin/events/events.go"
|
||||
via: "checkPost runs Filter.CheckHTML on ev.HTML after title and body"
|
||||
pattern: "ev\\.HTML"
|
||||
- from: "../sm-demo-app/go.mod"
|
||||
to: "../sm-moderation-plugin"
|
||||
via: "require at the new patch tag"
|
||||
pattern: "sm-moderation-plugin v0\\.1\\.[1-9]"
|
||||
- from: "../sm-demo-app/vue-demo-app/package.json"
|
||||
to: "../vue-chatvideo-layer"
|
||||
via: "git+https pin at the new tag"
|
||||
pattern: "vue-chatvideo-layer.git#v0\\.1\\.[1-9]"
|
||||
prohibitions:
|
||||
- statement: "No push, tag, rome change or changelog post happens before its checkpoint is answered; a tag is never moved or re-pushed; tags go on the default branch only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No secret value (JWTs, the journal token, .env values) appears in a command line, a log, a committed file or the SUMMARY; .env files are never read; JWTs reach the scripts through 0600 files or the environment and curl gets the header through a config on stdin."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The temporary integration (scratch go.work, packed layer tarballs) is never committed: vue-demo-app's package.json and pnpm-lock.yaml match their committed state when Task 2 ends."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "On rome only summercms-demo and summercms-demo-site restart; nginx, Centrifugo, LiveKit, the firewall and WavePath's programs are untouched."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): sm-demo-app runs every shared plugin and its public Nuxt site shows them working, deployed publicly with the backend call to action.
|
||||
|
||||
This plan's slice (gap round after the code review, plan 9 of 9, wave 8): it releases 20-07's and 20-08's fixes, redeploys the demo and re-checks it live.
|
||||
- Prove the unreleased fixes together locally.
|
||||
- Tag the four repositories after a go-ahead.
|
||||
- Consume the tags in the demo, including the one listener change that needs them.
|
||||
- Rebuild, redeploy on rome after a go-ahead, and probe the live site for the bypasses and the call flow.
|
||||
- Publish the two plugin changelog posts after a go-ahead.
|
||||
- Close 20-REVIEW-DISPOSITION.md.
|
||||
|
||||
**Unit tests rule for this gap round:** the project rule "unit tests are the last plan of every phase" is met because every fix in 20-07 and 20-08 carries its own regression tests and this plan runs every touched repository's full suite (Task 2) and the demo's whole gate (Task 6) before anything goes live.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Versions.** Each tag is the next patch after the latest tag, checked on 2026-11-02 with `git tag --sort=-v:refname` and `git ls-remote --tags origin`:
|
||||
|
||||
| Repo | Latest tag | New tag |
|
||||
|---|---|---|
|
||||
| sm-moderation-plugin | v0.1.0 | v0.1.1 |
|
||||
| sm-forum-plugin | v0.1.1 | v0.1.2 |
|
||||
| vue-chat-layer | v0.1.0 | v0.1.1 |
|
||||
| vue-chatvideo-layer | v0.1.0 | v0.1.1 |
|
||||
|
||||
The brief asked for moderation v0.1.2. No v0.1.1 exists, and skipping one would leave a hole in the series, so the plan proposes v0.1.1 and the Task 3 checkpoint shows the table for the user to confirm or override. Task 2 re-reads the tags, because another session could tag in between.
|
||||
- **Integration before tagging.** Tags are one-way, so Task 1 and Task 2 run the unreleased code together in the local demo. The Go side uses a scratch go.work that maps moderation and forum to the sibling checkouts (GOWORK is respected by dev.sh and build.sh dev). The site side installs the two layers from `pnpm pack` tarballs; unlike link pins, a tarball resolves vue-chat-layer once, as the git tag install will. Both are reverted before the release gate.
|
||||
- **The demo listener change comes after the bumps** (20-07's finding). `checkPost` gains `CheckHTML(ctx, ev.HTML)` in the commit right after the two `chore: bump` commits, so every sm-demo-app commit builds with its own pointers. Until then, the reviewer's three forum forms are already refused on the raw body by the new Check. The tarball and scratch integration therefore proves those three; the rendered-only forms (bit\.ly, an HTML comment) are pinned by TestDemoModeration in Task 5.
|
||||
- **Probes written as checks.** The bypass probe is `scripts/check-moderation.sh BASE`, reusable locally (`check-local.sh moderation`) and live. The call probe is check-chat.cjs --call, extended with the direct-load confirmation step, plus a logout step in its default mode. Both read JWTs from a 0600 file or the environment, never argv, and curl gets the bearer header through a config on stdin (the pattern IN-03 asks for). Live, the probe posts only in the visitor's own DM with a persona and as replies on a seeded thread. It starts with a control word that must be refused, and it deletes anything a probe managed to store.
|
||||
- **Live tokens are an authentication gate.** Live login needs a real Turnstile solve, so Task 9 asks the user to sign in in a browser and save the token cookie values into two files. The alternative is "manual": the user runs the same steps by hand.
|
||||
- **Changelog posts** go to sm-moderation-plugin and sm-forum-plugin only. The layers have no plugin.yaml and no changelog stream, and changelog-post.py refuses a repository without a manifest.
|
||||
- **Deferred findings** (WR-02, WR-03, WR-06, IN-01 to IN-09) each become one todo in the existing format. IN-07's logout half was fixed by 20-08 (dismiss on sign-out); its todo keeps the handle leak.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
<objective>
|
||||
Release the gap-round fixes, put them on the live demo, and prove there that the bypasses are closed and the call flow asks before media; close the review ledger.
|
||||
|
||||
Purpose: SC-2, SC-3 and SC-4 hold again after the code review; D-17 (moderation), D-18 (the seams) and D-13 (the call page) hold on the live site.
|
||||
Output: four tags, commits in sm-demo-app and vue-demo-app, a new release on rome, two changelog posts, the closed disposition ledger and twelve follow-up todos, each step after its go-ahead.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-07-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-08-SUMMARY.md
|
||||
|
||||
Repos: ../sm-moderation-plugin, ../sm-forum-plugin, ../vue-chat-layer, ../vue-chatvideo-layer (each on main, 20-07 and 20-08 commits unpushed); ../sm-demo-app (master; submodules plugins/golem15/* at their required tags; vue-demo-app submodule with 20-08's commit, gitlink moved but not committed); ../sm-summercmsio-app (scripts/changelog-post.py). Host: rome over `ssh -p 2222 debian@10.8.3.118` (passwordless sudo; the `rome` alias does not resolve here), with `RSH="ssh -p 2222"` and `ROME=debian@10.8.3.118` as DEPLOY.md uses them.
|
||||
|
||||
Rules (CLAUDE.md and the brief): no co-author tags; stage files by explicit path; one logical change per commit; planning docs and code in separate commits; the plugin or layer is pushed and tagged first, then the pointer or pin bump in the app as its own commit (one `chore: bump <name> to <tag>` per plugin); push vue-demo-app before the sm-demo-app commit that moves its gitlink; tags only on the default branch, annotated, never moved; never read `.env*` files; never print, copy or mint the journal token; leave other sessions' uncommitted edits (STATE.md, state.json, G15OFFICE.md) alone; Docker tests through `sudo -u golem` if the socket is denied. After Task 4, write a progress note into a draft 20-09-SUMMARY.md (tasks done, tags and commits, open points), so a context reset resumes cleanly. `SCRATCH` in the commands below is this session's scratchpad directory; set it once (`SCRATCH=<the scratchpad path>`) before Task 1 and again after a context reset.
|
||||
|
||||
<interfaces>
|
||||
sm-demo-app scripts: dev.sh up (Postgres, Centrifugo, LiveKit 127.0.0.1:7890, Go on :8097, Nuxt on :3020; builds through go.work, so GOWORK overrides it); check-local.sh CHECK... (helpers request, json_has, bad, visitor_jwt, throwaway_user, psql_value; ALL_CHECKS; check_call, two_member_chat, check_chatlive, check_callmedia); check-chat.cjs --base URL [--two|--call] (DEMO_JWT, DEMO_JWT2 from the environment; TOKEN_COOKIE summercms_demo_token; the call mode clicks .chatvideo-callbutton__btn, accepts on .chatvideo-incoming, waits for remote video, mutes, both click .chatvideo-button--leave, polls video/status); check-all.sh [--local] (step 5 runs check-local.sh back remote chatlive callmedia); check-pins.sh; build.sh [release|dev] (release refuses dirty trees, a stale ROADMAP snapshot and pins off go.mod); sync-roadmap.sh; smoke.sh [binary]; check-deploy.sh; check-live.sh URL.
|
||||
Demo APIs used by the probe: GET /_demo/api/v1/chat/members (persona Ada Hart); POST /_chat/api/v1/contexts/demo/1/dm {user_id}; POST /_chat/api/v1/conversations/{id}/messages {content}; DELETE /_chat/api/v1/messages/{id}; GET /_forum/api/v1/contexts/demo/1/categories and /threads; POST /_forum/api/v1/contexts/demo/1/categories/{categoryId}/threads {title, body}; POST /_forum/api/v1/contexts/demo/1/threads/{threadId}/posts {body}; DELETE /_forum/api/v1/contexts/demo/1/posts/{postId} and /threads/{threadId}. Refusal: 422 {"error":"Validation failed","errors":{field:[message]}} with field content (chat), title or body (forum).
|
||||
Live default lists (demo:seed, unless an admin changed them): words "buy followers", "casino bonus", "free crypto", "viagra"; links "bit.ly".
|
||||
20-08 names used by the checks: the confirmation block `.chatvideo-confirm`, its button `.chatvideo-confirm__go`; the call button `.chatvideo-callbutton__btn`.
|
||||
DEPLOY.md: "Build" (build.sh, smoke.sh, check-deploy.sh), "Upload" (three rsync lines with --chown=root:root and sudo rsync), "Release" (migrate and demo:seed with bin/demo.new, keep bin/demo.prev and site/.output.prev, restart summercms-demo and summercms-demo-site, websockets:health), "Verify" (check-live.sh, supervisorctl status, the reset schedule off), "Rollback".
|
||||
changelog (../sm-summercmsio-app/DEPLOY.md "Posting to the changelog"): `scripts/changelog-post.py source vX.Y.Z --repo ../sm-<name>-plugin`, then `scripts/changelog-post.py publish vX.Y.Z notes.md --excerpt "…" --repo ../sm-<name>-plugin` (`--draft` saves unpublished); the plugin checkout must be exactly at the tag with a clean worktree and a plugin.yaml that passes `summer plugin:manifest --check`; the token is read from ~/.config/summercms/journal-token by the script.
|
||||
Todo format (.planning/todos/pending/*.md): frontmatter title, date, priority, area, g15office (task id); a short body. .planning/G15OFFICE.md: each todo gets a g15office task in project summercms (type Bug, Feature, Improvement or Task; the description ends with `Local: .planning/todos/pending/<file>`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the bypass, call-confirmation and logout checks exist, and they pass against the unreleased fixes running together in the local demo</name>
|
||||
<precondition>The 20-07 and 20-08 SUMMARYs exist and record their commits; `git -C <repo> status --porcelain` is empty in sm-moderation-plugin, sm-forum-plugin, vue-chat-layer and vue-chatvideo-layer; in sm-demo-app it lists at most ` M vue-demo-app`; Docker runs.</precondition>
|
||||
<files>../sm-demo-app/scripts/check-moderation.sh, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-all.sh, ../sm-demo-app/README.md, ../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>../sm-demo-app/scripts/check-local.sh (header, helpers, visitor_jwt, check_call, two_member_chat, ALL_CHECKS), scripts/check-chat.cjs (whole file), scripts/check-all.sh (step 5), scripts/dev.sh (up, how it builds), go.work, README.md "Checks", DEPLOY.md "Verify", 20-REVIEW.md (the probe forms), 20-07-SUMMARY.md (probe table), 20-08-SUMMARY.md (class names and handoffs)</read_first>
|
||||
<action>Per D-17, D-13 and D-04.
|
||||
1. scripts/check-moderation.sh BASE (new, `set -euo pipefail`, header in the style of check-live.sh):
|
||||
- It reads the member JWT from the file named by DEMO_JWT_FILE (it must be mode 600; a looser mode stops the script) or from DEMO_JWT. It never prints the JWT and never puts it in argv: every request passes the Authorization header to curl as a config on stdin, written with bash builtins.
|
||||
- It finds the visitor's DM with the persona Ada Hart (members, then dm) and the first seeded forum category and thread.
|
||||
- Control first: the plain word "viagra" must answer 422 as a chat message (errors.content) and as a forum reply (errors.body). If either control is accepted, it deletes what was stored, prints "check-moderation: the filter is off or the lists differ from the defaults" and exits 1.
|
||||
- Then every probe from the review, in one array whose comment says it mirrors 20-07's TestMatchBypassWords and TestMatchBypassLinks. Words: viagra with U+200B, with U+00AD; "buy followers" with U+00A0, with U+2009, with U+3000; fullwidth viagra; viagra; via**gra**. Links: https://bit%2Ely/x, https://bit.l%79/x, https://bit.ly/x, https://bit。ly/x, https://user@bit.ly/x, [x](https://bit.ly/a). Write each with bash $'\u…' escapes and build the JSON bodies with python3 json.dumps, as check-local does.
|
||||
- Each probe must answer 422 as a chat message, as a forum reply and once as a forum thread title (errors.title). A probe that answers 2xx is deleted at once (the message, post or thread) and reported.
|
||||
- It ends with "check-moderation: ok", or one line per failure and exit 1.
|
||||
2. check-local.sh: `check_moderation` runs check-moderation.sh against $GO with the visitor's JWT through the environment; add `moderation` to ALL_CHECKS and the header list. check-all.sh step 5 runs `moderation` with the other browser and API checks (header comment too).
|
||||
3. check-chat.cjs:
|
||||
- Read DEMO_JWT_FILE and DEMO_JWT2_FILE when set (trimmed, never printed), else DEMO_JWT and DEMO_JWT2.
|
||||
- --call: before the call-button step, A loads /call/{dm}?start=1 directly. `.chatvideo-confirm` must be visible within 5 s. Watch the page's WebSockets for 3 s: none may open to a URL containing /livekit/rtc, and no .chatvideo-tile may render. GET .../video/status must show no session. Then the existing flow runs (the call button and Accept autostart through the handoffs), and the summary line stays "check-chat: ok call".
|
||||
- Default mode: after the message check, the member clicks Log out in the header. Within 5 s every WebSocket the page opened to /ws must be closed, and /chat must show the register link.
|
||||
- Update the header comment.
|
||||
4. README.md "Checks" and DEPLOY.md "Verify": the moderation check, the live bypass probe and the live call probe with token files (the commands, the file mode, that they post only in the visitor's DM and as thread replies and clean up), in the files' style.
|
||||
5. Commit in sm-demo-app (scripts and docs only): `test: moderation bypass probe, call confirmation and logout checks`.
|
||||
6. Temporary integration (never committed):
|
||||
- Write a scratch go.work, $SCRATCH/demo-gap.go.work under this session's scratchpad. It is ../sm-demo-app/go.work with every path made absolute, plugins/golem15/moderation replaced by the absolute path of ../sm-moderation-plugin and plugins/golem15/forum by ../sm-forum-plugin.
|
||||
- Pack both layers with `pnpm --dir ../vue-chat-layer pack --pack-destination $SCRATCH` (and the same for vue-chatvideo-layer), then `pnpm --dir ../sm-demo-app/vue-demo-app add <the two tarball paths>`.
|
||||
- Run `GOWORK=$SCRATCH/demo-gap.go.work ../sm-demo-app/scripts/dev.sh up`.
|
||||
- Run `../sm-demo-app/scripts/check-local.sh moderation chat callmedia` and see them pass. Leave the setup in place for Task 2, which reverts it; say so in the draft SUMMARY if the run stops here.</action>
|
||||
<verify>
|
||||
<automated>bash -n ../sm-demo-app/scripts/check-moderation.sh && node --check ../sm-demo-app/scripts/check-chat.cjs && ../sm-demo-app/scripts/check-local.sh moderation chat callmedia</automated>
|
||||
<fails_when>Non-zero exit: a script has a syntax error, or check-local reports moderation failed (a probe stored, a control accepted, a wrong field), chat failed (the message or the logout socket check) or callmedia failed (the direct load opened LiveKit or showed no confirmation, or the DM call did not complete).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-demo-app show --stat HEAD | grep -cE 'scripts/check-moderation.sh|scripts/check-chat.cjs|scripts/check-local.sh'` prints 3.
|
||||
- `grep -v '^\s*#' ../sm-demo-app/scripts/check-moderation.sh | grep -cE -- '-H +.?Authorization'` prints 0 (the bearer header never goes on curl's command line).
|
||||
</acceptance_criteria>
|
||||
<done>The bypass probe, the call confirmation step and the logout check exist and pass against all four unreleased fixes together, before anything is tagged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Full suites of every touched repository, the rest of the local checks on the unreleased code, revert of the temporary integration, and release prep</name>
|
||||
<files>../vue-chat-layer/package.json, ../vue-chatvideo-layer/package.json</files>
|
||||
<read_first>The Task 1 setup ($SCRATCH/demo-gap.go.work, the tarball pins), each repo's README Testing and Changelog sections, ../sm-moderation-plugin/plugin.yaml and ../sm-forum-plugin/plugin.yaml, .planning/notes/core-plugins-own-repos.md, ../sm-demo-app/scripts/check-pins.sh</read_first>
|
||||
<action>Per SC-4.
|
||||
1. Full suites (record pass counts in the draft SUMMARY):
|
||||
- sm-moderation-plugin and sm-forum-plugin: `go -C <repo> vet ./...` and `go -C <repo> test -count=1 ./...` with Docker.
|
||||
- vue-chat-layer and vue-chatvideo-layer: `pnpm --dir <repo> test` and `pnpm --dir <repo> run build:playground`.
|
||||
- vue-demo-app: `pnpm test` and `pnpm run build`, first under the tarball pins, then again after the revert below.
|
||||
- sm-demo-app: `go vet ./...` and `go test -count=1 ./...` under its own go.work (the committed pointers). Also `GOWORK=$SCRATCH/demo-gap.go.work go -C ../sm-demo-app test -count=1 -run '^(TestDemoModeration|TestBootAllPlugins|TestGoModRequiresTags)$' .`, which shows the new plugin code works with the demo before it is tagged. TestGoModRequiresTags only reads go.mod, so it must still pass.
|
||||
2. Remaining local checks on the unreleased code: `../sm-demo-app/scripts/check-local.sh chatlive call forum friends herald tour`.
|
||||
3. Revert the temporary integration: `git -C ../sm-demo-app/vue-demo-app checkout -- package.json pnpm-lock.yaml`, then `pnpm --dir ../sm-demo-app/vue-demo-app install --frozen-lockfile`. Restart the stack without the GOWORK override (`../sm-demo-app/scripts/dev.sh up` restarts or rebuilds per its own rules; stop the old Go process if dev.sh does not). Delete the scratch tarballs and go.work.
|
||||
4. Release prep, recorded as a table in the draft SUMMARY:
|
||||
- For each of the four repos: the latest tag (`git tag --sort=-v:refname | head -1` and `git ls-remote --tags origin`) and the next patch tag (expected v0.1.1, v0.1.2, v0.1.1, v0.1.1).
|
||||
- The default branch (`git remote show origin`, HEAD branch main), a fast-forward of origin main (`git merge-base --is-ancestor origin/main HEAD` after `git fetch`), and every earlier tag an ancestor of HEAD.
|
||||
- The README changelog entries for the new versions (moderation, forum), and `summer plugin:manifest --check` passing in both plugins (summer from PATH, else `go -C ../summercms.go run ./cmd/summer`).
|
||||
- Set "version" in the two layers' package.json to the new version, and commit each: `chore: release 0.1.1`.
|
||||
- The commits each tag will contain (`git log --oneline <latest tag>..HEAD`).</action>
|
||||
<verify>
|
||||
<automated>for r in sm-moderation-plugin sm-forum-plugin; do go -C ../$r vet ./... && go -C ../$r test -count=1 ./... || exit 1; done && for l in vue-chat-layer vue-chatvideo-layer; do pnpm --dir ../$l test || exit 1; done && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build && go -C ../sm-demo-app vet ./... && go -C ../sm-demo-app test -count=1 ./... && git -C ../sm-demo-app/vue-demo-app diff --quiet -- package.json pnpm-lock.yaml && for l in vue-chat-layer vue-chatvideo-layer; do node -e "process.exit(require('../$l/package.json').version === '0.1.1' ? 0 : 1)" || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: a suite fails in any touched repository, the temporary pins were not reverted, or a layer's package.json version was not set.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The draft SUMMARY holds the release table (repo, latest tag, new tag, HEAD, commits since the tag) and the suite results.
|
||||
- `git -C ../sm-demo-app status --porcelain` lists at most ` M vue-demo-app`.
|
||||
</acceptance_criteria>
|
||||
<done>Every touched repository is green on its own and together with the others, the integration left no trace, and the release table is ready for the go-ahead.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 3: Go-ahead to push main and tag the four repositories</name>
|
||||
<read_first>The Task 2 release table (repo, latest tag, proposed tag, HEAD, commits since the latest tag), the suite results and the local checks, the note on moderation's version (v0.1.1, not the brief's v0.1.2)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user's answer is recorded in the SUMMARY with the confirmed versions.
|
||||
</acceptance_criteria>
|
||||
<decision>Push main and create the annotated tags sm-moderation-plugin v0.1.1, sm-forum-plugin v0.1.2, vue-chat-layer v0.1.1 and vue-chatvideo-layer v0.1.1?</decision>
|
||||
<context>Tags are one-way: consumers' go.sum and lockfiles pin them, so they are never moved, only superseded. Each tag carries only the gap-round fixes listed in the table (plus vue-chat-layer's two test and refactor commits already on main since v0.1.0). The demo can consume them only after this. Other applications pin older tags and are unaffected, because the changes are additive (PostSaving gains a field, the filter matches more forms, the layers add a guard, handoffs and a confirmation).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Push and tag all four</name>
|
||||
<pros>The demo can pin the fixes and be redeployed.</pros>
|
||||
<cons>The versions are public and final.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>Push main, no tags yet</name>
|
||||
<pros>The commits are backed up and visible for review.</pros>
|
||||
<cons>The demo cannot pin them; the plan stops after Task 4 until tags are approved.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The live demo keeps the bypasses and the call race.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release" (optionally with other versions), "push-only" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Push the four repositories and tag them</name>
|
||||
<reversibility rating="one-way">Pushed tags are pinned by consumers' go.sum and lockfiles; they can be superseded but never moved.</reversibility>
|
||||
<precondition>Task 3 answered "release" or "push-only".</precondition>
|
||||
<files>(no files; git refs on origin)</files>
|
||||
<read_first>The Task 3 answer, the Task 2 release table, .planning/notes/core-plugins-own-repos.md</read_first>
|
||||
<action>For each of sm-moderation-plugin, sm-forum-plugin, vue-chat-layer and vue-chatvideo-layer, in that order:
|
||||
- `git fetch origin` and confirm origin/main is still an ancestor of HEAD (stop and report if another session pushed);
|
||||
- `git push origin main` and check `git ls-remote origin refs/heads/main` equals HEAD;
|
||||
- with "release": create the annotated tag at HEAD (`git tag -a <tag> -m "<one line: what the release fixes>"`), push only that tag (`git push origin <tag>`), check `git ls-remote origin "refs/tags/<tag>^{}"` equals HEAD and that the previous tag is an ancestor (`git merge-base --is-ancestor <previous> <tag>`).
|
||||
With "push-only", stop after the pushes, record it, and end the plan run here (resume at Task 3 when the user approves tags).
|
||||
Write the progress note into the draft 20-09-SUMMARY.md: the four pushed heads and tags.</action>
|
||||
<verify>
|
||||
<automated>for r in "sm-moderation-plugin v0.1.1" "sm-forum-plugin v0.1.2" "vue-chat-layer v0.1.1" "vue-chatvideo-layer v0.1.1"; do set -- $r; [ "$(git -C ../$1 ls-remote origin refs/heads/main | cut -f1)" = "$(git -C ../$1 rev-parse HEAD)" ] || exit 1; [ "$(git -C ../$1 ls-remote origin "refs/tags/$2^{}" | cut -f1)" = "$(git -C ../$1 rev-parse HEAD)" ] || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: a remote main differs from the local HEAD, or a tag is missing on origin or points elsewhere. Use the versions confirmed in Task 3 if they differ from the table; with "push-only" only the main check applies.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-moderation-plugin describe --exact-match HEAD` and the same in the other three repos print the new tag.
|
||||
</acceptance_criteria>
|
||||
<done>The four fixes are public at their patch tags on the default branch.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: The demo consumes the tags: layer pins, plugin bumps, the forum listener reads the rendered post, and the gitlink</name>
|
||||
<precondition>Task 4 created all four tags on origin.</precondition>
|
||||
<files>../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml, ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/plugins/golem15/moderation, ../sm-demo-app/plugins/golem15/forum, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/moderation_test.go, ../sm-demo-app/vue-demo-app, ../sm-demo-app/plugins/demo/content/ROADMAP.md</files>
|
||||
<read_first>../sm-demo-app/plugins/demo/moderation.go, moderation_test.go (TestDemoModeration: setLists, post, put, refused, the stored-count check), go.mod, .gitmodules, scripts/check-pins.sh, scripts/build.sh (the release refusals), scripts/sync-roadmap.sh, vue-demo-app/package.json, vue-demo-app/app/pages/call/[conversation].vue, 20-07-SUMMARY.md (the listener finding), ../sm-moderation-plugin/README.md (CheckHTML), ../sm-forum-plugin/README.md (PostSaving.HTML)</read_first>
|
||||
<behavior>
|
||||
- TestDemoModeration, with the lists set to the demo defaults plus the path rule example.com/promo: every probe of check-moderation.sh, the path-rule forms (https://example.com:443/promo, https://example.com//promo, https://sub.example.com/promo) and the decomposed żółw case (with żółw added to the words), is refused with 422 on chat send, reply and edit (errors.content) and on forum thread title, thread body, reply and edit (errors.title or errors.body); nothing is stored.
|
||||
- The rendered-only forum forms bit\.ly and via<!-- x -->gra are refused on reply and edit with errors.body (only CheckHTML on ev.HTML catches them).
|
||||
- The existing TestDemoModeration cases keep passing.
|
||||
</behavior>
|
||||
<action>Per D-17, D-18 and D-13, in this commit order:
|
||||
1. vue-demo-app: `pnpm --dir ../sm-demo-app/vue-demo-app add` the two git+https specs at the new tags (vue-chat-layer and vue-chatvideo-layer). Update the doc comment of app/pages/call/[conversation].vue: ?start=1 picks Start over Join on the confirmation; media starts only after a click there, the call button or Accept. Run `pnpm test` and `pnpm run build`, then commit in vue-demo-app: `chore: pin vue-chat-layer and vue-chatvideo-layer v0.1.1`.
|
||||
2. sm-demo-app, for moderation and then forum: fetch tags in the submodule and check it out at the new tag; `GOWORK=off GOPRIVATE=git.golem15.com/* go -C ../sm-demo-app get git.golem15.com/golem15/sm-<name>-plugin@<tag>` and `GOWORK=off GOPRIVATE=git.golem15.com/* go -C ../sm-demo-app mod tidy` (GOPRIVATE as build.sh sets it); commit go.mod, go.sum and the gitlink: `chore: bump sm-moderation-plugin to v0.1.1`, then `chore: bump sm-forum-plugin to v0.1.2`.
|
||||
3. plugins/demo/moderation.go: checkPost also runs `Filter.CheckHTML` on `ev.HTML` (when not empty), after title and body, and answers a violation as `Rejection{Field: "body"}`; update the function and file comments (chat messages are checked by Check, which normalises; forum posts also by what they render to). Extend TestDemoModeration per the behavior block (red first for the rendered-only forms). Commit: `fix: refuse forum posts whose rendered text carries a banned word or link`.
|
||||
4. Commit the vue-demo-app gitlink in sm-demo-app: `chore: bump vue-demo-app (layers v0.1.1, sign-out reset)`.
|
||||
5. If `scripts/sync-roadmap.sh --check` reports a stale snapshot, run `scripts/sync-roadmap.sh` and commit `chore: sync the roadmap snapshot`.
|
||||
Nothing is pushed in this task.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet ./... && go -C ../sm-demo-app test -count=1 ./... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoModeration|TestGoModRequiresTags)$' -v . 2>&1 | grep -cE -- '^--- PASS: Test(DemoModeration|GoModRequiresTags) ' | grep -qx 2 && ../sm-demo-app/scripts/check-pins.sh && pnpm --dir ../sm-demo-app/vue-demo-app test</automated>
|
||||
<fails_when>Non-zero exit: vet or a Go test fails (a probe stored, a rendered-only form accepted, a go.mod require not a tag), check-pins reports a checkout off its required tag, or the site tests fail on the new pins.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 'sm-moderation-plugin v0\.1\.1$|sm-forum-plugin v0\.1\.2$' ../sm-demo-app/go.mod` prints 2.
|
||||
- `grep -cE 'vue-chat(video)?-layer\.git#v0\.1\.1' ../sm-demo-app/vue-demo-app/package.json` prints 2.
|
||||
- `git -C ../sm-demo-app status --porcelain` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The demo builds from the released fixes only, and its own listener refuses forum posts by what they render to.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: The demo's whole gate passes locally on the released versions, including a release build and the smoke run</name>
|
||||
<files>(no files; build outputs bin/demo and vue-demo-app/.output, untracked)</files>
|
||||
<read_first>../sm-demo-app/scripts/check-all.sh (steps and --local), DEPLOY.md "Build", 20-06-SUMMARY.md (the last check-all run)</read_first>
|
||||
<action>Run `../sm-demo-app/scripts/dev.sh up` (the stack now builds from the bumped submodules through go.work), then `../sm-demo-app/scripts/check-all.sh --local`. It runs the app and plugin suites, vitest in vue-demo-app and the four layers, sync-roadmap --check, check-pins, `build.sh release`, smoke.sh, check-deploy, then check-local back remote chatlive callmedia moderation and check-live against the current live site. Record the release id from the `build: ok release <id>` line and each step's result in the draft SUMMARY. If a step fails, fix the cause in the repository that owns it. A fix to a tagged repository means a new patch tag through Task 3's gate again, never a moved tag.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-all.sh --local 2>&1 | tail -1 | grep -qx 'check-all: ok'</automated>
|
||||
<fails_when>Non-zero exit: any check-all step fails (a suite, the snapshot, pins, the release build, smoke, check-deploy, a browser check or check-live).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The draft SUMMARY records `build: ok release <id>` and `smoke: ok` from this run.
|
||||
</acceptance_criteria>
|
||||
<done>The exact release that would go to rome passed the whole gate on this machine.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 7: Go-ahead to push the demo repositories and deploy the release to rome</name>
|
||||
<read_first>The release id from Task 6 and the check-all result; `git -C ../sm-demo-app log --oneline origin/master..HEAD` and the same for vue-demo-app; the list of what changes on rome: binary, site, config rsync; `migrate` (no new migrations in the bumped plugins: check `git -C <plugin> diff --stat <previous tag> <new tag> -- updates/`) and `demo:seed`; restart of summercms-demo and summercms-demo-site only; no nginx, Centrifugo, LiveKit, firewall or WavePath change; the rollback commands from DEPLOY.md "Rollback"</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user's answer is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Push vue-demo-app and sm-demo-app and deploy release <id> to demo.summercms.io?</decision>
|
||||
<context>The live demo still has CR-01 (calls connect after Leave), CR-02 and CR-03 (filter bypasses), WR-01, WR-04 and WR-05. The release restarts only the demo's own two programs; bin/demo.prev and site/.output.prev stay for a rollback.</context>
|
||||
<options>
|
||||
<option id="deploy">
|
||||
<name>Push and deploy</name>
|
||||
<pros>The fixes are live today.</pros>
|
||||
<cons>A short restart of the demo's API and site.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>Push, deploy later</name>
|
||||
<pros>The pins are backed up.</pros>
|
||||
<cons>The live demo keeps the bugs; Tasks 8 to 10 wait.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>No change anywhere.</pros>
|
||||
<cons>As above, and the pins exist only here.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "deploy", "push-only" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 8: Push the demo, upload and release on rome by the runbook, and check the live site</name>
|
||||
<reversibility rating="costly">A release can be rolled back with bin/demo.prev and site/.output.prev (DEPLOY.md "Rollback"); migrations, if any ran, are not reversed.</reversibility>
|
||||
<precondition>Task 7 answered "deploy" (with "push-only" do steps 1 and 2 only and stop).</precondition>
|
||||
<files>(no repository files; rome: /srv/summercms-demo/bin, /srv/summercms-demo/site, /srv/summercms-demo/config)</files>
|
||||
<read_first>../sm-demo-app/DEPLOY.md "Upload", "Release", "Verify", "Rollback"; 20-05-SUMMARY.md (how the last release went, shared-host rules)</read_first>
|
||||
<action>Per SC-3 and D-10.
|
||||
1. Push vue-demo-app master, check `git ls-remote` equals HEAD; then push sm-demo-app master, check the same.
|
||||
2. With "push-only", record and stop here.
|
||||
3. Build machine: DEPLOY.md "Upload", the three rsync lines exactly (config without overrides.yaml, bin/demo to bin/demo.new, vue-demo-app/.output/ to site/.output.new/, `--chown=root:root`, sudo rsync, `$RSH`), from the release built in Task 6 (re-run `scripts/build.sh` if anything changed since, then smoke.sh again).
|
||||
4. rome: DEPLOY.md "Release", exactly as written, in this order:
|
||||
- migrate and demo:seed with bin/demo.new as summercms-demo with SUMMER_ENV=production;
|
||||
- keep bin/demo.prev and site/.output.prev, then swap in the new binary and site;
|
||||
- `sudo supervisorctl restart summercms-demo summercms-demo-site`;
|
||||
- websockets:health answers Configuration OK.
|
||||
Restart nothing else. Every command that could carry a secret runs on rome without echoing it.
|
||||
5. DEPLOY.md "Verify":
|
||||
- `scripts/check-live.sh https://demo.summercms.io` prints `check-live: ok`;
|
||||
- supervisorctl status shows the four demo programs RUNNING;
|
||||
- the reset schedule stays off;
|
||||
- `wavepath-livekit` keeps its pid (read-only check).
|
||||
6. Record in the draft SUMMARY: the release id, the commands' results, the pids before and after, and the rollback state.</action>
|
||||
<verify>
|
||||
<automated>[ "$(git -C ../sm-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app rev-parse HEAD)" ] && [ "$(git -C ../sm-demo-app/vue-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app/vue-demo-app rev-parse HEAD)" ] && ../sm-demo-app/scripts/check-live.sh https://demo.summercms.io</automated>
|
||||
<fails_when>Non-zero exit: a remote master differs from the local HEAD, or check-live reports any failure after the release (roll back per DEPLOY.md and report if the site or API is broken).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status summercms-demo summercms-demo-site summercms-demo-centrifugo summercms-demo-livekit` shows four RUNNING lines.
|
||||
</acceptance_criteria>
|
||||
<done>demo.summercms.io runs the release with all six fixes, and its anonymous checks pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking">
|
||||
<name>Task 9: The user signs in on the live demo so the probes can act as members (an authentication gate: live login needs a real Turnstile solve)</name>
|
||||
<action>Sign in on https://demo.summercms.io in a browser and save the session token for the probes. Claude cannot do this because live login is behind Turnstile.</action>
|
||||
<instructions>
|
||||
Claude already deployed the release and ran the anonymous live checks. The member probes need a signed-in session:
|
||||
1. In a browser, log in at https://demo.summercms.io/login as the demo's visitor account (or any member account you use for checks).
|
||||
2. In the browser's developer tools (Application, then Cookies), copy the value of the cookie `summercms_demo_token`.
|
||||
3. Save it in a file only you can read, without pasting it into this chat: `install -m 600 /dev/null ~/.config/summercms/demo-live-jwt`, then paste the value into that file with an editor.
|
||||
4. For the two-browser call probe, do the same with a second member account (register one in a private window if needed) into `~/.config/summercms/demo-live-jwt2`.
|
||||
Alternatively run the checks yourself ("manual"):
|
||||
- Send each probe from DEPLOY.md "Verify" as a DM to Ada and as a forum reply. Each must be refused, and the plain word viagra too.
|
||||
- Open /call/{your DM with Ada}?start=1 directly. A Start call button must show, and the camera must stay off until you click it.
|
||||
- From a second browser, call yourself from the DM button and accept. Both sides must connect.
|
||||
- Leave while the page still says "Connecting…". The camera light must go off and the other side must stop ringing.
|
||||
- Log out while a call rings. The prompt and the chat panel must go away.
|
||||
</instructions>
|
||||
<verification>Claude checks that each file exists with mode 600 and is not empty (`stat -c %a`, `test -s`), without reading its content into the conversation.</verification>
|
||||
<resume-signal>Reply "tokens ready" (both files), "one token" (only demo-live-jwt; the call probe is then your manual check) or "manual approved" / "manual failed: what you saw".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 10: Live recheck: the bypass probe, the call probe, check-live and smoke, then the changelog drafts</name>
|
||||
<precondition>Task 9 answered; for the automated probes the token files exist with mode 600.</precondition>
|
||||
<files>(no repository files; drafts in this session's scratchpad)</files>
|
||||
<read_first>The Task 9 answer, ../sm-demo-app/DEPLOY.md "Verify" (the probe commands from Task 1), ../sm-summercmsio-app/DEPLOY.md "Posting to the changelog", the CLAUDE.md changelog rules</read_first>
|
||||
<action>Per SC-2, D-17 and D-13.
|
||||
1. With a token: `DEMO_JWT_FILE=~/.config/summercms/demo-live-jwt ../sm-demo-app/scripts/check-moderation.sh https://demo.summercms.io` prints `check-moderation: ok`.
|
||||
2. With two tokens: `DEMO_JWT_FILE=~/.config/summercms/demo-live-jwt DEMO_JWT2_FILE=~/.config/summercms/demo-live-jwt2 NODE_PATH="$(npm root -g)" node ../sm-demo-app/scripts/check-chat.cjs --base https://demo.summercms.io --call` prints `check-chat: ok call`. Fake media goes through rome's LiveKit; the direct-load step shows the confirmation. Do not run the default mode live: it would post in public #general and log the token out.
|
||||
3. With "manual approved", record the user's answer instead of the automated probe it replaces; with "manual failed", stop and report what the user saw against the fix it points to.
|
||||
4. `../sm-demo-app/scripts/check-live.sh https://demo.summercms.io` and `../sm-demo-app/scripts/smoke.sh` (the deployed artifact bin/demo) again; both ok.
|
||||
5. Remove the two token files (`rm -f`) and tell the user their browser sessions stay signed in.
|
||||
6. Changelog drafts. From ../sm-summercmsio-app run `scripts/changelog-post.py source <tag> --repo ../sm-moderation-plugin`, and the same for sm-forum-plugin. Write the user-facing notes to $SCRATCH/changelog-moderation.md and $SCRATCH/changelog-forum.md, using sections `## Highlights`, `## Added`, `## Changed` and `## Fixed` as needed. Include no H1, no planning IDs and no `quick-*` codes, and no consuming application's name. Add a one-line plain-text excerpt for each.
|
||||
- Moderation: banned words and links now match what readers see (invisible characters, Unicode spaces, diacritics, entities, inline markup, URL forms browsers normalise); path entries cover subdomains; MatchHTML and Filter.CheckHTML for rendered content.
|
||||
- Forum: PostSaving carries the rendered post HTML.
|
||||
7. Record every result in the draft SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-live.sh https://demo.summercms.io && test -s "$SCRATCH/changelog-moderation.md" && test -s "$SCRATCH/changelog-forum.md" && ! grep -qE '^# |CR-0|WR-0|IN-0|20-0[0-9]|quick-' "$SCRATCH/changelog-moderation.md" "$SCRATCH/changelog-forum.md"</automated>
|
||||
<fails_when>Non-zero exit: check-live fails, a draft is missing, or a draft has an H1, a review or plan ID or a quick code. The probe runs of steps 1 and 2 are recorded with their output line; a probe failure stops the plan with a report, which is not a verify failure.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The draft SUMMARY records `check-moderation: ok` and `check-chat: ok call` against https://demo.summercms.io, or the user's manual answer for each.
|
||||
- `test -e ~/.config/summercms/demo-live-jwt || echo gone` prints gone.
|
||||
</acceptance_criteria>
|
||||
<done>The live demo refuses every bypass from the review, asks before a call page turns media on, completes a DM call, and passes its anonymous checks and smoke run.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 11: Go-ahead for the two plugin changelog posts</name>
|
||||
<read_first>$SCRATCH/changelog-moderation.md and $SCRATCH/changelog-forum.md with their excerpts, the tags (sm-moderation-plugin v0.1.1, sm-forum-plugin v0.1.2), and whether ~/.config/summercms/journal-token exists (`test -s`, never read)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user's answer is recorded in the SUMMARY, with any wording changes applied to the drafts.
|
||||
</acceptance_criteria>
|
||||
<decision>Publish the two plugin posts on https://summercms.io/changelog/plugins?</decision>
|
||||
<context>CLAUDE.md: plugin release tags get a post on /changelog/plugins the same day. The posts are public and can be updated later with --update. The layers get no post (no plugin.yaml, no changelog stream).</context>
|
||||
<options>
|
||||
<option id="publish">
|
||||
<name>Publish both</name>
|
||||
<pros>The releases are announced the day they are tagged.</pros>
|
||||
<cons>Public at once.</cons>
|
||||
</option>
|
||||
<option id="draft">
|
||||
<name>Save as drafts (--draft)</name>
|
||||
<pros>The text can be reviewed in the admin first.</pros>
|
||||
<cons>Not announced until published.</cons>
|
||||
</option>
|
||||
<option id="skip">
|
||||
<name>Skip</name>
|
||||
<pros>No post.</pros>
|
||||
<cons>Breaks the same-day rule.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "publish", "draft" or "skip", with any changes to the text.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 12: Publish the posts as answered, and close the review ledger with commit refs and follow-up todos</name>
|
||||
<precondition>Task 11 answered.</precondition>
|
||||
<files>.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md, .planning/todos/pending/chatchannels-pin-policy.md, .planning/todos/pending/moderation-profile-group-and-filenames.md, .planning/todos/pending/forum-friends-client-401-refresh.md, .planning/todos/pending/chatvideo-dm-only-host-hook.md, .planning/todos/pending/chatvideo-live-membership.md, .planning/todos/pending/check-local-jwt-out-of-argv.md, .planning/todos/pending/chat-slash-text-escape.md, .planning/todos/pending/demo-seed-moderation-lists-once.md, .planning/todos/pending/demo-nginx-livekit-rtc-only.md, .planning/todos/pending/chatvideo-incoming-handle-leak.md, .planning/todos/pending/demo-vapid-runbook-pipefail.md, .planning/todos/pending/demo-chat-members-read-only.md</files>
|
||||
<read_first>20-REVIEW-DISPOSITION.md, 20-REVIEW.md (WR-02, WR-03, WR-06, IN-01 to IN-09: files, issue, fix), two existing files in .planning/todos/pending/ (format), .planning/G15OFFICE.md (todo mirroring), the commit refs from the 20-07, 20-08 and draft 20-09 SUMMARYs</read_first>
|
||||
<action>Per SC-4 and the brief.
|
||||
1. Posts (with "publish" or "draft"):
|
||||
- From ../sm-summercmsio-app, for each plugin, check the checkout is exactly at its tag with a clean worktree (`git -C ../sm-<name>-plugin describe --exact-match HEAD`; check out the tag if main moved on).
|
||||
- Run `scripts/changelog-post.py publish <tag> <draft file> --excerpt "<line>" --repo ../sm-<name>-plugin`, adding --draft for "draft". The script reads the journal token itself.
|
||||
- If the token file is missing or the token is refused, stop and ask the user. Never print, copy or mint a token.
|
||||
- Check https://summercms.io/changelog/plugins lists the published posts.
|
||||
- Return the checkouts to main.
|
||||
2. 20-REVIEW-DISPOSITION.md: CR-01, CR-02, CR-03, WR-01, WR-04 and WR-05 become `fixed`. Each note names the repo, the commit short sha and the tag, for example `sm-moderation-plugin <sha> (v0.1.1)`, plus the live check that confirmed it. WR-02, WR-03, WR-06 and IN-01 to IN-09 become `deferred`, each note pointing at its todo file. Add a `closed: 2026-11-02` line (or the actual date) to the frontmatter.
|
||||
3. Todos, one per deferred finding, in the existing format: title, date, priority (high for WR-02 and WR-03, medium for WR-06, IN-01, IN-02 and IN-07, low for the rest), area (the repo and file), an empty g15office field until the task exists. The body gives the finding's issue and fix in two or three sentences, with "Source: phase 20 code review (20-REVIEW.md <ID>)". IN-07's todo notes that the logout half was fixed by 20-08. The files are:
|
||||
- chatchannels-pin-policy.md (WR-02)
|
||||
- moderation-profile-group-and-filenames.md (WR-03)
|
||||
- forum-friends-client-401-refresh.md (WR-06)
|
||||
- chatvideo-dm-only-host-hook.md (IN-01)
|
||||
- chatvideo-live-membership.md (IN-02)
|
||||
- check-local-jwt-out-of-argv.md (IN-03)
|
||||
- chat-slash-text-escape.md (IN-04)
|
||||
- demo-seed-moderation-lists-once.md (IN-05)
|
||||
- demo-nginx-livekit-rtc-only.md (IN-06)
|
||||
- chatvideo-incoming-handle-leak.md (IN-07)
|
||||
- demo-vapid-runbook-pipefail.md (IN-08)
|
||||
- demo-chat-members-read-only.md (IN-09)
|
||||
4. g15office (.planning/G15OFFICE.md):
|
||||
- Create one task per todo in project summercms (type Bug for WR-02, WR-03, WR-06, IN-02, IN-04, IN-05, IN-07 and IN-09, Improvement for the rest). Each description ends with `Local: .planning/todos/pending/<file>`. Write each task id into its todo's frontmatter.
|
||||
- Post a short comment on the Phase 20 milestone task: the gap round released and deployed, with the four tags.
|
||||
- If the g15office tools do not answer, leave the ids empty and list the todos as left for the user in the SUMMARY. Do not edit G15OFFICE.md itself.
|
||||
5. Commit in summercms.go by explicit path (planning docs only; STATE.md, state.json and G15OFFICE.md untouched): `docs(20): review disposition after the gap closure`.</action>
|
||||
<verify>
|
||||
<automated>D=.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md; for f in CR-01 CR-02 CR-03 WR-01 WR-04 WR-05; do grep -E "^\| $f \|" $D | grep -q '| fixed |' || exit 1; done; for f in WR-02 WR-03 WR-06 IN-01 IN-02 IN-03 IN-04 IN-05 IN-06 IN-07 IN-08 IN-09; do grep -E "^\| $f \|" $D | grep -q '| deferred |' || exit 1; done; ls .planning/todos/pending/{chatchannels-pin-policy,moderation-profile-group-and-filenames,forum-friends-client-401-refresh,chatvideo-dm-only-host-hook,chatvideo-live-membership,check-local-jwt-out-of-argv,chat-slash-text-escape,demo-seed-moderation-lists-once,demo-nginx-livekit-rtc-only,chatvideo-incoming-handle-leak,demo-vapid-runbook-pipefail,demo-chat-members-read-only}.md >/dev/null && git diff --quiet HEAD -- $D</automated>
|
||||
<fails_when>Non-zero exit: a finding has the wrong disposition, a todo file is missing, or the ledger change is not committed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '| open |' .planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md` prints 0.
|
||||
- Every fixed row names at least one commit sha and tag.
|
||||
- The SUMMARY records the post URLs (or the draft ids, or "skipped") and the g15office ids or the note that they are left for the user.
|
||||
</acceptance_criteria>
|
||||
<done>The gap round is announced as the user chose, every review finding has its final disposition, and every deferred one is a tracked todo.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| build machine to origin | pushes and one-way tags that other applications pin |
|
||||
| build machine to rome | rsync and sudo on a shared production host |
|
||||
| probes to the live demo | member sessions posting test content on a public site |
|
||||
| build machine to summercms.io | the journal token publishing posts |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-47 | Tampering | release tags | high | mitigate | annotated tags on the default branch only after Task 3's go-ahead; fast-forward check before push; ls-remote equals HEAD; earlier tags are ancestors; never moved, a fix gets a new patch tag |
|
||||
| T-20-48 | Information disclosure | live member JWTs | high | mitigate | the user writes them into 0600 files (never pasted); scripts read the files, never print them, and pass the bearer header to curl as a config on stdin; the files are removed after the probes; the live default mode of check-chat (which would log out and post publicly) is not run |
|
||||
| T-20-49 | Tampering | probe content on the live demo | medium | mitigate | probes post only in the visitor's own DM and as replies or one thread on seeded content; a control word must be refused first; anything a probe stored is deleted at once and reported |
|
||||
| T-20-50 | Information disclosure | journal token | high | mitigate | changelog-post.py reads ~/.config/summercms/journal-token itself; it is never printed, copied or minted; a missing or refused token stops the step and asks the user |
|
||||
| T-20-51 | Denial of service | the shared host during the release | medium | mitigate | DEPLOY.md's release restarts only summercms-demo and summercms-demo-site; WavePath's pid is checked before and after; rollback with bin/demo.prev and site/.output.prev |
|
||||
| T-20-52 | Tampering | temporary integration leaking into a release | medium | mitigate | the scratch go.work and the tarball pins live outside the repos or are reverted in Task 2 (verify checks package.json and pnpm-lock.yaml are unchanged); build.sh release refuses dirty trees and pins off go.mod |
|
||||
| T-20-SC | Tampering | npm and Go installs | high | mitigate | only the project's own layers and plugins change version, by git tag; no third-party package is added or bumped; Go tidy runs against tags already on origin |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the three checks pass against the unreleased fixes running together locally.
|
||||
- Task 2: every touched repository's full suite; the integration reverted; the release table.
|
||||
- Task 3: go-ahead for push and tags.
|
||||
- Task 4: remote heads and tags equal local HEAD.
|
||||
- Task 5: the demo on the tags, the listener on the rendered post, TestDemoModeration and check-pins green.
|
||||
- Task 6: check-all.sh --local ends with check-all: ok (release build and smoke included).
|
||||
- Task 7: go-ahead for push and deploy.
|
||||
- Task 8: pushed heads, the runbook release, check-live ok.
|
||||
- Task 9: live tokens or the user's manual answer.
|
||||
- Task 10: bypass probe and call probe against the live site, check-live and smoke again, changelog drafts clean.
|
||||
- Task 11: go-ahead for the posts.
|
||||
- Task 12: posts as answered; the ledger closed; twelve todos.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The six fixed findings are released at patch tags, consumed by the demo, deployed on demo.summercms.io and proven there (bypass probe, call probe, check-live, smoke); the two plugin posts are handled as the user decided; 20-REVIEW-DISPOSITION.md has no open row and every deferred finding is a todo.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-09-SUMMARY.md` when done (it grows from the draft started in Task 2): the release table and tags, every checkpoint answer, the demo commits, the release id and rome results, the live probe results, the post URLs, the ledger changes and the todo list with g15office ids.
|
||||
</output>
|
||||
Reference in New Issue
Block a user