Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76288cd9e8 | ||
|
|
f6d4c67314 | ||
|
|
024fd82732 | ||
|
|
c739a7dd06 | ||
|
|
211be60648 | ||
|
|
65e0cdd1fa | ||
|
|
ba39bb1125 | ||
|
|
999054b0bd | ||
|
|
87921c27f6 | ||
|
|
a9237c8ec3 | ||
|
|
7b60c17138 | ||
|
|
75a0dea0a5 | ||
|
|
e6b3aa2222 | ||
|
|
754ab5423a | ||
|
|
7b193032c8 | ||
|
|
2f3eba0cb1 | ||
|
|
9977e31ac9 | ||
|
|
07673e4f30 | ||
|
|
9b72439533 | ||
|
|
60551485ed | ||
|
|
d30ef5de47 | ||
|
|
16318b0c4a | ||
|
|
06ac86409a | ||
|
|
74c8146c46 | ||
|
|
dfc6127009 | ||
|
|
bad273b753 | ||
|
|
df2ba57e7b | ||
|
|
a231309721 | ||
|
|
7de774b0b3 | ||
|
|
9109e5b139 | ||
|
|
b93a6de20e | ||
|
|
b5f1ed202a | ||
|
|
8e8e0642b0 | ||
|
|
13324e7f01 | ||
|
|
678eac4637 | ||
|
|
7e8a19399d | ||
|
|
c4b1ac4076 | ||
|
|
0418900122 | ||
|
|
b91eadb5b6 | ||
|
|
96a7012ece | ||
|
|
d024ab4bb8 | ||
|
|
8093587696 | ||
|
|
a462bf12c9 | ||
|
|
9b23e877a6 | ||
|
|
e7eda88ddd | ||
|
|
77ac19a57c | ||
|
|
69c1fea766 | ||
|
|
f1f1d55fa7 | ||
|
|
e8d3d724f5 | ||
|
|
4916ee3d43 | ||
|
|
51ec2f9daa | ||
|
|
ec6a1955fc | ||
|
|
824e3d5882 | ||
|
|
ac9f6db03e | ||
|
|
c48301ef7e | ||
|
|
4449066fa6 | ||
|
|
b651a67356 | ||
|
|
e51df97db8 | ||
|
|
0e5bb0c44f | ||
|
|
bac8e7ef22 | ||
|
|
83a952a74b | ||
|
|
545c0b4350 | ||
|
|
13aa5f3227 | ||
|
|
860c41d829 | ||
|
|
6c606c666b | ||
|
|
6b9becd994 | ||
|
|
653d95a5b9 | ||
|
|
8cb56a4c51 | ||
|
|
c8a22e3e84 | ||
|
|
75e19dc153 | ||
|
|
64452a4919 | ||
|
|
291b18fb1e | ||
|
|
4957e5ce25 | ||
|
|
ba8432cfb8 | ||
|
|
61d10436c7 | ||
|
|
202d38ba3c | ||
|
|
10fec478be | ||
|
|
2da42e93f9 | ||
|
|
bb7e14bce9 | ||
|
|
4914cade5a | ||
|
|
f3f87f3cc4 | ||
|
|
3520d5d480 | ||
|
|
f91d70d05e | ||
|
|
6efb9a4997 | ||
|
|
4d75695582 | ||
|
|
795c4f6b29 | ||
|
|
e34d113c80 | ||
|
|
6294ac4141 | ||
|
|
53c0a65a43 | ||
|
|
39c59b8329 | ||
|
|
8476a65aa0 | ||
|
|
825a3fd2da | ||
|
|
36092f542c | ||
|
|
ebdfd78ab4 | ||
|
|
6ea2cb70e0 | ||
|
|
8c9c967c88 | ||
|
|
1a487bc644 | ||
|
|
8bc18c90b6 | ||
|
|
70e4b4183c | ||
|
|
a95a014b52 | ||
|
|
a941ab2394 | ||
|
|
017e0e9a6a | ||
|
|
2bef465ecd | ||
|
|
d788d12785 | ||
|
|
341a3a51a1 | ||
|
|
b5a20996c1 | ||
|
|
1488276bd2 | ||
| 3cc42f5079 | |||
| 1468344460 | |||
| 040187deb9 | |||
| bba6539883 | |||
| 9094bf9ad9 | |||
| 06a144e19f | |||
| 23238ccd47 | |||
| 3f52b1ab49 | |||
| 415a09333b | |||
| 876f0f09f2 | |||
| 9e29d6ff9b | |||
| 63d05e8acb | |||
| 2cea3127cc | |||
| 8416f10849 | |||
| 200c664a4b | |||
| 41d145dde1 | |||
| f5931d5698 | |||
| 3938ebbddd | |||
| 1173f35f80 | |||
| 88fbfb7b39 | |||
| 0db030598e | |||
| bd23a82f22 | |||
| a7df0caaa3 | |||
| 14979e7009 | |||
| df2a0753c7 | |||
| 2cc3c1728b | |||
| 69d5a24fa8 | |||
| 2ac8663fe7 | |||
| 126c0866fc | |||
| b8aa5d881c | |||
| 7c4dbb1252 | |||
| adf5cccc4a | |||
| 6472f2861e | |||
| 9fe489084c | |||
| 175e469587 | |||
| 0e187cfb6a | |||
| 1583651783 | |||
| 468af06c86 | |||
| d660cd05fa | |||
| 5471b66b8f | |||
| 676ca539aa | |||
| 2b7174d406 | |||
| 1259f5bd1d | |||
| 32141f803a | |||
| 8b4817fa0a | |||
| cffa49642c | |||
| f098ffdbf9 | |||
| a9a7f58eed |
@@ -192,6 +192,7 @@ Validated in Phase 18.1: Payment Gateway Subscriptions
|
||||
| OIDC on wristband with golang-jwt v5, not zitadel/oidc (Phase 19.4, `notes/2026-10-19-oidc-on-wristband-golang-jwt.md`) | wristband already owned the OAuth flows and PHP-compatible encoding; zitadel's own storage and routing would fight both. go-oidc and x/oauth2 are test-only, for relying-party interop | ✓ Good (Phase 19.4 UAT, 2026-10-20) |
|
||||
| PHP OIDC parity is recorded at the WavePath cutover, not in Phase 19.4 (D-14 amended) | The harness ships and replays Go-side; recording needs the live PHP site, and WavePath's provider must not move until the replay is green | — Pending (todo `2026-10-19-record-php-oidc-parity-against-wavepath`) |
|
||||
| Administrators and Roles live in cabana as an internal pseudo-plugin; privilege rules run in the save transaction; contributed Administrators Runs are wrapped with `guardOutranked` | WinterCMS screens were missing and 19.7 needs the record-action seam; a delegated editor must not outrank the target or capture a code-declared role | ✓ Good (Phase 19.6.1 UAT 26/26, 2026-10-22) |
|
||||
| MCP OAuth advertises one scope list, puts the MCP subject on the request, and stores one consent row per user and client (Phase 32.1) | Declared member-token scopes join the wristband and pier lists; an empty wristband list stays the five fixed names. `Implies` expands only for OAuth MCP subjects. A decision replaces the stored scopes, revoke deletes that client's MCP codes, and the one-time backfill rolls back as a no-op | ✓ Good (framework v0.5.2, sm-oauthserver-plugin v0.1.4, sm-user-plugin v0.1.16; vue-oauth-layer v0.1.0 is local only, 2026-10-28) |
|
||||
|
||||
## Evolution
|
||||
|
||||
@@ -211,4 +212,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-10-22 after Phase 19.6.1 completion*
|
||||
*Last updated: 2026-10-28 after Phase 32.1 completion*
|
||||
|
||||
@@ -1533,10 +1533,26 @@ Plans:
|
||||
3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 20 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 20-01-PLAN.md — shared plugin release: siblings fast-forwarded, chat and forum pre-save seams (D-18), new sm-moderation-plugin (D-17), go.mod fixes, eight tags after go-ahead
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 20-02-PLAN.md — demo backend: oauthserver bump (folded todo), eight plugins mounted by tag, demo context and channel adapter, bootstrap endpoints, realtime for members, slash commands, moderation wiring, EN/PL seed and reset, Centrifugo namespaces, local LiveKit
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 20-03-PLAN.md — vue-chat-layer v0.1.0 (bm port, channels, threads, pins, command picker, link embeds) and /chat, floating panel, Chat tour card in vue-demo-app
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 20-04-PLAN.md — vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer v0.1.0 (DM-only call button), their pages and tour cards, herald VAPID config without UI
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 20-05-PLAN.md — rome deploy: the demo's own LiveKit, Vuurmuur rule (checkpoint), nginx, .env keys, Centrifugo, release, DEPLOY.md, check-deploy and check-live
|
||||
|
||||
**Wave 6** *(blocked on Wave 5 completion)*
|
||||
- [ ] 20-06-PLAN.md — unit tests (Go, layers, site), two-browser chat and DM call checks, phase gate and live verification
|
||||
|
||||
### Phase 30: Płytarium cutover
|
||||
|
||||
@@ -1595,7 +1611,7 @@ Plans:
|
||||
4. Every SEO operation in the admin is available as an MCP tool, and a batch change can be previewed with dry-run and undone from history.
|
||||
5. The framework release with the seams is tagged, and the new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 5/6 plans executed
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1614,7 +1630,36 @@ Plans:
|
||||
- [x] 32-05-PLAN.md — /_seo/api/v1 management API and MCP tools, batch dry-run, undo, CSV, demo mount proof, check-phase32.sh
|
||||
|
||||
**Wave 6** *(blocked on Wave 5 completion)*
|
||||
- [ ] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog
|
||||
- [x] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog
|
||||
|
||||
### Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent (INSERTED)
|
||||
|
||||
**Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. A consuming app's agent-access work found three gaps (its D-36, D-37 and D-38); all three are additive and ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 19.4 (OIDC provider on wristband), Phase 19.7 (generic MCP module)
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth: the wristband metadata and authorize allow-list and pier's protected-resource scopes include them, so a tool route behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (and `backend`).
|
||||
2. A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context through an accessor, so an application records the OAuth client of a write; a personal-token call's credential keeps carrying the token id (sm-user-plugin v0.1.14).
|
||||
3. `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents.
|
||||
4. Module READMEs and `docs/` pages describe the three changes; the framework and `sm-oauthserver-plugin` get patch tags.
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 32.1-01-PLAN.md — Framework seam: catalog, MCPCall, one advertised scope list
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 32.1-02-PLAN.md — sm-user-plugin from v0.1.14: publish the catalog and Implies for OAuth subjects
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 32.1-03-PLAN.md — sm-oauthserver-plugin: consent replace, revoke, backfill
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 32.1-04-PLAN.md — vue-oauth-layer /connect checkboxes
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [x] 32.1-05-PLAN.md — Unit tests, then the release checkpoint
|
||||
|
||||
### Phase 33: SEO output, audit and content
|
||||
|
||||
@@ -1640,10 +1685,74 @@ Plans:
|
||||
3. The crawler reports SEO issues per URL, and each issue can be fixed through MCP.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 14 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 33 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 33-01-PLAN.md — framework: additive content descriptor fields and per-request field options, check-phase33.sh, v0.5.2 release log (no tag)
|
||||
- [ ] 33-07-PLAN.md — vue-seo-layer (new repository): CMS head and one JSON-LD graph, redirect/410 enforcement, root files, 404 report, shared purge route
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 33-02-PLAN.md — sm-journal-plugin: per-site H1 setting on every write path, editor J1–J4, descriptor facts, journal-author type, MCP tools
|
||||
- [ ] 33-03-PLAN.md — sm-sitemap-plugin: sitemap index with per-type files, slot table, real lastmod, hreflang, filter contract (breaking, D-32)
|
||||
- [ ] 33-08-PLAN.md — vue-seo-layer: SSR build guard, noindex routes, route report, drift check, breadcrumbs, pagination, HTML sitemap
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 33-04-PLAN.md — sm-sitemap-plugin: image and News sitemaps, IndexNow, HTML-sitemap API, MCP tools
|
||||
- [ ] 33-09-PLAN.md — journal, menu and FAQ layers on the SEO layer: headings in both H1 modes, one link per card, body links, breadcrumbs, pagination, related posts
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 33-05-PLAN.md — sm-seo-plugin I: sitemap filter, page registry with route report, site endpoint, llms.txt, Links settings, purge after writes
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 33-06-PLAN.md — sm-seo-plugin II: JSON-LD @graph, publisher, schema fields (E8/E9), breadcrumbs, hreflang, pagination; chromedp and webp legitimacy checkpoint
|
||||
|
||||
**Wave 6** *(blocked on Wave 5 completion)*
|
||||
- [ ] 33-10-PLAN.md — sm-seo-plugin: crawler with optional rendered comparison (chromedp), issue report with fix routes, Site audit and Crawls, MCP tools
|
||||
|
||||
**Wave 7** *(blocked on Wave 6 completion)*
|
||||
- [ ] 33-11-PLAN.md — internal linking and media: auto-links, link suggestions (E10), WebP variants with srcset
|
||||
|
||||
**Wave 8** *(blocked on Wave 7 completion)*
|
||||
- [ ] 33-12-PLAN.md — apps I: demo end to end on local links, summercms.io patches, sm-bm-app noindex (D-72)
|
||||
- [ ] 33-13-PLAN.md — grzybyfunkcjonalne.pl on the new sitemap format and the SEO layer; uat deploy by decision
|
||||
|
||||
**Wave 9** *(blocked on Wave 8 completion)*
|
||||
- [ ] 33-14-PLAN.md — unit tests, check-phase33.sh --all, release tags in order, app re-pins, changelog post
|
||||
|
||||
### Phase 34: Plugin marketplace
|
||||
|
||||
**Goal:** People browse the public Golem15 plugins at summercms.io/marketplace and add one to their application with a single command, `summer plugin:install <id>`. The same command installs private plugins straight from a git URL without contacting summercms.io. The admin SPA gains a read-only Plugins screen listing installed plugins and searching the marketplace, which shows the install command with a copy button. v1 has no paid plugins, no developer registration and no installs from the admin panel.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Nothing technical; ordered after Phase 33
|
||||
**Repos:** `summercms.go` (manifest spec and validation, `plugin:install`, cabana Plugins screen and boardwalk views), `sm-summercmsio-app` and its `golem15.summercms` site plugin (registry API, ingestion, admin presentation fields), `vue-summercmsio-app` (marketplace pages), and a `plugin.yaml` in each public `sm-*-plugin` repo.
|
||||
**Context:** decisions in `.planning/notes/2026-10-29-plugin-marketplace.md`; open question on `--module` for private git URLs in `.planning/research/questions.md`.
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. Every public `sm-*-plugin` repo carries a `plugin.yaml` manifest (ID, module path, name, description, required framework version, required plugins) that the framework validates, and anyone can fetch the repo anonymously.
|
||||
2. summercms.io ingests manifest, README and versions when a plugin is tagged, serves them through a registry API, and renders /marketplace with list, search, category and detail pages; category, featured, screenshots and icon are edited in the summercms.io admin.
|
||||
3. `summer plugin:install golem15.<name>` resolves the plugin through the registry and installs it as a Go module (`--source` checks it out as a submodule); `summer plugin:install <git-url>` installs from the repo alone as a submodule. Both register the plugin like `plugin:add` and check framework and plugin requirements.
|
||||
4. The admin Plugins screen lists installed plugins with versions and available updates, and its Marketplace tab searches the registry and shows the install command with a copy button.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 34-01-PLAN.md — kiosk module: plugin.yaml format, `summer plugin:manifest [--check]`, const-resolving ID reader, registry v1 wire types, PluginInfo/FrameworkVersion baked by summer build (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 34-03-PLAN.md — summercms.io registry: tables, pull ingester, /api/registry/v1, admin curation, `registry` scope ingest-now, hourly schedule, /marketplace pages, deploy, changelog-post.py hooks (wave 2)
|
||||
- [x] 34-04-PLAN.md — `summer plugin:install` (module, --source, git URL) and `summer plugin:update`, `plugins.registry_url`, go.work overrides (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 34-05-PLAN.md — admin Plugins screen: cabana endpoints, backend.view_plugins, Installed + Marketplace tabs with copy, openapi and dist (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 34-02-PLAN.md — plugin.yaml and TestPluginManifest in every public sm-*-plugin repo, framework minimum v0.6.0, app pointer bumps after Jakub's push (wave 4)
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [x] 34-06-PLAN.md — unit tests across all repos, scripts/check-phase34.sh, release ledger and validation map (wave 5)
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -1698,8 +1807,9 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
|
||||
| 30. Płytarium cutover | 0/TBD | Not started | - |
|
||||
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
|
||||
| 32. SEO foundation | 5/6 | In Progress| |
|
||||
| 33. SEO output, audit and content | 0/TBD | Not started | - |
|
||||
| 32. SEO foundation | 6/6 | In Progress| |
|
||||
| 33. SEO output, audit and content | 0/14 | Planned | - |
|
||||
| 34. Plugin marketplace | 6/6 | In Progress| |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -1000,6 +1000,12 @@ Recent decisions affecting current work:
|
||||
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
|
||||
- Phase 19.4: review warnings WR-01..06 are open (19.4-REVIEW-DISPOSITION.md); WR-01 (a replayed code does not revoke rotated refresh tokens) must be fixed before any client enables refresh. Release still pending: tag summercms, bump sm-oauthserver-plugin go.mod to sm-user-plugin v0.1.1 / summercms v0.2.4.
|
||||
|
||||
| 261028-rqb | Admin SPA reload on deploy (todo .planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md, admin half only; site half already shipped in vue-demo-app): boardwalk exposes a build version derived from the embedded dist (header like X-Summer-Build on admin API responses and/or a version endpoint), the admin SPA records the version it booted with and shows a non-blocking reload banner when a newer build is seen (focus/visibility/interval check), plus a reload-once guard on Vite chunk-load errors (vite:preloadError + sessionStorage flag); unit tests; boardwalk README; rebuild admin and commit modules/boardwalk/dist (scripts/check-admin-dist.sh must pass) | 2026-10-28 | c8a22e3 | .planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026 |
|
||||
| 261028-rqc | Quiet plugin boot logs on console commands like --help, framework side only (todo .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md): help/list/completion style commands must not print plugin Register/Boot Info logs (e.g. raise the default console log level for non-serve introspection commands or defer Boot for them), without hiding logs for serve/worker/migrate; no sm-* plugin edits (plugin-side Info->Debug changes are a separate follow-up); docs for the behaviour | 2026-10-28 | 6c606c6 | .planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo |
|
||||
| 261028-rqd | Console review info findings IN-01..IN-13 and the delegate signal exit code 128+n (todo .planning/todos/pending/2026-10-22-console-review-info-findings.md, review .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md); all 14 verified still open on 2026-10-28 in modules/bonfire, modules/diary, cmd/summer, scripts/check-phase19.8.sh; fix each, keep behaviour backward compatible, update docs/console pages and TestDelegateExitCodePassthrough for 128+n | 2026-10-28 | 202d38b | .planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si |
|
||||
| 261028-rqe | attach.Store writes non-inline types with a non-rendering disposition (todo .planning/todos/pending/2026-10-28-attach-store-public-content-type.md): in modules/lagoon/attach/store.go pass Content-Disposition attachment (blob.WriterOptions) for anything not in an inline-safe raster allow-list (jpeg, png, gif, webp, avif), keep the DB ContentType unchanged, additive and non-breaking; tests; lagoon README and docs/services/storage.md deploy note updated | 2026-10-28 | 4957e5c | .planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di |
|
||||
| 261028-rqf | Audit-log successful admin action runs with the admin id (todo .planning/todos/pending/admin-action-audit-log.md): in modules/cabana/actions.go runAction log success with admin_id (bouncer.User(ctx).ID), controller, action and kind (toolbar/field), matching the existing bulk/record action log fields; tests | 2026-10-28 | 61d1043 | .planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod |
|
||||
| 261028-rqg | Show the administrators holding a role on the Roles screen (todo .planning/todos/pending/roles-screen-holders-list.md, origin .planning/phases/19.6.1-backend-administrators-screen/19.6.1-UAT.md:44): read-only list of holders on the Roles form/preview in modules/cabana backend_roles_admin.go (and admin SPA if needed), superusers hidden from non-superuser editors (D-03), assignment stays on the Administrators form; tests; if admin/ changes, rebuild and commit modules/boardwalk/dist | 2026-10-28 | 653d95a | .planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t |
|
||||
### Quick Tasks Completed
|
||||
|
||||
| # | Description | Date | Commit | Directory |
|
||||
@@ -1059,6 +1065,9 @@ Recent decisions affecting current work:
|
||||
| 261022-wah | User plugin opt-in golem15.user.jwt.token_cookie (default off) sets the HttpOnly auth.cookie session cookie on /login, /register and /activate-by-code instead of the body token; a /refresh carried by the auth.cookie cookie rewrites it (sm-user-plugin 846087d, 8bdf2c7, ships v0.5.0; verified) | 2026-10-22 | 846087d, 8bdf2c7 (sm-user-plugin) | [261022-wah-opt-in-httponly-session-cookie-on-user-p](./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) |
|
||||
| 261022-x7a | User plugin golem15.user.password.public_routes (default true): false leaves POST /login, /forgot-password and /reset-password unregistered so they 404 like unknown paths; /register and the rest unchanged (sm-user-plugin 3828a65, ships v0.5.0; verified) | 2026-10-22 | e5dd457 | [261022-x7a-user-plugin-golem15-user-password-public](./quick/261022-x7a-user-plugin-golem15-user-password-public/) |
|
||||
| 55 | fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged after commit | 2026-10-23 | 7c43820 | — |
|
||||
| 261028-pq8 | Fix the four internal/build scaffolder todos (make:plugin vendor path, make:admin-controller output, scaffold header and make:command deps, same-second migration order) | 2026-10-28 | 8bc18c9 | [261028-pq8-fix-the-four-internal-build-scaffolder-t](./quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/) |
|
||||
| 261028-r16 | Fix the two lagoon todos: stored-upload extension from sniffed MIME (tests + deploy note) and the lagoon README after-commit callback order | 2026-10-28 | 36092f5 | [261028-r16-fix-the-two-lagoon-todos-derive-stored-u](./quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/) |
|
||||
| fast | ensureToolchain keeps an existing toolchain line instead of adding a second one | 2026-10-28 | 6ea2cb7 | — |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: awaiting_human_verify
|
||||
status: resolved
|
||||
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
|
||||
created: 2026-10-22T00:00:00Z
|
||||
updated: 2026-10-22T00:00:00Z
|
||||
@@ -42,3 +42,7 @@ The console layer (`modules/bonfire`) and the new logging module (`modules/diary
|
||||
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
|
||||
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
|
||||
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.
|
||||
|
||||
## Addendum 2026-10-28: fang removed
|
||||
|
||||
`charm.land/fang/v2` was removed in quick 261028-rqd (console review finding IN-06). `fang.Execute` installs its own help function on the root just before `root.ExecuteContext`, so after `bonfire.Execute` the root's help function was no longer bonfire's list renderer, and cobra offers no per-root hook in between to restore it. bonfire had already switched off every fang feature except the error handler, so `bonfire.Execute` now runs `root.ExecuteContext` itself and renders errors with `RenderError` on the root's error stream. The only other thing fang did, enabling VT processing on Windows consoles, is done through `lipgloss.EnableLegacyWindowsANSI` on the output and error streams when they are files (a no-op off Windows). `go mod tidy` dropped fang and its exclusive transitive modules (mango, mango-cobra, mango-pflag, roff, x/exp/charmtone); no module was added. The list, help and error output stayed byte-identical (the goldens pass unchanged). The stack is now five charm modules plus colorprofile.
|
||||
|
||||
44
.planning/notes/2026-10-29-plugin-marketplace.md
Normal file
44
.planning/notes/2026-10-29-plugin-marketplace.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Decision: Plugin marketplace on summercms.io and `summer plugin:install`
|
||||
|
||||
**Date:** 2026-10-29
|
||||
**Status:** Accepted (exploration)
|
||||
**Context:** `/msd-explore` session on summercms.io/marketplace, modelled on octobercms.com/marketplace
|
||||
|
||||
## Scope of v1
|
||||
|
||||
- The marketplace lists the public Golem15 plugins (`sm-*-plugin` repos that stay public). No paid plugins and no developer registration: Golem15 publishes every listing.
|
||||
- Its job is browsing plugins and getting the install command, nothing more.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Listing data: facts in the repo, presentation on summercms.io
|
||||
|
||||
- Each plugin repo carries a `plugin.yaml` manifest next to `plugin.go`: plugin ID, module path, name, description, required framework version, required plugins. The README and git tags (`vX.Y.Z`) complete the facts.
|
||||
- summercms.io ingests the manifest, README and tags when a plugin is tagged. The hook is the existing `scripts/changelog-post.py publish … --repo ../sm-<name>-plugin` step or a webhook.
|
||||
- Presentation fields (category, featured flag, screenshots, icon, sort order) are edited in the summercms.io admin, in the `golem15.summercms` site plugin.
|
||||
- Reason: install-critical data (module path, versions, requirements) cannot drift from the repo, while marketing details change without a plugin release. A repo manifest also leaves room for third-party submissions later (submit a repo URL).
|
||||
|
||||
### `summer plugin:install`
|
||||
|
||||
The form of the argument decides where the plugin comes from:
|
||||
|
||||
| Argument | Lookup | Default install mode | Override |
|
||||
|----------|--------|----------------------|----------|
|
||||
| Plugin ID, e.g. `golem15.journal` | summercms.io registry API: module path, versions, requirements | Go module (`require` in `go.mod`, `go mod download`) | `--source`: git submodule checkout at `plugins/<vendor>/<name>`, as `plugin:add` does today |
|
||||
| Git URL, e.g. `git@github.com:acme/sm-servers-plugin.git` | None. summercms.io is never contacted. ID and requirements come from the repo's `plugin.yaml`, module path from its `go.mod` | Git submodule checkout, which reuses the SSH access the user already has | `--module` (see the open research question on private modules) |
|
||||
|
||||
- Git URLs cover closed or private plugins from repositories the user has access to.
|
||||
- Both modes end with the same registration that `plugin:add` does (`summer.yaml`, `go.mod`, `go.work` where applicable), then `summer build`.
|
||||
- Precondition: public plugin repos on git.golem15.com fetch anonymously (HTTPS clone and `?go-get=1` meta). Confirmed as the intent for public plugins on 2026-10-29.
|
||||
|
||||
### Admin "Plugins" screen (read-only)
|
||||
|
||||
- **Installed** tab: the activated plugins from `backpack.App`, with version and description, and updates available compared against the registry.
|
||||
- **Marketplace** tab: searches the summercms.io registry and shows `summer plugin:install <id>` with a copy button. There is no install button.
|
||||
- The running binary never installs plugins. Plugins are compiled in; runtime loading stays rejected (`why-go-not-scala.md`, `.planning/research/FEATURES.md` anti-features). Queued installs and dev-mode live installs were considered and dropped as unnecessary for v1.
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- Paid plugins, licensing, developer accounts and submissions.
|
||||
- Themes in the marketplace.
|
||||
- Installing from the admin panel.
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
phase: "16.2"
|
||||
slug: "plugin-provided-field-types-and-the-blog-editor-formwidget"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 16.2 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Paths are relative to the meta repo (`summercms.go/`, `sm-journal-plugin/`, `vue-journal-layer/`, `sm-grzybyfunkcjonalne-app/`). Code was checked at each repo's HEAD; for code that later moved (the site-cache purger now lives in `summercms.go/modules/cooler`), the 16.2 release commit `5eea57f` was checked too.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| admin browser → cabana admin API | Save bodies, upload bodies and field-type requests (cookie or Bearer session) | field values in every locale, uploaded files |
|
||||
| plugin Go code → cabana | Decode, Load, ListValue and Upload run inside cabana's handlers and save transaction | decoded values, plugin errors and notices |
|
||||
| plugin element JS → admin SPA DOM | The element runs in the admin origin under CSP `script-src 'self'` | `value` property, plain attributes, `summer-upload` events |
|
||||
| stored post HTML → admin editor DOM | Imported or previously saved HTML is parsed into the editor | untrusted HTML |
|
||||
| admin save body → Journal Decode | Every locale's content value crosses here through cabana | HTML to sanitize |
|
||||
| editor upload → media bucket | User files become public URLs under `/storage/app/uploads/public` | image bytes, object keys |
|
||||
| Journal API `content_html` → site `v-html` | Stored HTML becomes page markup | HTML, embed placeholders |
|
||||
| site page → youtube-nocookie.com | The visitor's browser loads a third-party frame | referrer, visitor IP |
|
||||
| Go host → Nuxt (loopback HTTP) | Journal calls the layer purge route with a bearer token | purge token, slug |
|
||||
| internet → nginx → Nuxt | Visitors reach Nuxt with GET/HEAD only; nginx adds forwarding headers | requests, X-Forwarded-For |
|
||||
| npm registry → plugin build | `@tiptap/*` packages enter the committed bundle | third-party code |
|
||||
| executor → git origin / UAT server | Tags and pushes read by other repos; release runs next to the live Winter site | tags, binaries, `.env` secrets (server only) |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-16.2-01 | Spoofing | compileFieldTypes alias registry | high | mitigate | `summercms.go/modules/cabana/field_type.go:106` refuses core aliases, `:166-171` refuses a cross-plugin alias naming both owners, `:172` checkWidgetTag (prefix check `extension.go:346`) | closed |
|
||||
| T-16.2-02 | Tampering (stored XSS) | decodePluginFields | high | mitigate | `crud.go:830-852` runs it inside the save transaction; `field_type.go:465/489/511` decode host value and every locale; only skip is the served-value match `:539-541`, which writes nothing | closed |
|
||||
| T-16.2-03 | Elevation of Privilege | fieldUpload route | high | mitigate | `http.go:452` requireAjax; `field_type_upload.go:35` protect (guard and permissions `http.go:1216-1225`), `:49/:142-160` field/context check and 404 without Upload, `:124` loadRecord scope; `auth.go:253` SameSite=Strict | closed |
|
||||
| T-16.2-04 | Denial of Service | fieldUpload body | medium | mitigate | `field_type_upload.go:69` MaxBytesReader on `upload_bytes`, `:85/:168-177` exactly one part, `:200-204` 413 | closed |
|
||||
| T-16.2-05 | Information Disclosure | PluginField / element | high | mitigate | `admin/src/components/form/fields/PluginField.vue:63-65,76-84` value and plain attributes only, upload via SPA `api.POST` `:133`; test `admin/tests/form/PluginField.test.ts:103` | closed |
|
||||
| T-16.2-06 | Tampering | typeOptions to element | low | mitigate | `PluginField.vue:44,81` JSON attribute string; no `v-html` | closed |
|
||||
| T-16.2-07 | Information Disclosure | plugin errors | medium | mitigate | `field_type.go:669-686` only `*pact.FormFieldError` → 422; `:690-693` and `:727-729` opaque 500 | closed |
|
||||
| T-16.2-08 | Tampering | untouched skip | low | accept | See AR-16.2-01 | closed |
|
||||
| T-16.2-33 | Tampering (admin XSS) / DoS | save notices | medium | mitigate | `admin/src/components/form/FormField.vue:86`, `ui/Toast.vue:34` text interpolation only; `field_type.go:40-42,799,811` 10 notices per field/locale, 500 runes; test `FormField.test.ts:50` | closed |
|
||||
| T-16.2-09 | Elevation of Privilege (admin XSS) | rawHtml node view | high | mitigate | `sm-journal-plugin/assets/src/blogeditor/extensions.ts:301,315` textContent; parse in inert document `:270-273`; test `tests/blogeditor/smoke.test.ts:135` | closed |
|
||||
| T-16.2-10 | Tampering (stored XSS) | Source tab and pasted HTML | high | mitigate | `classes/blog_editor.go:164` SanitizeHTMLReport on every locale; admin `FormView.vue:478` adopts the stored value; layer `PostBody.vue:13` sanitizes again | closed |
|
||||
| T-16.2-11 | Tampering | link mark | medium | mitigate | `extensions.ts:42-63` isPolicyUrl (control chars removed first), `:545`; `element.ts:465,570`; tests `smoke.test.ts:417-424` | closed |
|
||||
| T-16.2-12 | Elevation of Privilege | bundle under admin CSP | medium | mitigate | no `eval(`/`new Function(` in `assets/dist/blogeditor.js`; `element.ts:202` `injectCSS: false`; CSS scoped under `golem15-journal-blogeditor` | closed |
|
||||
| T-16.2-13 | Information Disclosure | uploads | high | mitigate | `element.ts:559` dispatches `summer-upload {id, file}` only; no fetch, XHR, cookie or storage access in `assets/src/blogeditor` | closed |
|
||||
| T-16.2-14 | Tampering (stored XSS) | decodeBlogHTML (posts, categories) | high | mitigate | `blog_editor.go:154-166`; `models/post/fields.yaml:19`, `models/category/fields.yaml:28`; tests `posts_admin_smoke_test.go:360`, `categories_admin_editor_test.go:56` | closed |
|
||||
| T-16.2-31 | Tampering (stored XSS) | newHTMLPolicy editor-output extension | high | mitigate | `classes/format_html.go:63-72`, exactly four AllowAttrs added since `c60abc6`; 16.1 policy tests unchanged; `editor_policy_test.go:68` | closed |
|
||||
| T-16.2-34 | Denial of Service | SanitizeHTMLReport and removal notices | low | mitigate | `format_html.go:105` (`maxRemovalNames = 10`), `:145-153`; cabana cap (T-16.2-33) | closed |
|
||||
| T-16.2-15 | Tampering / Elevation | uploadBlogImage | high | mitigate | `media_store.go:89-93` allowlist without svg, `:20,98-104` 10240 KiB cap; `blog_editor.go:365-375` DetectContentType; staging vhost `:89` nosniff | closed |
|
||||
| T-16.2-16 | Tampering | upload keys | medium | mitigate | `blog_editor.go:129-147` folder check at boot, `:397-426` safeUploadName; `media_store.go:155,193-224` never overwrites | closed |
|
||||
| T-16.2-17 | Repudiation / contract | public posts and media routes | medium | mitigate | `controllers/api/posts_helpers.go`, `posts.go`, `routes.go` unchanged `c60abc6..5eea57f`; `api/media.go:43-63` keeps body and errors | closed |
|
||||
| T-16.2-18 | Information Disclosure | content_html companion writes | low | mitigate | `controllers/posts.go:216-224,271-308` written locales only, in the transaction | closed |
|
||||
| T-16.2-19 | Tampering (iframe injection) | expandEmbeds | high | mitigate | `vue-journal-layer/app/utils/embeds.ts:41` exact regex, `:51` provider allowlist, `:53` id pattern, `:29` fixed host | closed |
|
||||
| T-16.2-20 | Elevation of Privilege | sanitize-then-expand order | high | mitigate | `Journal/PostBody.vue:13`, `CategoryHeader.vue:11` `expandEmbeds(sanitizeJournalHtml(...))`, no other caller; gate `check-phase16.2.sh:123` | closed |
|
||||
| T-16.2-32 | Tampering (policy drift) | htmlGate.ts mirror | high | mitigate | `app/utils/htmlGate.ts:68-71,122-127`, only these rules since `294a8d8`; corpus copies identical (`cmp`) | closed |
|
||||
| T-16.2-21 | Information Disclosure (visitor privacy) | YouTube frame | low | mitigate | `embeds.ts:29,57-58` nocookie host, lazy, strict-origin-when-cross-origin; staging vhost `:93` frame-src (report-only; server install pending, WINDOWS 19) | closed |
|
||||
| T-16.2-22 | Tampering / Repudiation | layer tag and pushes | medium | mitigate | v0.2.0 annotated (`042b65d → 759e684`); `scripts/check-git-install.sh`; 04-SUMMARY records `check-git-install: ok` and pushed heads | closed |
|
||||
| T-16.2-23 | Tampering (false-green release) | release gate | medium | mitigate | 05-SUMMARY `release-gate-ok`, Docker-only suites listed, human reply "release v0.1.18" | closed |
|
||||
| T-16.2-24 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | v0.1.18 annotated (`04dc5bd → f1d8a75`), contains plan 01 head `4adb48a`; binary sha256 matched; heads on origin | closed |
|
||||
| T-16.2-25 | Information Disclosure | credentials | high | mitigate | `sm-grzybyfunkcjonalne-app/DEPLOY.md:74` no `.env` rsync; no env file tracked; `.gitignore:5`; 05-SUMMARY no auth gates | closed |
|
||||
| T-16.2-26 | Tampering | UAT database and Winter site | medium | mitigate | 05-SUMMARY: SELECT-only checks, `.prev` rollback copies kept | closed |
|
||||
| T-16.2-35 | Tampering | UAT strip check | low | mitigate | Strip verified (05-SUMMARY edit-ok); test post `uat-16-2-blog-editor` (id 105) deleted by the user on 2026-10-28 | closed |
|
||||
| T-16.2-27 | Elevation of Privilege | sudo on the server | low | accept | See AR-16.2-02 | closed |
|
||||
| T-16.2-28 | Tampering | test isolation | low | mitigate | `sm-journal-plugin/vite.config.ts:38`; memblob, testcontainers; no ssh, curl or child_process in tests | closed |
|
||||
| T-16.2-29 | Repudiation (false green) | phase gate | medium | mitigate | `summercms.go/scripts/check-phase16.2.sh:143-270` detectors, `:376-450` self-test, `:570-586` NOT RUN and `PHASE162_REQUIRE_POSTGRES` | closed |
|
||||
| T-16.2-30 | Tampering (security regression) | strip, policy, permission tests | medium | mitigate | `check-phase16.2.sh:64-130` named tests, `:479-482` 16.1 policy tests pinned to `c60abc6`, `:502` corpus `cmp` | closed |
|
||||
| T-16.2-36 | Spoofing / DoS | layer purge route | medium | mitigate | `server/api/__journal__/cache/purge.post.ts:25-30`; `app/utils/siteCache.ts:39` 32-char minimum, `:40` proxied → 404, `:43` timingSafeEqual over sha256; staging vhost `:236-239` `limit_except`; 07-SUMMARY purge-ok `200 83 403 401` | closed |
|
||||
| T-16.2-37 | Information Disclosure | purge token | high | mitigate | log-capture tests `5eea57f:classes/site_cache_test.go:158-162`, now `modules/cooler/sitecache_test.go:257`; `DEPLOY.md:86,210-213` urandom/printf, `-H @-`; only `token-match` crossed ssh. See Follow-ups for a later leak path | closed |
|
||||
| T-16.2-38 | Tampering (stale content) | purge ordering | medium | mitigate | lagoon.AfterCommit `5eea57f:classes/site_cache.go:181`, now `cooler/sitecache.go:306`; tests `site_cache_admin_test.go:162,240` | closed |
|
||||
| T-16.2-39 | Denial of Service | admin save path | low | mitigate | 2 s WithTimeout(WithoutCancel) `cooler/sitecache.go:32,159`; tests `site_cache_admin_test.go:319,349` | closed |
|
||||
| T-16.2-40 | Information Disclosure | purge URL misconfiguration | medium | mitigate | `cooler/sitecache.go:82-109` (was `site_cache.go:57-84`): both-or-neither, no userinfo or fragment, http only to loopback, 32-char token | closed |
|
||||
| T-16.2-41 | Tampering (log injection) | layer purge log line | low | mitigate | `siteCache.ts:83` strips control chars, `:14` 200-char slug cap | closed |
|
||||
| T-16.2-42 | Tampering (cache poisoning) | Nitro route cache | medium | mitigate | `nuxt.config.ts:77` `cache: false`; `purge.post.ts:18` no-store; `testing/purge-check.mjs:175-184` | closed |
|
||||
| T-16.2-43 | Denial of Service | full route-cache purge | low | accept | See AR-16.2-03 | closed |
|
||||
| T-16.2-44 | Repudiation (false-green gate) | phase gate | medium | mitigate | `check-phase16.2.sh:72,88,93,557,565`; VALIDATION 07-T3 green with `PHASE162_REQUIRE_POSTGRES=1` | closed |
|
||||
| T-16.2-45 | Tampering / Repudiation | tags and pushes | high | mitigate | v0.1.20 (`ad7981f → aba063f`), layer v0.3.0 (`c09e0d0 → 25e9bfe`) match 07-SUMMARY; heads on origin; sha256 matched; site lockfile `3a920c7` resolves to `25e9bfe` | closed |
|
||||
| T-16.2-46 | Tampering | UAT database and Winter site | medium | mitigate | 07-SUMMARY: read-only snapshots, human-only data changes, deploy-ok `.prev` rollback checks | closed |
|
||||
| T-16.2-SC | Tampering | packages (all seven plans) | high | mitigate | 01, 03, 06: no dependency file changes; 02: exact `@tiptap/*` pins 3.31.4, committed lockfile, `scripts/check-dist.sh` rebuild diff, human "packages approved"; 04, 07: layer version bump and pin only, `node:crypto` only; 05: `build.sh:53` `--frozen-lockfile` | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-16.2-01 | T-16.2-08 | Submitting the served value only skips the write; the skip cannot store anything Decode did not already accept (`field_type.go:539-541`) | plan 16.2-01 threat model; reconfirmed by audit | 2026-10-28 |
|
||||
| AR-16.2-02 | T-16.2-27 | Server sudo is limited to `nginx -t`, nginx reload and supervisorctl (`DEPLOY.md:34`); the release used supervisorctl only | plan 16.2-05 threat model; reconfirmed by audit | 2026-10-28 |
|
||||
| AR-16.2-03 | T-16.2-43 | Only authenticated loopback callers can purge; admin saves are rare; each page re-renders once from the local API | plan 16.2-07 threat model; reconfirmed by audit | 2026-10-28 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Follow-ups (outside the 16.2 register)
|
||||
|
||||
- **Purge token likely exposed on UAT (later regression, not 16.2 code).** The site app pins vue-journal-layer v0.4.0, which brings `@nuxtjs/seo` / nuxt-site-config; that copies every `NUXT_SITE_*` variable into page payloads, and `sm-grzybyfunkcjonalne-app/DEPLOY.md:86` names the token `NUXT_SITE_CACHE_PURGE_TOKEN`. Fixed in the layer at v0.6.0 (`e0cc871`). Not checked on the live server. Action: bump the pin to ≥ v0.6.0, rename to `NUXT_CACHE_PURGE_TOKEN`, rotate the token.
|
||||
- **Surface added after planning, no threat ID:** Blog Editor preview pane (empty-sandbox `srcdoc` iframe with CSP meta, `sm-journal-plugin/assets/src/blogeditor/preview.ts:6-14,190`, accepted in UAT test 5); admin "View site" link (`admin/src/components/shell/AppShell.vue:80-81`, `noopener noreferrer`); visitor X-Forwarded-For forwarded to Go on cached renders (`vue-journal-layer/server/plugins/journal-client-address.ts`, commit `03a90e9`).
|
||||
- **Housekeeping:** WINDOWS entry 19 still needs the staging vhost install (frame-src); the test-post part is done. 16.2-VALIDATION rows 07-T4..T6 still read pending although 07-SUMMARY records them as passed.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-28 | 47 | 47 | 0 | msd-security-auditor (retroactive, State B); T-16.2-35 closed after the user deleted UAT post 105 |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-28
|
||||
@@ -98,7 +98,7 @@ Post-plan attack surface from the quick tasks, mapped to existing entries: `pact
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-18.1-01 | T-18.1-04 | Reorder POST runs under the controller's protect; taglist adds no route and rides the existing saves | plan 18.1-01 (verified by audit) | 2026-10-16 |
|
||||
| AR-18.1-02 | T-18.1-40 | product_id and plan_code are Stripe ids and a plan code, not PII; TestJobArgsMinimal pins the key list and refuses PII | plan 18.1-07 (verified by audit) | 2026-10-16 |
|
||||
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) | user | 2026-10-16 |
|
||||
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) Resolved 2026-10-28 by quick 261028-rqf (61d1043): toolbar and field action runs are logged with admin_id on success. | user | 2026-10-16 |
|
||||
| AR-18.1-04 | T-18.1-43 | A Checkout Session already paid but whose webhook is not yet processed is complete, not open, so it is not expired; a second checkout in that window can bill twice. Detected by warnDuplicateOpen (Error log). Replaces the old 10-minute idempotency key, which covered only identical parameters | user (UAT row 5, quick 261016-myi) | 2026-10-16 |
|
||||
| AR-18.1-05 | T-18.1-39 | Session advisory locks hold a pooled connection; a capped MaxOpenConns or a re-entrant listener could starve the pool (review IN-02, open) | audit residual | 2026-10-16 |
|
||||
| AR-18.1-06 | T-18.1-24 | The customer API throttle keys on client IP in practice (JWT check runs inside the handler); no 429 test for this bucket | audit residual | 2026-10-16 |
|
||||
|
||||
87
.planning/phases/19-chat-and-chatchannels/19-SECURITY.md
Normal file
87
.planning/phases/19-chat-and-chatchannels/19-SECURITY.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
phase: "19"
|
||||
slug: "chat-and-chatchannels"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 19 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Implementation lives in the sibling repos `sm-chat-plugin` and `sm-chatchannels-plugin`; the user-namespace authorizer and `user.Deleting` event are in `sm-user-plugin` v0.1.2. Paths below are relative to the meta repo.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| client → /_chat/api/v1, /_chatchannels/api/v1 | untrusted JSON, ids and multipart files cross jwt.auth | message bodies, attachment bytes, conversation ids |
|
||||
| ChatService write → Postgres | authenticated actor ids become conversation and message rows | user-authored content |
|
||||
| ChatService → lighthouse / Centrifugo | realtime payloads cross to subscribers | event payloads (ids only) |
|
||||
| Authorizer → subscribe | client-chosen channel string trusted only after participant check | channel names |
|
||||
| download handler → blob store | private system_files bytes leave only after a participation check | private attachments |
|
||||
| DM start → other users | unsolicited conversation creation | conversation rows |
|
||||
| ChannelService → resolver | host-installed access policy; default deny-all | context access decisions |
|
||||
| mention body → users.handle | untrusted @tokens become mention rows | user ids |
|
||||
| user delete → chat/channel tables | GDPR cascade must not leave prefs/members or wipe others' rows | personal data |
|
||||
| golden files / check-phase19.sh → CI | recorded PHP payloads and sibling paths become the acceptance oracle | test oracle |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-19-01 | Information disclosure | api/download.go, message GET | high | mitigate | `sm-chat-plugin/api/download.go` collapses missing/unauthorized to `writeNotFound`; `AttachmentForDownload` checks `CanView`; `TestAttachmentDownload` (http_tracer_test.go:340) | closed |
|
||||
| T-19-02 | Elevation of privilege | api/dm.go | medium | mitigate | `chat-dm` bucket Max 10 / minute (routes.go:69); `TestDMThrottle` in PG_CHAT_TESTS | closed |
|
||||
| T-19-03 | Tampering | classes/attachment_policy.go | high | mitigate | `MaxAttachments = 5`, `MaxAttachmentBytes`, `BannedExts` svg/html/xml + banned MIME list; `TestAttachmentPolicy` | closed |
|
||||
| T-19-04 | Information disclosure | lighthouse Bind payload | high | mitigate | ids-only broadcast payload; `TestChatTracer` (tracer_test.go:17) and golden_test.go fail if a publication contains `content` | closed |
|
||||
| T-19-05 | Elevation of privilege | classes/authorizer.go | high | mitigate | parses `chat:conversation:{id}` (one optional presence: prefix), `Denied("not a participant")`, capabilities `["prs"]`, checked per subscribe | closed |
|
||||
| T-19-06 | Elevation of privilege | classes/mention_parser.go | high | mitigate | `ResolveMentionedParticipantIDs` joins `golem15_chat_participants` for the conversation and excludes `senderID` (channel_service.go:1158) | closed |
|
||||
| T-19-07 | Information disclosure | user.Deleting → SeverUserFromContext | high | mitigate | `FireDeletingEvent` fires inside the delete tx (sm-user-plugin controllers/users_admin_controller.go:525); chatchannels `onUserDeleting` → `SeverUserFromContext`; `TestDeletingCascade` | closed |
|
||||
| T-19-08 | Denial of service | classes/search_indexer.go | medium | mitigate | index/deindex errors logged at Warn, never returned (fail-open); `TestSearchIndexer` | closed |
|
||||
| T-19-09 | Elevation of privilege | api/attachments.go pending field | high | mitigate | `PendingField(actorID)` = `chat-pending-{actorID}`; claiming another actor's pending row refused (classes/attachments.go:22) | closed |
|
||||
| T-19-10 | Elevation of privilege | user namespace authorizer | high | mitigate | sm-user-plugin `AuthorizeUser` allows only `user:{id}` for that user, rejects double presence/extra segments; `RegisterUserAuthorizer` skips if registered; `TestUserAuthorizer` in check-phase19.sh | closed |
|
||||
| T-19-11 | Denial of service | mention regex | medium | mitigate | RE2 `@([A-Za-z0-9_.-]{2,255})`, bounded, no nested quantifiers (mention_parser.go:8) | closed |
|
||||
| T-19-12 | Elevation of privilege | DenyAll + Boot Warn | medium | mitigate | `DenyAll` returns false for both methods; boot.go:64 Warns while resolver is deny-all | closed |
|
||||
| T-19-13 | Information disclosure | TestGoldenEvents | high | mitigate | golden_test.go:241 fails when a publication payload contains key `content` | closed |
|
||||
| T-19-14 | Elevation of privilege | mention resolve | high | mitigate | participant-only handle match (same query as T-19-06); `TestHandleMention` (sm-chatchannels-plugin http_tracer_test.go:37) | closed |
|
||||
| T-19-15 | Elevation of privilege | classes/service.go canSend | high | mitigate | `canSend` returns `isParticipant` only (service.go:304); `ErrNotParticipant` mapped to 404 (api/errors.go:57); `TestGapClosureSmoke`, `TestTeammateDMWriteIDOR` | closed |
|
||||
| T-19-16 | Tampering | user.Deleting → created_by CASCADE | high | mitigate | chat `onUserDeleting` → `ReassignCreatedBy` in the Deleting tx (boot.go:187, service.go:115); baseline CASCADE untouched | closed |
|
||||
| T-19-17 | Elevation of privilege | TestTeammateDMWriteIDOR + PG_CHAT_TESTS | high | mitigate | listed in `PG_CHAT_TESTS` and in the self-test required tokens of scripts/check-phase19.sh | closed |
|
||||
| T-19-18 | Tampering | TestDeletingCascade creator path | high | mitigate | test fires Deleting then `Unscoped` deletes Alice (http_tracer_test.go:301); `TestDeletingCascade` in `PG_CHATCHANNELS_TESTS` | closed |
|
||||
| T-19-SC | Tampering | go.mod requires, scripts/check-phase19.sh | high | mitigate | plugin direct requires (gormigrate, pgx, testcontainers, gocloud.dev, gorm) are the framework's pinned versions plus Golem15 modules; gate installs no packages and `--self-test` plants failures | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
No accepted risks.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-28 | 19 | 19 | 0 | /msd-secure-phase (orchestrator, ASVS L1 grep verification) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-28
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
phase: "19.6"
|
||||
slug: "two-factor-auth-and-device-flow-in-sm-user-plugin"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 19.6 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
Implementation lives in sibling repos: **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **M** = `sm-mfa-plugin`, **S** = `sm-sociallogin-plugin`, **D** = `sm-demo-app/vue-demo-app`; **F** = this repo. The detailed per-threat evidence (mutation checks, log-call sweep, D-14 passkey rules, D-18 hardening) is in `19.6-SECURITY-REVIEW.md`; this file is the register and sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| first-factor login → token mint | a correct password must not yield a token while a second factor is pending | credentials, JWT |
|
||||
| sm-user-plugin → other plugins | gates, admin reset service and listeners run M's code inside U's requests and transactions | user ids, transaction handle |
|
||||
| unauthenticated device → devices/initiate, devices/status | anyone can create sessions and poll with a token | device token, short code, device name |
|
||||
| signed-in user → devices/authorize, list, revoke | a bearer JWT binds or ends device sessions | device session ids, jti |
|
||||
| JWT refresh → device revocation | a refreshed device token must stay revocable | jti lineage |
|
||||
| holder of a password → challenge routes | unauthenticated clients present challenge tokens and guessed codes | challenge token, TOTP/email/recovery codes |
|
||||
| signed-in user → management routes | a JWT changes the user's own factors | TOTP secrets, recovery codes, credentials |
|
||||
| plugin → database / mailer | secrets at rest; one-time codes leave by email | hashed tokens, encrypted secret, bcrypt codes |
|
||||
| browser authenticator / frontend origin → WebAuthn routes | attestations and assertions bound to server-side ceremony state, RP ID and origins | WebAuthn ceremony data |
|
||||
| anonymous client → passwordless routes | a passkey alone yields a token | assertion, JWT |
|
||||
| social provider callback → signIn | a social identity must not bypass an enabled second factor | provider identity, JWT |
|
||||
| administrator → Users screen, settings | privileged reset of a user's factors; global 2FA policy | settings values |
|
||||
| demo frontend → API | server-generated SVG rendered in the browser | QR SVG |
|
||||
| test results → phase verdict; releases → app pins | a skipped test must not pass; fixes must reach every pin | gate output, git refs |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-19.6-01 | Elevation of privilege | login gate consultation | high | mitigate | U `controllers/login_gates.go` `CheckLoginGates`, called from `Login` before restore/mint; gate error → opaque 500; `TestLoginGates`, `TestLoginGateOrder`, `TestLoginGateSmoke/gate_error` | closed |
|
||||
| T-19.6-02 | Tampering | soft-delete restore before second factor | medium | mitigate | restore after the gates; `TestLoginGateOrder/challenged_soft_deleted_stays_deleted` (U), `TestGate/deactivated_user_is_challenged_and_stays_deactivated` (M) | closed |
|
||||
| T-19.6-03 | Spoofing | trusted devices surviving a password change | medium | mitigate | `AccessRevokedEvent` in the write transaction (U); M `classes/admin.go` `OnAccessRevoked`; `TestPasswordChangedSources`, `TestRevocation` | closed |
|
||||
| T-19.6-04 | Elevation of privilege | Reset 2FA record action | medium | mitigate | U `classes/two_factor_admin.go` `PermissionResetTwoFactor` registered in `admin_permissions.go`, no default role; `TestResetTwoFactorAction`, `TestAdminReset` | closed |
|
||||
| T-19.6-05 | Tampering | short-code guessing | medium | mitigate | U `classes/device_auth.go` `NewDeviceShortCode` (crypto/rand, 31^8); guarded authorize UPDATE; `TestDeviceCodes`, `TestDeviceAuthorize` (mutation-checked) | closed |
|
||||
| T-19.6-06 | Spoofing | device-code phishing | medium | accept | D-12c, see AR-01 | closed |
|
||||
| T-19.6-07 | Elevation of privilege | device hand-over race | high | mitigate | U `controllers/devices.go:282,300` guarded `status = 'confirmed'` UPDATE; `TestDeviceHandOverConcurrent` (mutation-checked) | closed |
|
||||
| T-19.6-08 | Elevation of privilege | revoked device keeps refreshing | high | mitigate | U `classes/device_auth.go` `HandOverDeviceJTI`; revoke blacklists jti; `TestDeviceRevokeAfterRefresh`; `blacklist_grace` fork see AR-02 | closed |
|
||||
| T-19.6-09 | Information disclosure | device tokens at rest and in URLs | medium | mitigate | SHA-256 only; F `modules/surf/accesslog.go` logs no path; `TestDeviceTokensAtRest`; proxy logging see AR-05 | closed |
|
||||
| T-19.6-10 | Denial of service | initiate filling the table | low | mitigate | user-api bucket, bounded cleanup (≤500); `TestDeviceInitiate/cleanup_is_bounded` | closed |
|
||||
| T-19.6-11 | Tampering | attacker-chosen device_name | low | mitigate | ≤255 runes, 422; `TestDeviceInitiate/device_name` | closed |
|
||||
| T-19.6-12 | Tampering | core plugin contract | medium | mitigate | additive only; gate `--userplugin`, `--fonoteka` | closed |
|
||||
| T-19.6-13 | Spoofing | TOTP/email brute force | high | mitigate | M `classes/challenge.go` `FailureCapReached`, 2fa-verify bucket, 5 attempts; `TestFailureCap`, `TestRoutesAndBuckets` | closed |
|
||||
| T-19.6-14 | Tampering | parallel verifies | high | mitigate | M `classes/challenge.go:96,119` `completed_at IS NULL AND attempts < max_attempts`; `TestChallengeLifecycle/one_completion_when_factors_race` (mutation-checked) | closed |
|
||||
| T-19.6-15 | Spoofing | TOTP replay | medium | mitigate | M `classes/methods.go` `AcceptTOTPCounter`; `TestTOTP/replay` | closed |
|
||||
| T-19.6-16 | Spoofing | email downgrade of TOTP-only user | high | mitigate | M `classes/email.go` `SendEmailCode` gated on enabled email method; `TestEmailCode/gating` | closed |
|
||||
| T-19.6-17 | Denial of service | mail bombing | medium | mitigate | M `classes/email.go:83` `email_sends < 3`; `TestEmailCode/sends_replace_and_cap` (mutation-checked) | closed |
|
||||
| T-19.6-18 | Information disclosure | secrets at rest | high | mitigate | SHA-256 tokens, M `models/two_factor_method.go` `Secret lagoon.Encrypted`, bcrypt codes; `TestTokensAtRest` | closed |
|
||||
| T-19.6-19 | Information disclosure | logs and error strings | medium | mitigate | log-call sweep (SECURITY-REVIEW "Log calls"); `TestEmailCode/never_logged` | closed |
|
||||
| T-19.6-20 | Spoofing | stolen trusted-device token | medium | mitigate | M `classes/trusted.go`, `classes/gate.go`; `TestTrustedDevice`, `TestRevocation` | closed |
|
||||
| T-19.6-21 | Elevation of privilege | management routes on other users' rows | high | mitigate | JWT-scoped queries, digit `{id}`; `TestRoutesAndBuckets`, `TestWebAuthn/delete_scoped_to_the_owner` | closed |
|
||||
| T-19.6-22 | Information disclosure | recovery codes after creation | medium | mitigate | shown once, count route needs password; `TestRecovery/first_set_only_when_created`, `count_route` | closed |
|
||||
| T-19.6-23 | Denial of service | bcrypt CPU per wrong code | low | mitigate | M `classes/recovery.go` `NormalizeRecoveryCode` (≤32 chars); `TestRecoveryCodeFormat` | closed |
|
||||
| T-19.6-24 | Tampering | settings values | low | mitigate | M `models/settings.go` `BeforeValidate` allow-lists; defect fixed in M `71d8f27`, released v0.1.1; `TestSettings`, `TestPHPUser005` | closed |
|
||||
| T-19.6-25 | Spoofing | client-chosen TOTP secret at confirm | low | accept | PHP parity, see AR-03 | closed |
|
||||
| T-19.6-26 | Spoofing | client-supplied WebAuthn challenge | high | mitigate | server-side state in cooler, single-use `Pull`; `TestWebAuthnShapes/registration_state`, `TestWebAuthn/registration` | closed |
|
||||
| T-19.6-27 | Elevation of privilege | passkey login with second-factor-only credential (D-14) | high | mitigate | M `classes/passwordless.go` `ValidatePasskeyLogin` with UV, `passwordless` column check; `TestPasswordless` (mutation-checked); D-14 table in SECURITY-REVIEW | closed |
|
||||
| T-19.6-28 | Spoofing | cloned authenticator | medium | mitigate | `CloneWarning` refusal (M `classes/webauthn.go:506`, `classes/passwordless.go:110`); `TestWebAuthn/assertions` | closed |
|
||||
| T-19.6-29 | Spoofing | RP ID / origin misconfiguration | medium | mitigate | M `classes/config.go` `loadWebAuthnConfig`; `TestWebAuthnShapes`, `TestPasswordless/refused_foreign_origin` | closed |
|
||||
| T-19.6-30 | Elevation of privilege | social sign-in bypassing 2FA (D-09) | high | mitigate | S `classes/flow.go:343` `CheckLoginGates` with `LoginSourceSocial` before FinishLogin; `TestSocialGate`, `TestEveryLoginPathIsGated/social_sign_in` | closed |
|
||||
| T-19.6-31 | Elevation of privilege | passkey login of banned/suspended/inactive account | high | mitigate | M `classes/passwordless.go:126` `RejectIfThrottled` + activation check; `TestPasswordless/refused_banned`, `refused_suspended`, `refused_not_activated` | closed |
|
||||
| T-19.6-32 | Tampering | remote operations (plan 03 pushes) | medium | mitigate | process control: fetch + range check before every push, no force, no deploy (19.6-03-SUMMARY "Remote operations") | closed |
|
||||
| T-19.6-33 | Tampering | demo rendering QR SVG | low | mitigate | D `app/lib/twoFactor.ts` `svgDataUrl` (img data URL, no `v-html`); `twoFactor.test.ts` | closed |
|
||||
| T-19.6-34 | Information disclosure | user handle from numeric id (A4) | low | accept | see AR-04 | closed |
|
||||
| T-19.6-35 | Denial of service | ceremony state in memory cooler, multi-instance (A11) | low | accept | README; fails closed; see AR-06 | closed |
|
||||
| T-19.6-36 | Tampering | check-phase19.6.sh verdict | medium | mitigate | F `scripts/check-phase19.6.sh --self-test` (fail/skip/zero-match/missing-name detectors) | closed |
|
||||
| T-19.6-37 | Tampering | post-release fixes not reaching apps | medium | mitigate | `71d8f27` is an ancestor of M tag v0.1.1; pinned by sm-demo-app `9166a1e`, sm-bm-app `fe4e292` | closed |
|
||||
| T-19.6-38 | Elevation of privilege | token mint path bypassing 2FA | high | mitigate | M `login_paths_test.go` `TestEveryLoginPathIsGated` (every mint site listed); activate-by-code residual = F-1, see AR-07 | closed |
|
||||
| T-19.6-SC | Tampering | Go module installs (plans 01–04) | high | mitigate | M `go.mod`: pquerna/otp v1.5.0, boombuler/barcode v1.1.0, go-webauthn v0.18.2 (approved, `.planning/notes/2026-10-21-mfa-otp-webauthn.md`); `go list -deps` of M has no `gopkg.in/yaml.v3`; fonoteka.go `go.mod`/`go.sum` have no go-webauthn | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-01 | T-19.6-06 | Device-code phishing: D-12c keeps PHP's authorize-with-JWT rule; the device list shows name and IP and revoke signs the device out at once; the frontend must show device name and address before confirming. | user (D-12c, 19.6-CONTEXT) | 2026-10-26 |
|
||||
| AR-02 | T-19.6-08 | `blacklist_grace` > 0 lets two concurrent refreshes fork; only one jti is followed, so revoke misses the other until it expires. Default grace is 0; a password change ends both. | plan 01 (Pitfall 15) | 2026-10-26 |
|
||||
| AR-03 | T-19.6-25 | Client-chosen TOTP secret at confirm (PHP parity): own account only, base32 and 16–64 chars validated, a current code from the secret required. | plan 02 threat model | 2026-10-26 |
|
||||
| AR-04 | T-19.6-34 | PHP-compatible 8-byte user handle reveals only the internal user id to the user's own authenticator; keeps imported passkeys mappable. | plan 03 threat model (A4) | 2026-10-26 |
|
||||
| AR-05 | T-19.6-09 | Reverse proxies may log `devices/status/{token}`; the token lives five minutes and is single use (README). | plan 01 threat model | 2026-10-26 |
|
||||
| AR-06 | T-19.6-35 | With the memory cooler a WebAuthn ceremony must stay on one instance; multi-instance apps use the Redis cooler (README); ceremonies fail closed otherwise. | plan 03 threat model (A11) | 2026-10-26 |
|
||||
| AR-07 | T-19.6-38 (F-1) | activate-by-code mints a token without the second factor only when `require_activation: false` and the user enabled 2FA before activating; the mailed activation code stands in for the second factor once. Low impact, non-default config. Deferred; pinned by `TestEveryLoginPathIsGated/mint_sites`; todo `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md`. | user (plan 04 Task 5, option c) | 2026-10-26 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-26 | 42 | 42 | 0 | plan 04 security review (`19.6-SECURITY-REVIEW.md`; mutation checks, `check-phase19.6.sh --all` with Docker) |
|
||||
| 2026-10-28 | 42 | 42 | 0 | /msd-secure-phase (L1 grep verification of cited mitigations in U, M, S, D, F; short-circuit: register authored at plan time, ASVS 1) |
|
||||
|
||||
Threat count: T-19.6-01..38 plus four T-19.6-SC rows (plans 01–04), merged into one row above.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-28
|
||||
@@ -29,19 +29,19 @@ findings:
|
||||
title: "Process's plain path prints item names without Sanitize"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "console.Error during a live view writes straight to the terminal and tears the view"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "A blank line printed during a live view is dropped"
|
||||
- id: IN-12
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "Signal relay edge cases: a signal during view start-up, and Windows"
|
||||
- id: IN-13
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "A delegate's stderr always goes to os.Stderr, bypassing the root's error writer"
|
||||
- id: IN-14
|
||||
severity: info
|
||||
@@ -97,39 +97,39 @@ findings:
|
||||
title: "The wrong-directory hint repeats every argument, including a --password value, on stderr"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "A forced-color console drops HideOnTTY records even when stderr is not a terminal"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "PeekGlobals and cobra disagree on some argument forms"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "isUsage recognises cobra's unknown-command error by its text"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "The --json flag is read two different ways"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "summer's scaffolding result lines are now hidden by -q"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "fang replaces the root help function that NewRootIO installs"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "diary pulls the whole TUI stack into security modules"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "The console sanitizer passes raw C1 bytes through on its fast path"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "The phase gate names consuming applications"
|
||||
open: 0
|
||||
total: 32
|
||||
@@ -141,15 +141,15 @@ recorded: 2026-10-22T01:19:33.337Z
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-09 | warning | fixed | fix fce232d (quick 261022-4z8) |
|
||||
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); follow-up bb7e14b (quick 261028-rqd): a delegate killed by a signal exits 128+n, silently for SIGINT and SIGTERM |
|
||||
| WR-11 | warning | fixed | fix 707372b (quick 261022-4z8) |
|
||||
| WR-12 | warning | fixed | fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only and migrate:status adds the framework sets itself (quick 261022-4z8) |
|
||||
| WR-13 | warning | fixed | fix 0691d26 (quick 261022-4z8) |
|
||||
| WR-14 | warning | fixed | fix 48e13f9 (quick 261022-4z8) |
|
||||
| IN-10 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-11 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-12 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-13 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-10 | info | fixed | fix 6efb9a4 (quick 261028-rqd) |
|
||||
| IN-11 | info | fixed | fix 4d75695 (quick 261028-rqd) |
|
||||
| IN-12 | info | fixed | fix f91d70d (quick 261028-rqd); part 2 (Windows) not changed, documented in raise's doc comment: Go cannot report another handler, and returning without exiting would swallow Ctrl+C |
|
||||
| IN-13 | info | fixed | fix 4914cad, bonfire.ErrWriter (quick 261028-rqd) |
|
||||
| IN-14 | info | fixed | docs 7c15095 (quick 261022-4z8) |
|
||||
| IN-15 | info | fixed | fix 1ac75ad (quick 261022-4z8) |
|
||||
| CR-01 | critical | fixed | fix b077dc3 (19.8-05) (not in the current review) |
|
||||
@@ -163,15 +163,15 @@ recorded: 2026-10-22T01:19:33.337Z
|
||||
| WR-06 | warning | fixed | fix 39677f1 (19.8-05) (not in the current review) |
|
||||
| WR-07 | warning | fixed | fix 020fe83 (19.8-05) (not in the current review); remainder WR-12 (fix 57a849d) |
|
||||
| WR-08 | warning | fixed | fix 9b62285 (19.8-05) (not in the current review); remainder WR-11 (fix 707372b) |
|
||||
| IN-01 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-02 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-03 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-04 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-05 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-06 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-07 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-08 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-09 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-01 | info | fixed | fix 3520d5d (quick 261028-rqd) (not in the current review) |
|
||||
| IN-02 | info | fixed | fix e34d113 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-03 | info | fixed | fix 39c59b8 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-04 | info | fixed | feat 795c4f6, bonfire.FlagBool (quick 261028-rqd); summer's flagTrue/flagBool and flare's flagSet keep their wider value sets for compatibility (not in the current review) |
|
||||
| IN-05 | info | fixed | docs 2da42e9 (quick 261028-rqd): documentation fix, the code is unchanged (0.4 shipped the lines; -q hiding them is now documented) (not in the current review) |
|
||||
| IN-06 | info | fixed | refactor 53c0a65, fang removed; planning docs 6294ac4 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-07 | info | fixed | refactor f3f87f3, internal/consolekit (quick 261028-rqd) (not in the current review) |
|
||||
| IN-08 | info | fixed | fix 8476a65 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-09 | info | fixed | fix 10fec47 (quick 261028-rqd) (not in the current review) |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-chat-plugin/events/events.go
|
||||
- ../sm-chat-plugin/contracts/rejection.go
|
||||
- ../sm-chat-plugin/classes/service.go
|
||||
- ../sm-chat-plugin/classes/writes.go
|
||||
- ../sm-chat-plugin/api/errors.go
|
||||
- ../sm-chat-plugin/saving_smoke_test.go
|
||||
- ../sm-chat-plugin/README.md
|
||||
- ../sm-forum-plugin/events/events.go
|
||||
- ../sm-forum-plugin/contracts/rejection.go
|
||||
- ../sm-forum-plugin/classes/forum_service.go
|
||||
- ../sm-forum-plugin/classes/lifecycle.go
|
||||
- ../sm-forum-plugin/api/errors.go
|
||||
- ../sm-forum-plugin/saving_smoke_test.go
|
||||
- ../sm-forum-plugin/README.md
|
||||
- ../sm-chatvideo-plugin/go.mod
|
||||
- ../sm-chatvideo-plugin/go.sum
|
||||
- ../sm-chatvideo-plugin/README.md
|
||||
- ../sm-herald-plugin/go.mod
|
||||
- ../sm-herald-plugin/go.sum
|
||||
- ../sm-herald-plugin/README.md
|
||||
- ../sm-userfriends-plugin/go.mod
|
||||
- ../sm-userfriends-plugin/go.sum
|
||||
- ../sm-userfriends-plugin/README.md
|
||||
- ../sm-moderation-plugin/.gitignore
|
||||
- ../sm-moderation-plugin/go.mod
|
||||
- ../sm-moderation-plugin/go.sum
|
||||
- ../sm-moderation-plugin/go.work
|
||||
- ../sm-moderation-plugin/plugin.go
|
||||
- ../sm-moderation-plugin/admin.go
|
||||
- ../sm-moderation-plugin/admin_permissions.go
|
||||
- ../sm-moderation-plugin/logger.go
|
||||
- ../sm-moderation-plugin/classes/filter.go
|
||||
- ../sm-moderation-plugin/classes/filter_smoke_test.go
|
||||
- ../sm-moderation-plugin/models/settings.go
|
||||
- ../sm-moderation-plugin/models/registry.go
|
||||
- ../sm-moderation-plugin/models/settings/fields.yaml
|
||||
- ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go
|
||||
- ../sm-moderation-plugin/updates/registry.go
|
||||
- ../sm-moderation-plugin/lang/en/lang.yaml
|
||||
- ../sm-moderation-plugin/lang/pl/lang.yaml
|
||||
- ../sm-moderation-plugin/config/config.yaml
|
||||
- ../sm-moderation-plugin/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, D-17, D-18, D-19, D-20]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-18 (chat): with a festival listener on events.MessageSaving that returns contracts.Rejection{Field: \"content\", Message: m}, POST /_chat/api/v1/conversations/{id}/messages, POST /_chat/api/v1/messages/{id}/replies and PUT /_chat/api/v1/messages/{id} answer 422 {\"error\":\"Validation failed\",\"errors\":{\"content\":[m]}}; no golem15_chat_messages row is inserted or changed, MessageCreated and MessageUpdated do not fire and nothing is published."
|
||||
- "D-18 (forum): ForumService.CreateThread, Reply and EditPost fire events.PostSaving before breakwater.Render; a contracts.Rejection from a listener maps to the existing 422 envelope under errors.title or errors.body and nothing is persisted."
|
||||
- "D-18 (parity): with no MessageSaving or PostSaving listener every existing route, payload, status, table, migration ID and config key of sm-chat-plugin and sm-forum-plugin is unchanged; their php_method_map_test.go and golden_test.go pass without edits; the seams name no application and ship as patch-level additions."
|
||||
- "D-17: the new shared plugin golem15.moderation (module git.golem15.com/golem15/sm-moderation-plugin) publishes *classes.Filter at boot; Filter.Check returns a Violation for a banned word or phrase (case-insensitive, Unicode letter boundaries, Polish letters included) or a banned link (a listed domain, its subdomains, with or without scheme or www, and URL-prefix entries), and nil for clean text; the lists are the banned_words and banned_links textareas of its admin settings screen (singleton golem15_moderation_settings) behind the permission golem15.moderation.manage_settings; the plugin imports neither chat nor forum."
|
||||
- "SC-1: sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin go.mod files keep only the framework replace and require sm-chat-plugin v0.1.3, sm-user-plugin v0.1.13 and summercms v0.5.1 where they depend on them; every one of the eight repos passes go vet and go test -short, and the five chat dependants build with GOWORK=off."
|
||||
- "SC-1 (release, after the Task 5 go-ahead): origin of each repo carries its new tag on the pushed HEAD that passed the checks: sm-chat-plugin v0.1.4 (patch, the seam), sm-chatchannels-plugin, sm-chatbots-plugin, sm-chatvideo-plugin, sm-forum-plugin, sm-herald-plugin, sm-userfriends-plugin and sm-moderation-plugin v0.1.0 (or the next free number read from the remote); a scratch module requiring all eight tags and summercms v0.5.1 builds with GOWORK=off."
|
||||
- "D-20 (D-19 superseded): this is plan 01 of the six plans confirmed at plan time; the chat family release precedes the demo backend (plan 02)."
|
||||
artifacts:
|
||||
- path: "../sm-chat-plugin/events/events.go"
|
||||
provides: "MessageSaving pre-save event and MessageSavingEventName"
|
||||
contains: "MessageSaving"
|
||||
- path: "../sm-chat-plugin/contracts/rejection.go"
|
||||
provides: "Rejection{Field, Message} error a pre-save listener returns"
|
||||
contains: "type Rejection struct"
|
||||
- path: "../sm-forum-plugin/events/events.go"
|
||||
provides: "PostSaving pre-save event and PostSavingEventName"
|
||||
contains: "PostSaving"
|
||||
- path: "../sm-forum-plugin/contracts/rejection.go"
|
||||
provides: "Rejection{Field, Message} for forum listeners"
|
||||
contains: "type Rejection struct"
|
||||
- path: "../sm-moderation-plugin/classes/filter.go"
|
||||
provides: "Filter, NewFilter, Check, Match, ParseList, Violation"
|
||||
contains: "func (f *Filter) Check"
|
||||
- path: "../sm-moderation-plugin/admin.go"
|
||||
provides: "moderation_settings settings item (HasSettings, HasLang)"
|
||||
contains: "moderation_settings"
|
||||
key_links:
|
||||
- from: "../sm-chat-plugin/classes/service.go"
|
||||
to: "../sm-chat-plugin/events/events.go"
|
||||
via: "SendMessageWith fires MessageSaving on s.bus before lagoon.Transaction"
|
||||
pattern: "MessageSaving\\{"
|
||||
- from: "../sm-chat-plugin/api/errors.go"
|
||||
to: "../sm-chat-plugin/contracts/rejection.go"
|
||||
via: "writeChatError maps errors.As(err, &contracts.Rejection) to writeValidation"
|
||||
pattern: "errors\\.As\\(err, &rej\\)"
|
||||
- from: "../sm-forum-plugin/classes/forum_service.go"
|
||||
to: "../sm-forum-plugin/events/events.go"
|
||||
via: "CreateThread and Reply fire PostSaving on s.bus before breakwater.Render"
|
||||
pattern: "PostSaving\\{"
|
||||
- from: "../sm-moderation-plugin/plugin.go"
|
||||
to: "../sm-moderation-plugin/classes/filter.go"
|
||||
via: "Boot publishes classes.NewFilter(db) through app.Publish inside lagoon.OnDatabase"
|
||||
pattern: "app\\.Publish"
|
||||
prohibitions:
|
||||
- statement: "No existing route, request or response field, status code, table, migration ID or config key of sm-chat-plugin or sm-forum-plugin changes; the seams are additive (WavePath parity, Phase 31)."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "sm-moderation-plugin depends on neither sm-chat-plugin nor sm-forum-plugin."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No README, comment or identifier in the eight repos names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed, no remote repository is created and no tag is set before the Task 5 go-ahead."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here, see the planning return): `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. Phase 19.4.1 deployed the demo early; this phase adds the chat family to that live instance.
|
||||
|
||||
This plan's slice (plan 1 of 6, wave 1, per D-20): the shared plugin release the demo needs before it can require the chat family by tag. Fast-forward the stale sibling checkouts, add the generic pre-save seams to chat and forum (D-18), create `sm-moderation-plugin` (D-17), fix the go.mod files that break module mode, and, after the user's go-ahead, push and tag all eight repositories.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Seam shape (D-18 discretion):** a festival "-ing" event fired before the write transaction, mirroring `user.Deleting` / `user.Deactivating` in sm-user-plugin. chat gets `events.MessageSaving`, forum a new `events` package with `events.PostSaving`. A listener refuses by returning the plugin's own `contracts.Rejection{Field, Message}`. Each plugin declares its own type, so forum does not import chat and neither imports moderation. The API maps `errors.As(err, &rej)` to the existing 422 envelope. Rationale: `festival.Bus.Fire` already returns the joined listener errors, chat already holds `s.bus`, forum holds an unused `Bus` field, and no service gains a new registration method. RESEARCH Pattern 2.
|
||||
- **Event names:** `MessageSavingEventName = "golem15.chat.message.saving"` and `PostSavingEventName = "golem15.forum.post.saving"`. They are Go-only seams with no PHP counterpart; the doc comments say so.
|
||||
- **Op values:** chat `OpCreate = "create"` and `OpUpdate = "update"`; forum `OpThread = "thread"`, `OpReply = "reply"` and `OpUpdate = "update"`.
|
||||
- **Moderation lists:** two textareas, one entry per line; an `enabled` switch defaults to true; at most 500 entries of at most 200 characters per list (validated in `BeforeValidate`). The filter reads the singleton row on every `Check` and recompiles its matchers only when the stored text changed. RE2's `\b` is ASCII-only, so word boundaries are built from `[^\p{L}\p{N}_]` to cover Polish letters.
|
||||
- **Violation message:** `Violation{Kind, Match, Message}`. Message comes from the plugin's lang files (`golem15.moderation::lang.violation.word` and `.link`, EN and PL) in the locale `towel.Locale(ctx)` carries, resolved through the framework's phrasebook. Without a locale it is English. The chat-family APIs answer in English today, so English is an acceptable fallback.
|
||||
- **Dependants require existing tags:** chatvideo, herald and userfriends require `sm-chat-plugin v0.1.3` (the newest tag before this plan), `sm-user-plugin v0.1.13` and `summercms v0.5.1`, the set RESEARCH compiled. That keeps their module-mode check independent of the new chat tag; minimal version selection gives the demo chat v0.1.4.
|
||||
- **Repository creation:** with the go-ahead, Claude creates `golem15/sm-moderation-plugin` on git.golem15.com with `tea` when `tea login list` shows a git.golem15.com login, using the same visibility as `sm-sitemap-plugin`. Otherwise the user creates it.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- sm-chat-plugin (v0.1.4): `events.MessageSaving`, `events.MessageSavingEventName`, `events.OpCreate`, `events.OpUpdate`, `contracts.Rejection` (`Error()`), the 422 mapping in `writeChatError`, smoke test `TestMessageSavingTracer` (`saving_smoke_test.go`), a README changelog entry v0.1.4.
|
||||
- sm-forum-plugin (v0.1.0): package `events` (`PostSaving`, `PostSavingEventName`, `OpThread`, `OpReply`, `OpUpdate`), `contracts.Rejection`, the 422 mapping in `writeForumError`, smoke test `TestPostSavingTracer`.
|
||||
- sm-moderation-plugin (new repo, v0.1.0): plugin `golem15.moderation`, package `moderation`, `classes.Filter`, `classes.NewFilter(db *gorm.DB) *Filter`, `(*Filter).Check(ctx context.Context, text string) (*Violation, error)`, `classes.Match(words, links []string, text string) *Violation`, `classes.ParseList(text string) []string`, `classes.Violation{Kind, Match, Message}`, `classes.KindWord`, `classes.KindLink`, table `golem15_moderation_settings` (migration `202610280001_create_golem15_moderation_settings`), settings item `moderation_settings`, permission `golem15.moderation.manage_settings`, config keys `golem15.moderation.max_entries` and `golem15.moderation.max_entry_length`, smoke test `TestFilterSmoke`.
|
||||
- go.mod fixes in sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin.
|
||||
- Tags: chat v0.1.4; chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation v0.1.0 (numbers read from the remote at release time).
|
||||
- Full unit tests come in plan 06 (project rule).
|
||||
|
||||
<objective>
|
||||
Release the chat family so the demo can require it by tag: the D-18 pre-save seams in chat and forum, the D-17 moderation plugin, go.mod hygiene, and eight tags after the user's go-ahead.
|
||||
|
||||
Purpose: SC-1 needs `sm-demo-app` to build from tagged modules (19.4.1 D-03, `TestGoModRequiresTags`, `scripts/check-pins.sh`). D-17 and D-18 need the seams and the filter before plan 02 wires them. D-20 makes this plan 01 of six and supersedes D-19's earlier split.
|
||||
Output: commits and, after the go-ahead, pushes and tags in eight plugin repositories. No change in sm-demo-app, vue-demo-app or summercms.go code.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
|
||||
Repos (siblings of summercms.go): ../sm-chat-plugin, ../sm-chatchannels-plugin, ../sm-chatbots-plugin (all three behind origin: fast-forward first), ../sm-chatvideo-plugin, ../sm-forum-plugin, ../sm-herald-plugin, ../sm-userfriends-plugin, and the new ../sm-moderation-plugin. Read-only references: ../sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots} (the origin-main trees, for reading only), ../sm-sitemap-plugin (plugin and settings analog), ../sm-demo-app/plugins/golem15/user/classes/events.go (the "-ing" event analog).
|
||||
|
||||
Rules: no co-author tags in commits (user instruction overrides any attribution default); stage files by explicit path, never `git add -A`; one logical change per commit; nothing pushed, created or tagged before Task 5. Verify commands run from the framework root and address siblings with `go -C ../<repo>`. Plugin repos with a tracked go.work resolve their siblings through it (the seam-carrying ../sm-chat-plugin included). If the Docker socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md).
|
||||
|
||||
<interfaces>
|
||||
chat (origin main 51c9e17 = v0.1.3, same tree as ../sm-bm-app/plugins/golem15/chat):
|
||||
- `classes.Service` fields `bus *festival.Bus` (from `Options.Bus`); `SendMessageWith(ctx, in SendInput) (*models.Message, error)` validates metadata, loads the conversation, checks `canSend`, trims content, returns `ErrEmptyMessage` when content and attachments are empty, then opens `lagoon.Transaction` and fires `events.MessageCreated` inside it (classes/service.go:287-345). `SendMessage` and `ReplyTo` (classes/attachments.go:218) funnel into it.
|
||||
- `EditMessage(ctx, actorID, messageID uint, content string) (*models.Message, error)` in classes/writes.go:248, after `CanEditMessage` (writes.go:202).
|
||||
- events/events.go: `MessageEvent{MessageID, ConversationID, ActorID uint; RootID *uint; Tx *gorm.DB}`, `MessageCreated`, `MessageUpdated`, `MessageDeleted`, consts `MessageCreatedEventName` etc.; the package doc says every event fires inside the transaction.
|
||||
- api/errors.go: `writeValidation(w, map[string][]string)`, `writeChatError(w, what, err)` (switch over errors.Is, default 500).
|
||||
- Test helpers: harness_test.go `bootChat`, `(*chatEnv).user`, `.count`, `.memory`, `.conversation(t, createdBy, typ, alias, contextID, participants...)`; http_tracer_test.go `bootWeb`, `(*webEnv).frontendToken`, `.call(t, method, path, token, body)`, `decodeObject`.
|
||||
forum (../sm-forum-plugin, origin main 2779341):
|
||||
- `classes.ForumService{bus *festival.Bus}` built in boot.go `bootService` with `Bus: app.Events`; `CreateThread(ctx, actorID, contextType, contextID, categoryID, title, body)` renders at forum_service.go:281, `Reply(...)` at :330, `EditPost(ctx, actorID, contextType, contextID, postID, body)` in classes/lifecycle.go:217. No `Fire(` call exists in the plugin today.
|
||||
- api/errors.go: `writeValidation`, `writeForumError` (ErrForbidden/ErrNotFound 404, ErrTitleRequired/ErrBodyRequired 422).
|
||||
- Test helpers: harness_test.go `bootForum`, `.user`, `.count`; http_tracer_test.go `bootWeb`, `.frontendToken`, `.call`.
|
||||
festival (framework): `(*Bus).Listen[T any](owner string, fn func(context.Context, T) error)`, `(*Bus).Fire[T any](ctx, event T) error` returns errors.Join of listener errors (callers use errors.As).
|
||||
sitemap analog: plugin.go (embed config, `pact.HasConfig/HasMigrations/HasModels`), admin.go (`Settings() []pact.SettingsItem` with Code, Label, Description, Category, Icon, Permissions, Form, NewModel; `AdminFS`, `LangFS`), admin_permissions.go (`Permissions() []pact.Permission`), models/settings.go (singleton row id 1, `*string` columns, `BeforeValidate` with a lang-key error), models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go (CREATE TABLE plus INSERT id 1), go.mod replace `git.golem15.com/golem15/summercms => ../summercms.go`, tracked go.work.
|
||||
go.mod today: chatvideo requires chat v0.0.0 and replaces chat and user with sibling paths; herald replaces user with ../fonoteka.go/plugins/golem15/user; userfriends requires chat pseudo-version v0.0.0-20261017200243-4d0e764ca443.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a rejecting MessageSaving listener turns a chat send into the existing 422 envelope and nothing is stored</name>
|
||||
<reversibility rating="costly">D-18: the seam becomes a public plugin contract other consumers can start using; changing its shape later touches every listener.</reversibility>
|
||||
<precondition>`git -C ../sm-<name>-plugin status --porcelain` prints nothing for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends; `docker info` exits 0.</precondition>
|
||||
<files>../sm-chat-plugin/events/events.go, ../sm-chat-plugin/contracts/rejection.go, ../sm-chat-plugin/classes/service.go, ../sm-chat-plugin/classes/writes.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/saving_smoke_test.go, ../sm-chat-plugin/README.md</files>
|
||||
<read_first>../sm-chat-plugin/classes/service.go (SendMessageWith, Options.Bus), ../sm-chat-plugin/classes/writes.go (EditMessage, CanEditMessage), ../sm-chat-plugin/classes/attachments.go (ReplyTo), ../sm-chat-plugin/events/events.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/api/messages.go and api/replies.go, ../sm-chat-plugin/http_tracer_test.go and harness_test.go, ../sm-chat-plugin/php_method_map_test.go, ../sm-chat-plugin/README.md, ../sm-demo-app/plugins/golem15/user/classes/events.go (DeactivatingEvent doc style), modules/festival/bus.go, 20-RESEARCH.md (Pattern 2, Pitfall 1), 20-PATTERNS.md (Chat / forum pre-save seam)</read_first>
|
||||
<action>Per D-18 and D-20; wire one path end to end: listener, service, API, HTTP response, database unchanged.
|
||||
|
||||
1. Fast-forward every sibling first (RESEARCH Pitfall 1): for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends run `git -C ../<repo> pull --ff-only` and confirm `git -C ../<repo> rev-parse HEAD` equals the hash `git -C ../<repo> ls-remote origin refs/heads/main` prints (use the default branch each repo tracks). Record both hashes per repo for the SUMMARY. A non-fast-forward stops the task.
|
||||
2. events/events.go: keep the three existing events untouched. Add `MessageSavingEventName = "golem15.chat.message.saving"` on its own line and a `MessageSaving` struct with `ConversationID`, `ActorID` and `MessageID uint` (MessageID is 0 on create), `RootID *uint`, `Content string` and `Op string`, plus the constants `OpCreate = "create"` and `OpUpdate = "update"`. MessageSaving carries no Tx because it fires before the transaction. Rewrite the package doc: the three message events fire inside the transaction after the save; MessageSaving fires before it, has no PHP counterpart, and a listener error aborts the write; a listener refuses with contracts.Rejection.
|
||||
3. contracts/rejection.go: `type Rejection struct { Field, Message string }` with a value-receiver `Error() string` returning "golem15.chat: rejected " + Field + ": " + Message. The doc comment explains that listeners return the value (not a pointer) and callers match it with errors.As, because Fire joins listener errors.
|
||||
4. classes/service.go `SendMessageWith`: after the ErrEmptyMessage check and before `lagoon.Transaction`, when `s.bus != nil`, fire `&events.MessageSaving{Op: events.OpCreate, ConversationID, ActorID: senderID, RootID: rootID, Content: content}` (the trimmed content) and return `nil, err` on error. classes/writes.go `EditMessage`: after CanEditMessage allows the edit and the new content is validated, before its transaction, fire the same event with `Op: events.OpUpdate` and the MessageID. No other line of either method changes.
|
||||
5. api/errors.go `writeChatError`: before the existing switch, declare `var rej contracts.Rejection` and, when `errors.As(err, &rej)`, call `writeValidation(w, map[string][]string{field: {rej.Message}})` with field = rej.Field, or "content" when Field is empty, then return. The other branches stay byte-identical.
|
||||
6. saving_smoke_test.go `TestMessageSavingTracer` (Docker; skips under -short like the other HTTP tests): boot with bootWeb; register on the app's event bus a `*events.MessageSaving` listener returning `contracts.Rejection{Field: "content", Message: "not allowed"}` when Content contains the word "blocked"; create a group conversation with two participants through the harness; then
|
||||
- POST `/_chat/api/v1/conversations/{id}/messages` with content "this is blocked": 422, body errors.content equals ["not allowed"], and golem15_chat_messages has 0 rows;
|
||||
- POST the content "hello": 201, 1 row;
|
||||
- PUT `/_chat/api/v1/messages/{id}` with "blocked edit": 422, and the stored content is still "hello";
|
||||
- POST `/_chat/api/v1/messages/{id}/replies` with "blocked reply": 422, and the row count is still 1.
|
||||
7. README.md: in Features and API reference, add MessageSaving (when it fires, no Tx, Op values), MessageSavingEventName, OpCreate, OpUpdate and contracts.Rejection, with a neutral example host listener (`acme`). Add a changelog entry "v0.1.4: pre-save MessageSaving event and Rejection (additive; no listener means no change)". Name no consuming application.
|
||||
|
||||
Commit in ../sm-chat-plugin by path (`feat: pre-save MessageSaving event and Rejection`), README in the same commit. Do not push or tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-chat-plugin vet ./... && go -C ../sm-chat-plugin test -count=1 -short ./... && go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v . 2>&1 | grep -q -- '^--- PASS: TestMessageSavingTracer'</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red (php_method_map or golden tests broken by the change), or no "--- PASS: TestMessageSavingTracer" line (Docker unavailable so it skipped, a 201 where 422 was expected, or a stored row after a refusal).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-chat-plugin test -count=1 ./...` (with Docker) passes, including the unchanged php_method_map_test.go and golden_test.go.
|
||||
- `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go testdata` prints nothing.
|
||||
- `grep -c 'MessageSavingEventName = "golem15.chat.message.saving"' ../sm-chat-plugin/events/events.go` prints 1.
|
||||
- `grep -c 'errors.As(err, &rej)' ../sm-chat-plugin/api/errors.go` prints 1.
|
||||
- `grep -c 'v0.1.4' ../sm-chat-plugin/README.md` prints at least 1.
|
||||
- The SUMMARY records the before and after HEAD of all seven siblings, and each after-HEAD equals origin's default branch.
|
||||
</acceptance_criteria>
|
||||
<done>A host can refuse a chat message before it is stored, the client gets the existing 422 envelope, and without a listener chat behaves byte-identically.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The forum gets the same seam: PostSaving before render on thread, reply and edit</name>
|
||||
<reversibility rating="costly">D-18: a new public event package and Rejection type in a WavePath port-target plugin; later consumers depend on the field names.</reversibility>
|
||||
<files>../sm-forum-plugin/events/events.go, ../sm-forum-plugin/contracts/rejection.go, ../sm-forum-plugin/classes/forum_service.go, ../sm-forum-plugin/classes/lifecycle.go, ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/saving_smoke_test.go, ../sm-forum-plugin/README.md</files>
|
||||
<read_first>../sm-forum-plugin/classes/forum_service.go (Options, New, CreateThread, Reply), ../sm-forum-plugin/classes/lifecycle.go (EditPost), ../sm-forum-plugin/boot.go (bootService passes app.Events), ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/api/threads.go and api/posts.go, ../sm-forum-plugin/http_tracer_test.go, harness_test.go and fixture_plugin_test.go (test access resolver), ../sm-forum-plugin/php_method_map_test.go, ../sm-forum-plugin/README.md, ../sm-chat-plugin/events/events.go and contracts/rejection.go (Task 1, for the shape)</read_first>
|
||||
<action>Per D-18.
|
||||
|
||||
- New package events (events/events.go):
|
||||
- `PostSavingEventName = "golem15.forum.post.saving"`;
|
||||
- `PostSaving` with `ContextType string`, `ContextID`, `ActorID`, `ThreadID` and `PostID uint`, `Title`, `Body` and `Op string`;
|
||||
- constants `OpThread = "thread"` (ThreadID 0, Title set), `OpReply = "reply"` and `OpUpdate = "update"`.
|
||||
- The package doc says forum fires no post-save events, PostSaving is a Go-only seam fired before render and persistence, and a listener error aborts the write.
|
||||
- contracts/rejection.go: the same shape as chat's, with "golem15.forum: rejected " in Error().
|
||||
- ForumService, all guarded by `s.bus != nil`, firing after the access and required-field checks and before `breakwater.Render`, returning the error unchanged:
|
||||
- CreateThread fires PostSaving with OpThread, the trimmed title and the body;
|
||||
- Reply fires it with OpReply, the ThreadID and the body;
|
||||
- EditPost (lifecycle.go) fires it with OpUpdate, the PostID, its ThreadID and the body.
|
||||
- api/errors.go `writeForumError`: before the switch, `errors.As(err, &rej)` writes 422 `{rej.Field: [rej.Message]}`, with field "body" when Field is empty.
|
||||
- saving_smoke_test.go `TestPostSavingTracer` (Docker): boot through bootWeb with the fixture resolver that allows posting; register a PostSaving listener rejecting Title or Body containing "blocked" (Field "title" or "body"). Then:
|
||||
- POST a thread whose title holds the word: 422 errors.title, no thread and no post row;
|
||||
- a clean thread: 201;
|
||||
- a reply whose body holds the word: 422 errors.body;
|
||||
- PUT the first post with the word: 422, and content_html is unchanged.
|
||||
- README: add Features "Pre-save seam" and the API reference rows (events.PostSaving, PostSavingEventName, Op constants, contracts.Rejection), and correct the Overview so it says forum fires only this pre-save event. Add a changelog line "v0.1.0: first tag; adds the pre-save PostSaving seam". Name no application.
|
||||
|
||||
Commit in ../sm-forum-plugin by path (`feat: pre-save PostSaving event and Rejection`). Do not push or tag.</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 '^TestPostSavingTracer$' -v . 2>&1 | grep -q -- '^--- PASS: TestPostSavingTracer'</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red, or no "--- PASS: TestPostSavingTracer" line (skipped without Docker, a refused write that still stored a row, or the wrong 422 field).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-forum-plugin test -count=1 ./...` passes with Docker, php_method_map_test.go and golden_test.go unedited (`git -C ../sm-forum-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` prints nothing).
|
||||
- `grep -rn 'sm-chat-plugin' ../sm-forum-plugin/go.mod ../sm-forum-plugin/events ../sm-forum-plugin/contracts` prints nothing.
|
||||
- `grep -c 'PostSaving{' ../sm-forum-plugin/classes/forum_service.go` prints 2 and `grep -c 'PostSaving{' ../sm-forum-plugin/classes/lifecycle.go` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>Forum threads, replies and edits can be refused before render and storage with the existing 422 envelope; without a listener nothing changes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: New shared plugin sm-moderation-plugin: banned words and links, editable in admin settings, published as a Filter</name>
|
||||
<files>../sm-moderation-plugin/.gitignore, ../sm-moderation-plugin/go.mod, ../sm-moderation-plugin/go.sum, ../sm-moderation-plugin/go.work, ../sm-moderation-plugin/plugin.go, ../sm-moderation-plugin/admin.go, ../sm-moderation-plugin/admin_permissions.go, ../sm-moderation-plugin/logger.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/filter_smoke_test.go, ../sm-moderation-plugin/models/settings.go, ../sm-moderation-plugin/models/registry.go, ../sm-moderation-plugin/models/settings/fields.yaml, ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go, ../sm-moderation-plugin/updates/registry.go, ../sm-moderation-plugin/lang/en/lang.yaml, ../sm-moderation-plugin/lang/pl/lang.yaml, ../sm-moderation-plugin/config/config.yaml, ../sm-moderation-plugin/README.md</files>
|
||||
<read_first>../sm-sitemap-plugin/plugin.go, admin.go, admin_permissions.go, logger.go, models/settings.go, models/registry.go, models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go, updates/registry.go, lang/en/lang.yaml, config/config.yaml, go.mod, go.work, README.md (the whole plugin layout), modules/phrasebook/README.md and modules/towel/README.md (locale lookup), `summer help make:plugin` (scaffold option), .planning/notes/core-plugins-own-repos.md, 20-PATTERNS.md (sm-moderation-plugin)</read_first>
|
||||
<action>Per D-17. Create the repository at /media/nvme/dev/golem15/summercms.io/summercms/sm-moderation-plugin with `git init -b main`. Add no remote yet. Start from `summer make:plugin golem15.moderation` if it emits a standalone module layout, otherwise copy the sitemap layout. Module `git.golem15.com/golem15/sm-moderation-plugin`, package `moderation`, plugin ID `golem15.moderation`, `Requires()` nil, display name Moderation / Moderacja. Standard library plus gorm, gormigrate and framework modules only.
|
||||
|
||||
- go.mod: go 1.27.0, require `git.golem15.com/golem15/summercms v0.5.1` plus gorm and gormigrate at the versions sitemap's go.mod uses, and `replace git.golem15.com/golem15/summercms => ../summercms.go`. Run `go mod tidy`. Track a go.work with `use .` and the same replace, as sitemap does.
|
||||
- plugin.go: the interface assertions (party.Plugin, pact.HasConfig, pact.HasMigrations, pact.HasModels) and `//go:embed config`. Register keeps the app. Boot calls lagoon.OnDatabase and publishes `classes.NewFilter(db)` with `app.Publish`. The package doc says the plugin is a content filter other plugins' hosts wire into their pre-save seams, and that it imports no content plugin.
|
||||
- config/config.yaml: `max_entries: 500`, `max_entry_length: 200`.
|
||||
- models/settings.go: `Settings` singleton on `golem15_moderation_settings` (`ID uint`, `Enabled bool`, `BannedWords *string`, `BannedLinks *string`). `BeforeValidate` refuses:
|
||||
- a list longer than max_entries (lang key `golem15.moderation::lang.errors.too_many`);
|
||||
- an entry longer than max_entry_length (`errors.too_long`);
|
||||
- a link entry that is neither a host name nor an http(s) URL (`errors.link`).
|
||||
models/registry.go `All()`.
|
||||
- models/settings/fields.yaml: `enabled` (switch), `banned_words` (textarea, comment "one word or phrase per line"), `banned_links` (textarea, comment "one domain or URL per line; subdomains match"), with the sitemap header comment block.
|
||||
- updates/202610280001_create_golem15_moderation_settings.go: `CREATE TABLE IF NOT EXISTS golem15_moderation_settings (id INTEGER PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT TRUE, banned_words TEXT NULL, banned_links TEXT NULL)` and `INSERT INTO golem15_moderation_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`. Rollback drops the table. updates/registry.go `All()`.
|
||||
- admin.go: `Settings()` returns item Code `moderation_settings` (lang keys for label, description and category, Icon "shield", Permissions [`golem15.moderation.manage_settings`], Form `models/settings/fields.yaml`, NewModel), plus AdminFS and LangFS, with the sitemap assertions (AdminAssets, HasSettings, HasLang). admin_permissions.go declares the permission on the role list sitemap uses.
|
||||
- lang/en and lang/pl: plugin name, settings label, description and category, field labels and comments, `errors.*`, `violation.word` ("The text contains a word that is not allowed." / "Tekst zawiera niedozwolone słowo.") and `violation.link` ("The text contains a link that is not allowed." / "Tekst zawiera niedozwolony link.").
|
||||
- classes/filter.go:
|
||||
- `KindWord = "word"`, `KindLink = "link"`; `Violation{Kind, Match, Message string}`;
|
||||
- `ParseList(text string) []string` splits lines, trims them, lowercases them and drops empty lines and duplicates;
|
||||
- `Match(words, links []string, text string) *Violation` is pure and returns the first violation, words before links:
|
||||
- a word or phrase matches case-insensitively between non-letter, non-digit boundaries (built from `[^\p{L}\p{N}_]`, start or end of text) with regexp.QuoteMeta;
|
||||
- a link entry that is a host (example.com) matches any host in the text equal to it or ending in "." plus it: http(s) URLs, `www.` prefixes and bare domains (labels of letters, digits and hyphens with at least one dot);
|
||||
- an entry with a path matches a URL whose host and path start with it.
|
||||
- `NewFilter(db) *Filter`. `(*Filter).Check(ctx, text) (*Violation, error)` reads the singleton row, returns nil when Enabled is false, recompiles the matchers only when the stored text differs from the cached text (guard the cache with a sync.Mutex), and fills Message from the lang phrase for `towel.Locale(ctx)` through the framework phrasebook, falling back to the English text.
|
||||
- classes/filter_smoke_test.go `TestFilterSmoke` (pure, no Docker): "Spam here" against the word spam gives a word violation; "spamming" gives none; "Kup TANIO teraz" against the phrase "kup tanio" gives a word violation; "visit https://www.bad.example/x" and "bad.example" against the link bad.example give link violations; "good.example" gives none.
|
||||
- README.md in the standard plugin structure:
|
||||
- H1, one-sentence summary, import line, a `**Name:**` beach mnemonic, Overview, Features, Usage, API reference, Configuration (the two keys), Dependencies, Testing;
|
||||
- Usage shows a neutral host (`acme`) wiring Check into another plugin's pre-save event without importing it here.
|
||||
|
||||
Commit in ../sm-moderation-plugin (`feat: golem15.moderation banned word and link filter`). Do not create a remote, push or tag.</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 -run '^TestFilterSmoke$' -v ./classes 2>&1 | grep -q -- '^--- PASS: TestFilterSmoke'</automated>
|
||||
<fails_when>Non-zero exit: the module does not build or vet, the short suite fails, or no "--- PASS: TestFilterSmoke" line (a boundary or host rule wrong).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-moderation-plugin list -deps ./... | grep -cE 'sm-(chat|forum)-plugin'` prints 0.
|
||||
- `grep -c 'golem15_moderation_settings' ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go` prints at least 2.
|
||||
- `grep -c 'golem15.moderation.manage_settings' ../sm-moderation-plugin/admin_permissions.go` prints at least 1.
|
||||
- `grep -c '^\*\*Name:\*\*' ../sm-moderation-plugin/README.md` prints 1.
|
||||
- `git -C ../sm-moderation-plugin remote` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A shared, chat-agnostic moderation plugin exists locally: admins edit banned words and links in settings, and any host can call Filter.Check from a pre-save seam.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Module-mode hygiene: chatvideo, herald and userfriends require tags, and the whole family builds and tests</name>
|
||||
<files>../sm-chatvideo-plugin/go.mod, ../sm-chatvideo-plugin/go.sum, ../sm-chatvideo-plugin/README.md, ../sm-herald-plugin/go.mod, ../sm-herald-plugin/go.sum, ../sm-herald-plugin/README.md, ../sm-userfriends-plugin/go.mod, ../sm-userfriends-plugin/go.sum, ../sm-userfriends-plugin/README.md</files>
|
||||
<read_first>../sm-chatvideo-plugin/go.mod and go.work, ../sm-herald-plugin/go.mod and go.work, ../sm-userfriends-plugin/go.mod and go.work, ../sm-chatchannels-plugin/go.mod and ../sm-chatbots-plugin/go.mod (after the Task 1 fast-forward), ../sm-bm-app/plugins/golem15/chatbots/go.mod (the "drop sibling replaces" analog), each README's Dependencies section, 20-RESEARCH.md (Repo State and Tagging, Pitfall 2)</read_first>
|
||||
<action>Per SC-1 and RESEARCH Pitfall 2. With `GOWORK=off GOPRIVATE=git.golem15.com/*`, edit each go.mod:
|
||||
- sm-chatvideo-plugin: delete the sm-chat-plugin and sm-user-plugin replace lines. Require `git.golem15.com/golem15/sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`. Keep `replace git.golem15.com/golem15/summercms => ../summercms.go`.
|
||||
- sm-herald-plugin: delete the sm-user-plugin replace. Require sm-user-plugin v0.1.13 and summercms v0.5.1.
|
||||
- sm-userfriends-plugin: replace the chat pseudo-version with v0.1.3. Require sm-user-plugin v0.1.13 and summercms v0.5.1.
|
||||
- sm-chatchannels-plugin and sm-chatbots-plugin (fast-forwarded in Task 1): confirm their go.mod holds no replace other than the framework one and only tag requires. If either does not, fix it the same way and add it to this task's commits.
|
||||
Run `go mod tidy` in each changed repo (GOWORK=off). The tracked go.work files stay: they are development conveniences and module consumers ignore them. Update each changed README's Dependencies section with the required versions and add a changelog line "v0.1.0: first tag; requires tagged chat, user and SummerCMS modules" where the README keeps a changelog.
|
||||
|
||||
Then verify the family:
|
||||
- all eight repos (chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation) pass `go vet ./...` and `go test -count=1 -short ./...` through their go.work;
|
||||
- chatchannels, chatbots, chatvideo, herald and userfriends pass `GOWORK=off go build ./...`, which resolves chat v0.1.3 and user v0.1.13 from git.golem15.com and proves no sibling path is needed.
|
||||
Grep the changed files of all eight repos for consuming application names (the sm-*-app repository names next to summercms.go, and their vendor prefixes) and remove any hit in the files this plan changed.
|
||||
|
||||
Commit per repo by path (`chore: require tagged chat, user and SummerCMS modules; drop sibling replaces`). Do not push or tag.</action>
|
||||
<verify>
|
||||
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... || exit 1; done && for r in sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-herald-plugin sm-userfriends-plugin; do GOWORK=off GOPRIVATE='git.golem15.com/*' go -C ../$r build ./... || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: any repo fails vet or its short suite, or a GOWORK=off build fails (for example "unknown revision v0.0.0" or a sibling replace path that no longer resolves).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^replace .*=> \.\./(sm-|fonoteka)' ../sm-chatvideo-plugin/go.mod ../sm-herald-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file.
|
||||
- `grep -c 'v0.0.0' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file.
|
||||
- `grep -c 'sm-chat-plugin v0.1.3' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 1 for each file.
|
||||
- `git -C ../sm-<repo> status --porcelain` prints nothing for all eight repos after the commits.
|
||||
</acceptance_criteria>
|
||||
<done>Every chat-family module resolves its dependencies by tag, so a demo build without go.work can require them.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead for the one-way release: create sm-moderation-plugin on git.golem15.com, push eight repositories and tag them</name>
|
||||
<read_first>The release table Claude prints before asking, one row per repository: current remote tags (`git ls-remote --tags origin`), the next free tag, the commit it will sit on, the commits it adds since the last tag, and why (seam, first tag, go.mod fix, new repo). Also whether `tea login list` shows a git.golem15.com login, and the visibility of sm-sitemap-plugin.</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY. With "release", git.golem15.com/golem15/sm-moderation-plugin exists (`git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0) before Task 6 pushes.
|
||||
</acceptance_criteria>
|
||||
<decision>Create the sm-moderation-plugin repository, push the commits of this plan and set the eight tags now?</decision>
|
||||
<context>A pushed Go module tag is cached by module proxies and can never point anywhere else (one-way, as 19.4.1 D-03). Tags freeze exactly the commits Tasks 1-4 verified. chat v0.1.4 is a patch: the seam is additive and WavePath's parity tests stay green (D-18). Plan 02 cannot mount the chat family by tag until this runs. Creating a repository on git.golem15.com and pushing need your go-ahead (CLAUDE.md).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Create the repository, push and tag</name>
|
||||
<pros>Plan 02 can mount all eight plugins by tag; other applications can adopt the seams and the filter.</pros>
|
||||
<cons>The tags are permanent; a mistake needs a new patch tag, not a moved tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing permanent happens.</pros>
|
||||
<cons>Plan 02 and every later plan are blocked; the demo cannot require the chat family.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "release": if Claude reported no tea login for git.golem15.com, create the repository golem15/sm-moderation-plugin (empty, same visibility as sm-sitemap-plugin, default branch main) in the git.golem15.com web UI first, then reply. Do not paste tokens into the chat.</instructions>
|
||||
<resume-signal>Reply "release" (add "repo ready" if you created the repository yourself) or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Release: push, tag and prove the eight tags resolve in module mode</name>
|
||||
<reversibility rating="one-way">Pushed module tags are cached by proxies and every consumer's go.sum; they can only be superseded, never moved.</reversibility>
|
||||
<precondition>Task 5 resolved with "release"; `git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0 or `tea login list` lists git.golem15.com.</precondition>
|
||||
<files>../sm-moderation-plugin/README.md</files>
|
||||
<read_first>The Task 5 release table, each repository's README changelog, .planning/notes/core-plugins-own-repos.md</read_first>
|
||||
<action>Per SC-1 and D-17/D-18.
|
||||
1. If the repository does not exist yet, create it with `tea repo create --name sm-moderation-plugin --owner golem15` at sm-sitemap-plugin's visibility. Then `git -C ../sm-moderation-plugin remote add origin git@git.golem15.com:golem15/sm-moderation-plugin.git` and push main with upstream.
|
||||
2. Push the default branch of chat, forum, chatvideo, herald and userfriends (and chatchannels or chatbots only if Task 4 committed there). No force-push.
|
||||
3. Read the next free tag from `git ls-remote --tags origin` per repository: chat the next v0.1.x patch (v0.1.4 expected), the others v0.1.0 unless taken. Create annotated tags (`git tag -a <tag> -m "<one-line summary>"`) on the HEADs Tasks 1-4 verified, chat first. Push each tag. Confirm `git ls-remote origin refs/tags/<tag>^{}` equals the local HEAD.
|
||||
4. If the moderation README says no tag exists yet, fix it in a commit before tagging that repository, never after.
|
||||
5. Module-mode proof: in a fresh `mktemp -d` directory, `go mod init example.invalid/familycheck`, require all eight modules at their new tags plus `git.golem15.com/golem15/summercms v0.5.1`, write a main.go that blank-imports the eight plugin packages, then run `GOWORK=off GOPRIVATE=git.golem15.com/* GOFLAGS=-mod=mod go build ./...`. Delete the directory afterwards. The SUMMARY records every tag with its commit hash for plan 02.</action>
|
||||
<verify>
|
||||
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do t=$(git -C ../$r describe --tags --exact-match HEAD) && [ "$(git -C ../$r ls-remote origin "refs/tags/$t^{}" | cut -f1)" = "$(git -C ../$r rev-parse HEAD)" ] || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: a repository HEAD carries no tag (describe fails), or the remote tag is missing or points at a different commit than the local HEAD.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-chat-plugin describe --tags --exact-match HEAD` prints a v0.1.x tag at least v0.1.4.
|
||||
- The scratch module build printed no error, and the SUMMARY lists the eight tags with commit hashes.
|
||||
- `git -C ../sm-<repo> status --porcelain` prints nothing in all eight repositories.
|
||||
</acceptance_criteria>
|
||||
<done>All eight chat-family modules are on git.golem15.com at verified tags and resolve together without go.work.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| chat/forum HTTP clients to the plugin APIs | message and post bodies are untrusted input; the seam runs on them before storage |
|
||||
| host listeners to plugin services | host code decides refusals; a listener error must not leak internals |
|
||||
| admin settings to the filter | administrators edit the lists that every message is matched against |
|
||||
| build machine to git.golem15.com and module proxies | tags become immutable inputs of every consumer |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-01 | Tampering | chat write paths that skip the seam | high | mitigate | MessageSaving fires in SendMessageWith (SendMessage, replies and ReplyTo funnel into it) and EditMessage; TestMessageSavingTracer covers send, reply and edit; plan 06 adds the full matrix |
|
||||
| T-20-02 | Information disclosure | listener errors surfacing in API bodies | medium | mitigate | only contracts.Rejection maps to 422 with its own Field and Message; any other listener error keeps the existing opaque 500 path of writeChatError and writeForumError |
|
||||
| T-20-03 | Denial of service | filter matching cost | medium | mitigate | RE2 linear-time regexps from QuoteMeta'd entries; at most 500 entries of at most 200 characters per list (BeforeValidate); matchers compiled once per list version |
|
||||
| T-20-04 | Elevation of privilege | moderation settings screen | medium | mitigate | settings item behind the permission golem15.moderation.manage_settings, granted to the same role as sitemap settings |
|
||||
| T-20-05 | Tampering | one-way module tags | medium | mitigate | tags only on HEADs that passed vet and tests in Tasks 1-4; next free number read from the remote; go-ahead at Task 5; ls-remote check and a GOWORK=off scratch build after pushing |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install: Go modules are the project's own tagged repositories plus gorm and gormigrate at versions already in sm-sitemap-plugin's go.sum (GOPRIVATE, go.sum committed) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: chat seam tracer green, chat parity tests unchanged, seven siblings at origin HEAD.
|
||||
- Task 2: forum seam tracer green, forum parity tests unchanged.
|
||||
- Task 3: moderation plugin builds, filter smoke green, no dependency on chat or forum.
|
||||
- Task 4: all eight repositories vet and test; five chat dependants build with GOWORK=off.
|
||||
- Task 5: go-ahead recorded.
|
||||
- Task 6: eight tags on origin equal to the verified HEADs; the scratch module builds them together.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The chat family and the moderation plugin are released by tag (SC-1 prerequisite), with the D-18 seams additive and parity-safe and the D-17 filter shared and chat-agnostic.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md` when done: per repository the before and after HEAD, the tag and its commit, the go.mod changes, how the moderation repository was created, and the checkpoint answer.
|
||||
</output>
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["20-01"]
|
||||
files_modified:
|
||||
- ../sm-demo-app/.gitmodules
|
||||
- ../sm-demo-app/go.work
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/summer.yaml
|
||||
- ../sm-demo-app/plugins.gen.go
|
||||
- ../sm-demo-app/plugins/golem15/oauthserver
|
||||
- ../sm-demo-app/plugins/golem15/chat
|
||||
- ../sm-demo-app/plugins/golem15/chatchannels
|
||||
- ../sm-demo-app/plugins/golem15/chatbots
|
||||
- ../sm-demo-app/plugins/golem15/chatvideo
|
||||
- ../sm-demo-app/plugins/golem15/forum
|
||||
- ../sm-demo-app/plugins/golem15/herald
|
||||
- ../sm-demo-app/plugins/golem15/userfriends
|
||||
- ../sm-demo-app/plugins/golem15/moderation
|
||||
- ../sm-demo-app/config/golem15.chatvideo.yaml
|
||||
- ../sm-demo-app/plugins/demo/plugin.go
|
||||
- ../sm-demo-app/plugins/demo/config/config.yaml
|
||||
- ../sm-demo-app/plugins/demo/chat.go
|
||||
- ../sm-demo-app/plugins/demo/chat_api.go
|
||||
- ../sm-demo-app/plugins/demo/chatbots.go
|
||||
- ../sm-demo-app/plugins/demo/moderation.go
|
||||
- ../sm-demo-app/plugins/demo/realtime_access.go
|
||||
- ../sm-demo-app/plugins/demo/routes.go
|
||||
- ../sm-demo-app/plugins/demo/classes/chat_context.go
|
||||
- ../sm-demo-app/plugins/demo/classes/forum_access.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed_chat.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed_forum.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed_friends.go
|
||||
- ../sm-demo-app/plugins/demo/console/reset.go
|
||||
- ../sm-demo-app/chat_smoke_test.go
|
||||
- ../sm-demo-app/boot_test.go
|
||||
- ../sm-demo-app/realtime_test.go
|
||||
- ../sm-demo-app/deploy/centrifugo.json.example
|
||||
- ../sm-demo-app/deploy/livekit.yaml.example
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/scripts/dev.sh
|
||||
- ../sm-demo-app/README.md
|
||||
- .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
|
||||
- .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, D-05, D-06, D-07, D-08, D-09, D-10, D-12, D-14, D-17, D-20, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 320000
|
||||
raw_tokens: 320000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1: sm-demo-app requires chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation at the plan 01 tags, mounts them as submodules at plugins/golem15/<name> checked out on those tags, lists them in summer.yaml before golem15.demo, and passes TestGoModRequiresTags, TestBootAllPlugins and scripts/check-pins.sh; scripts/build.sh dev builds."
|
||||
- "Folded todo: plugins/golem15/oauthserver sits on tag v0.1.3 (which contains b6a9586), go.mod requires sm-oauthserver-plugin v0.1.3, the change is one commit `chore: bump sm-oauthserver-plugin to v0.1.3`, and the todo file moves to .planning/todos/done with g15office task ssuyq2os set to Live."
|
||||
- "D-06: golem15.demo registers the chat context alias demo (id 1, broadcast chat:context:1) whose members are every activated, non-deleted, non-banned user, and the alias channel (chatchannels' ChannelAlias) that resolves a seeded channel's backing conversation to the same membership with broadcast chat:channel:{id}; both require live membership."
|
||||
- "D-07: channels exist only through demo:seed; the chatchannels access resolver answers CanManageContext false, so POST /_chatchannels/api/v1/contexts/demo/1/channels is refused for every member; a member can join and leave seeded channels and post in them."
|
||||
- "D-08: any member can open a DM with any other member through POST /_chat/api/v1/contexts/demo/1/dm; userfriends does not gate it."
|
||||
- "D-09: demo:seed creates persona users (no usable password) with EN and PL channel history, threads, reactions and pins, forum categories, threads and posts in EN and PL, friendships and a friend group, the demo-moderators user group and default moderation lists; a second run reports exists; demo:reset wipes every chat-family table in foreign-key order and reseeds, keeping golem15_moderation_settings and the userfriends context singleton."
|
||||
- "D-17: a banned word or link in a chat message (create, reply, edit) or a forum thread, reply or edit is refused with 422 through the demo's listeners on events.MessageSaving and events.PostSaving calling the published moderation Filter."
|
||||
- "D-22: members of the user group named by golem15.demo.chat.moderator_group (default demo-moderators) can delete other members' messages in channel conversations (CanModerateMessage) and manage forum posts (forum CanManage); the bootstrap reports moderator true for them; banning stays in the user admin."
|
||||
- "D-05: chatbots lists the demo's ephemeral slash commands in GET /_chatbots/api/v1/commands and POST /_chatbots/api/v1/dispatch answers them in ephemeral mode; there is no bot persona."
|
||||
- "D-12, D-23: config/golem15.chatvideo.yaml sets max_duration 900, max_participants 4, token_ttl 900 and screen_share false; the server does not restrict calls by conversation type (calls are limited to DMs by the UI, plan 04)."
|
||||
- "GET /_demo/api/v1/chat answers a member with {member, moderator, context, channels[], conversations[], limits} and GET /_demo/api/v1/chat/members with {members[]}; both need a JWT."
|
||||
- "GET /api/realtime/token answers 200 for any member (guard demo.realtime-access) and 401 without a JWT; Centrifugo's committed config has the namespaces presentations, chat, user and video with the subscribe proxy; scripts/dev.sh up starts a local LiveKit on 127.0.0.1:7890 (TCP 7891, UDP 7892) with keys written to .env only (D-10 locally)."
|
||||
- "D-14: the forum resolver lets every member read and post in context demo/1 and the seeded EN and PL categories are listed by GET /_forum/api/v1/contexts/demo/1/categories."
|
||||
artifacts:
|
||||
- path: "../sm-demo-app/plugins/demo/classes/chat_context.go"
|
||||
provides: "DemoContext, ChannelContext adapter, ChatAccess resolver, membership query"
|
||||
contains: "ChatContextType"
|
||||
- path: "../sm-demo-app/plugins/demo/chat.go"
|
||||
provides: "registerChat: registry aliases demo and channel, resolvers, limits"
|
||||
contains: "ChannelAlias"
|
||||
- path: "../sm-demo-app/plugins/demo/chat_api.go"
|
||||
provides: "GET /_demo/api/v1/chat and /chat/members (host bootstrap contract)"
|
||||
contains: "/chat/members"
|
||||
- path: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
provides: "MessageSaving and PostSaving listeners calling the moderation Filter"
|
||||
contains: "MessageSaving"
|
||||
- path: "../sm-demo-app/plugins/demo/realtime_access.go"
|
||||
provides: "demo.realtime-access middleware"
|
||||
contains: "demo.realtime-access"
|
||||
- path: "../sm-demo-app/plugins/demo/console/seed_chat.go"
|
||||
provides: "persona users, channels, messages, threads, reactions, pins (EN/PL)"
|
||||
contains: "persona"
|
||||
- path: "../sm-demo-app/deploy/livekit.yaml.example"
|
||||
provides: "LiveKit config template: port 7890, tcp 7891, udp mux 7892, room caps"
|
||||
contains: "udp_port: 7892"
|
||||
- path: "../sm-demo-app/chat_smoke_test.go"
|
||||
provides: "TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke"
|
||||
contains: "TestDemoChatTracer"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/plugins/demo/chat.go"
|
||||
to: "../sm-demo-app/plugins/golem15/chat/contracts/registry.go"
|
||||
via: "reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, ...})"
|
||||
pattern: "Alias: +chanmodels\\.ChannelAlias"
|
||||
- from: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
to: "../sm-demo-app/plugins/golem15/moderation/classes/filter.go"
|
||||
via: "app.Lookup[*modclasses.Filter]() then Check(ctx, content)"
|
||||
pattern: "Check\\(ctx"
|
||||
- from: "../sm-demo-app/plugins/demo/routes.go"
|
||||
to: "../sm-demo-app/plugins/demo/realtime_access.go"
|
||||
via: "lighthouse.Mount UserAuth surf.Use(\"jwt.auth\", \"demo.realtime-access\", bucketRealtime)"
|
||||
pattern: "demo\\.realtime-access"
|
||||
- from: "../sm-demo-app/plugins/demo/console/reset.go"
|
||||
to: "../sm-demo-app/plugins/demo/console/seed_chat.go"
|
||||
via: "resetTables lists the chat-family tables before users; runSeed reseeds them"
|
||||
pattern: "golem15_chat_messages"
|
||||
prohibitions:
|
||||
- statement: "No file inside a plugin submodule is edited from sm-demo-app; shared plugin fixes go to the plugin repos (plan 01 pattern) and need a new tag."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "go.mod carries no replace directive and no pseudo-version for a golem15 module."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No LiveKit key, VAPID key or persona password appears in a committed file or in command output; .env holds them."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed in this plan (plan 05 pushes with the deploy go-ahead)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): `sm-demo-app` runs every shared Golem15 plugin in one binary, and its public Nuxt site shows them working; this phase adds the chat family to the live demo from Phase 19.4.1.
|
||||
|
||||
This plan's slice (plan 2 of 6, wave 2, per D-20): the demo backend. After it, a registered demo member can read and post in seeded channels, DM anyone, use slash commands, read and post in the forum and manage friends through the plugins' APIs. Moderation refuses banned content, and a local LiveKit can mint calls. Nothing in the Nuxt site changes yet (plans 03 and 04).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Contexts (D-06):** alias `demo`, id 1, group name "General", broadcast `chat:context:1`. Alias `channel` (chatchannels' `models.ChannelAlias`, legacy class `models.ChannelMorphName`) loads the channel by id, with broadcast `chat:channel:{id}`. Neither broadcast is subscribable, because AuthorizeChat admits only `chat:conversation:{id}`. Both implement `contracts.LiveMembership` (true) and `contracts.MessageModerator` (moderator group, non-DM conversations only), as bm does.
|
||||
- **Joining a channel (RESEARCH assumption A3):** the layer opens a channel with `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, whose GetGroupChat lazy-joins a context member. The tracer test proves it. If it does not join, the bootstrap adds the caller as participant of each joined channel with chat's `AddUserToGroupChat`, and the SUMMARY records which path holds.
|
||||
- **Personas (discretion):** four, Ada and Ben (English) and Ola and Tomek (Polish). Emails are `<name>@personas.demo.invalid` (RFC 2606, undeliverable). Each gets a random bcrypt hash that is never printed, so nobody can log in as a persona. Channels: `general` (#general), `announcements` (#announcements), `ogolny` (#ogólny, Polish). About 15 to 25 messages per language with two threads, reactions and one pin per channel. Persona Ada sends a welcome DM to the visitor account when golem15.demo.visitor.email is set.
|
||||
- **Seeding writes:** raw SQL in the existing seed style (`insertID`, `exists`), or a ChannelService instance built with a seed-only resolver and no bus or realtime, for the channel rows. The demo's own resolver refuses CanManageContext (RESEARCH Anti-Patterns), so it cannot seed. Forum posts store content_html from `breakwater.Render`, the plugin's own renderer.
|
||||
- **Moderator group (D-22):** user group code `demo-moderators` (config `golem15.demo.chat.moderator_group`), created by the seed with no members. demo:reset wipes memberships with the users, so moderators are reassigned after a reset.
|
||||
- **Slash commands (D-05 discretion):** `/demo` explains what the demo shows and `/tour` lists the site sections with their paths. Both are ephemeral, English and Polish chosen from the actor's locale when known (else English). `/help` is chatbots' built-in.
|
||||
- **Bootstrap contract:** bm's `/_bm/api/v1/chat` shape with `general` replaced by `channels[]`. Each channel is `{id, slug, name, conversation_id, joined, unread, mentions}`. vue-chat-layer documents this contract under a neutral name (plan 03).
|
||||
- **Local LiveKit (D-10 locally):** ports 7890/7891/7892, the same as rome (RESEARCH Pitfall 7), so dev and production configs differ only in keys and external IP. `livekit_url` locally is `ws://127.0.0.1:7890`.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Submodules `plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends,moderation}`; summer.yaml entries; go.mod requires at the plan 01 tags; sm-oauthserver-plugin v0.1.3.
|
||||
- golem15.demo: `classes.DemoContext`, `classes.ChannelContext`, `classes.ChatAccess`, `classes.ForumAccess`, `classes.ChatContextType = "demo"`, `classes.ChatContextID = 1`, `classes.ChatBroadcastChannel = "chat:context:1"`, `classes.ChannelBroadcastChannel(id uint) string`, `classes.MemberIDs`; `(*Plugin).registerChat`; `MiddlewareRealtimeAccess = "demo.realtime-access"` with `pact.HasMiddleware`; routes `GET /_demo/api/v1/chat` and `GET /_demo/api/v1/chat/members`; slash commands `/demo` and `/tour`; seam listeners for `chatevents.MessageSaving` and `forumevents.PostSaving`.
|
||||
- Config keys: `golem15.demo.chat.moderator_group` (default `demo-moderators`), `golem15.demo.chat.max_embeds` (default 3); `config/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`, `max_duration: 900`, `max_participants: 4`, `token_ttl: 900`, `screen_share: false`).
|
||||
- demo:seed steps: `chat personas`, `chat channels and history (en)`, `chat channels and history (pl)`, `forum categories and threads (en, pl)`, `friends and group`, `moderator group demo-moderators`, `moderation lists`; demo:reset table list extended.
|
||||
- Deploy and dev: `deploy/livekit.yaml.example`, Centrifugo namespaces `chat`, `user`, `video`; `scripts/dev.sh` LiveKit start and stop (pid name `livekit`); env keys `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY` and `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET` (names only in env.example).
|
||||
- Smoke tests: `TestDemoChatTracer`, `TestDemoModerationSmoke`, `TestSeedChatSmoke` (chat_smoke_test.go). Full unit tests come in plan 06.
|
||||
|
||||
<objective>
|
||||
Mount the released chat family in sm-demo-app and give it a demo host: community context, channel adapter, resolvers, bootstrap endpoints, realtime for every member, moderation wiring, slash commands, EN/PL showcase seed with reset, and a local LiveKit.
|
||||
|
||||
Purpose: SC-1 (builds with every shared plugin; demo:seed fills each), D-05 to D-09, D-12, D-14, D-17, D-22, D-23 on the server side, and the folded oauthserver todo. Plans 03 and 04 build the site UI on top of these endpoints.
|
||||
Output: commits in ../sm-demo-app (code) and one planning commit in summercms.go (the todo move). Nothing pushed.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md
|
||||
@.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
|
||||
@.planning/G15OFFICE.md
|
||||
|
||||
Repo: ../sm-demo-app (all code edits). Read-only: the mounted plugin submodules, ../sm-bm-app/plugins/jz/bm (host analog), ../sm-bm-app/deploy/centrifugo/bm-config.json (namespace options).
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; one `feat: mount golem15.<id> <tag>` commit per plugin (precedent 9451b31: submodule, .gitmodules, go.work, go.mod, go.sum, summer.yaml, plugins.gen.go, boot_test.go); planning files in their own commit in summercms.go. Other sessions (Phase 33) may have uncommitted edits in sm-demo-app or vue-demo-app: check `git status --porcelain` before each commit, never stage or revert files this plan did not change, and stop if a file this plan must change carries foreign edits. Verify commands run from the framework root with `go -C ../sm-demo-app` and scripts by path (they cd to their own root). Docker tests: if the socket is denied, run through `sudo -u golem`.
|
||||
|
||||
<interfaces>
|
||||
Host analog ../sm-bm-app/plugins/jz/bm:
|
||||
- chat.go: `registerChat` (HasPlugin guard, `app.Lookup[*chatcontracts.Registry]()`, `reg.Add(p.ID(), chatcontracts.Registration{Alias, Loader})`, `lagoon.OnDatabase` then `app.Lookup[*chanclasses.ChannelService]()` and `SetAccessResolver`; `installChatLimits` via `svc.SetMaxMessageBytes`).
|
||||
- classes/chat_context.go: assertions `chatcontracts.Context`, `MessageModerator`, `LiveMembership`, `chancontracts.AccessResolver`; methods ID, Type, ChatEnabled, GroupName, BroadcastChannel, CanActFor (`chatcontracts.DefaultCanActFor`), CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members; `ChatContextLoader`; member query joins users_groups and user_groups.
|
||||
- realtime_access.go: `MiddlewareRealtimeAccess`, `Middlewares() map[string]pact.Middleware`, `realtimeAccess`, `writeRealtimeGuard`.
|
||||
- controllers/api/chat.go: bootstrap JSON structs (member, moderator, context, general, conversations, limits; members with id, name, surname, handle, is_bot).
|
||||
chatchannels: `models.ChannelAlias = "channel"`, `models.ChannelMorphName`, `models.Channel{ContextType, Name, Slug, Visibility...}`; `chancontracts.AccessResolver{CanAccessContext, CanManageContext}`; `ChannelService.CreateChannel` gated by CanManageContext (channel_service.go:136); `JoinChannel` only upserts a pref row (:959).
|
||||
chat: `Service.GetGroupChat(ctx, actorID, alias, contextID)`, `AddUserToGroupChat(ctx, userID, alias, contextID)`, `GetConversationsForUser`, `GetAvailableDmRecipients`, `SetMaxMessageBytes`, `MaxMessageBytes`; events `MessageSaving` and `contracts.Rejection` (plan 01).
|
||||
forum: `forum.Plugin.SetAccessResolver`, `contracts.AccessResolver` (CanRead, CanPost, CanManage, CanManageContext), `ForumService.RegisterContext(alias, legacyPHPClass)`, events `PostSaving`, `contracts.Rejection` (plan 01).
|
||||
chatbots: `contracts.SlashCommand{Trigger, Description, IsAvailable, Handle}`, `classes.Ephemeral(payload)`, `(*classes.Registry).Register` (help is reserved), Registry published in chatbots' OnDatabase hook.
|
||||
moderation: `*classes.Filter` published at boot, `Check(ctx, text) (*Violation, error)` (plan 01).
|
||||
chatvideo config keys (config/config.yaml of the plugin): livekit_url, livekit_api_key, livekit_api_secret, max_duration, token_ttl, livekit_admin_url, max_participants, screen_share; env `SUMMER_GOLEM15__CHATVIDEO__*` or PHP names `LIVEKIT_*`.
|
||||
sm-demo-app today: plugins/demo/plugin.go (Requires, Boot calls wireRealtime), routes.go (`APIPrefix`, `mountRealtime` limited to presentations with `presentations.realtime-owner`), console/seed.go (`runSeed` step table, `created`, `insertID`, `exists`), console/reset.go (`resetTables`, children first), boot_test.go (`wantPlugins`, routed prefixes), realtime_test.go (`TestRealtimeRoutes`, `wantNamespaces`), testmain_test.go (`bootDemo`), go_mod_test.go, scripts/dev.sh (`ensure_env`, `start_centrifugo`, `claim_port`, `up`/`down`/`status`), deploy/centrifugo.json.example (namespaces array with `subscribe_proxy_enabled`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a registered member reads the chat bootstrap and posts in the seeded #general channel through the chat API</name>
|
||||
<precondition>The plan 01 SUMMARY lists the eight tags and `git ls-remote --tags git@git.golem15.com:golem15/sm-chat-plugin.git` shows the chat tag; `git -C ../sm-demo-app status --porcelain` shows nothing except ` M plugins/golem15/oauthserver`; `docker info` exits 0.</precondition>
|
||||
<files>../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chat, ../sm-demo-app/plugins/golem15/chatchannels, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/classes/chat_context.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go, .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md, .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md</files>
|
||||
<read_first>../sm-demo-app/summer.yaml, go.mod, go.work, .gitmodules, plugins.gen.go, `git -C ../sm-demo-app show 9451b31 --stat` (mount commit shape), ../sm-demo-app/plugins/demo/plugin.go, routes.go, console/seed.go, ../sm-demo-app/boot_test.go, testmain_test.go, internal/demotest/postgres.go, ../sm-bm-app/plugins/jz/bm/chat.go, classes/chat_context.go, controllers/api/chat.go, ../sm-demo-app/plugins/golem15/chat/contracts/*.go and classes/writes.go (GetGroupChat, AddUserToGroupChat) after mounting, ../sm-demo-app/plugins/golem15/chatchannels/models/channel.go, classes/channel_service.go (CreateChannel, JoinChannel), contracts/*.go, ../sm-demo-app/scripts/check-pins.sh, the folded todo, .planning/G15OFFICE.md</read_first>
|
||||
<action>Per D-06, D-07, SC-1 and the folded todo.
|
||||
|
||||
1. Folded todo first:
|
||||
- `git -C ../sm-demo-app/plugins/golem15/oauthserver fetch --tags`, confirm `git merge-base --is-ancestor b6a9586 v0.1.3`, then check out v0.1.3;
|
||||
- in ../sm-demo-app run `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` and `go mod tidy`;
|
||||
- commit the gitlink with go.mod and go.sum as `chore: bump sm-oauthserver-plugin to v0.1.3`.
|
||||
In summercms.go, `git mv` the todo to .planning/todos/done/, add a "Done in Phase 20 plan 02" line, and commit it as a planning commit. Move g15office task ssuyq2os to Live with a one-line comment, as .planning/G15OFFICE.md describes.
|
||||
2. Mount chat and chatchannels at their plan 01 tags:
|
||||
- `git submodule add -b main git@git.golem15.com:golem15/sm-<name>-plugin.git plugins/golem15/<name>`, then check out the tag;
|
||||
- add both to go.work `use`, and run `GOWORK=off go get <module>@<tag>` plus `go mod tidy`;
|
||||
- add summer.yaml entries after golem15.user (chat, then chatchannels) and before golem15.demo;
|
||||
- regenerate plugins.gen.go the way 9451b31 did (the summer build generator);
|
||||
- append the IDs to boot_test.go wantPlugins and `/_chat/api/v1` and `/_chatchannels/api/v1` to its routed prefixes.
|
||||
One `feat: mount golem15.<id> <tag>` commit per plugin.
|
||||
3. plugins/demo/classes/chat_context.go, ported from bm's chat_context.go:
|
||||
- constants ChatContextType "demo", ChatContextID 1, ChatGroupName "General", ChatBroadcastChannel "chat:context:1", and `ChannelBroadcastChannel(id)` returning "chat:channel:{id}";
|
||||
- `MemberIDs(ctx, db)` and `IsMember(ctx, db, userID)`: users that are activated, have no deleted_at and are not banned (use the user plugin's own ban column; read its models);
|
||||
- DemoContext and ChannelContext implement chatcontracts.Context, MessageModerator and LiveMembership. CanModerateMessage is true only for a member of the moderator group in a non-DM conversation. RequireLiveMembership returns true;
|
||||
- ChatAccess (chancontracts.AccessResolver): CanAccessContext is true for a member when the type is "demo" and the id is 1; CanManageContext is always false (D-07);
|
||||
- keep bm's `WithMembership` and `MemberFunc` injection so tests can stub membership.
|
||||
4. plugins/demo/chat.go `registerChat(app)`, called from Boot after wireRealtime:
|
||||
- with the HasPlugin guard, register alias demo (loader returns DemoContext for id 1 and the unknown-context error otherwise) and alias `chanmodels.ChannelAlias` with `LegacyPHPClass: chanmodels.ChannelMorphName` (loader reads the channel row by id and returns ChannelContext);
|
||||
- in lagoon.OnDatabase, install ChatAccess on the ChannelService.
|
||||
Requires() gains golem15.chat and golem15.chatchannels. Config keys go into plugins/demo/config/config.yaml in Task 2.
|
||||
5. console/seed_chat.go: a step `chat channels and history (en)` that, in this task, creates only #general in context demo/1 (channel row plus backing conversation, exactly the rows CreateChannel writes, through a seed-only resolver or raw SQL) and one welcome message from the first admin or a persona. It is idempotent by slug. Add it to the runSeed step table.
|
||||
6. plugins/demo/chat_api.go: `GET /_demo/api/v1/chat` behind `jwt.auth` and `throttle:120,1`, mounted from Routes. For a member it answers `{member: true, moderator, context: {type: "demo", id: 1}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [DMs as bm's chatConversationJSON], limits: {max_files, max_bytes, max_embeds}}`. For a non-member it answers `{member: false}`. Errors use `wire.WriteJSON` with `{"error": ...}`.
|
||||
7. chat_smoke_test.go `TestDemoChatTracer` (Docker): bootDemo, run demo:seed, create and activate a user, then with its JWT:
|
||||
- GET /_demo/api/v1/chat shows #general with a conversation_id;
|
||||
- GET /_chat/api/v1/contexts/channel/{channelId}/conversation answers 200;
|
||||
- POST /_chat/api/v1/conversations/{conversation_id}/messages answers 201;
|
||||
- GET .../messages contains the text;
|
||||
- POST /_chatchannels/api/v1/contexts/demo/1/channels is refused (403 or 404).
|
||||
If the lazy join does not make the user a participant, implement the AddUserToGroupChat fallback in the bootstrap and record it.
|
||||
|
||||
Commit the host wiring and the test as `feat: demo chat context, channel adapter and bootstrap`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer|TestGoModRequiresTags|TestBootAllPlugins)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && ../sm-demo-app/scripts/check-pins.sh</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red; fewer than three "--- PASS" lines (a replace or pseudo-version in go.mod, a plugin not activated, a missing route prefix, the member cannot post, or the channel create was allowed); or check-pins prints a mismatch line instead of "check-pins: ok".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-demo-app log --oneline -6` shows `chore: bump sm-oauthserver-plugin to v0.1.3`, `feat: mount golem15.chat`, `feat: mount golem15.chatchannels` and the host commit, each separate.
|
||||
- `git -C ../sm-demo-app submodule status plugins/golem15/oauthserver plugins/golem15/chat plugins/golem15/chatchannels` shows no leading `+` and each describes as its tag.
|
||||
- `test -f .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md && ! test -e .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md` exits 0.
|
||||
- `grep -c 'Alias: *chanmodels.ChannelAlias' ../sm-demo-app/plugins/demo/chat.go` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>A demo member can open the seeded #general channel and post in it through the shared chat API, from a build that requires every module by tag.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The rest of the family is mounted and wired: forum, friends, slash commands, video config, herald, and moderation refusing banned content</name>
|
||||
<files>../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chatbots, ../sm-demo-app/plugins/golem15/chatvideo, ../sm-demo-app/plugins/golem15/forum, ../sm-demo-app/plugins/golem15/herald, ../sm-demo-app/plugins/golem15/userfriends, ../sm-demo-app/plugins/golem15/moderation, ../sm-demo-app/config/golem15.chatvideo.yaml, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/config/config.yaml, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/chatbots.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/forum/README.md, boot.go, contracts/access_resolver.go, ../sm-demo-app/plugins/golem15/chatbots/README.md, contracts/slash_command.go, classes/registry.go, classes/result.go, boot.go, ../sm-demo-app/plugins/golem15/chatvideo/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/herald/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/userfriends/README.md, ../sm-demo-app/plugins/golem15/moderation/README.md and classes/filter.go, ../sm-demo-app/plugins/golem15/chat/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/golem15/forum/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/demo/chat.go (Task 1), ../sm-bm-app/plugins/jz/bm/chat.go (config key block)</read_first>
|
||||
<action>Per D-05, D-08, D-12, D-14, D-17, D-22, D-23, SC-1.
|
||||
|
||||
1. Mount chatbots, chatvideo, forum, herald, userfriends and moderation at their plan 01 tags as in Task 1, one `feat: mount golem15.<id> <tag>` commit each. Order in summer.yaml: chatbots and chatvideo after chatchannels; forum, herald and userfriends after them (userfriends after chat, so it sees the chat registry at Boot); moderation; all before golem15.demo. Extend boot_test.go wantPlugins and its prefixes: /_chatbots/api/v1, /_chatvideo/api/v1, /_forum/api/v1, /_herald/api/v1, /_userfriends/api/v1 and /_demo/api/v1/chat.
|
||||
2. config/golem15.chatvideo.yaml (D-12, D-23): livekit_url "ws://127.0.0.1:7890", livekit_admin_url "http://127.0.0.1:7890", max_duration 900, max_participants 4, token_ttl 900, screen_share false. API key and secret come only from the environment. Herald gets no committed config: its keys come from .env (plan 04).
|
||||
3. plugins/demo/config/config.yaml: `chat: {moderator_group: demo-moderators, max_embeds: 3}`. chat.go reads them as in bm's config key block and reports the limits in the bootstrap (max_bytes from the chat service's MaxMessageBytes).
|
||||
4. Forum (D-14, D-22): classes/forum_access.go `ForumAccess`. CanRead and CanPost are true for a member when the context is demo/1; CanManage and CanManageContext are true for members of the moderator group. registerChat looks up `*forum.Plugin` and calls SetAccessResolver, and registers the context alias demo with the forum service when its API requires registration (check ListCategories on an unregistered alias).
|
||||
5. DMs (D-08): nothing to add beyond DemoContext.HasMember. Verify in the smoke test that two members open a DM with POST /_chat/api/v1/contexts/demo/1/dm.
|
||||
6. chatbots.go (D-05): `/demo` and `/tour` implement contracts.SlashCommand with IsAvailable true for any non-zero actor and Handle returning classes.Ephemeral with a short text (/tour lists /blog, /presentations, /quizzes, /subscriptions, /faq, /chat, /forum, /friends). Register them in lagoon.OnDatabase after chatbots published its Registry; a missing registry is a boot error naming the summer.yaml order.
|
||||
7. moderation.go (D-17): in Boot, `app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)` looks up `*modclasses.Filter` per event and returns `chatcontracts.Rejection{Field: "content", Message: v.Message}` when Check finds a violation. `app.Events.Listen[*forumevents.PostSaving]` checks Title (Field "title"), then Body (Field "body"), returning `forumcontracts.Rejection`. A Check error is returned as is (500). A missing filter is a boot error, because golem15.demo requires golem15.moderation.
|
||||
8. plugin.go Requires() lists every plugin whose tables the seed or reset writes or that the demo wires: chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation, plus the existing ones. The package doc mentions the chat host.
|
||||
9. chat_smoke_test.go `TestDemoModerationSmoke` (Docker): seed, then set golem15_moderation_settings banned_words to "blockedword" and banned_links to "bad.example". A member's message "this has blockedword" in #general answers 422 errors.content; a forum thread titled "visit bad.example" in a seeded category answers 422 errors.title; a clean message answers 201. Also assert the DM open from step 5 and that GET /_chatbots/api/v1/commands lists demo and tour.
|
||||
|
||||
Commit wiring (`feat: demo hosts forum, friends, slash commands and moderation`) separately from the six mount commits.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoModerationSmoke|TestBootAllPlugins|TestGoModRequiresTags|TestDemoChatTracer)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && ../sm-demo-app/scripts/check-pins.sh && ../sm-demo-app/scripts/build.sh dev</automated>
|
||||
<fails_when>Non-zero exit: vet or short suite red; fewer than four "--- PASS" lines (banned content stored, a plugin not activated, a prefix not routed, a go.mod rule broken); check-pins reports a mismatch; or the dev build fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^ - id: golem15\.(chat|chatchannels|chatbots|chatvideo|forum|herald|userfriends|moderation)$' ../sm-demo-app/summer.yaml` prints 8.
|
||||
- `grep -c 'max_participants: 4' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1 and `grep -c 'max_duration: 900' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1.
|
||||
- `grep -rn 'livekit_api_secret: *"[^"]' ../sm-demo-app/config` prints nothing (no committed secret).
|
||||
- `git -C ../sm-demo-app status --porcelain -- plugins/golem15` prints nothing (submodules clean, on tags).
|
||||
</acceptance_criteria>
|
||||
<done>Every chat-family plugin boots in the demo with its host policy; banned words and links are refused in chat and forum; slash commands answer; video is configured with the D-12 caps.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: demo:seed fills chat, forum and friends with EN and PL showcase data, and demo:reset wipes and reseeds all of it</name>
|
||||
<files>../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/plugins/demo/console/seed_forum.go, ../sm-demo-app/plugins/demo/console/seed_friends.go, ../sm-demo-app/plugins/demo/console/reset.go, ../sm-demo-app/chat_smoke_test.go</files>
|
||||
<read_first>../sm-demo-app/plugins/demo/console/seed.go (step table, created, insertID, exists, visitor), seed_locales.go, reset.go (resetTables order and comments), ../sm-demo-app/reset_test.go and seed_test.go, the baseline migrations of every mounted chat-family plugin (../sm-demo-app/plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends}/updates/*.go) for table names and foreign keys, ../sm-demo-app/plugins/golem15/chatchannels/models (pins, mentions, members, prefs), ../sm-demo-app/plugins/golem15/chat/models (message, reaction, thread summary), modules/breakwater/README.md, 20-RESEARCH.md (Pitfall 10, Anti-Patterns)</read_first>
|
||||
<action>Per D-09, D-14, D-15 (data), D-22, D-17.
|
||||
|
||||
- seed_chat.go:
|
||||
- `chat personas`: Ada, Ben, Ola and Tomek, as decided above. Each is activated, has a random password hash that is never printed, and is skipped when the email exists.
|
||||
- `chat channels and history (en)`: #general and #announcements, with English history from the personas, two threads (root plus 2 to 4 replies, keeping chat's thread summary rows consistent the way the service writes them), reactions on a few messages using the keys chat allows (GET /reactions catalog in the plugin), and one pin per channel in chatchannels' pins table.
|
||||
- `chat channels and history (pl)`: #ogólny (slug ogolny) with Polish history, a thread, reactions and a pin.
|
||||
- A welcome DM from Ada to the visitor when golem15.demo.visitor.email is set (skipped otherwise).
|
||||
- Rows are written in created order with timestamps spread over the last few days. Messages are inserted directly: no realtime, no jobs, no events. Steps are idempotent by channel slug and persona email.
|
||||
- seed_forum.go `forum categories and threads (en, pl)`: categories "General discussion" and "Show and tell" (EN) and "Rozmowy" (PL) in context demo/1, two or three threads each with replies by personas. content is Markdown and content_html comes from `breakwater.Render`. Idempotent by category slug.
|
||||
- seed_friends.go `friends and group`: accepted friendships among the personas, one pending request from Ola to the visitor when set, and a friend group "Demo crew" owned by Ada with Ben and Tomek as members. Use userfriends' tables and the context singleton id 1. Idempotent.
|
||||
- seed.go: steps `moderator group demo-moderators` (a user_groups row with the configured code and name "Demo moderators", no members) and `moderation lists` (when banned_words and banned_links are both empty, fill a short, mild default list of common spam words and one link-shortener domain; otherwise "exists"). Order the new steps after the visitor and before the roadmap decks.
|
||||
- reset.go `resetTables` (RESEARCH Pitfall 10), children first, all before `users`:
|
||||
- chatvideo participants, then sessions;
|
||||
- chat reactions, thread mutes, thread summaries, messages, participants, conversations;
|
||||
- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels, categories;
|
||||
- forum thread reads, posts, threads, categories;
|
||||
- userfriends group bans, invites, group members, groups, friendships;
|
||||
- herald push subscriptions;
|
||||
- chatbots bot flags.
|
||||
Use the exact table names from the migrations. Keep golem15_moderation_settings and golem15_userfriends_contexts. Update the comment and the command description. Remove the chat-family uploads (chat attachments in system_files) the same way the reset already removes other owners' attachments, keeping resetKeptAttachments.
|
||||
- chat_smoke_test.go `TestSeedChatSmoke` (Docker): seed with a visitor email.
|
||||
- Three channels exist, #ogólny holds Polish text, at least one thread root has replies, at least one reaction and one pin exist, the forum has three categories with posts whose content_html is non-empty, there are at least three accepted friendships and the group "Demo crew", and the visitor has a DM.
|
||||
- A second seed run reports every new step as "exists".
|
||||
- demo:reset --force empties golem15_chat_messages before reseeding and leaves golem15_moderation_settings unchanged.
|
||||
|
||||
Commit `feat: seed and reset the chat family (EN/PL)`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke|TestResetKeepsPlans|TestSeedRoadmapUpsert)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit or fewer than three "--- PASS" lines: seed not idempotent, a missing EN/PL item, a foreign-key violation in the reset order, moderation settings wiped, or the existing reset and roadmap tests broken.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'golem15_chat_messages' ../sm-demo-app/plugins/demo/console/reset.go` prints at least 1, and TestSeedChatSmoke asserts the moderation settings row is identical before and after demo:reset --force.
|
||||
- `grep -c 'personas.demo.invalid' ../sm-demo-app/plugins/demo/console/seed_chat.go` prints at least 1.
|
||||
- Running `../sm-demo-app/scripts/dev.sh seed` twice against the local stack prints no "created" for the new steps on the second run.
|
||||
</acceptance_criteria>
|
||||
<done>A fresh or reset demo shows lived-in EN and PL channels, threads, reactions, pins, forum threads, friendships and a friend group, and the reset job keeps it that way.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Every member gets realtime, the layers get their members endpoint, and dev runs Centrifugo namespaces and a local LiveKit</name>
|
||||
<files>../sm-demo-app/plugins/demo/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/scripts/dev.sh, ../sm-demo-app/README.md</files>
|
||||
<read_first>../sm-bm-app/plugins/jz/bm/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go (mountRealtime), realtime.go, ../sm-demo-app/realtime_test.go (TestRealtimeRoutes, wantNamespaces, realtimeApp), ../sm-bm-app/deploy/centrifugo/bm-config.json (chat, user and video namespace options), ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/scripts/dev.sh (ensure_env, start_centrifugo, claim_port, up, down, status, the usage header), ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/plugins/golem15/chatvideo/README.md (keys, admin URL, TrustedMode), 20-RESEARCH.md (Pitfalls 4, 5, 7; LiveKit config block)</read_first>
|
||||
<action>Per D-10 (locally), D-06, SC-1.
|
||||
|
||||
1. realtime_access.go: `MiddlewareRealtimeAccess = "demo.realtime-access"`, exported through `Middlewares() map[string]pact.Middleware` (assert pact.HasMiddleware in plugin.go). The guard reads `bouncer.User(r.Context())`: no user gives 401, a database not yet published gives 503, and a user for whom classes.IsMember is true passes. Otherwise it falls back to the presentations owner guard looked up once (sync.Once) by name, as bm does. routes.go mountRealtime mounts when golem15.presentations or golem15.chat is active, with `UserAuth: surf.Use("jwt.auth", MiddlewareRealtimeAccess, bucketRealtime)`. Update its doc comment.
|
||||
2. realtime_test.go: the token route's expected middleware becomes ["jwt.auth", "demo.realtime-access", "throttle:golem15.demo.realtime"], and wantNamespaces gains the namespaces the chat, chatvideo and user plugins register. Plan 06 adds the member/non-member cases.
|
||||
3. chat_api.go `GET /_demo/api/v1/chat/members` (jwt.auth, throttle:120,1) answers `{members: [{id, name, surname, handle, is_bot}]}` for every member except the caller, as bm's ChatMembers does (handle from the user plugin's username or a slug of the name; is_bot false). A non-member gets 403.
|
||||
4. deploy/centrifugo.json.example: add namespaces `chat` (presence, join_leave, force_push_join_leave, allow_presence_for_subscriber and subscribe_proxy_enabled, with the option names bm's config uses translated to this file's v6 format), `user` (subscribe_proxy_enabled) and `video` (subscribe_proxy_enabled). Run `centrifugo checkconfig -c deploy/centrifugo.json.example`, then diff the option names against `centrifugo defaultconfig`, because checkconfig misses misspelt names. Update the _comment lines.
|
||||
5. deploy/livekit.yaml.example (RESEARCH LiveKit block):
|
||||
- port 7890, bind_addresses [127.0.0.1];
|
||||
- rtc tcp_port 7891, udp_port 7892 (UDP mux only, no port-range keys), use_external_ip true and a comment that local runs set it false;
|
||||
- room max_participants 4, empty_timeout 120, departure_timeout 20;
|
||||
- keys with one placeholder pair (CHANGE_ME_KEY: CHANGE_ME_SECRET);
|
||||
- a header comment naming rome's path /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo), the supervisor program plan 05 adds, and the env keys.
|
||||
6. scripts/dev.sh:
|
||||
- LIVEKIT=${LIVEKIT:-$(command -v livekit-server || true)};
|
||||
- ensure_env appends SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY and SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET (random, umask 077, never echoed) when missing;
|
||||
- `livekit_config` writes logs/livekit.yaml from the example with the .env keys and use_external_ip false;
|
||||
- `start_livekit` claims ports 7890 and 7891, starts `livekit-server --config logs/livekit.yaml` under the pid name livekit with output to logs/livekit.log, waits for http://127.0.0.1:7890/ and warns and continues without video when the binary is missing;
|
||||
- up starts it after Centrifugo, down, destroy and status include livekit, and the header usage lines mention it.
|
||||
7. deploy/env.example: the two LiveKit key names with empty values and a comment. README.md: a "Chat family" section covering the mounted plugins, the contexts, the bootstrap contract of /_demo/api/v1/chat and /chat/members, the seed data, the moderator group, slash commands and local LiveKit.
|
||||
|
||||
Commit `feat: realtime for every demo member, members endpoint, Centrifugo namespaces and local LiveKit`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && centrifugo checkconfig -c ../sm-demo-app/deploy/centrifugo.json.example && ../sm-demo-app/scripts/dev.sh up && curl -fsS http://127.0.0.1:7890/ >/dev/null && ../sm-demo-app/scripts/check-local.sh realtime</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red (TestRealtimeRoutes middleware or namespaces), checkconfig refuses the Centrifugo file, dev.sh up fails to start a service, LiveKit does not answer on 127.0.0.1:7890, or check-local realtime reports a non-200 token for the visitor or a token without a JWT.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"name": "chat"' ../sm-demo-app/deploy/centrifugo.json.example` prints 1, and the same for "user" and "video".
|
||||
- `grep -v '^\s*#' ../sm-demo-app/deploy/livekit.yaml.example | grep -cE 'port_range_(start|end)'` prints 0, and `grep -c 'udp_port: 7892' ../sm-demo-app/deploy/livekit.yaml.example` prints 1.
|
||||
- With the local stack up, `curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $VISITOR_JWT" http://127.0.0.1:8097/api/realtime/token` prints 200 (JWT from the login API).
|
||||
- `grep -c 'LIVEKIT_API_SECRET=' ../sm-demo-app/deploy/env.example` prints 1 and that line has no value after the equals sign.
|
||||
</acceptance_criteria>
|
||||
<done>Every demo member gets a realtime token and the member list, Centrifugo knows the chat namespaces, and a local LiveKit runs with the dev stack.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser to /_demo, /_chat, /_chatchannels, /_forum, /_userfriends, /_chatbots, /_chatvideo | every request untrusted; actor only from the JWT |
|
||||
| Centrifugo subscribe proxy to Go | channel names come from clients; authorizers decide |
|
||||
| seed and reset commands to the database | trusted operator commands; persona accounts must stay unusable |
|
||||
| Go to LiveKit RoomService on loopback | admin calls with the API secret |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-06 | Elevation of privilege | channel creation by members | high | mitigate | ChatAccess.CanManageContext is always false; TestDemoChatTracer asserts POST channels is refused (D-07) |
|
||||
| T-20-07 | Information disclosure | subscribing to other conversations | high | mitigate | broadcast channels chat:context:1 and chat:channel:{id} are not subscribable (AuthorizeChat admits only chat:conversation:{id}); user:{id} only to itself (user plugin authorizer); video:session:{id} only to session participants |
|
||||
| T-20-08 | Spoofing | persona accounts | medium | mitigate | random password hashes never printed, emails under .invalid, so no login and no mail; reset wipes and recreates them |
|
||||
| T-20-09 | Elevation of privilege | moderator rights | medium | mitigate | moderator group membership only through the user admin (D-22); CanModerateMessage limited to non-DM conversations; banned users fail HasMember with live membership on |
|
||||
| T-20-10 | Tampering | spam and abuse in public chat and forum | high | mitigate | moderation listeners on both seams (TestDemoModerationSmoke), plugin buckets (chat-api, chat-dm, forum-api, video-initiate), Turnstile on register (19.4.1), reset job |
|
||||
| T-20-11 | Denial of service | realtime token minting for every member | low | mitigate | guard needs a JWT of a live member, per-user bucket golem15.demo.realtime 120/min unchanged |
|
||||
| T-20-12 | Information disclosure | LiveKit and VAPID secrets | high | mitigate | values only in .env (dev.sh writes them with umask 077, never echoed); env.example holds names; config files hold no secret (acceptance grep) |
|
||||
| T-20-13 | Denial of service | stale chat-family rows after reset | low | mitigate | reset table list in FK order covering every chat-family table; TestSeedChatSmoke checks messages are emptied |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install in this plan; Go modules are the project's tagged plugins from plan 01 (go.sum committed, GOPRIVATE) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: tracer test, go.mod tags, boot test, check-pins.
|
||||
- Task 2: moderation smoke, boot and go.mod tests, check-pins, dev build.
|
||||
- Task 3: seed smoke, reset and roadmap tests.
|
||||
- Task 4: short suite, Centrifugo checkconfig, dev stack up with LiveKit, check-local realtime.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
sm-demo-app builds with every shared plugin including the chat family, and demo:seed fills each with EN/PL showcase data (SC-1). The demo hosts one community where every member can chat, DM, post in the forum and befriend others, with moderation, slash commands and video configured (D-05 to D-09, D-12, D-14, D-17, D-22, D-23).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md` when done: mount commits with tags, which channel join path holds (lazy join or bootstrap), persona and channel names, the reset table order, the Centrifugo option names used, and any boot warnings left by plugins (for example herald's presence gate).
|
||||
</output>
|
||||
@@ -0,0 +1,446 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["20-02"]
|
||||
files_modified:
|
||||
- ../vue-chat-layer/.gitignore
|
||||
- ../vue-chat-layer/.nvmrc
|
||||
- ../vue-chat-layer/package.json
|
||||
- ../vue-chat-layer/pnpm-lock.yaml
|
||||
- ../vue-chat-layer/pnpm-workspace.yaml
|
||||
- ../vue-chat-layer/tsconfig.json
|
||||
- ../vue-chat-layer/nuxt.config.ts
|
||||
- ../vue-chat-layer/vitest.config.ts
|
||||
- ../vue-chat-layer/README.md
|
||||
- ../vue-chat-layer/.playground/nuxt.config.ts
|
||||
- ../vue-chat-layer/.playground/app/app.vue
|
||||
- ../vue-chat-layer/app/lib/client.ts
|
||||
- ../vue-chat-layer/app/lib/types.ts
|
||||
- ../vue-chat-layer/app/lib/realtime.ts
|
||||
- ../vue-chat-layer/app/lib/chatEvents.ts
|
||||
- ../vue-chat-layer/app/lib/chatFormat.ts
|
||||
- ../vue-chat-layer/app/lib/chatText.ts
|
||||
- ../vue-chat-layer/app/lib/chatUnread.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.ts
|
||||
- ../vue-chat-layer/app/lib/format.ts
|
||||
- ../vue-chat-layer/app/lib/media.ts
|
||||
- ../vue-chat-layer/app/lib/tabs.ts
|
||||
- ../vue-chat-layer/app/composables/useChatApi.ts
|
||||
- ../vue-chat-layer/app/composables/useChat.ts
|
||||
- ../vue-chat-layer/app/composables/useChatNotice.ts
|
||||
- ../vue-chat-layer/app/components/Chat/Page.vue
|
||||
- ../vue-chat-layer/app/components/Chat/ChannelList.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Panel.vue
|
||||
- ../vue-chat-layer/app/components/Chat/MessageList.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Message.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Composer.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Header.vue
|
||||
- ../vue-chat-layer/app/components/Chat/DmList.vue
|
||||
- ../vue-chat-layer/app/components/Chat/MemberPicker.vue
|
||||
- ../vue-chat-layer/app/components/Chat/MentionPopup.vue
|
||||
- ../vue-chat-layer/app/components/Chat/ReactionBar.vue
|
||||
- ../vue-chat-layer/app/components/Chat/LinkEmbed.vue
|
||||
- ../vue-chat-layer/app/components/Chat/StagingChips.vue
|
||||
- ../vue-chat-layer/app/components/Chat/DeleteDialog.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Avatar.vue
|
||||
- ../vue-chat-layer/app/components/Chat/ThreadPane.vue
|
||||
- ../vue-chat-layer/app/components/Chat/PinsPanel.vue
|
||||
- ../vue-chat-layer/app/components/Chat/CommandPicker.vue
|
||||
- ../vue-chat-layer/app/components/Chat/FloatingPanel.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Icon.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Notices.vue
|
||||
- ../vue-chat-layer/app/assets/css/chat-tokens.css
|
||||
- ../vue-chat-layer/app/assets/css/chat-base.css
|
||||
- ../vue-chat-layer/i18n/locales/en.json
|
||||
- ../vue-chat-layer/i18n/locales/pl.json
|
||||
- ../vue-chat-layer/tests/smoke.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/package.json
|
||||
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
|
||||
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/lib/realtime.ts
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
|
||||
- ../sm-demo-app/scripts/check-chat.cjs
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/smoke.sh
|
||||
autonomous: false
|
||||
requirements: [SC-2, D-01, D-02, D-03, D-04, D-05, D-07, D-20, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 380000
|
||||
raw_tokens: 380000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: vue-chat-layer is a new shared Nuxt layer (own repository, tag v0.1.0) ported from the bm SPA's chat components, store, API client and chat libraries, with no Pinia (state is useState composables), no bm entity embeds and no name of a consuming application; host specifics enter only through runtime config (public.chat.hostApiBase), the $chatAuth hook, slots and the link-embed renderer registry."
|
||||
- "D-02: on /chat a logged-in member sees the seeded channels in a sidebar, opens one, reads history, sends and edits messages, replies in threads (thread pane), sees typing, unread and mention counts, adds and removes reactions, attaches files, mentions members with @, picks a DM partner from the member picker, deletes own messages (moderators also others' in channels, D-22) through the delete dialog, sees channel pins and sees URLs as links with a generic link embed."
|
||||
- "D-03: every page of vue-demo-app shows a floating chat panel (DM list and unread badge) for logged-in users (not on /chat and not on the full-screen deck pages); the homepage tour has a Chat card; the site is EN and PL."
|
||||
- "D-04: an anonymous visitor on /chat sees a description and a register-to-chat link to /register?next=/chat; there is no read-only preview, and the floating panel is not rendered."
|
||||
- "D-05: typing / at the start of the composer opens a command picker filled from GET /_chatbots/api/v1/commands; choosing or sending a command calls POST /_chatbots/api/v1/dispatch and shows the ephemeral answer only to the sender; there is no chatbots layer."
|
||||
- "D-07: the layer offers no channel creation UI; members join and leave seeded channels."
|
||||
- "D-23 (extension point): the conversation header exposes a slot that the layer renders only for DM conversations, so a call button placed there (plan 04) appears only in DMs."
|
||||
- "SC-2: messages from another member arrive without reload through the shared Centrifuge client (one connection per page, shared with the presentations remote through vue-demo-app's app/lib/realtime.ts re-export)."
|
||||
- "After the Task 5 go-ahead, git.golem15.com/golem15/vue-chat-layer exists with tag v0.1.0 and vue-demo-app pins git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0 in a committed package.json and pnpm-lock.yaml."
|
||||
artifacts:
|
||||
- path: "../vue-chat-layer/nuxt.config.ts"
|
||||
provides: "layer config: runtimeConfig chat.apiBase, public.chat.hostApiBase, css, i18n registration (no routes, no route rules)"
|
||||
contains: "hostApiBase"
|
||||
- path: "../vue-chat-layer/app/composables/useChat.ts"
|
||||
provides: "useState-based chat store (channels, DMs, threads, unread, typing, realtime)"
|
||||
contains: "useState"
|
||||
- path: "../vue-chat-layer/app/lib/realtime.ts"
|
||||
provides: "one shared Centrifuge client with connect, subscribe, presence, openChannel and onUserEvent"
|
||||
contains: "onUserEvent"
|
||||
- path: "../vue-chat-layer/app/lib/linkEmbeds.ts"
|
||||
provides: "linkify and the link-embed renderer registry (registerEmbedRenderer)"
|
||||
contains: "registerEmbedRenderer"
|
||||
- path: "../vue-chat-layer/app/components/Chat/CommandPicker.vue"
|
||||
provides: "slash command picker over the chatbots catalog and dispatch"
|
||||
contains: "dispatch"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/chat.vue"
|
||||
provides: "/chat: ChatPage for members, register prompt for anonymous visitors"
|
||||
contains: "registerToChat"
|
||||
- path: "../sm-demo-app/scripts/check-chat.cjs"
|
||||
provides: "Playwright check: the visitor sends a message in #general on /chat"
|
||||
contains: "check-chat: ok"
|
||||
key_links:
|
||||
- from: "../vue-chat-layer/app/composables/useChatApi.ts"
|
||||
to: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
|
||||
via: "the layer reads useNuxtApp().$chatAuth {token, refresh, unauthorized}; the app provides it"
|
||||
pattern: "chatAuth"
|
||||
- from: "../vue-chat-layer/app/composables/useChat.ts"
|
||||
to: "../sm-demo-app/plugins/demo/chat_api.go"
|
||||
via: "bootstrap GET {public.chat.hostApiBase}/chat and /chat/members"
|
||||
pattern: "hostApiBase"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/lib/realtime.ts"
|
||||
to: "../vue-chat-layer/app/lib/realtime.ts"
|
||||
via: "re-export so presentations and chat share one connection"
|
||||
pattern: "vue-chat-layer/app/lib/realtime"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/layouts/default.vue"
|
||||
to: "../vue-chat-layer/app/components/Chat/FloatingPanel.vue"
|
||||
via: "ClientOnly ChatFloatingPanel when logged in"
|
||||
pattern: "ChatFloatingPanel"
|
||||
prohibitions:
|
||||
- statement: "vue-chat-layer names no consuming application and has no default host bootstrap path."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No message, name or attachment field is rendered with v-html except the escaped output of the ported chatText renderer."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No Pinia and no new npm package except those already pinned in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3)."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "Nothing is pushed and no repository is created before the Task 5 go-ahead; vue-demo-app is not pushed in this plan (plan 05)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working, chat and channels with realtime included, through the shared layers.
|
||||
|
||||
This plan's slice (plan 3 of 6, wave 3, per D-20): `vue-chat-layer` v0.1.0, ported from bm's chat SPA and widened to several channels, threads, pins, a command picker and generic link embeds, then used in vue-demo-app as `/chat`, a floating panel and a tour card, in EN and PL.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Layout of the layer:** pure modules in `app/lib/` (imported explicitly, never auto-imported, so generic names like `initials` cannot clash with an app's utils). Composables `useChat`, `useChatApi` and `useChatNotice` in `app/composables/`. Components in `app/components/Chat/`, auto-registered with the `Chat` prefix (`ChatPage`, `ChatFloatingPanel`). The layer ships components; the app owns routes, page copy and the anonymous prompt (as vue-paymentgateway-layer does).
|
||||
- **Host contract:** `runtimeConfig.public.chat.hostApiBase` defaults to an empty string, and the layer refuses to bootstrap without it, with a console error naming the key. vue-demo-app sets `/_demo/api/v1`. The README documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the contract any host implements, under neutral names.
|
||||
- **Auth hook:** `$chatAuth` with `token(): string | null`, `refresh(): Promise<string | null>` and `unauthorized(): void`. One refresh-and-retry on 401 (bm's client renews through `/_user/api/v1/fetch`), then `unauthorized()`.
|
||||
- **Store:** bm's Pinia setup store becomes a module-level composable over `useState` keys prefixed `chat:` (19.4.1 no-Pinia rule). `general`/`generalId` become `channels[]` and `activeId`.
|
||||
- **Realtime:** bm's current `lib/realtime.ts` (shared client) is ported with `onUserEvent(handler)`, so other layers (chatvideo, plan 04) get `user:{me}` events they do not own without a second subscription. vue-demo-app's `app/lib/realtime.ts` becomes a re-export, so the presentations remote and chat share one WebSocket.
|
||||
- **Embeds:** bm's entity embeds and picker are dropped. URLs in text are linkified (escaped, `rel="noopener noreferrer nofollow"`, `target="_blank"`). A message with a URL shows a `ChatLinkEmbed` card with host and path; there is no unfurl backend. `registerEmbedRenderer(match, component)` lets a host render its own entity links (D-01 extension point).
|
||||
- **Extension slots:** `conversation-actions` in ChatHeader, rendered only for `type === 'dm'` (D-23), and `sidebar-footer` in ChannelList (the friends link, plan 04).
|
||||
- **Floating panel:** hidden on `/chat`, `/p/**` and `/presentations/**` (full-screen decks) and for anonymous visitors.
|
||||
- **Development pin:** while the layer has no remote, vue-demo-app depends on `link:../../vue-chat-layer`, committed with the first vue-demo-app commit so every commit builds on this machine. Task 6 replaces it with the tag pin; vue-demo-app is pushed only in plan 05, after the swap, so no pushed commit needs the local path.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `vue-chat-layer` (tag v0.1.0):
|
||||
- components `ChatPage`, `ChatChannelList`, `ChatPanel`, `ChatMessageList`, `ChatMessage`, `ChatComposer`, `ChatHeader`, `ChatDmList`, `ChatMemberPicker`, `ChatMentionPopup`, `ChatReactionBar`, `ChatLinkEmbed`, `ChatStagingChips`, `ChatDeleteDialog`, `ChatAvatar`, `ChatThreadPane`, `ChatPinsPanel`, `ChatCommandPicker`, `ChatFloatingPanel`, `ChatIcon`, `ChatNotices`;
|
||||
- composables `useChat`, `useChatApi`, `useChatNotice`;
|
||||
- lib exports: `createChatClient`, `REALTIME_WS_PATH`, `connect`, `subscribe`, `presence`, `openChannel`, `onUserEvent`, `disconnect`, `linkify`, `registerEmbedRenderer`, `embedRendererFor`, and the ported chatEvents, chatFormat, chatText and chatUnread functions;
|
||||
- runtime config `chat.apiBase` (`NUXT_CHAT_API_BASE`), `public.chat.apiBase`, `public.chat.channelsApiBase`, `public.chat.botsApiBase`, `public.chat.hostApiBase`;
|
||||
- Nuxt plugin hook `$chatAuth`; slots `conversation-actions` and `sidebar-footer`; i18n keys under `chat.*` (EN and PL);
|
||||
- smoke test `tests/smoke.test.ts`.
|
||||
- vue-demo-app: page `/chat`, the floating panel in the default layout, Chat tour card (TourCard icon `chat`), i18n keys `chat.*` and `tour.chat.*`, goPaths `/_chat`, `/_chatchannels`, `/_chatbots`, ChatPage inside ClientOnly (the page itself is server-rendered so the banner and the anonymous prompt are in the HTML), `chatAuth` provided in app/plugins/auth.ts, app/lib/realtime.ts re-exporting the layer client.
|
||||
- sm-demo-app: `scripts/check-chat.cjs`, check-local check `chat`, check-local `tour` expecting the Chat card, smoke.sh covering `/chat`.
|
||||
|
||||
<objective>
|
||||
Build and release vue-chat-layer v0.1.0 and put chat on the demo site: `/chat`, the floating panel, the Chat tour card and the anonymous prompt, in EN and PL.
|
||||
|
||||
Purpose: SC-2 (chat and channels with realtime on the public site), D-01 to D-05, D-07 (no channel creation), D-22 (moderator delete) and the D-23 extension point plan 04 uses.
|
||||
Output: a new repository vue-chat-layer (pushed and tagged after the go-ahead), commits in ../sm-demo-app/vue-demo-app (the submodule; not pushed) and ../sm-demo-app (scripts; not pushed).
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
|
||||
|
||||
Repos: new ../vue-chat-layer; ../sm-demo-app/vue-demo-app (the submodule, origin golem15/vue-demo-app; never the stale ../vue-demo-app sibling); ../sm-demo-app (scripts). Read-only port source: ../sm-bm-app/frontend/src (components/chat/*, stores/chat.ts and chat.test.ts, api/chat.ts, api/types.ts, lib/chat*.ts, lib/realtime.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue). Layer conventions: ../vue-faq-layer and ../vue-paymentgateway-layer.
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; the layer repository gets no remote before Task 5; vue-demo-app is committed in its own repository (never staged from sm-demo-app) and not pushed here; check `git -C ../sm-demo-app/vue-demo-app status --porcelain` before each commit and leave other sessions' edits alone (Phase 33 also edits nuxt.config.ts). The local dev stack runs with `../sm-demo-app/scripts/dev.sh up` (Go 127.0.0.1:8097, Nuxt 127.0.0.1:3020, Centrifugo, LiveKit). Playwright is the global install (`NODE_PATH="$(npm root -g)"`, scripts/lib/browser.cjs).
|
||||
|
||||
<interfaces>
|
||||
Host bootstrap (plan 02, ../sm-demo-app/plugins/demo/chat_api.go): GET /_demo/api/v1/chat gives {member, moderator, context: {type, id}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [{id, type: "dm", other_user_id, unread, last_message: {id, sender_id, content, created_at}}], limits: {max_files, max_bytes, max_embeds}}; GET /_demo/api/v1/chat/members gives {members: [{id, name, surname, handle, is_bot}]}.
|
||||
chat API /_chat/api/v1: GET|POST /contexts/{type}/{id}/conversation; GET|POST /conversations/{id}/messages; POST /conversations/{id}/read, /typing, /mute; GET|PUT|DELETE /messages/{id}; GET /reactions; POST /messages/{id}/reactions; DELETE /messages/{id}/reactions/{key}; GET|POST /messages/{id}/replies; PUT /threads/{rootId}/mute; POST /attachments (multipart, body limit); GET /attachments/{messageId}/{fileId}; POST /contexts/{type}/{id}/dm. 422 envelope {"error":"Validation failed","errors":{field:[msg]}}, other refusals {"error": msg}, IDOR 404.
|
||||
chatchannels /_chatchannels/api/v1/contexts/demo/1: GET channels, POST channels/{id}/join and /leave, GET|POST channels/{id}/pins, DELETE channels/{id}/pins/{messageId}, GET channels/{id}/mentions, GET mentions.
|
||||
chatbots /_chatbots/api/v1: GET /commands gives {commands: [{trigger, description}]}; POST /dispatch {input} gives {mode, payload}; unknown gives 422 errors.input.
|
||||
Realtime: token GET /api/realtime/token; WebSocket /ws; channels user:{me} (chat:message, typing, read, unread, deleted, reacted, updated events per bm's chatEvents.ts; video:* from chatvideo), chat:conversation:{id}.
|
||||
vue-demo-app today: nuxt.config.ts (`goPaths`, `extends`, `runtimeConfig`, `routeRules`, `robots.disallow: goPaths`), app/plugins/auth.ts (`paymentGatewayAuth`), app/utils/authCookie.ts (`DEMO_TOKEN_COOKIE`), app/composables/useDemoAuth.ts (`loggedIn`), app/pages/quizzes/index.vue (anonymous prompt with `registerLink` and `quiz.registerToTake`), app/pages/index.vue (`stops`, TourCard), app/components/TourCard.vue (`icon` union, `ICONS` paths), app/lib/realtime.ts (openChannel subset of bm's file), vitest.config.ts (app/**/*.test.ts), package.json (centrifuge 5.7.3, layers pinned by git+https tag).
|
||||
Layer analogs: ../vue-faq-layer/nuxt.config.ts (currentDir, absolute paths, runtimeConfig), ../vue-paymentgateway-layer/nuxt.config.ts (i18n:registerModule for EN/PL), app/composables/usePaymentGatewayApi.ts (auth hook through useNuxtApp), package.json (main nuxt.config.ts, peerDependencies nuxt 4.4.8, engines node >=22.18).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a logged-in visitor opens /chat, picks #general from the seeded channels and sends a message that appears; an anonymous visitor gets the register prompt</name>
|
||||
<reversibility rating="costly">D-01: a public layer with tagged releases that later sites pin; its runtime-config keys, auth hook and bootstrap contract become a contract.</reversibility>
|
||||
<precondition>The plan 02 SUMMARY exists; `../sm-demo-app/scripts/dev.sh up` brings the stack up and `curl -fsS http://127.0.0.1:8097/_demo/api/v1/roadmap` answers; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes.</precondition>
|
||||
<files>../vue-chat-layer/.gitignore, ../vue-chat-layer/.nvmrc, ../vue-chat-layer/package.json, ../vue-chat-layer/pnpm-lock.yaml, ../vue-chat-layer/pnpm-workspace.yaml, ../vue-chat-layer/tsconfig.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/.playground/nuxt.config.ts, ../vue-chat-layer/.playground/app/app.vue, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/lib/types.ts, ../vue-chat-layer/app/composables/useChatApi.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/components/Chat/Page.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/tests/smoke.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh</files>
|
||||
<read_first>../vue-faq-layer/{nuxt.config.ts,package.json,pnpm-workspace.yaml,tsconfig.json,.gitignore,.nvmrc,.playground/nuxt.config.ts}, ../vue-paymentgateway-layer/nuxt.config.ts and app/composables/usePaymentGatewayApi.ts and app/utils/client.ts, ../sm-bm-app/frontend/src/api/chat.ts and api/types.ts, ../sm-bm-app/frontend/src/stores/chat.ts (bootstrap, open, send), ../sm-bm-app/frontend/src/components/chat/{ChatPanel,MessageList,ChatComposer}.vue, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts,app/pages/quizzes/index.vue,app/composables/useDemoAuth.ts,app/utils/authCookie.ts,i18n/locales/en.json}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, check_remote, ALL_CHECKS), ../sm-demo-app/scripts/check-remote.cjs and scripts/lib/browser.cjs, 20-PATTERNS.md (vue-chat-layer)</read_first>
|
||||
<action>Per D-01, D-04, D-07, SC-2. One path through every layer: app page, layer component, store, client, host bootstrap, chat API, database, back to the list.
|
||||
|
||||
1. Create the repository: `git init -b main` at /media/nvme/dev/golem15/summercms.io/summercms/vue-chat-layer with the vue-faq-layer files (.gitignore, .nvmrc, tsconfig.json, pnpm-workspace.yaml allowing the same builds plus `protobufjs: false`).
|
||||
- package.json: name vue-chat-layer, version 0.1.0, private, type module, main ./nuxt.config.ts, engines node >=22.18, peerDependencies nuxt 4.4.8;
|
||||
- dependencies centrifuge 5.7.3 and @nuxtjs/i18n 10.4.0; devDependencies nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vitest 3.2.7;
|
||||
- scripts test (`vitest run`), dev (`nuxi dev .playground`), build:playground and dev:prepare.
|
||||
Run pnpm install.
|
||||
2. nuxt.config.ts:
|
||||
- the header comment says an app consumes it with `extends: ['vue-chat-layer']`, provides `$chatAuth` and sets `public.chat.hostApiBase`; currentDir with absolute paths;
|
||||
- runtimeConfig.chat.apiBase for SSR (`http://127.0.0.1:8096/_chat/api/v1`, NUXT_CHAT_API_BASE);
|
||||
- public.chat: apiBase `/_chat/api/v1`, channelsApiBase `/_chatchannels/api/v1`, botsApiBase `/_chatbots/api/v1`, hostApiBase empty;
|
||||
- no pages and no route rules: the app owns its routes and wraps the chat components in ClientOnly (the components touch window and WebSocket, RESEARCH Pitfall 9);
|
||||
- the i18n:registerModule block of vue-paymentgateway-layer for en and pl (strings come in Task 3; create empty-object locale files now if the module needs them).
|
||||
3. app/lib/client.ts: `createChatClient({ bases, auth, fetch })`, pure and Nuxt-free so tests import it. It builds URLs, sends Bearer from `auth.token()`, retries once after `auth.refresh()` on 401 and then calls `auth.unauthorized()`, and turns the 422 envelope into a typed `ChatApiError` with field errors. Methods for the tracer: `bootstrap()`, `members()`, `openConversation(type, id)`, `messages(conversationId, opts)` and `send(conversationId, body)`. app/lib/types.ts holds the bootstrap and message types ported from bm's api/types.ts. app/composables/useChatApi.ts binds the client to runtime config and `useNuxtApp().$chatAuth`, following usePaymentGatewayApi.
|
||||
4. app/composables/useChat.ts, minimal for now, over `useState('chat:…')`: `start()` loads the bootstrap (and logs a console error, then stops, when hostApiBase is empty); `channels`; `activeId`; `open(channel)` calls openConversation('channel', channel.id) then messages; `send(text)`.
|
||||
5. Components ChatPage (sidebar plus pane), ChatChannelList (channel names, no create button), ChatMessageList (plain text in this task) and ChatComposer (textarea plus send). No v-html.
|
||||
6. vue-demo-app:
|
||||
- run `pnpm --dir ../sm-demo-app/vue-demo-app add vue-chat-layer@link:../../vue-chat-layer` (development pin, committed with this task's vue-demo-app commit; Task 6 swaps it for the tag before any push);
|
||||
- nuxt.config.ts: append 'vue-chat-layer' to extends; add '/_chat', '/_chatchannels' and '/_chatbots' to goPaths; set `public.chat.hostApiBase: '/_demo/api/v1'` and `chat.apiBase` from `api`; no route rule for /chat (the page is server-rendered with the chat inside ClientOnly, so smoke.sh still sees the banner and the prompt);
|
||||
- app/plugins/auth.ts provides `chatAuth: { token, refresh, unauthorized }`. token reads DEMO_TOKEN_COOKIE. refresh calls the user plugin's refresh endpoint that the login flow uses (read useDemoAuth) and stores the new token. unauthorized clears the cookie;
|
||||
- app/pages/chat.vue: for loggedIn, `<ClientOnly><ChatPage /></ClientOnly>`; otherwise a description and a `NuxtLink class="button"` to `{ path: '/register', query: { next: '/chat' } }` labelled `t('chat.registerToChat')`, as the quizzes page does. useSeoMeta with title and description;
|
||||
- i18n en and pl: chat.title, chat.lead and chat.registerToChat.
|
||||
7. sm-demo-app/scripts/check-chat.cjs (CommonJS, Playwright, modeled on check-remote.cjs):
|
||||
- `--base` (Nuxt origin) and DEMO_JWT from the environment;
|
||||
- sets the DEMO_TOKEN_COOKIE cookie with the JWT, opens {base}/chat, clicks the channel whose name is general and sends a unique text;
|
||||
- waits up to 10 s for the text in the message list, then fails on a console error or a 4xx/5xx response from /_chat;
|
||||
- opens {base}/chat in a fresh context without the cookie and asserts the register link to /register?next=/chat;
|
||||
- prints `check-chat: ok`.
|
||||
check-local.sh gets `check_chat`, which takes visitor_jwt and runs the script as check_remote does, and `chat` in ALL_CHECKS plus the header list.
|
||||
8. tests/smoke.test.ts (vitest): createChatClient builds `/_chat/api/v1/conversations/7/messages`, sends the Bearer header, retries once on 401 after refresh and maps a 422 body to field errors.
|
||||
|
||||
Commit vue-chat-layer (`feat: chat layer tracer: bootstrap, channel list, messages, composer`). Commit vue-demo-app package.json, pnpm-lock.yaml (link pin), nuxt.config.ts, auth.ts, chat.vue and locales by path (`feat: /chat page on vue-chat-layer`). Commit sm-demo-app scripts (`test: check-chat browser check`).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && ../sm-demo-app/scripts/check-local.sh chat</automated>
|
||||
<fails_when>Non-zero exit: the layer smoke test fails; the Nuxt build fails (layer not resolved, type or template error); the stack does not come up; or check-local prints "check-local: chat failed" (the sent text does not appear, /_chat answered 4xx/5xx, or the anonymous page lacks the register link).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-chat-layer remote` prints nothing.
|
||||
- `grep -c "hostApiBase: ''" ../vue-chat-layer/nuxt.config.ts` prints 1.
|
||||
- `grep -rniE 'sm-bm|jz\.bm|_demo/api' ../vue-chat-layer/app ../vue-chat-layer/nuxt.config.ts ../vue-chat-layer/README.md 2>/dev/null | wc -l` prints 0.
|
||||
- `grep -c "'/_chat'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1.
|
||||
- `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `link:../../vue-chat-layer` (the development pin, swapped in Task 6).
|
||||
</acceptance_criteria>
|
||||
<done>A visitor can chat in #general on the local demo site through the new layer, and an anonymous visitor is invited to register.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The bm chat experience on the layer: realtime, DMs, member picker, mentions, unread counts and typing</name>
|
||||
<files>../vue-chat-layer/app/lib/realtime.ts, ../vue-chat-layer/app/lib/chatEvents.ts, ../vue-chat-layer/app/lib/chatFormat.ts, ../vue-chat-layer/app/lib/chatText.ts, ../vue-chat-layer/app/lib/chatUnread.ts, ../vue-chat-layer/app/lib/format.ts, ../vue-chat-layer/app/lib/media.ts, ../vue-chat-layer/app/lib/tabs.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/composables/useChatNotice.ts, ../vue-chat-layer/app/components/Chat/Panel.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/DmList.vue, ../vue-chat-layer/app/components/Chat/MemberPicker.vue, ../vue-chat-layer/app/components/Chat/MentionPopup.vue, ../vue-chat-layer/app/components/Chat/Avatar.vue, ../vue-chat-layer/app/components/Chat/Icon.vue, ../vue-chat-layer/app/components/Chat/Notices.vue, ../vue-chat-layer/app/components/Chat/Page.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts</files>
|
||||
<read_first>../sm-bm-app/frontend/src/stores/chat.ts (whole store), stores/chat.test.ts, lib/realtime.ts, lib/chatEvents.ts, lib/chatFormat.ts, lib/chatText.ts, lib/chatUnread.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue, components/chat/{ChatPanel,MessageList,ChatMessage,ChatComposer,ChatHeader,DmList,MemberPicker,MentionPopup,Avatar}.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts and realtime.test.ts, app/pages/presentations/[slug]/index.vue and pilot.vue (openChannel callers), ../vue-chat-layer/app/composables/useChat.ts (Task 1), 20-RESEARCH.md (Pattern 3, Pitfall 9)</read_first>
|
||||
<action>Per D-01, D-02, SC-2.
|
||||
|
||||
- Port bm's lib/realtime.ts into app/lib/realtime.ts. It keeps the same exports (REALTIME_WS_PATH, tokenFailureIsFinal, wsURL, connect, subscribe, presence, onConnection, onUnavailable, realtimeState, realtimeUnavailable, disconnect, openChannel). The token is fetched from `/api/realtime/token` with the `$chatAuth` token through a setter `setRealtimeAuth(getToken)`, so the module stays Nuxt-free. `centrifuge` is loaded with a dynamic import. Add `onUserEvent(handler): () => void`: the chat store's `user:{me}` subscription hands every event it does not handle to the registered handlers, so a second layer never subscribes to `user:{me}` itself.
|
||||
- Port chatEvents, chatFormat, chatText and chatUnread unchanged except for import paths, and the helpers they need from format, media and tabs. The bm tests come in plan 06. chatText's renderer escapes before it marks mentions; keep it the only raw-HTML binding in the layer.
|
||||
- useChat: port bm's store as a composable over useState. Replace `general` and `generalId` with `channels`, `activeId` and a `kind` ('channel' or 'dm') for the open conversation. Keep:
|
||||
- DMs (`conversations`; `openDm(userId)` through POST /contexts/demo/1/dm, using the context from the bootstrap);
|
||||
- the member list from `/chat/members`;
|
||||
- unread and mention counts, read marks and typing, with bm's polling fallback when realtime is unavailable (15 s open conversation, 60 s counts);
|
||||
- realtime start for a member session, edits and deletions from events.
|
||||
Channel join and leave call chatchannels join and leave and refresh the bootstrap. Errors go to useChatNotice (a small toast state rendered by ChatNotices inside ChatPage and the floating panel), replacing bm's toast store.
|
||||
- Port the components ChatPanel, ChatMessageList, ChatMessage (text through chatText, attachment rendering in Task 3), ChatComposer (mention popup, Enter to send, Shift+Enter newline), ChatHeader (unread and mention badges), ChatDmList, ChatMemberPicker, ChatMentionPopup and ChatAvatar, plus ChatIcon with the icons they use. ChatPage composes sidebar (channels, DMs, a new-DM button opening the member picker) and pane. Strip every bm-specific element (entity embeds, deadline pins, the student app's tabs) or leave a named slot in its place.
|
||||
- vue-demo-app app/lib/realtime.ts becomes a re-export of `vue-chat-layer/app/lib/realtime` (with a comment), and the app plugin calls `setRealtimeAuth` with the DEMO_TOKEN_COOKIE reader. If the existing app/lib/realtime.test.ts fails against the shared client, replace it with bm's current realtime.test.ts, adjusted only in import paths.
|
||||
|
||||
Commit vue-chat-layer (`feat: port the chat store, realtime client and components`) and vue-demo-app (`refactor: share the chat layer's realtime client`).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh chat realtime remote</automated>
|
||||
<fails_when>Non-zero exit: layer smoke or vue-demo-app vitest red (the realtime re-export broke the presentations tests), the build fails, or check-local reports chat, realtime or remote failed (the presentations remote no longer works through the shared client).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'export function onUserEvent' ../vue-chat-layer/app/lib/realtime.ts` prints 1.
|
||||
- `grep -rn "from 'pinia'" ../vue-chat-layer/app | wc -l` prints 0.
|
||||
- `grep -c 'vue-chat-layer/app/lib/realtime' ../sm-demo-app/vue-demo-app/app/lib/realtime.ts` prints 1.
|
||||
- `grep -rn 'v-html' ../vue-chat-layer/app/components | grep -v 'renderChatText\|rendered' | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>The layer behaves like bm's chat across several channels and DMs, live, with one shared realtime connection on every page.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Threads, pins, reactions, attachments, moderator delete, link embeds and the slash command picker</name>
|
||||
<files>../vue-chat-layer/app/components/Chat/ThreadPane.vue, ../vue-chat-layer/app/components/Chat/PinsPanel.vue, ../vue-chat-layer/app/components/Chat/ReactionBar.vue, ../vue-chat-layer/app/components/Chat/StagingChips.vue, ../vue-chat-layer/app/components/Chat/DeleteDialog.vue, ../vue-chat-layer/app/components/Chat/LinkEmbed.vue, ../vue-chat-layer/app/components/Chat/CommandPicker.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/lib/linkEmbeds.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/assets/css/chat-tokens.css, ../vue-chat-layer/app/assets/css/chat-base.css, ../vue-chat-layer/i18n/locales/en.json, ../vue-chat-layer/i18n/locales/pl.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/README.md</files>
|
||||
<read_first>../sm-bm-app/frontend/src/components/chat/{ReactionBar,StagingChips,DeleteDialog,ChatMessage,ChatComposer,EmbedCard}.vue, ../sm-bm-app/frontend/src/lib/embeds.ts (what to drop), ../sm-demo-app/plugins/golem15/chat/README.md (replies, thread mute, reactions catalog, attachments and limits), ../sm-demo-app/plugins/golem15/chatchannels/README.md (pins, mentions), ../sm-demo-app/plugins/golem15/chatbots/README.md and api/commands.go, ../vue-paymentgateway-layer/{README.md,app/assets/css/pg-tokens.css}, ../vue-faq-layer/README.md (layer README structure), 20-RESEARCH.md (Open Question 4, Anti-Patterns)</read_first>
|
||||
<action>Per D-02, D-05, D-22, D-23, D-01.
|
||||
|
||||
- Threads: a reply count on root messages opens ChatThreadPane, which lists replies (GET /messages/{id}/replies), posts replies (POST /messages/{id}/replies) and mutes the thread (PUT /threads/{rootId}/mute). Realtime reply events update the count.
|
||||
- Pins: ChatPinsPanel, opened from the header of a channel, lists GET .../channels/{id}/pins. Pin and unpin show only when the API allows it: a refused pin shows a notice and hides the action for the session.
|
||||
- Reactions: ChatReactionBar ported, with the keys from GET /reactions.
|
||||
- Attachments: ChatStagingChips ported. Uploads go to POST /attachments within the bootstrap limits (max_files, max_bytes), and the message shows a download link (GET /attachments/{messageId}/{fileId} with the auth header, fetched as a blob).
|
||||
- Delete: ChatDeleteDialog ported. Own messages always; others' only when the bootstrap's moderator is true and the conversation is a channel (D-22).
|
||||
- Edits: an Edit action on own messages through PUT /messages/{id}.
|
||||
- Link embeds: app/lib/linkEmbeds.ts with `linkify(escapedHtml)`, `registerEmbedRenderer(match: (url: URL) => boolean, component)` and `embedRendererFor(url)`. ChatMessage renders the first URL of a message with a registered renderer or with ChatLinkEmbed (host and path, no fetch). max_embeds caps the cards.
|
||||
- Commands (D-05): typing "/" at the start of the composer opens ChatCommandPicker, filled from GET /commands (cached per session) and filtered by prefix. Sending a "/" message calls POST /dispatch with {input} instead of posting. An ephemeral answer renders as a local, dismissable, sender-only system row with the payload text (escaped); a 422 shows the error.
|
||||
- Slots: ChatHeader renders `<slot name="conversation-actions" :conversation>` only when the open conversation is a DM (D-23). ChatChannelList renders `<slot name="sidebar-footer" />`. ChatPage forwards both slots.
|
||||
- Styling: chat-tokens.css and chat-base.css (neutral tokens an app overrides, as pg-tokens.css does), registered in nuxt.config.ts css with absolute paths.
|
||||
- i18n: every UI string of the layer in en and pl under `chat.*`. The app's own copy stays in the app.
|
||||
- README.md in the layer structure of vue-faq-layer and vue-paymentgateway-layer:
|
||||
- install by tag; extends;
|
||||
- the `$chatAuth` hook; the runtime-config keys;
|
||||
- the host bootstrap contract (the two JSON shapes with a neutral `acme` host);
|
||||
- slots, `registerEmbedRenderer`, `onUserEvent`;
|
||||
- client-only rendering (wrap ChatPage and ChatFloatingPanel in ClientOnly); testing.
|
||||
Name no consuming application.
|
||||
|
||||
Commit vue-chat-layer (`feat: threads, pins, reactions, attachments, link embeds and the command picker`).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../vue-chat-layer run build:playground && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh chat</automated>
|
||||
<fails_when>Non-zero exit: the layer smoke test, the playground build or the app build fails, or check-local prints "check-local: chat failed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'conversation-actions' ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1, and the slot sits inside a condition on the DM type (`grep -c "type === 'dm'" ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1).
|
||||
- `grep -c 'export function registerEmbedRenderer' ../vue-chat-layer/app/lib/linkEmbeds.ts` prints 1.
|
||||
- `grep -c '/dispatch' ../vue-chat-layer/app/lib/client.ts` prints at least 1.
|
||||
- `node -e "const a=require('../vue-chat-layer/i18n/locales/en.json'),b=require('../vue-chat-layer/i18n/locales/pl.json');const k=o=>Object.entries(o).flatMap(([x,v])=>typeof v==='object'?k(v).map(y=>x+'.'+y):[x]);const d=k(a).filter(x=>!k(b).includes(x));if(d.length){console.error(d);process.exit(1)}"` exits 0 (PL covers every EN key).
|
||||
- In a manual run on the local stack, a "/demo" command in the composer shows the ephemeral answer and nothing is posted to the channel (recorded in the SUMMARY).
|
||||
</acceptance_criteria>
|
||||
<done>The layer has the full D-02 feature set plus the slash command picker and the extension points other layers and hosts plug into.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Chat everywhere on the demo site: floating panel, Chat tour card, EN/PL copy and updated checks</name>
|
||||
<files>../vue-chat-layer/app/components/Chat/FloatingPanel.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
|
||||
<read_first>../sm-demo-app/vue-demo-app/app/layouts/default.vue, app/pages/index.vue (stops, TourCard), app/components/TourCard.vue (icon union, ICONS), app/composables/useDemoAuth.ts, i18n/locales/en.json and pl.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour, card_titles), ../sm-demo-app/scripts/smoke.sh (page loop at line 237), ../sm-bm-app/frontend/src/components/chat/DmList.vue</read_first>
|
||||
<action>Per D-03, D-04.
|
||||
- Layer ChatFloatingPanel: a corner button with the total unread badge (DMs plus channel mentions). It opens a compact panel with ChatDmList and the open DM's message list and composer, and has a link to the full chat page whose path comes from a `chatPath` prop (default '/chat'). It starts the chat store only for a member and renders nothing otherwise.
|
||||
- vue-demo-app default.vue: inside `<ClientOnly>`, render `<ChatFloatingPanel>` when loggedIn and the route is not /chat, /p/** or /presentations/**.
|
||||
- index.vue: a Chat stop `{ key: 'chat', to: '/chat' }` after quizzes. TourCard gets an outline chat icon in ICONS and 'chat' in the icon union. en and pl get tour.chat.title and tour.chat.text, and the tour heading text matches the new count ("Seven things to try" / the Polish equivalent).
|
||||
- chat.vue: final copy (EN/PL) explaining channels, DMs, threads and slash commands, plus the register prompt.
|
||||
- check-local.sh check_tour expects Chat (EN) and Czat (PL) among the cards and seven cards. smoke.sh adds /chat to the page loop (banner present; the anonymous prompt link to /register?next=%2Fchat in the HTML).
|
||||
|
||||
Commit the layer (`feat: floating chat panel`), vue-demo-app (`feat: floating chat panel, Chat tour card`) and sm-demo-app scripts separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh tour chat</automated>
|
||||
<fails_when>Non-zero exit: build or tests fail, or check-local reports tour failed (missing Chat or Czat card, wrong card count) or chat failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'ChatFloatingPanel' ../sm-demo-app/vue-demo-app/app/layouts/default.vue` prints 1 and the element sits inside ClientOnly.
|
||||
- `grep -c "key: 'chat'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 1.
|
||||
- `grep -c '/chat' ../sm-demo-app/scripts/smoke.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Chat is reachable from every page for members and from the tour for everyone, in EN and PL.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead to create vue-chat-layer on git.golem15.com, push it and tag v0.1.0</name>
|
||||
<read_first>Claude's summary before asking: `git -C ../vue-chat-layer log --oneline`, the README's install line, the visibility of vue-faq-layer on git.golem15.com (consumers install layers over git+https), whether `tea login list` shows a git.golem15.com login, and the result of the Task 4 checks.</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY; with "release", `git ls-remote https://git.golem15.com/golem15/vue-chat-layer.git` exits 0 before Task 6 pushes, or Task 6 creates it with tea.
|
||||
</acceptance_criteria>
|
||||
<decision>Create the vue-chat-layer repository, push main and tag v0.1.0 now?</decision>
|
||||
<context>D-01 rates the layer costly: later sites pin it by tag, so v0.1.0 freezes the hook, runtime-config and bootstrap contract shown in the README. A pushed tag must not move. vue-demo-app can only pin the layer by tag after this; until then it builds from a local link that cannot be pushed. Repository creation and pushes need your go-ahead (CLAUDE.md).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Create, push and tag v0.1.0</name>
|
||||
<pros>vue-demo-app pins the tag and plan 04 can build vue-chatvideo-layer against it.</pros>
|
||||
<cons>The v0.1.0 contract is public; changes need v0.1.1 or v0.2.0.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>The layer's contract can still change freely.</pros>
|
||||
<cons>Plan 04 (chatvideo extends the chat contract) and the deploy are blocked; vue-demo-app keeps a local link pin, so it cannot be pushed.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "release" without a tea login: create golem15/vue-chat-layer (empty, default branch main, same visibility as vue-faq-layer) in the git.golem15.com web UI, then reply. Do not paste tokens.</instructions>
|
||||
<resume-signal>Reply "release" (add "repo ready" if you created it) or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Release vue-chat-layer v0.1.0 and pin it in vue-demo-app</name>
|
||||
<reversibility rating="one-way">A pushed layer tag is installed by consumers' lockfiles; it can be superseded but must never move.</reversibility>
|
||||
<precondition>Task 5 resolved with "release".</precondition>
|
||||
<files>../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml</files>
|
||||
<read_first>../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json (other layer pins), the Task 5 answer</read_first>
|
||||
<action>Per D-01.
|
||||
1. If the repository is missing, create it with `tea repo create --name vue-chat-layer --owner golem15` at vue-faq-layer's visibility.
|
||||
2. Add the remote `git@git.golem15.com:golem15/vue-chat-layer.git` and push main.
|
||||
3. If the README's install line says no tag exists yet, fix it before tagging. Create the annotated tag v0.1.0, push it, and confirm `git ls-remote origin refs/tags/v0.1.0^{}` equals HEAD.
|
||||
4. In vue-demo-app run `pnpm add "vue-chat-layer@git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0"` (replacing the link pin), then build and test.
|
||||
5. Commit package.json and pnpm-lock.yaml (`feat: pin vue-chat-layer v0.1.0`). Do not push vue-demo-app (plan 05).</action>
|
||||
<verify>
|
||||
<automated>[ "$(git -C ../vue-chat-layer ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../vue-chat-layer rev-parse HEAD)" ] && grep -q 'vue-chat-layer.git#v0.1.0' ../sm-demo-app/vue-demo-app/package.json && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
|
||||
<fails_when>Non-zero exit: the remote tag is missing or differs from HEAD, package.json still holds the link pin, or the vue-demo-app tests or build fail against the tagged layer.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0`.
|
||||
- `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing.
|
||||
- `../sm-demo-app/scripts/check-local.sh chat tour` prints `check-local: ok chat tour`.
|
||||
</acceptance_criteria>
|
||||
<done>vue-chat-layer v0.1.0 is published and the demo site builds against the tag.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| other members' messages, names and URLs to the browser | untrusted content rendered in every member's page |
|
||||
| browser to the chat-family APIs | JWT bearer from the demo cookie; actor fixed server-side |
|
||||
| browser to Centrifugo | connection token per member; subscriptions authorized server-side |
|
||||
| pnpm install from git.golem15.com | the layer tag becomes an input of every consuming site |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-14 | Tampering | stored XSS through message text, names or URLs | high | mitigate | text rendered through the ported chatText renderer that escapes before marking mentions; linkify runs on escaped HTML and only emits http(s) anchors with rel noopener noreferrer nofollow; names and payloads are text bindings; acceptance grep limits v-html to the renderer output |
|
||||
| T-20-15 | Information disclosure | JWT handling in the layer | medium | mitigate | the token comes only from the app's $chatAuth hook (cookie reader), is sent as a Bearer header to same-origin paths, never stored by the layer, and cleared through unauthorized() after a failed refresh |
|
||||
| T-20-16 | Information disclosure | attachment downloads | medium | mitigate | downloads go through GET /attachments/{messageId}/{fileId} with the Bearer header (participants only server-side); no public URL is built |
|
||||
| T-20-17 | Spoofing | slash command answers | low | mitigate | ephemeral answers are rendered locally as escaped text marked as a system row and never posted as the user |
|
||||
| T-20-18 | Tampering | layer supply chain | medium | mitigate | the layer pins exact versions already audited in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0); vue-demo-app pins the layer by tag with a committed lockfile |
|
||||
| T-20-19 | Denial of service | realtime connections | low | mitigate | one shared Centrifuge client per page (re-export in vue-demo-app); polling fallback intervals of 15 s and 60 s from bm |
|
||||
| T-20-SC | Tampering | npm installs | high | mitigate | no new package: centrifuge 5.7.3 was audited in 19.4.1 (protobufjs postinstall disabled in pnpm-workspace.yaml); @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt, vue and typescript are the pins already in the workspace lockfiles |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: layer smoke, app build, check-local chat (browser send, anonymous prompt).
|
||||
- Task 2: layer and app vitest, build, check-local chat, realtime and remote.
|
||||
- Task 3: layer smoke, playground build, app build, check-local chat, PL key coverage.
|
||||
- Task 4: build, check-local tour and chat, smoke.sh page list.
|
||||
- Task 5: go-ahead recorded.
|
||||
- Task 6: remote tag equals HEAD, vue-demo-app builds against the tag.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The public demo site has `/chat` with channels, DMs, threads, pins, reactions, attachments, mentions, typing, unread counts, link embeds and slash commands working live (SC-2, D-01 to D-05), a floating panel for members and an invitation for visitors. vue-chat-layer v0.1.0 is a reusable, app-neutral layer.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md` when done: the layer's public API (runtime keys, hook, slots, exports), what was stripped from bm and why, the refresh endpoint used by chatAuth, which realtime test file is used in vue-demo-app, the tag and commit, and the checkpoint answer.
|
||||
</output>
|
||||
@@ -0,0 +1,517 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["20-03"]
|
||||
files_modified:
|
||||
- ../vue-forum-layer/.gitignore
|
||||
- ../vue-forum-layer/.nvmrc
|
||||
- ../vue-forum-layer/package.json
|
||||
- ../vue-forum-layer/pnpm-lock.yaml
|
||||
- ../vue-forum-layer/pnpm-workspace.yaml
|
||||
- ../vue-forum-layer/tsconfig.json
|
||||
- ../vue-forum-layer/nuxt.config.ts
|
||||
- ../vue-forum-layer/README.md
|
||||
- ../vue-forum-layer/.playground/nuxt.config.ts
|
||||
- ../vue-forum-layer/.playground/app/app.vue
|
||||
- ../vue-forum-layer/app/utils/forumClient.ts
|
||||
- ../vue-forum-layer/app/composables/useForumApi.ts
|
||||
- ../vue-forum-layer/app/composables/useForum.ts
|
||||
- ../vue-forum-layer/app/components/Forum/Page.vue
|
||||
- ../vue-forum-layer/app/components/Forum/CategoryList.vue
|
||||
- ../vue-forum-layer/app/components/Forum/ThreadList.vue
|
||||
- ../vue-forum-layer/app/components/Forum/Thread.vue
|
||||
- ../vue-forum-layer/app/components/Forum/PostEditor.vue
|
||||
- ../vue-forum-layer/app/assets/css/forum-base.css
|
||||
- ../vue-forum-layer/i18n/locales/en.json
|
||||
- ../vue-forum-layer/i18n/locales/pl.json
|
||||
- ../vue-forum-layer/tests/client.test.ts
|
||||
- ../vue-userfriends-layer/.gitignore
|
||||
- ../vue-userfriends-layer/.nvmrc
|
||||
- ../vue-userfriends-layer/package.json
|
||||
- ../vue-userfriends-layer/pnpm-lock.yaml
|
||||
- ../vue-userfriends-layer/pnpm-workspace.yaml
|
||||
- ../vue-userfriends-layer/tsconfig.json
|
||||
- ../vue-userfriends-layer/nuxt.config.ts
|
||||
- ../vue-userfriends-layer/README.md
|
||||
- ../vue-userfriends-layer/.playground/nuxt.config.ts
|
||||
- ../vue-userfriends-layer/.playground/app/app.vue
|
||||
- ../vue-userfriends-layer/app/utils/friendsClient.ts
|
||||
- ../vue-userfriends-layer/app/composables/useFriendsApi.ts
|
||||
- ../vue-userfriends-layer/app/composables/useFriends.ts
|
||||
- ../vue-userfriends-layer/app/components/Friends/Page.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/List.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/Requests.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/Groups.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/AddFriend.vue
|
||||
- ../vue-userfriends-layer/app/assets/css/friends-base.css
|
||||
- ../vue-userfriends-layer/i18n/locales/en.json
|
||||
- ../vue-userfriends-layer/i18n/locales/pl.json
|
||||
- ../vue-userfriends-layer/tests/client.test.ts
|
||||
- ../vue-chatvideo-layer/.gitignore
|
||||
- ../vue-chatvideo-layer/.nvmrc
|
||||
- ../vue-chatvideo-layer/package.json
|
||||
- ../vue-chatvideo-layer/pnpm-lock.yaml
|
||||
- ../vue-chatvideo-layer/pnpm-workspace.yaml
|
||||
- ../vue-chatvideo-layer/tsconfig.json
|
||||
- ../vue-chatvideo-layer/nuxt.config.ts
|
||||
- ../vue-chatvideo-layer/README.md
|
||||
- ../vue-chatvideo-layer/.playground/nuxt.config.ts
|
||||
- ../vue-chatvideo-layer/.playground/app/app.vue
|
||||
- ../vue-chatvideo-layer/app/lib/videoClient.ts
|
||||
- ../vue-chatvideo-layer/app/lib/callMachine.ts
|
||||
- ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
|
||||
- ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css
|
||||
- ../vue-chatvideo-layer/i18n/locales/en.json
|
||||
- ../vue-chatvideo-layer/i18n/locales/pl.json
|
||||
- ../vue-chatvideo-layer/tests/callMachine.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/package.json
|
||||
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
|
||||
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/friends.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/smoke.sh
|
||||
- ../sm-demo-app/scripts/dev.sh
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/README.md
|
||||
- .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, D-11, D-12, D-13, D-14, D-15, D-16, D-20, D-21, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 380000
|
||||
raw_tokens: 380000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-14, D-21: /forum shows a member the seeded EN and PL categories and their threads; a thread page lists posts as the server's content_html; a member creates a thread, replies, edits own posts and deletes own posts (moderators any post, D-22); an anonymous visitor on /forum and on a thread URL sees a description and a register-to-join link (/register?next=...), and no forum API call is made without a JWT."
|
||||
- "D-15: /friends shows a member their friends, incoming and sent requests (accept, reject, cancel, unfriend) and friend groups (list, create, join, leave, members); a member sends a request to another member chosen from the app-provided directory; /friends is linked from the chat sidebar (sidebar-footer slot) and from the account area (header link and /account/security)."
|
||||
- "D-11, D-23: the call button (ChatvideoCallButton) appears only in DM conversations through vue-chat-layer's conversation-actions slot; there is no call button in channels; the server is not changed to refuse channel calls."
|
||||
- "D-13: /call/{conversation} is a minimal call page: own camera, remote tiles, mute, camera toggle and leave, built on livekit-client loaded with a dynamic import; an incoming video:call:incoming event (received through vue-chat-layer's onUserEvent, no second user:{me} subscription) shows an accept/decline prompt on every page for members."
|
||||
- "D-12: the call page shows the D-12 caps' effects (a started call ends at the server's max_duration; a fifth participant is refused by LiveKit's room cap) without client-side limits of its own."
|
||||
- "D-16: herald runs with VAPID keys from .env (SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, __PRIVATE_KEY, __SUBJECT; dev.sh generates them with herald:vapid-generate, never printing them) and there is no subscribe UI; /_herald is not added to vue-demo-app goPaths."
|
||||
- "The homepage tour has Forum, Friends and Calls cards next to Chat (ten cards, EN and PL)."
|
||||
- "livekit-client is installed only after the blocking human-verify checkpoint, at an exact version the user confirmed."
|
||||
- "After the Task 6 go-ahead, vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer exist on git.golem15.com with tag v0.1.0 each, and vue-demo-app pins all three by git+https tag in a committed package.json and pnpm-lock.yaml."
|
||||
artifacts:
|
||||
- path: "../vue-forum-layer/app/components/Forum/Page.vue"
|
||||
provides: "categories and thread list of one forum context"
|
||||
contains: "contextType"
|
||||
- path: "../vue-userfriends-layer/app/components/Friends/Page.vue"
|
||||
provides: "friends, requests and groups"
|
||||
contains: "Requests"
|
||||
- path: "../vue-chatvideo-layer/app/lib/callMachine.ts"
|
||||
provides: "pure call state machine (idle, starting, connecting, connected, ended, failed)"
|
||||
contains: "connected"
|
||||
- path: "../vue-chatvideo-layer/app/composables/useCall.ts"
|
||||
provides: "LiveKit room lifecycle over the chatvideo API, dynamic import of livekit-client"
|
||||
contains: "import('livekit-client')"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue"
|
||||
provides: "/call/{conversation} page"
|
||||
contains: "ChatvideoCall"
|
||||
- path: ".planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md"
|
||||
provides: "API coverage decisions for livekit-client, LiveKit server, Centrifugo and Web Push"
|
||||
contains: "livekit-client"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/vue-demo-app/app/pages/chat.vue"
|
||||
to: "../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue"
|
||||
via: "template #conversation-actions renders ChatvideoCallButton (DM only, D-23)"
|
||||
pattern: "conversation-actions"
|
||||
- from: "../vue-chatvideo-layer/app/composables/useIncomingCalls.ts"
|
||||
to: "../vue-chat-layer/app/lib/realtime.ts"
|
||||
via: "onUserEvent receives video:call:incoming from the chat store's user:{me} subscription"
|
||||
pattern: "onUserEvent"
|
||||
- from: "../vue-chatvideo-layer/app/composables/useCall.ts"
|
||||
to: "../sm-demo-app/plugins/golem15/chatvideo/api/video.go"
|
||||
via: "POST /_chatvideo/api/v1/conversations/{id}/video/start or /join gives {livekit_url, token}"
|
||||
pattern: "video/(start|join)"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
|
||||
to: "../vue-forum-layer/app/composables/useForumApi.ts"
|
||||
via: "provide forumAuth and userfriendsAuth token getters (chatvideo reuses chatAuth)"
|
||||
pattern: "forumAuth"
|
||||
prohibitions:
|
||||
- statement: "None of the three layers names a consuming application or carries a default host path."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "Forum post HTML is rendered only from the server's content_html; user Markdown is never rendered client-side."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "livekit-client is not added to any package.json before the Task 3 checkpoint is approved."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No push, repository creation or tag before the Task 6 go-ahead; vue-demo-app and sm-demo-app are not pushed (plan 05)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working through shared layers, video calls included.
|
||||
|
||||
This plan's slice (plan 4 of 6, wave 4, per D-20): three new shared layers at v0.1.0 (`vue-forum-layer`, `vue-userfriends-layer`, `vue-chatvideo-layer`), their pages and tour cards in vue-demo-app, and herald's VAPID configuration without UI.
|
||||
|
||||
**Wave note (deviation from D-20, recorded):** D-20 lets plans 03 and 04 run in parallel after plan 02. This plan still depends on plan 03 and runs after it, for two reasons:
|
||||
1. It consumes plan 03's vue-chat-layer outputs: vue-chatvideo-layer imports the shared realtime client (`onUserEvent`, `subscribe`) and renders its call button in vue-chat-layer's DM-only `conversation-actions` slot (D-23), and the friends link goes into its `sidebar-footer` slot.
|
||||
2. Both plans edit the same vue-demo-app files (package.json, pnpm-lock.yaml, nuxt.config.ts, app/plugins/auth.ts, app/pages/index.vue, the locales) in one checkout. Parallel execution runs without worktrees, so the two plans would collide in the same wave.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Layer shape:** like vue-faq-layer and vue-paymentgateway-layer. A Nuxt-free client in `app/utils/*Client.ts` (tested with `node --test`), an auth-hook composable, components with a directory prefix (`Forum*`, `Friends*`, `Chatvideo*`), EN/PL strings through `i18n:registerModule`, and no pages or route rules: the app owns routes and wraps client-side parts in ClientOnly.
|
||||
- **Auth hooks:** `$forumAuth` and `$userfriendsAuth` are token getters, the vue-paymentgateway-layer shape. vue-chatvideo-layer reuses vue-chat-layer's `$chatAuth`, because it has a peer dependency on that layer.
|
||||
- **Forum context:** props `contextType` and `contextId`; vue-demo-app passes `demo` and `1`. Thread URLs are built by an app-supplied `threadPath(id)` prop. Markdown is written in a textarea with the server preview endpoint (`POST /contexts/{type}/{id}/preview`); nothing is rendered client-side.
|
||||
- **Friend directory:** the layer takes an optional `$userfriendsDirectory(): Promise<{id, name}[]>` hook for the add-friend picker and falls back to a username field (the API accepts user_id, username or email). vue-demo-app provides the directory from `GET /_demo/api/v1/chat/members`.
|
||||
- **chatvideo and chat:** vue-chatvideo-layer lists vue-chat-layer v0.1.0 as a peer dependency and dev dependency, without `extends`. The app extends both, so the chat layer is not loaded twice. It imports `onUserEvent` and `subscribe` from `vue-chat-layer/app/lib/realtime`.
|
||||
- **Call flow:** the call button starts (`video/start`) and navigates to `/call/{conversation}`. The call page joins (`video/join`) when it was not the starter and connects with the returned `livekit_url` and `token`. Leave calls `video/leave` then `room.disconnect()`. An incoming call prompt offers Accept (navigate) and Decline (`video/leave`; the service publishes video:call:declined for a participant who never joined; Task 4 confirms this in video_service.go). Screen share stays off (D-13 minimal, `screen_share: false`).
|
||||
- **Profile link (D-15):** the demo has no profile page. The account area stands for it: a "Friends" header link for members next to "Security", and a link on /account/security.
|
||||
- **Tour:** ten cards (Journal, Presentations, Quizzes, Chat, Forum, Friends, Calls, Payments, FAQ, Accounts). The Calls card links to /chat with text saying calls start from a DM.
|
||||
- **Development pins:** `link:` dependencies to the three local checkouts until Task 7 swaps them for tags, as in plan 03.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `vue-forum-layer` v0.1.0:
|
||||
- components `ForumPage`, `ForumCategoryList`, `ForumThreadList`, `ForumThread`, `ForumPostEditor`;
|
||||
- composables `useForum`, `useForumApi`; client `createForumClient`;
|
||||
- runtime keys `forum.apiBase` (`NUXT_FORUM_API_BASE`) and `public.forum.apiBase`; hook `$forumAuth`; i18n `forum.*`.
|
||||
- Repository `vue-userfriends-layer` v0.1.0:
|
||||
- components `FriendsPage`, `FriendsList`, `FriendsRequests`, `FriendsGroups`, `FriendsAddFriend`;
|
||||
- composables `useFriends`, `useFriendsApi`; client `createFriendsClient`;
|
||||
- runtime keys `userfriends.apiBase` and `public.userfriends.apiBase`; hooks `$userfriendsAuth` and `$userfriendsDirectory`; i18n `friends.*`.
|
||||
- Repository `vue-chatvideo-layer` v0.1.0:
|
||||
- components `ChatvideoCall`, `ChatvideoTile`, `ChatvideoCallButton`, `ChatvideoIncomingCall`;
|
||||
- composables `useCall`, `useIncomingCalls`; lib `createVideoClient`, `callMachine` (`initialCallState`, `transition`);
|
||||
- runtime key `public.chatvideo.apiBase`; prop `callPath`; i18n `chatvideo.*`; dependency `livekit-client` at the confirmed exact version.
|
||||
- vue-demo-app:
|
||||
- pages `/forum`, `/forum/t/[id]`, `/friends`, `/call/[conversation]`;
|
||||
- ChatvideoIncomingCall in the default layout; the call button in /chat's conversation-actions slot; the friends link in /chat's sidebar-footer slot, the header and /account/security;
|
||||
- tour cards forum, friends and calls; goPaths `/_forum`, `/_userfriends`, `/_chatvideo`; hooks `forumAuth`, `userfriendsAuth`, `userfriendsDirectory`.
|
||||
- sm-demo-app: check-local checks `forum`, `friends`, `call`, `herald`; tour expects ten cards; smoke.sh pages /forum and /friends; dev.sh VAPID generation; env keys `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in env.example).
|
||||
- `.planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` (API coverage matrix).
|
||||
|
||||
<objective>
|
||||
Build and release the forum, friends and video-call layers, put their pages and tour cards on the demo site, and configure herald's VAPID keys without UI.
|
||||
|
||||
Purpose: SC-2 (video calls and the other plugins visible through shared layers), D-11, D-12, D-13, D-14, D-15, D-16, D-21, D-22 (forum moderation), D-23.
|
||||
Output: three new repositories (pushed and tagged after the go-ahead), commits in vue-demo-app and sm-demo-app (not pushed), and the phase COVERAGE.md.
|
||||
</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-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md
|
||||
|
||||
Repos: new ../vue-forum-layer, ../vue-userfriends-layer and ../vue-chatvideo-layer; ../sm-demo-app/vue-demo-app (submodule); ../sm-demo-app. Layer analogs: ../vue-faq-layer (structure, node --test, client in app/utils), ../vue-paymentgateway-layer (auth hook, i18n registration), ../vue-chat-layer (plan 03: slots, onUserEvent, $chatAuth).
|
||||
|
||||
Rules: as in plan 03 (no co-author tags, stage by path, no remote before the go-ahead, vue-demo-app committed in its own repository and not pushed, check for other sessions' edits before each commit). The local stack: `../sm-demo-app/scripts/dev.sh up` with LiveKit on 127.0.0.1:7890.
|
||||
|
||||
Size (accepted under D-20): seven tasks over three new layer repositories are above the usual per-plan budget, but the user confirmed exactly six plans (D-20, the lean-planning rule in CLAUDE.md), so this plan is not split. After Task 4 is committed, write a progress note into a draft `20-04-SUMMARY.md` (Tasks 1 to 4 with their commits, the approved livekit-client version, the decline endpoint, open points) so a context reset resumes from Task 5.
|
||||
|
||||
<interfaces>
|
||||
forum API /_forum/api/v1 (JWT, forum-api 120/min): GET /contexts/{type}/{id}/categories; GET /contexts/{type}/{id}/categories/{categoryId}/threads; POST same (create thread {title, body}); GET /contexts/{type}/{id}/threads; GET /contexts/{type}/{id}/threads/{threadId}/posts; POST same (reply {body}); PUT /contexts/{type}/{id}/posts/{postId} ({body}); DELETE /contexts/{type}/{id}/posts/{postId}; DELETE /contexts/{type}/{id}/threads/{threadId}; POST /contexts/{type}/{id}/threads/{threadId}/read; GET /contexts/{type}/{id}/unread; POST /contexts/{type}/{id}/preview. Posts carry content_html (server-rendered with breakwater). 422 envelope as chat.
|
||||
userfriends API /_userfriends/api/v1 (JWT; userfriends-api 120/min, community-join 10/min): GET /friends, /friends/requests, /friends/sent; POST /friends/request ({user_id} or {username} or {email}); POST /friends/requests/{id}/accept; DELETE /friends/requests/{id}; DELETE /friends/sent/{id}; DELETE /friends/{userId}; GET|POST /groups; GET|PUT|DELETE /groups/{id}; POST /groups/{id}/join, /leave; GET|POST /groups/{id}/members; DELETE /groups/{id}/members/{userId}; GET|POST|DELETE /groups/{id}/invite; GET|POST /groups/join/{code}.
|
||||
chatvideo API /_chatvideo/api/v1 (JWT): POST /conversations/{id}/video/start (video-initiate 5/min) and /join give {livekit_url, token, ...}; POST /leave, /end; GET /status. Realtime: video:call:incoming on user:{id}; video:call:accepted, declined, ended and participant events on video:session:{id}.
|
||||
vue-chat-layer v0.1.0 (plan 03): slots conversation-actions (DM only) and sidebar-footer on ChatPage; `onUserEvent(handler)`, `subscribe(channel, hooks)` in `vue-chat-layer/app/lib/realtime`; `$chatAuth {token, refresh, unauthorized}`.
|
||||
herald: keys golem15.herald.vapid.{subject, public_key, private_key}; env SUMMER_GOLEM15__HERALD__VAPID__*; command herald:vapid-generate (--json prints {"public_key", "private_key"}; -q quiet).
|
||||
vue-demo-app (after plan 03): app/pages/chat.vue (ChatPage in ClientOnly), app/layouts/default.vue (ChatFloatingPanel), app/pages/index.vue (stops with chat), app/components/TourCard.vue, app/components/SiteHeader.vue (links mine and security), app/pages/account/security.vue, app/plugins/auth.ts (paymentGatewayAuth, chatAuth).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a member opens /forum, sees the seeded categories, opens a thread and posts a reply that the server renders; an anonymous visitor gets the register prompt</name>
|
||||
<precondition>The plan 03 SUMMARY records vue-chat-layer v0.1.0 on git.golem15.com; `../sm-demo-app/scripts/dev.sh up` is running; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes.</precondition>
|
||||
<files>../vue-forum-layer/.gitignore, ../vue-forum-layer/.nvmrc, ../vue-forum-layer/package.json, ../vue-forum-layer/pnpm-lock.yaml, ../vue-forum-layer/pnpm-workspace.yaml, ../vue-forum-layer/tsconfig.json, ../vue-forum-layer/nuxt.config.ts, ../vue-forum-layer/README.md, ../vue-forum-layer/.playground/nuxt.config.ts, ../vue-forum-layer/.playground/app/app.vue, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-forum-layer/app/composables/useForumApi.ts, ../vue-forum-layer/app/composables/useForum.ts, ../vue-forum-layer/app/components/Forum/Page.vue, ../vue-forum-layer/app/components/Forum/CategoryList.vue, ../vue-forum-layer/app/components/Forum/ThreadList.vue, ../vue-forum-layer/app/components/Forum/Thread.vue, ../vue-forum-layer/app/components/Forum/PostEditor.vue, ../vue-forum-layer/app/assets/css/forum-base.css, ../vue-forum-layer/i18n/locales/en.json, ../vue-forum-layer/i18n/locales/pl.json, ../vue-forum-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue, ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
|
||||
<read_first>../vue-faq-layer/{nuxt.config.ts,package.json,README.md,app/utils/client.ts,app/composables/useFaqApi.ts,tests/client.test.ts}, ../vue-paymentgateway-layer/{nuxt.config.ts,app/composables/usePaymentGatewayApi.ts}, ../sm-demo-app/plugins/golem15/forum/README.md and api/*.go (payloads), ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,app/plugins/auth.ts,app/pages/chat.vue,app/pages/quizzes/index.vue}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, json_has, ALL_CHECKS) and smoke.sh (page loop)</read_first>
|
||||
<action>Per D-14, D-21, D-22, SC-2.
|
||||
|
||||
1. Create ../vue-forum-layer (`git init -b main`) with the vue-faq-layer skeleton:
|
||||
- package.json: name vue-forum-layer, version 0.1.0, main nuxt.config.ts, peer nuxt 4.4.8, dependency @nuxtjs/i18n 10.4.0, dev deps nuxt, vue and typescript at the workspace pins, test `node --test --test-reporter=tap tests/*.test.ts`;
|
||||
- nuxt.config.ts: header comment, currentDir, runtimeConfig `forum.apiBase` (NUXT_FORUM_API_BASE) and `public.forum.apiBase: '/_forum/api/v1'`, the EN/PL i18n registration, absolute css path.
|
||||
2. app/utils/forumClient.ts `createForumClient({ base, token, fetch })`: categories, threads (per category and all), posts, createThread, reply, editPost, deletePost, deleteThread, markRead, unread and preview, each building `/contexts/{type}/{id}/...` paths, plus the 422 field-error mapping. app/composables/useForumApi.ts binds it to runtime config and `$forumAuth`. useForum keeps the selected category, the threads, and the open thread and posts.
|
||||
3. Components:
|
||||
- ForumPage (props contextType, contextId, threadPath(id)): ForumCategoryList plus ForumThreadList with a "new thread" ForumPostEditor;
|
||||
- ForumThread (props contextType, contextId, threadId): each post renders the server's content_html as raw HTML (the only raw-HTML binding in the layer), with author and date, reply editor, Edit and Delete on own posts and on all posts when the API reports manage rights (a refused delete hides the action);
|
||||
- ForumPostEditor: textarea plus a server preview button.
|
||||
Mark the thread read on open.
|
||||
4. vue-demo-app:
|
||||
- run `pnpm add vue-forum-layer@link:../../vue-forum-layer` (development pin, committed; Task 7 swaps it for the tag before any push);
|
||||
- nuxt.config.ts: extends gains 'vue-forum-layer'; goPaths gains '/_forum'; `forum.apiBase` comes from `api`;
|
||||
- auth.ts provides `forumAuth`;
|
||||
- app/pages/forum/index.vue and forum/t/[id].vue: for members, ClientOnly ForumPage and ForumThread with context demo/1 and `threadPath: id => '/forum/t/' + id`; for anonymous visitors, a server-rendered description and a register link with next set to the current path (D-21, the chat page pattern). useSeoMeta on both;
|
||||
- EN/PL copy: forum.title, forum.lead and forum.registerToJoin.
|
||||
5. check-local.sh `check_forum`:
|
||||
- with the visitor JWT, GET /_forum/api/v1/contexts/demo/1/categories answers 200 with at least three categories;
|
||||
- POST a reply to the first thread answers 201 and its content_html holds a rendered paragraph;
|
||||
- GET $NUXT/forum without a cookie holds the register link with next=%2Fforum.
|
||||
Add it to ALL_CHECKS. smoke.sh adds /forum to the page loop.
|
||||
6. tests/client.test.ts: URL building for every method, the Bearer header, and the 422 mapping.
|
||||
|
||||
Commit the layer (`feat: forum layer: categories, threads and Markdown posts`), vue-demo-app (package.json and pnpm-lock.yaml with the link pin, pages, config, auth and locales) and sm-demo-app scripts separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-forum-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && ../sm-demo-app/scripts/check-local.sh forum</automated>
|
||||
<fails_when>Non-zero exit: the forum client tests fail, the Nuxt build fails, the stack does not start, or check-local prints "check-local: forum failed" (categories missing, the reply not rendered, or no register link for anonymous visitors).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -rniE 'golem15\.demo|sm-demo|demo/1' ../vue-forum-layer/app ../vue-forum-layer/nuxt.config.ts ../vue-forum-layer/README.md | wc -l` prints 0.
|
||||
- `grep -rn 'v-html' ../vue-forum-layer/app/components | grep -vc 'content_html'` prints 0.
|
||||
- `grep -c "'/_forum'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>Members read and post in the seeded forum on the demo site through a new shared layer; anonymous visitors are invited to register.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Friends: list, requests and groups on /friends, linked from the chat sidebar and the account area</name>
|
||||
<files>../vue-userfriends-layer/.gitignore, ../vue-userfriends-layer/.nvmrc, ../vue-userfriends-layer/package.json, ../vue-userfriends-layer/pnpm-lock.yaml, ../vue-userfriends-layer/pnpm-workspace.yaml, ../vue-userfriends-layer/tsconfig.json, ../vue-userfriends-layer/nuxt.config.ts, ../vue-userfriends-layer/README.md, ../vue-userfriends-layer/.playground/nuxt.config.ts, ../vue-userfriends-layer/.playground/app/app.vue, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../vue-userfriends-layer/app/composables/useFriendsApi.ts, ../vue-userfriends-layer/app/composables/useFriends.ts, ../vue-userfriends-layer/app/components/Friends/Page.vue, ../vue-userfriends-layer/app/components/Friends/List.vue, ../vue-userfriends-layer/app/components/Friends/Requests.vue, ../vue-userfriends-layer/app/components/Friends/Groups.vue, ../vue-userfriends-layer/app/components/Friends/AddFriend.vue, ../vue-userfriends-layer/app/assets/css/friends-base.css, ../vue-userfriends-layer/i18n/locales/en.json, ../vue-userfriends-layer/i18n/locales/pl.json, ../vue-userfriends-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/friends.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/pages/account/security.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/userfriends/README.md and api/friends.go and api/groups.go (bodies, 422 behaviour), ../vue-forum-layer (Task 1, same skeleton), ../vue-chat-layer/README.md (sidebar-footer slot), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/components/SiteHeader.vue,app/pages/account/security.vue,app/plugins/auth.ts}, ../sm-demo-app/plugins/demo/chat_api.go (members endpoint)</read_first>
|
||||
<action>Per D-15.
|
||||
- Create ../vue-userfriends-layer with the Task 1 skeleton (runtime `userfriends.apiBase`, `public.userfriends.apiBase: '/_userfriends/api/v1'`).
|
||||
- app/utils/friendsClient.ts `createFriendsClient`: friends, incoming, sent, request({user_id} or {username}), accept, reject, cancel, unfriend, groups, group, createGroup, updateGroup, deleteGroup, join, leave, members, kick, invite, joinByCode. useFriendsApi binds `$userfriendsAuth`.
|
||||
- Components:
|
||||
- FriendsList: friends with Unfriend;
|
||||
- FriendsRequests: incoming with Accept and Reject, sent with Cancel;
|
||||
- FriendsAddFriend: picks from `$userfriendsDirectory()` when provided, else a username field; the generic 422 shows the server message;
|
||||
- FriendsGroups: my groups, create (name, visibility as the API allows), join by code, leave, member list;
|
||||
- FriendsPage: all four in sections.
|
||||
- vue-demo-app:
|
||||
- pnpm link dependency; extends 'vue-userfriends-layer'; goPaths '/_userfriends'; runtime config;
|
||||
- auth.ts provides `userfriendsAuth` and `userfriendsDirectory` (GET /_demo/api/v1/chat/members with the JWT, mapped to {id, name: name plus surname});
|
||||
- app/pages/friends.vue: ClientOnly FriendsPage for members, the register prompt for anonymous visitors;
|
||||
- chat.vue passes `<template #sidebar-footer>` with a NuxtLink to /friends;
|
||||
- SiteHeader shows "Friends" for members next to Security, and account/security.vue links to /friends;
|
||||
- EN/PL copy.
|
||||
- check-local.sh `check_friends`, repeatable without touching seed data:
|
||||
- with the visitor JWT, GET /_userfriends/api/v1/friends/requests lists the seeded pending request (read-only);
|
||||
- two fresh users from the throwaway registration helper (extend it so the caller also gets the e-mail it registered): A sends POST /friends/request with {email: B's e-mail} and gets 2xx, B finds the request in GET /friends/requests and accepts it with 2xx, and A's GET /friends lists B.
|
||||
Add it to ALL_CHECKS. smoke.sh adds /friends.
|
||||
- tests/client.test.ts: URL building, Bearer header, 422 mapping.
|
||||
|
||||
Commit the layer, vue-demo-app (with the link pin) and the scripts separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-userfriends-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh friends forum chat</automated>
|
||||
<fails_when>Non-zero exit: the friends client tests or the build fail, or check-local reports friends, forum or chat failed (the seeded request missing, accept refused, or the chat page broken by the slot).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'sidebar-footer' ../sm-demo-app/vue-demo-app/app/pages/chat.vue` prints 1.
|
||||
- `grep -c "to=\"/friends\"" ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue` prints 1.
|
||||
- `grep -rniE 'golem15\.demo|sm-demo|_demo/api' ../vue-userfriends-layer/app ../vue-userfriends-layer/nuxt.config.ts ../vue-userfriends-layer/README.md | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Members manage friends, requests and friend groups on /friends, reachable from the chat sidebar and the account area.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 3: Package legitimacy check before installing livekit-client</name>
|
||||
<read_first>20-RESEARCH.md Package Legitimacy Audit (livekit-client flagged [SUS] too-new for its latest release; assumption A7), the output Claude prints before asking: `npm view livekit-client versions --json | tail -15`, `npm view livekit-client time --json` for the last five versions, `npm view livekit-client repository.url maintainers dist-tags scripts --json`, and the proposed exact version (the newest release at least 7 days old)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user approved a named exact version (recorded in the SUMMARY) or rejected the package; Task 4 installs only that version with `--save-exact`.
|
||||
</acceptance_criteria>
|
||||
<what-built>Nothing is installed yet. Claude proposes an exact livekit-client version for vue-chatvideo-layer, the client SDK the minimal call page (D-13) is built on.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://www.npmjs.com/package/livekit-client and check that the publisher and maintainers are the LiveKit organization and the repository is github.com/livekit/client-sdk-js.
|
||||
2. Check that the proposed version exists, is at least 7 days old and that `scripts` shows no install or postinstall hook.
|
||||
3. Check the client SDK's server compatibility notes for LiveKit server 1.13.1 (the rome and local binary).
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved <version>" or "rejected" with the reason.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Video calls from DMs: the call button, the minimal call page and incoming-call prompts</name>
|
||||
<precondition>Task 3 answered "approved <version>"; LiveKit answers on http://127.0.0.1:7890/ (dev stack).</precondition>
|
||||
<files>../vue-chatvideo-layer/.gitignore, ../vue-chatvideo-layer/.nvmrc, ../vue-chatvideo-layer/package.json, ../vue-chatvideo-layer/pnpm-lock.yaml, ../vue-chatvideo-layer/pnpm-workspace.yaml, ../vue-chatvideo-layer/tsconfig.json, ../vue-chatvideo-layer/nuxt.config.ts, ../vue-chatvideo-layer/README.md, ../vue-chatvideo-layer/.playground/nuxt.config.ts, ../vue-chatvideo-layer/.playground/app/app.vue, ../vue-chatvideo-layer/app/lib/videoClient.ts, ../vue-chatvideo-layer/app/lib/callMachine.ts, ../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts, ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue, ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue, ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue, ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue, ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css, ../vue-chatvideo-layer/i18n/locales/en.json, ../vue-chatvideo-layer/i18n/locales/pl.json, ../vue-chatvideo-layer/tests/callMachine.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/chatvideo/README.md, api/video.go, classes/video_service.go (Start, Join, Leave, End, declined path), classes/notifier.go (event names), ../vue-chat-layer/app/lib/realtime.ts (onUserEvent, subscribe) and README.md (slots), the livekit-client README and API docs for the approved version (Room, RoomEvent, Track, connect, localParticipant.setCameraEnabled and setMicrophoneEnabled, track attach and detach; through Context7 or ctx7 when available), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/layouts/default.vue}, .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/COVERAGE.md (matrix format), 20-RESEARCH.md (Pitfalls 8 and 9, assumption A1)</read_first>
|
||||
<action>Per D-11, D-12, D-13, D-23.
|
||||
|
||||
1. Create ../vue-chatvideo-layer with the skeleton:
|
||||
- dependencies `livekit-client@<approved>` installed with `--save-exact`, and @nuxtjs/i18n 10.4.0;
|
||||
- peerDependencies nuxt 4.4.8 and `vue-chat-layer: "^0.1.0"`; devDependency vue-chat-layer at the git+https v0.1.0 tag for the playground;
|
||||
- test script `vitest run` with vitest 3.2.7;
|
||||
- runtime `public.chatvideo.apiBase: '/_chatvideo/api/v1'`;
|
||||
- no `extends` of vue-chat-layer (the app extends both).
|
||||
2. app/lib/callMachine.ts (pure): states idle, starting, connecting, connected, ended and failed, and `transition(state, event)` for the events start, joined(token, url), connected, participantJoined, participantLeft, mute, unmute, cameraOff, cameraOn, leave, ended(reason) and error(code). Mute and camera flags live in the state. Token errors (401, 403, 404, 422 too many participants) map to failed with a reason key.
|
||||
3. app/lib/videoClient.ts: start, join, leave, end and status over `/conversations/{id}/video/...` with the `$chatAuth` token getter.
|
||||
4. app/composables/useCall.ts:
|
||||
- drives the machine; on start or join, `await import('livekit-client')`, creates a Room (adaptiveStream and dynacast on), connects with livekit_url and token, and enables camera and microphone;
|
||||
- maps RoomEvent participant and track events to tiles and attaches and detaches tracks to video elements;
|
||||
- offers mute and camera toggles through the local participant, and leave (disconnect, then POST leave);
|
||||
- subscribes to the session channel through vue-chat-layer's `subscribe` for ended and declined events, with the channel name always built by an exported helper `videoSessionChannel(id)` in app/lib/videoClient.ts, and disconnects on route leave and pagehide.
|
||||
useIncomingCalls registers `onUserEvent` for `video:call:incoming` (never its own user:{me} subscription) and exposes the pending call.
|
||||
5. Components:
|
||||
- ChatvideoCall (prop conversationId, start boolean): own camera tile, remote tiles (ChatvideoTile), mute, camera and leave buttons, the state text and failure messages from i18n;
|
||||
- ChatvideoCallButton (props conversationId and callPath(id)): starts and navigates;
|
||||
- ChatvideoIncomingCall: the caller name from the event payload, Accept (navigate to callPath) and Decline (POST leave; confirm in video_service.go that this publishes declined for a participant who never joined, else use the endpoint that does).
|
||||
No screen share (D-13).
|
||||
6. vue-demo-app:
|
||||
- pnpm link dependency; extends gains 'vue-chatvideo-layer' after 'vue-chat-layer'; goPaths '/_chatvideo';
|
||||
- app/pages/call/[conversation].vue: ClientOnly ChatvideoCall for members (start when the query has start=1), the register prompt otherwise;
|
||||
- chat.vue `<template #conversation-actions="{ conversation }">` renders ChatvideoCallButton with callPath `id => '/call/' + id + '?start=1'` (the slot only renders in DMs, D-23);
|
||||
- default.vue renders ClientOnly ChatvideoIncomingCall for members;
|
||||
- EN/PL copy (call.title, call.registerToCall).
|
||||
7. check-local.sh `check_call`:
|
||||
- with the visitor JWT, POST /_chat/api/v1/contexts/demo/1/dm to a persona gives the DM id;
|
||||
- POST /_chatvideo/api/v1/conversations/{dm}/video/start answers 2xx with livekit_url and token;
|
||||
- GET http://127.0.0.1:7890/rtc/validate?access_token=<token> answers 200 (LiveKit accepts the token, proving the keys and the room);
|
||||
- POST .../video/end answers 2xx.
|
||||
Add `call` to ALL_CHECKS.
|
||||
8. tests/callMachine.test.ts (smoke): start, joined, connected, mute, leave and ended; a 422 start ends in failed with the too-many reason.
|
||||
9. COVERAGE.md in the phase directory (format of 19.4.1's):
|
||||
- livekit-client capabilities (Room connect and disconnect, publish camera and microphone, subscribe and attach remote tracks, mute and unmute, camera toggle, participant events, connection state and reconnection, adaptiveStream and dynacast INTEGRATE; screen share, data channel, device selection, E2EE, recording and egress, simulcast tuning, audio-only mode OPT-OUT, each with a reason);
|
||||
- LiveKit server through chatvideo (room create and delete, token mint, list participants, reconcile INTEGRATE; egress, ingress, SIP and webhooks OPT-OUT);
|
||||
- Centrifugo (the chat, user and video namespaces with presence on chat INTEGRATE; history, recovery and client publish OPT-OUT);
|
||||
- Web Push through herald (VAPID keys and the public key route INTEGRATE; subscribe UI and push on chat events OPT-OUT, deferred per D-16).
|
||||
|
||||
Commit the layer, vue-demo-app (with the link pin), the scripts and COVERAGE.md (planning commit) separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chatvideo-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh call chat</automated>
|
||||
<fails_when>Non-zero exit: the call machine tests fail, the build fails (livekit-client not resolvable or a type error), or check-local reports call failed (start refused, no token or livekit_url, LiveKit's rtc/validate not 200) or chat failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "import('livekit-client')" ../vue-chatvideo-layer/app/composables/useCall.ts` prints 1 and `grep -rn "^import .*from 'livekit-client'" ../vue-chatvideo-layer/app | grep -vc 'import type'` prints 0 (no static runtime import).
|
||||
- `grep -rn 'subscribe(' ../vue-chatvideo-layer/app | grep -vc 'videoSessionChannel'` prints 0 (the layer's only subscriptions are session channels; incoming calls come through onUserEvent).
|
||||
- `node -p "require('../vue-chatvideo-layer/package.json').dependencies['livekit-client']"` prints the approved version with no range character.
|
||||
- `grep -c 'conversation-actions' ../sm-demo-app/vue-demo-app/app/pages/chat.vue` prints 1.
|
||||
- COVERAGE.md has a reason on every OPT-OUT row (`grep -c 'OPT-OUT | *|' .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` prints 0).
|
||||
</acceptance_criteria>
|
||||
<done>A member can start a video call from a DM, the other member gets an accept prompt on any page, and both meet on a minimal call page served by LiveKit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Herald's VAPID keys without UI, and the Forum, Friends and Calls tour cards</name>
|
||||
<files>../sm-demo-app/scripts/dev.sh, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/herald/README.md (keys, herald:vapid-generate, presence gate warning), ../sm-demo-app/scripts/dev.sh (ensure_env, build, up), ../sm-demo-app/deploy/env.example, ../sm-demo-app/vue-demo-app/app/pages/index.vue, app/components/TourCard.vue, i18n/locales/*.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour)</read_first>
|
||||
<action>Per D-16 and the tour part of D-03.
|
||||
- dev.sh: after build in `up` and `seed`, when .env lacks SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, run `./bin/demo herald:vapid-generate --json -q`, append SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY and __PRIVATE_KEY from the JSON (python3 parse, umask 077, values never echoed) and SUMMER_GOLEM15__HERALD__VAPID__SUBJECT=mailto:demo@example.test. Print only "added the herald VAPID keys to .env".
|
||||
- deploy/env.example: the three herald key names with empty values and a comment (generated on rome with herald:vapid-generate; no subscribe UI in this phase, D-16). README "Chat family" section: herald is mounted without UI.
|
||||
- Tour:
|
||||
- index.vue stops in order journal, presentations, quizzes, chat, forum (/forum), friends (/friends), calls (/chat with text tour.calls.text), payments, faq, accounts;
|
||||
- TourCard icons forum, friends and calls added to the union and ICONS (outline, 24-unit grid, currentColor);
|
||||
- en and pl tour.forum, tour.friends and tour.calls titles and texts, and the heading for ten cards.
|
||||
- check-local.sh check_tour expects ten cards including Forum, Friends and Calls (EN) and their Polish titles. New `check_herald`: GET $GO/_herald/api/v1/push/vapid-public-key answers 401 without a JWT and 200 with the visitor's JWT and a non-empty publicKey (json_has); add `herald` to ALL_CHECKS and the header list.
|
||||
|
||||
Commit sm-demo-app (dev.sh, env.example, README, check-local) and vue-demo-app (tour) separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && grep -q '^SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY=.' ../sm-demo-app/.env && ../sm-demo-app/scripts/check-local.sh herald tour</automated>
|
||||
<fails_when>Non-zero exit: the build fails, dev.sh does not write the VAPID public key into .env, or check-local reports herald failed (the public-key route is not 200 with a non-empty publicKey for the visitor, or not 401 without a JWT) or tour failed (card count or titles wrong).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'HERALD__VAPID__PRIVATE_KEY=' ../sm-demo-app/deploy/env.example` prints 1, with no value after the equals sign.
|
||||
- `grep -c "'/_herald'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 0.
|
||||
- `grep -cE "key: '(forum|friends|calls)'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 3.
|
||||
- dev.sh output contains no line with a key value (manual look at the `up` output, recorded in the SUMMARY).
|
||||
</acceptance_criteria>
|
||||
<done>Herald runs with VAPID keys and no UI, and the tour shows all ten features in EN and PL.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 6: Go-ahead to create the forum, friends and video-call layer repositories, push them and tag v0.1.0</name>
|
||||
<read_first>Claude's summary: `git log --oneline` of the three layers, their README install lines, vue-faq-layer's visibility, whether `tea login list` shows a git.golem15.com login, the livekit-client version approved in Task 3, and the results of check-local forum friends call tour chat.</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY; with "release", the three repositories exist on git.golem15.com (`git ls-remote` exits 0 for each) before Task 7 pushes, or Task 7 creates them with tea.
|
||||
</acceptance_criteria>
|
||||
<decision>Create vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer on git.golem15.com, push main and tag v0.1.0 each?</decision>
|
||||
<context>Each layer is public with tagged releases that later sites pin (like D-01 for chat). v0.1.0 freezes their hooks, props and runtime keys. vue-chatvideo-layer's peer dependency on vue-chat-layer ^0.1.0 is part of its contract. vue-demo-app can pin them only after this. Repository creation and pushes need your go-ahead.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Create, push and tag all three</name>
|
||||
<pros>vue-demo-app pins the tags; the deploy (plan 05) can build.</pros>
|
||||
<cons>The v0.1.0 contracts are public.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Contracts stay open for changes.</pros>
|
||||
<cons>The deploy is blocked; vue-demo-app keeps local link pins, so it cannot be pushed.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "release" without a tea login: create the three repositories (empty, default branch main, vue-faq-layer's visibility) in the git.golem15.com web UI, then reply. Do not paste tokens.</instructions>
|
||||
<resume-signal>Reply "release" (add "repos ready" if you created them) or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Release the three layers v0.1.0 and pin them in vue-demo-app</name>
|
||||
<reversibility rating="one-way">Pushed layer tags are installed by consumers' lockfiles; they can be superseded but must never move.</reversibility>
|
||||
<precondition>Task 6 resolved with "release".</precondition>
|
||||
<files>../vue-forum-layer/README.md, ../vue-userfriends-layer/README.md, ../vue-chatvideo-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml</files>
|
||||
<read_first>The three READMEs, ../sm-demo-app/vue-demo-app/package.json, the Task 6 answer</read_first>
|
||||
<action>For each of the three layers:
|
||||
- create the repository with tea if missing, at vue-faq-layer's visibility;
|
||||
- add the origin `git@git.golem15.com:golem15/<layer>.git` and push main;
|
||||
- fix a README install line that says the tag does not exist before tagging;
|
||||
- create the annotated tag v0.1.0, push it and check `ls-remote` against HEAD.
|
||||
Then in vue-demo-app run `pnpm add` with the three `git+https://git.golem15.com/golem15/<layer>.git#v0.1.0` specs (replacing the link pins), run the tests and the build, and commit package.json and pnpm-lock.yaml (`feat: pin the forum, friends and video-call layers v0.1.0`). Do not push vue-demo-app.</action>
|
||||
<verify>
|
||||
<automated>for l in vue-forum-layer vue-userfriends-layer vue-chatvideo-layer; do [ "$(git -C ../$l ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../$l rev-parse HEAD)" ] || exit 1; grep -q "$l.git#v0.1.0" ../sm-demo-app/vue-demo-app/package.json || exit 1; done && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh forum friends call chat tour</automated>
|
||||
<fails_when>Non-zero exit: a remote tag is missing or differs from HEAD, a tag pin is missing from package.json, the vue-demo-app tests or build fail, or a check-local check fails against the tagged layers.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing.
|
||||
- `node -p "Object.values(require('../sm-demo-app/vue-demo-app/package.json').dependencies).filter(v=>v.startsWith('link')).length"` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Forum, friends and video-call layers are published at v0.1.0 and the demo site builds against all tagged layers.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| forum posts and friend names to other members' browsers | untrusted content |
|
||||
| browser to LiveKit (signal through nginx, media direct) | WebRTC with a short-lived join token |
|
||||
| realtime events to the incoming-call prompt | payloads from the server about other users |
|
||||
| npm registry to the chatvideo layer | a new third-party dependency (livekit-client) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-20 | Tampering | forum stored XSS | high | mitigate | posts render only content_html, which the server sanitizes on write with breakwater (D-18 of the forum port); Markdown is previewed through the server endpoint; acceptance grep limits v-html to content_html |
|
||||
| T-20-21 | Spoofing | call joins | high | mitigate | tokens are minted by chatvideo only for conversation participants (sessionForActor) with token_ttl 900; the client never builds tokens; LiveKit room max_participants 4 (D-12) |
|
||||
| T-20-22 | Information disclosure | camera and microphone | medium | mitigate | media starts only after an explicit start or accept on the call page; leave and pagehide disconnect and stop local tracks |
|
||||
| T-20-23 | Tampering | incoming-call payloads | low | mitigate | caller names render as text bindings; Accept navigates only to the app's callPath built from the numeric conversation id |
|
||||
| T-20-24 | Denial of service | call spam | medium | mitigate | server bucket video-initiate 5/min, max_duration 900 and the reconcile job (D-12); calls only offered in DMs (D-23) |
|
||||
| T-20-25 | Information disclosure | friend enumeration | low | mitigate | the API's generic 422 for refused requests is shown as is; the directory lists demo members only (the same set the chat member picker shows) |
|
||||
| T-20-26 | Information disclosure | VAPID private key | high | mitigate | generated into .env only (umask 077, never echoed); env.example holds names; /_herald is not routed by the site's goPaths |
|
||||
| T-20-SC | Tampering | npm installs | high | mitigate | livekit-client installed only after the blocking human-verify legitimacy checkpoint (Task 3, gate blocking-human, never auto-approved) at an exact version with no install scripts; every other package is an existing workspace pin |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: forum client tests, app build, check-local forum.
|
||||
- Task 2: friends client tests, build, check-local friends forum chat.
|
||||
- Task 3: livekit-client version approved by the user.
|
||||
- Task 4: call machine tests, build, check-local call chat, COVERAGE.md complete.
|
||||
- Task 5: build, VAPID keys in .env, herald route present, check-local tour.
|
||||
- Task 6: go-ahead recorded.
|
||||
- Task 7: three remote tags equal HEAD; vue-demo-app tests, build and checks green on tags.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The demo site shows the forum, friends and DM video calls through three new shared layers (SC-2, D-11 to D-15, D-21, D-23), herald is configured without UI (D-16), and the tour lists every feature.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md` when done: the approved livekit-client version, the decline endpoint used, each layer's public API, the three tags and commits, and both checkpoint answers.
|
||||
</output>
|
||||
@@ -0,0 +1,359 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["20-02", "20-03", "20-04"]
|
||||
files_modified:
|
||||
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
|
||||
- ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf
|
||||
- ../sm-demo-app/deploy/livekit.yaml.example
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/config/env/production/golem15.chatvideo.yaml
|
||||
- ../sm-demo-app/scripts/check-deploy.sh
|
||||
- ../sm-demo-app/scripts/check-live.sh
|
||||
- ../sm-demo-app/DEPLOY.md
|
||||
- ../sm-demo-app/README.md
|
||||
- ../sm-demo-app/vue-demo-app
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, D-10, D-12, D-16, D-20, D-21]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-10: rome runs the demo's own LiveKit 1.13.1 under supervisor as summercms-demo-livekit (user summercms-demo, config /srv/summercms-demo/livekit.yaml, 0640 root:summercms-demo, own key pair also in /srv/summercms-demo/.env), signal on 127.0.0.1:7890 behind nginx location /livekit/, ICE TCP 7891 and UDP mux 7892 on the public address; it does not touch WavePath's LiveKit (7880, 7881, 50000-60000) or its keys."
|
||||
- "D-10: Vuurmuur on rome allows TCP 7891 and UDP 7892 from the internet to the firewall through a rule the user approved; DEPLOY.md documents the service, the rule, how to verify it and how to remove it."
|
||||
- "D-12: the deployed LiveKit has room max_participants 4, empty_timeout 120 and departure_timeout 20; production chatvideo config keeps max_duration 900 and max_participants 4; the chatvideo reconcile job runs every 5 minutes inside serve."
|
||||
- "D-16: rome's .env holds herald VAPID keys generated on rome with herald:vapid-generate; /_herald is not routed by nginx."
|
||||
- "SC-3: after the release https://demo.summercms.io serves /chat, /forum, /friends and /call/{id} with the banner and the backend call to action; /backend and /backend/x answer 404; anonymous /chat and /forum show the register prompt (D-04, D-21); the admin stays at /smbackend as decided in 19.4.1."
|
||||
- "SC-2: on the live demo the chat-family APIs answer 401 without a JWT under /_chat, /_chatchannels, /_chatbots, /_chatvideo, /_forum and /_userfriends, /livekit/rtc/validate reaches LiveKit, /ws upgrades to Centrifugo, and the Centrifugo config on rome has the chat, user and video namespaces."
|
||||
- "scripts/check-deploy.sh proves locally that nginx, supervisor, livekit.yaml.example and the production chatvideo config agree on ports (7890, 7891, 7892) and caps, that each chat-family prefix goes to the Go port and that /_herald is not routed; scripts/check-live.sh https://demo.summercms.io prints check-live: ok after the release."
|
||||
- "vue-demo-app's master is pushed before sm-demo-app's pointer bump commit; sm-demo-app is pushed with the go-ahead; robots.txt on the live site disallows the new API prefixes."
|
||||
artifacts:
|
||||
- path: "../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf"
|
||||
provides: "supervisor program summercms-demo-livekit"
|
||||
contains: "program:summercms-demo-livekit"
|
||||
- path: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
|
||||
provides: "Go locations for the chat family and the /livekit/ WebSocket location"
|
||||
contains: "location ^~ /livekit/"
|
||||
- path: "../sm-demo-app/config/env/production/golem15.chatvideo.yaml"
|
||||
provides: "production livekit_url and livekit_admin_url"
|
||||
contains: "wss://demo.summercms.io/livekit"
|
||||
- path: "../sm-demo-app/DEPLOY.md"
|
||||
provides: "LiveKit, firewall, chat family release, verify and rollback"
|
||||
contains: "summercms-demo-livekit"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
|
||||
to: "../sm-demo-app/deploy/livekit.yaml.example"
|
||||
via: "proxy_pass http://127.0.0.1:7890/ equals LiveKit port 7890 (check-deploy compares)"
|
||||
pattern: "127\\.0\\.0\\.1:7890"
|
||||
- from: "../sm-demo-app/config/env/production/golem15.chatvideo.yaml"
|
||||
to: "../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf"
|
||||
via: "livekit_admin_url http://127.0.0.1:7890 is the program's signal port"
|
||||
pattern: "livekit_admin_url"
|
||||
- from: "../sm-demo-app/scripts/check-live.sh"
|
||||
to: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
|
||||
via: "live 401s under the chat-family prefixes and LiveKit's answer on /livekit/rtc/validate"
|
||||
pattern: "/livekit/rtc/validate"
|
||||
prohibitions:
|
||||
- statement: "No change to WavePath's LiveKit program, its config /etc/livekit/livekit.yaml or its ports, and no second key on its server."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No secret value (LiveKit, VAPID, Centrifugo) appears in a committed file, the SUMMARY, a log line or a command line; values are generated on rome."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No push, rome change or firewall change happens before its checkpoint is answered; raw iptables is never used (Vuurmuur manages rome's INPUT chain)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): the demo is deployed publicly with the chat family, the frontend open, `/backend` unreachable and the backend call to action shown.
|
||||
|
||||
This plan's slice (plan 5 of 6, wave 5, per D-20): the rome deploy of everything plans 01 to 04 built. That means the demo's own LiveKit under supervisor, the Vuurmuur rule for its media ports (a checkpoint, shared production host), nginx locations, `.env` keys (LiveKit, VAPID), the Centrifugo namespaces, the release, DEPLOY.md, and the check-deploy and check-live extensions.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Media path (D-10 discretion):** ICE over the UDP mux port 7892 with ICE-TCP 7891 as fallback, and no embedded TURN. nginx `stream` with `ssl_preread` would restructure every 443 vhost on a shared host (RESEARCH Alternatives). If NAT'd browsers fail in plan 06's live check, the fallback recorded in DEPLOY.md is LiveKit's embedded TURN on UDP 3478 and TLS 5349 with a DNS-only host name, which needs a new checkpoint.
|
||||
- **Signal path:** same origin under `/livekit` (`livekit_url: wss://demo.summercms.io/livekit`). livekit-client appends `/rtc`, so nginx `location ^~ /livekit/` proxies to `http://127.0.0.1:7890/` with the WebSocket upgrade headers, `proxy_read_timeout 7d` and `proxy_buffering off`, as lk.wavepath.org does. Go talks to the RoomService on loopback (`livekit_admin_url: http://127.0.0.1:7890`).
|
||||
- **ICE reveals rome's public address** despite Cloudflare. This is accepted, because rome already exposes services on that address (RESEARCH Security Domain). DEPLOY.md says so.
|
||||
- **Admin stays at /smbackend** (19.4.1's decision change of 2026-10-20, RESEARCH Open Question 1). `/backend` stays 404. The deploy checkpoint restates it.
|
||||
- **Robots:** the new prefixes go into SEO › robots.txt on rome (the DEPLOY.md "SEO" procedure). `/_herald` stays unrouted and so needs no line.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Supervisor program `summercms-demo-livekit` (`deploy/supervisor/summercms-demo-livekit.conf`); rome files `/srv/summercms-demo/livekit.yaml` and `/etc/supervisor/conf.d/summercms-demo-livekit.conf`.
|
||||
- nginx locations `^~ /_chat/`, `^~ /_chatchannels/`, `^~ /_chatbots/`, `^~ /_chatvideo/`, `^~ /_forum/`, `^~ /_userfriends/` (Go, 127.0.0.1:8098) and `^~ /livekit/` (LiveKit, 127.0.0.1:7890).
|
||||
- `config/env/production/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`).
|
||||
- env keys on rome: `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY`, `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET`, `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in git).
|
||||
- Vuurmuur service `summercms-demo-livekit` and its rule (rome).
|
||||
- check-deploy.sh LiveKit and chat-family checks; check-live.sh chat-family checks; DEPLOY.md sections "LiveKit (the demo's own)", "Firewall (Vuurmuur)", "Chat family"; the sm-demo-app pointer bump of vue-demo-app.
|
||||
|
||||
<objective>
|
||||
Deploy the chat family to the live demo on rome: LiveKit, firewall, nginx, keys, Centrifugo namespaces and the release, with the checks and the runbook to keep it repeatable.
|
||||
|
||||
Purpose: SC-3 (deployed publicly, /backend unreachable, call to action) and the live half of SC-2, D-10, D-12 and D-16 on rome.
|
||||
Output: commits in ../sm-demo-app, pushes of vue-demo-app and sm-demo-app, and changes on rome, all after the 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-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md
|
||||
|
||||
Repo: ../sm-demo-app (deploy files, scripts, DEPLOY.md), its submodule vue-demo-app (push only). Host: rome over `ssh -p 2222 debian@10.8.3.118` (passwordless sudo; the `rome` alias does not resolve here), with the variables `$RSH` and `$ROME` used in DEPLOY.md. Read-only on rome: WavePath's `/etc/livekit/livekit.yaml` and `wavepath-livekit` program, `/etc/vuurmuur/services`, `/etc/vuurmuur/rules/rules.conf`.
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; push vue-demo-app first, then commit and push the pointer bump in sm-demo-app (core-plugins-own-repos note); secrets are generated on rome with `openssl rand` or `herald:vapid-generate` and written by remote commands that never echo values; follow DEPLOY.md's Build, Upload and Release sections; nothing changes on rome and nothing is pushed before Task 3's answer; the firewall changes only after Task 5's answer.
|
||||
|
||||
<interfaces>
|
||||
sm-demo-app deploy today: deploy/nginx/demo.summercms.io.conf (one `location ^~ /_<prefix>/` per Go prefix with proxy_pass http://127.0.0.1:8098, `location = /ws` to Centrifugo 8010 with Upgrade headers, `location ^~ /backend { return 404; }`, `/smbackend` to Go), deploy/supervisor/summercms-demo-centrifugo.conf (program shape), deploy/centrifugo.json.example (namespaces from plan 02), deploy/livekit.yaml.example (plan 02), scripts/check-deploy.sh (nginx -t in a temp prefix, supervisor parse, port agreement, prints "check-deploy: ok"), scripts/check-live.sh (bad, get, header, ws_upgrade helpers; prints "check-live: ok"), DEPLOY.md (Overview, Launch checklist, One-time server setup, Realtime, MCP endpoint, SEO, Build, Upload, Release, Verify, Rollback, Reset, Secrets).
|
||||
RESEARCH facts: rome LiveKit 1.13.1 at /usr/local/bin/livekit-server; WavePath's program uses 7880, 7881/tcp and 50000-60000/udp; ports 7890-7892 were free on 2026-10-28 (re-check with `ss -ltnup`); rome's INPUT policy is DROP under Vuurmuur (vrmr NFLOG prefixes); a Vuurmuur service Livekit exists but no rule uses it.
|
||||
LiveKit validate endpoint: GET /rtc/validate answers 401 without a token and 200 with a valid one.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the deploy files carry one chat-family release end to end, proven locally by check-deploy before rome is touched</name>
|
||||
<precondition>The plan 04 SUMMARY records the three layer tags pinned in vue-demo-app; `git -C ../sm-demo-app status --porcelain` and `git -C ../sm-demo-app/vue-demo-app status --porcelain` list no foreign edit in the files of this task.</precondition>
|
||||
<files>../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/config/env/production/golem15.chatvideo.yaml, ../sm-demo-app/scripts/check-deploy.sh, ../sm-demo-app/scripts/check-live.sh, ../sm-demo-app/DEPLOY.md, ../sm-demo-app/README.md</files>
|
||||
<read_first>../sm-demo-app/deploy/nginx/demo.summercms.io.conf (whole file), deploy/supervisor/summercms-demo-centrifugo.conf, deploy/livekit.yaml.example, deploy/env.example, config/env/production/*.yaml, scripts/check-deploy.sh (whole file), scripts/check-live.sh (whole file), DEPLOY.md (whole file), 20-RESEARCH.md (Pitfalls 6, 7, 8, 11; Supervisor program and LiveKit config blocks)</read_first>
|
||||
<action>Per D-10, D-12, D-16, SC-2, SC-3.
|
||||
- nginx: add `location ^~ /_chat/`, `/_chatchannels/`, `/_chatbots/`, `/_chatvideo/`, `/_forum/` and `/_userfriends/` with the exact /_demo/ block, and `client_max_body_size 2m` on /_chat/ for attachments (chat's 1 MiB upload limit plus multipart overhead). Add `location ^~ /livekit/` with proxy_pass `http://127.0.0.1:7890/`, `proxy_http_version 1.1`, the Upgrade and Connection headers, Host and X-Forwarded headers, `proxy_read_timeout 7d`, `proxy_send_timeout 7d` and `proxy_buffering off`, and a comment that media bypasses nginx. Update the `/api/realtime/token` comment: every demo member now gets a token (demo.realtime-access). Add nothing for /_herald.
|
||||
- deploy/supervisor/summercms-demo-livekit.conf: RESEARCH's program block, with a header comment in the Centrifugo file's style (ports, config path and mode, binary, that WavePath's instance is separate).
|
||||
- deploy/livekit.yaml.example: confirm or adjust the plan 02 file so production uses `use_external_ip: true`, and comment the rome install command.
|
||||
- config/env/production/golem15.chatvideo.yaml: `livekit_url: "wss://demo.summercms.io/livekit"`, `livekit_admin_url: "http://127.0.0.1:7890"`.
|
||||
- deploy/env.example: the herald subject key with its comment, if plan 04 left it out.
|
||||
- check-deploy.sh, new checks with header lines:
|
||||
- summercms-demo-livekit.conf parses and declares its program with the user summercms-demo;
|
||||
- LiveKit ports agree: the example's `port` equals the nginx /livekit/ proxy_pass port and the production livekit_admin_url port; `rtc.tcp_port` 7891 and `rtc.udp_port` 7892 are set, no port_range key is set, and bind_addresses is 127.0.0.1;
|
||||
- room.max_participants equals config/golem15.chatvideo.yaml max_participants (D-12, D-23);
|
||||
- each of the six chat-family prefixes is a location proxied to the Go port;
|
||||
- no location routes /_herald;
|
||||
- the example holds placeholder keys only.
|
||||
Parse YAML with python3 as the script already does for JSON, and fail with one line per mismatch.
|
||||
- check-live.sh, new checks with header lines:
|
||||
- /chat, /forum and /friends answer 200 with the banner and a register link (D-04, D-21);
|
||||
- GET /_chatchannels/api/v1/contexts/demo/1/channels, /_forum/api/v1/contexts/demo/1/categories, /_userfriends/api/v1/friends, /_chatbots/api/v1/commands and /_chatvideo/api/v1/conversations/1/video/status answer 401;
|
||||
- GET /livekit/rtc/validate answers 401 (or 400) from LiveKit, not Nuxt's HTML 404 (the body is not text/html);
|
||||
- GET /_herald/api/v1/push/vapid-public-key does not answer JSON from Go;
|
||||
- robots.txt holds `Disallow: /_chat` and `Disallow: /_forum`.
|
||||
- DEPLOY.md:
|
||||
- Overview: ports 7890-7892, the new program;
|
||||
- One-time server setup "LiveKit (the demo's own)": install the config with generated keys, the supervisor program, verify with curl on 127.0.0.1:7890;
|
||||
- "Firewall (Vuurmuur)": the service definition (TCP 7891, UDP 7892), the rule from any to the firewall, `vuurmuur_script` apply and reload, verification with `ss -lunp` and `iptables -S | grep 789`, and removal;
|
||||
- "Chat family": the .env keys, the Centrifugo namespaces and when to restart Centrifugo, the VAPID keys, the robots.txt lines to add;
|
||||
- Verify: the new check-live items;
|
||||
- Rollback: stopping summercms-demo-livekit turns calls off only;
|
||||
- Reset: the chat family is included.
|
||||
README links to the new sections.
|
||||
|
||||
Commit `feat: deploy files for the chat family and the demo's LiveKit`.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-deploy.sh 2>&1 | tail -1 | grep -qx 'check-deploy: ok' && bash -n ../sm-demo-app/scripts/check-live.sh</automated>
|
||||
<fails_when>The last line is not "check-deploy: ok" (nginx -t fails with the new locations, a port or cap mismatch, a missing prefix, a routed /_herald, or a supervisor parse error), or check-live.sh has a syntax error.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'location ^~ /livekit/' ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints 1.
|
||||
- `grep -cE 'location \^~ /_(chat|chatchannels|chatbots|chatvideo|forum|userfriends)/' ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints 6.
|
||||
- `grep -c '\[program:summercms-demo-livekit\]' ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf` prints 1.
|
||||
- `grep -c 'Vuurmuur' ../sm-demo-app/DEPLOY.md` prints at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>The repository holds every file the rome release needs, and the local check proves they agree.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Read rome's current state and prepare the release build without changing rome</name>
|
||||
<files>../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>../sm-demo-app/DEPLOY.md (Build, Upload, Release, Secrets), ../sm-demo-app/scripts/build.sh, .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/19.4.1-02-SUMMARY.md (first deploy notes)</read_first>
|
||||
<action>Per D-10 and SC-3. Read-only on rome:
|
||||
- `ss -ltnup` filtered on 7890, 7891 and 7892 (free?);
|
||||
- `supervisorctl status`;
|
||||
- `grep -c` for each new key name in /srv/summercms-demo/.env (counts only, never values);
|
||||
- whether /srv/summercms-demo/centrifugo.json lists the chat namespace (`grep -c '"chat"'`);
|
||||
- `ls /etc/vuurmuur/services` and `grep -n Livekit /etc/vuurmuur/rules/rules.conf` (no edit);
|
||||
- `/usr/local/bin/livekit-server --version`.
|
||||
Locally:
|
||||
- `scripts/check-pins.sh`, `scripts/build.sh release` (refuses a dirty tree or off-tag submodules) and `scripts/smoke.sh` against the release build as DEPLOY.md's Build section says;
|
||||
- `git -C vue-demo-app status` (the commits from plans 03 and 04 are local only) and `git log origin/master..master` in both repositories.
|
||||
Record everything in a deploy table for the Task 3 question. If a port is taken, pick the next free trio and update the deploy files and check-deploy in a commit before asking. If DEPLOY.md's Build steps no longer match the scripts, fix them in DEPLOY.md.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-pins.sh && ../sm-demo-app/scripts/build.sh release && ../sm-demo-app/scripts/smoke.sh</automated>
|
||||
<fails_when>Non-zero exit: a submodule off its go.mod tag, the release build refuses (dirty tree, stale ROADMAP snapshot) or fails to compile without go.work, or smoke.sh reports a missing banner, an admin path or a credential on a rendered page.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY's deploy table lists rome's listeners on 7890-7892 (or the chosen trio), the supervisor programs, which env keys exist (counts only), whether centrifugo.json has the chat namespace, the LiveKit version and the unpushed commits per repository.
|
||||
- No file on rome changed (no sudo write command was run).
|
||||
</acceptance_criteria>
|
||||
<done>The release build is ready and the go-ahead question rests on rome's actual state.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 3: Go-ahead to push the demo and deploy the chat family to rome</name>
|
||||
<read_first>The Task 2 deploy table; ../sm-demo-app/DEPLOY.md sections "LiveKit (the demo's own)", "Chat family" and "Release"</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Push vue-demo-app and sm-demo-app and release the chat family on rome now (LiveKit program, keys, nginx, Centrifugo namespaces, binary and site)?</decision>
|
||||
<context>The release changes a shared production host: a new supervisor program (summercms-demo-livekit on 127.0.0.1:7890), new .env keys generated on rome (LiveKit and VAPID), an nginx reload for the demo vhost, a Centrifugo restart for the demo's own instance, a migration of the chat-family tables and demo:seed. WavePath's LiveKit and keys are not touched. The admin stays at /smbackend with login (19.4.1's decision) and /backend stays 404. Media ports stay closed until the separate firewall question (Task 5), so calls connect only after that. Rollback is in DEPLOY.md (previous binary and site; stop the LiveKit program).</context>
|
||||
<options>
|
||||
<option id="deploy">
|
||||
<name>Push and deploy</name>
|
||||
<pros>Chat, forum and friends go live; calls follow after the firewall rule.</pros>
|
||||
<cons>Changes rome; the new tables are migrated (not reversed on rollback).</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>Push the repositories, deploy later</name>
|
||||
<pros>Code is on origin and reviewed; rome is unchanged.</pros>
|
||||
<cons>SC-3 stays open; plan 06's live verification waits.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The phase cannot verify live.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "deploy", "push-only" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Release: push, install LiveKit and keys on rome, roll out the binary and site, and check the live demo</name>
|
||||
<precondition>Task 3 answered "deploy" (or "push-only", which runs steps 1 and 2 only).</precondition>
|
||||
<files>../sm-demo-app/vue-demo-app, ../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>../sm-demo-app/DEPLOY.md (whole runbook), the Task 2 deploy table</read_first>
|
||||
<action>Per D-10, D-12, D-16, SC-2, SC-3.
|
||||
1. Push vue-demo-app master, then in sm-demo-app commit the pointer bump `chore: bump vue-demo-app (chat, forum, friends, calls)` and push sm-demo-app.
|
||||
2. Confirm both remotes equal local HEAD (`git ls-remote`).
|
||||
3. On rome, following DEPLOY.md. Never echo or log a value; write secrets with remote heredocs fed from `openssl rand` inside the remote shell.
|
||||
- Generate a LiveKit key and secret.
|
||||
- Install /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo) from the example with them, and append the same values to .env as the SUMMER_GOLEM15__CHATVIDEO__ keys.
|
||||
- Run `herald:vapid-generate --json -q` with the new binary on rome and append the VAPID keys. Set the subject to the demo's contact address from ~/.config/summercms/demo-secrets.env, sent over SSH stdin.
|
||||
- Update /srv/summercms-demo/centrifugo.json: merge the chat, user and video namespaces from the example while keeping its secrets, run `centrifugo checkconfig`, and restart summercms-demo-centrifugo.
|
||||
- Install and start summercms-demo-livekit (reread, update, status RUNNING) and check that `curl -s http://127.0.0.1:7890/` answers OK on rome.
|
||||
- Upload and release the build per DEPLOY.md (migrate, demo:seed, swap, restart), then install the nginx file and reload.
|
||||
- Add the robots.txt lines for the six prefixes through SEO › robots.txt (DEPLOY.md "SEO"; through an SSH tunnel to the admin with the credentials in /root/summercms-demo-admin.txt, or the seo MCP tools with the administrator's connector). If neither path works unattended, list it for the user in Task 6.
|
||||
4. Run `scripts/check-live.sh https://demo.summercms.io`. The /livekit/rtc/validate check passes without the firewall rule, because signalling goes through nginx.
|
||||
5. Record in DEPLOY.md any step that differed from the runbook.</action>
|
||||
<verify>
|
||||
<automated>[ "$(git -C ../sm-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app rev-parse HEAD)" ] && ../sm-demo-app/scripts/check-live.sh https://demo.summercms.io 2>&1 | tail -1 | grep -qx 'check-live: ok'</automated>
|
||||
<fails_when>sm-demo-app's remote master differs from HEAD (not pushed), or check-live's last line is not "check-live: ok" (a chat-family prefix not 401, /livekit not reaching LiveKit, /backend not 404, missing banner or call to action, robots lines missing, or /ws not 101).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status summercms-demo-livekit` shows RUNNING.
|
||||
- `ssh -p 2222 debian@10.8.3.118 'sudo grep -c "^SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET=." /srv/summercms-demo/.env'` prints 1 (count only).
|
||||
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status wavepath-livekit` shows the same uptime it showed in Task 2 (not restarted).
|
||||
- The SUMMARY holds no secret value.
|
||||
</acceptance_criteria>
|
||||
<done>The chat family is live on demo.summercms.io: chat, forum, friends and the call signalling path answer, with WavePath's services untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead for the Vuurmuur rule that opens LiveKit's media ports on rome</name>
|
||||
<read_first>DEPLOY.md "Firewall (Vuurmuur)"; the read-only output Claude prints: the proposed service file content (TCP 7891, UDP 7892), the proposed rule line, the current rules.conf lines around the Livekit service, and `iptables -S | grep -c vrmr` before the change</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Open TCP 7891 and UDP 7892 on rome for the demo's LiveKit through Vuurmuur?</decision>
|
||||
<context>rome's INPUT chain is managed by Vuurmuur with policy DROP; raw iptables rules would be overwritten. Without this rule calls signal but never connect media. The rule only admits traffic to the demo's LiveKit ports (the program binds media on the public address; signalling stays on loopback behind nginx). It is reversible: removing the rule and reloading Vuurmuur closes the ports. WavePath's own Livekit service definition stays unused and unchanged.</context>
|
||||
<options>
|
||||
<option id="claude-applies">
|
||||
<name>Claude applies the rule</name>
|
||||
<pros>Done now, with the verification commands from DEPLOY.md.</pros>
|
||||
<cons>A change to the shared host's firewall made by the agent.</cons>
|
||||
</option>
|
||||
<option id="user-applies">
|
||||
<name>I apply it myself</name>
|
||||
<pros>You make the firewall change on the shared host.</pros>
|
||||
<cons>Calls stay disconnected until you reply that it is done.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>The firewall stays as it is.</pros>
|
||||
<cons>Video calls do not connect on the live demo (D-10 open); plan 06's call check is skipped.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "user-applies": follow DEPLOY.md "Firewall (Vuurmuur)" and reply "firewall done".</instructions>
|
||||
<resume-signal>Reply "claude-applies", "firewall done" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Apply and verify the media ports, and hand the live demo to plan 06</name>
|
||||
<precondition>Task 5 answered "claude-applies" or "firewall done".</precondition>
|
||||
<files>../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>DEPLOY.md "Firewall (Vuurmuur)", the Task 5 answer</read_first>
|
||||
<action>Per D-10.
|
||||
- For "claude-applies": on rome, add the service file /etc/vuurmuur/services/summercms-demo-livekit (TCP 7891, UDP 7892) and the rule line from DEPLOY.md, apply with Vuurmuur's own tooling (`vuurmuur_script` apply and reload, never raw iptables), and back up rules.conf first to rules.conf.bak-<date>.
|
||||
- Either way, verify:
|
||||
- `sudo iptables -S | grep -E '7891|7892'` shows ACCEPT rules with the vrmr prefix;
|
||||
- `ss -lunp | grep 7892` shows livekit-server;
|
||||
- from this machine, `nc -zv -w 3 <rome public address> 7891` connects (UDP is verified by the live call in plan 06).
|
||||
- Note in DEPLOY.md the date applied and the backup path.
|
||||
- If robots.txt could not be updated unattended in Task 4, list the exact lines for the user in the SUMMARY.
|
||||
Commit any DEPLOY.md change (`docs: record the LiveKit firewall rule`), then push sm-demo-app.</action>
|
||||
<verify>
|
||||
<automated>ssh -p 2222 debian@10.8.3.118 "sudo iptables -S | grep -cE 'dport (7891|7892)'" | grep -qvx 0 && nc -zv -w 3 54.36.173.176 7891</automated>
|
||||
<fails_when>The iptables count is 0 (Vuurmuur did not apply the rule), or the TCP connect to 7891 on the public address times out or is refused.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ssh -p 2222 debian@10.8.3.118 test -f /etc/vuurmuur/services/summercms-demo-livekit` exits 0.
|
||||
- `../sm-demo-app/scripts/check-live.sh https://demo.summercms.io` still prints `check-live: ok`.
|
||||
- The SUMMARY states whether robots.txt was updated and lists any item left for the user.
|
||||
</acceptance_criteria>
|
||||
<done>LiveKit's media ports are reachable from the internet through Vuurmuur and the live demo passes its checks.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet to nginx (Cloudflare) on demo.summercms.io | untrusted HTTP and WebSocket, now including /livekit/ and six API prefixes |
|
||||
| internet to rome's public address on 7891/tcp and 7892/udp | WebRTC media straight to LiveKit, bypassing Cloudflare |
|
||||
| build machine to rome over SSH | secrets generated on rome, never transferred |
|
||||
| demo services to WavePath services on the same host | must stay isolated |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-27 | Elevation of privilege | LiveKit co-tenancy with WavePath | high | mitigate | own instance, own key pair, own ports and config file (D-10); no key added to /etc/livekit/livekit.yaml; Task 4 asserts wavepath-livekit was not restarted |
|
||||
| T-20-28 | Information disclosure | LiveKit, VAPID and Centrifugo secrets | high | mitigate | generated on rome inside the remote shell, written to 0640 root:summercms-demo files, never echoed; repository files hold names and placeholders; check-deploy fails on non-placeholder keys in the example |
|
||||
| T-20-29 | Denial of service | open media ports | medium | mitigate | only TCP 7891 and UDP 7892 via a Vuurmuur rule the user approved; room max_participants 4, empty_timeout 120, departure_timeout 20, max_duration 900 with reconcile (D-12) |
|
||||
| T-20-30 | Information disclosure | origin IP through ICE candidates | low | accept | rome already exposes services on its public address; recorded in DEPLOY.md |
|
||||
| T-20-31 | Elevation of privilege | unintended Go routes | medium | mitigate | nginx allow-list gains exactly six prefixes and /livekit/; /_herald stays unrouted; check-deploy asserts both; /backend 404 unchanged |
|
||||
| T-20-32 | Tampering | firewall drift | medium | mitigate | changes only through Vuurmuur with a dated backup of rules.conf; DEPLOY.md documents removal |
|
||||
| T-20-33 | Denial of service | release breaking the live demo | medium | mitigate | release build and smoke before the go-ahead (Task 2); DEPLOY.md rollback keeps bin/demo.prev and site/.output.prev; check-live after release |
|
||||
| T-20-34 | Spoofing | signalling WebSocket | low | mitigate | LiveKit validates every join token (HS256, token_ttl 900) minted only for conversation participants by chatvideo |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new package: the release installs vue-demo-app's frozen lockfile (layers pinned by tag, livekit-client approved in plan 04) and Go modules by tag with go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: check-deploy ok with the new locations, program and port agreement.
|
||||
- Task 2: check-pins, release build and smoke; rome read-only table.
|
||||
- Task 3: go-ahead recorded.
|
||||
- Task 4: remotes pushed; check-live ok on https://demo.summercms.io; LiveKit running; WavePath untouched.
|
||||
- Task 5: firewall decision recorded.
|
||||
- Task 6: iptables shows the Vuurmuur rule; TCP 7891 reachable; check-live still ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The chat family runs on the public demo with its own LiveKit and open media ports, /backend refused and the call to action shown (SC-3), with a runbook and checks that make the next release routine.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md` when done: the deploy table (no secret values), ports used, the commits pushed, what changed on rome, both checkpoint answers, the robots.txt state, and anything left for the user.
|
||||
</output>
|
||||
@@ -0,0 +1,447 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 6
|
||||
depends_on: ["20-01", "20-02", "20-03", "20-04", "20-05"]
|
||||
files_modified:
|
||||
- ../sm-moderation-plugin/classes/filter_test.go
|
||||
- ../sm-moderation-plugin/models/settings_test.go
|
||||
- ../sm-moderation-plugin/plugin_test.go
|
||||
- ../sm-chat-plugin/saving_test.go
|
||||
- ../sm-forum-plugin/saving_test.go
|
||||
- ../sm-demo-app/moderation_test.go
|
||||
- ../sm-demo-app/chat_test.go
|
||||
- ../sm-demo-app/video_test.go
|
||||
- ../sm-demo-app/seed_chat_test.go
|
||||
- ../sm-demo-app/reset_test.go
|
||||
- ../sm-demo-app/realtime_test.go
|
||||
- ../sm-demo-app/boot_test.go
|
||||
- ../sm-demo-app/plugins/demo/classes/chat_context_test.go
|
||||
- ../sm-demo-app/plugins/demo/chatbots_test.go
|
||||
- ../sm-demo-app/scripts/check-chat.cjs
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/check-all.sh
|
||||
- ../sm-demo-app/README.md
|
||||
- ../vue-chat-layer/vitest.config.ts
|
||||
- ../vue-chat-layer/app/lib/chatEvents.test.ts
|
||||
- ../vue-chat-layer/app/lib/chatFormat.test.ts
|
||||
- ../vue-chat-layer/app/lib/chatText.test.ts
|
||||
- ../vue-chat-layer/app/lib/chatUnread.test.ts
|
||||
- ../vue-chat-layer/app/lib/realtime.test.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
|
||||
- ../vue-chat-layer/tests/client.test.ts
|
||||
- ../vue-chat-layer/tests/store.test.ts
|
||||
- ../vue-chatvideo-layer/tests/callMachine.test.ts
|
||||
- ../vue-chatvideo-layer/tests/videoClient.test.ts
|
||||
- ../vue-chatvideo-layer/tests/useCall.test.ts
|
||||
- ../vue-forum-layer/tests/client.test.ts
|
||||
- ../vue-userfriends-layer/tests/client.test.ts
|
||||
- ../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/lib/realtime.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-04, D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-13, D-14, D-15, D-17, D-18, D-20, D-21, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 360000
|
||||
raw_tokens: 360000
|
||||
tasks: 8
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: every behaviour row of 20-VALIDATION.md has a passing automated test under the name it lists, in the repository it lists; the new code of plans 01 to 05 is covered by unit tests in this last plan (project rule)."
|
||||
- "D-18: TestMessageSaving* (sm-chat-plugin) and TestPostSaving* (sm-forum-plugin) pin: no listener gives byte-identical responses and stored rows; a rejecting listener on create, reply, ReplyTo and edit (chat) and thread, reply and edit (forum) gives 422 under the Rejection's field, writes and changes nothing, fires no post-save event and publishes nothing; a non-Rejection listener error keeps the 500 path; the existing php_method_map and golden tests pass unedited."
|
||||
- "D-17: TestMatch*, TestParseList and TestFilterCheck* (sm-moderation-plugin) pin case-insensitive word and phrase matching with Unicode letter boundaries (Polish letters, digits, underscores), host, subdomain, www and scheme-less link matching, URL-prefix entries, the disabled switch, the recompile-on-change cache, the list limits in BeforeValidate and the locale fallback of the message; TestDemoModeration pins the demo wiring end to end through HTTP."
|
||||
- "D-06, D-07, D-08, D-22: TestDemoChatContext*, TestDemoChannelAdapter, TestDemoChannelsSeededOnly, TestDemoChatBootstrap, TestDemoChatMembers, TestDemoDMs and TestDemoModeratorDelete pin membership (banned and deleted users excluded with live membership), the channel alias, refused channel creation, the bootstrap and members JSON shapes, DMs between any members, and moderator deletion limited to channel conversations."
|
||||
- "D-11, D-12, D-23: TestDemoVideoStart pins that a DM call starts with a token and livekit_url against a fake RoomService and that the server does not refuse by conversation type (calls are limited to DMs by the UI); the vue-chat-layer tests pin that the conversation-actions slot renders only for DMs."
|
||||
- "D-05: TestDemoCommands pins the catalog entries demo and tour and their ephemeral dispatch; the layer tests pin the command picker's dispatch path."
|
||||
- "D-09: TestSeedChat pins EN and PL channels, threads, reactions, pins, forum categories and posts, friendships and the group, and idempotency; TestResetKeepsPlans pins that every chat-family table is emptied and reseeded and that moderation settings survive."
|
||||
- "D-01, D-02, D-04: bm's chatEvents, chatFormat, chatText, chatUnread and realtime tests run under vitest in vue-chat-layer with only import paths changed, next to new tests for the multi-channel store, threads, pins, link embeds, the client's refresh-and-retry and the anonymous no-bootstrap path."
|
||||
- "D-13, D-14, D-15, D-21: the chatvideo machine and useCall (mocked livekit-client Room) tests, and the forum and friends client tests pass under their layers' test scripts."
|
||||
- "SC-2: locally, scripts/check-local.sh chatlive callmedia passes: two browsers see each other's channel message live, and a DM call shows each side a playing remote video with fake media through the local LiveKit."
|
||||
- "SC-2, SC-3: scripts/check-all.sh (including check-live against https://demo.summercms.io) prints check-all: ok, and the user confirms a two-browser chat and a DM call on the live demo."
|
||||
artifacts:
|
||||
- path: "../sm-moderation-plugin/classes/filter_test.go"
|
||||
provides: "TestMatch*, TestParseList, TestFilterCheck*"
|
||||
contains: "TestMatch"
|
||||
- path: "../sm-chat-plugin/saving_test.go"
|
||||
provides: "TestMessageSaving*"
|
||||
contains: "TestMessageSaving"
|
||||
- path: "../sm-forum-plugin/saving_test.go"
|
||||
provides: "TestPostSaving*"
|
||||
contains: "TestPostSaving"
|
||||
- path: "../sm-demo-app/chat_test.go"
|
||||
provides: "TestDemoChat*, TestDemoChannelsSeededOnly, TestDemoDMs, TestDemoModeratorDelete, TestDemoCommands"
|
||||
contains: "TestDemoChannelsSeededOnly"
|
||||
- path: "../sm-demo-app/video_test.go"
|
||||
provides: "TestDemoVideoStart with a fake RoomService"
|
||||
contains: "TestDemoVideoStart"
|
||||
- path: "../sm-demo-app/scripts/check-chat.cjs"
|
||||
provides: "two-browser realtime and DM call checks"
|
||||
contains: "use-fake-device-for-media-stream"
|
||||
- path: "../vue-chat-layer/tests/store.test.ts"
|
||||
provides: "multi-channel store, threads, pins, commands"
|
||||
contains: "describe"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/scripts/check-all.sh"
|
||||
to: "../sm-demo-app/scripts/check-live.sh"
|
||||
via: "the gate runs every suite, the layers and the chat-family plugins, then check-live"
|
||||
pattern: "check-live\\.sh"
|
||||
- from: "../sm-demo-app/scripts/check-local.sh"
|
||||
to: "../sm-demo-app/scripts/check-chat.cjs"
|
||||
via: "check_chatlive and check_callmedia run the Playwright script with two contexts"
|
||||
pattern: "check-chat\\.cjs"
|
||||
- from: "../sm-demo-app/moderation_test.go"
|
||||
to: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
via: "HTTP send with a banned word answers 422 through the demo listener and the moderation Filter"
|
||||
pattern: "422"
|
||||
prohibitions:
|
||||
- statement: "Tests use no real secret, no production database and no network service except the Docker Postgres, the local dev stack and in-process fakes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No production behaviour changes in this plan except fixes for bugs the tests expose, within Task 7's fix budget (small, no feature, schema or contract change, at most one patch tag per repository and one redeploy), each in its own commit, named in the SUMMARY, and released through the Task 6 checkpoint; the app-wiring extraction in vue-demo-app is behaviour-preserving."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Test-only commits in plugin and layer repositories get no tag; nothing is pushed before the Task 6 go-ahead."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): SC-4 says the new code has unit tests, delivered in the phase's last plan. The live demo shows the chat family working.
|
||||
|
||||
This plan's slice (plan 6 of 6, wave 6, per D-20 and the project rule that unit tests are the last plan): unit tests for everything plans 01 to 05 built, in Go, the four layers and the site, then a local two-browser chat and call check and the live verification, ending with the user's look at the live demo.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Where tests live:**
|
||||
- plugin seam tests next to the plan 01 smoke tests;
|
||||
- the demo's database tests in the root package (they compile every plugin through PluginIDs and the shared Postgres harness, as 19.4.1 did), with pure demo classes tested in plugins/demo/classes;
|
||||
- bm's chat tests next to the ported sources in vue-chat-layer/app/lib (copied with only import paths changed, the 19.4.1 vue-demo-app pattern), new layer tests in tests/.
|
||||
- **Fake LiveKit:** TestDemoVideoStart points `golem15.chatvideo.livekit_admin_url` at an httptest server answering the Twirp RoomService calls Start makes (CreateRoom, and ListParticipants if used), so chatvideo's real code path runs without a LiveKit.
|
||||
- **useCall test:** livekit-client is replaced with vi.mock of a minimal Room (connect, disconnect, localParticipant setters, event emitter). The real SDK is exercised by check-chat.cjs against the local LiveKit with fake media.
|
||||
- **Site tests:** the hook factories in vue-demo-app's app/plugins/auth.ts (token, refresh and unauthorized for chat; token getters; the friends directory mapping) move unchanged into app/utils/communityAuth.ts, so vitest can test them without Nuxt.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Go tests:
|
||||
- sm-moderation-plugin `TestMatchWords`, `TestMatchLinks`, `TestParseList`, `TestFilterCheckCache`, `TestFilterCheckDisabled`, `TestSettingsLimits`, `TestBootPublishesFilter`;
|
||||
- sm-chat-plugin `TestMessageSavingNoListener`, `TestMessageSavingRejects`, `TestMessageSavingOtherError`;
|
||||
- sm-forum-plugin `TestPostSavingNoListener`, `TestPostSavingRejects`;
|
||||
- sm-demo-app `TestDemoModeration`, `TestDemoChatContext*`, `TestDemoChannelAdapter`, `TestDemoChannelsSeededOnly`, `TestDemoChatBootstrap`, `TestDemoChatMembers`, `TestDemoDMs`, `TestDemoModeratorDelete`, `TestDemoCommands`, `TestDemoVideoStart`, `TestSeedChat`, extended `TestResetKeepsPlans`, `TestRealtimeRoutes`, `TestRealtimeMemberToken`, `TestBootAllPlugins`.
|
||||
- Layer tests: vue-chat-layer (ported bm tests plus `store.test.ts`, `client.test.ts`, `linkEmbeds.test.ts`), vue-chatvideo-layer (`callMachine`, `videoClient`, `useCall`), vue-forum-layer and vue-userfriends-layer client tests.
|
||||
- Site: `app/utils/communityAuth.ts` with its test.
|
||||
- Scripts: check-chat.cjs modes `--two` (realtime) and `--call` (fake media); check-local checks `chatlive` and `callmedia`; check-all.sh steps for the chat-family plugins and the four layers.
|
||||
|
||||
<objective>
|
||||
Cover the phase's new code with unit tests, prove two-browser chat and DM calls locally, and verify the live demo with the user.
|
||||
|
||||
Purpose: SC-4 (unit tests in the last plan) and the live proof of SC-2 and SC-3 for every decision the phase implemented.
|
||||
Output: test commits in eight Go and five frontend repositories, script commits in sm-demo-app, pushes after the go-ahead, and the user's sign-off on the live demo.
|
||||
</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-VALIDATION.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md
|
||||
|
||||
Repos: ../sm-moderation-plugin, ../sm-chat-plugin, ../sm-forum-plugin, ../sm-demo-app (root tests, plugins/demo, scripts), ../sm-demo-app/vue-demo-app, ../vue-chat-layer, ../vue-chatvideo-layer, ../vue-forum-layer, ../vue-userfriends-layer. bm test sources (read-only): ../sm-bm-app/frontend/src/lib/{chatEvents,chatFormat,chatText,chatUnread,realtime}.test.ts and stores/chat.test.ts.
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; Docker tests skip under -short and the TestMain calls flag.Parse and testing.Short (CLAUDE.md); if the Docker socket is denied, run through `sudo -u golem`; production code changes only for bugs the tests expose and only within the fix budget in Task 7 (own commit each, named in the SUMMARY; anything larger becomes a todo and decimal-phase candidate); nothing is pushed before Task 6.
|
||||
|
||||
<interfaces>
|
||||
sm-demo-app test harness: testmain_test.go `bootDemo(t, environ, set) *demotest.Demo`; internal/demotest (App, DB, Options{PluginIDs, Environ, Set}); existing tests TestGoModRequiresTags, TestBootAllPlugins (wantPlugins), TestRealtimeRoutes (realtimeApp, realtimeRoutes, wantNamespaces), TestResetKeepsPlans, TestSeedRoadmapUpsert; smoke tests from plan 02: TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke (chat_smoke_test.go).
|
||||
Plugin harnesses: chat bootWeb, frontendToken, call, conversation, count, memory (lighthouse MemoryDriver publications); forum bootWeb, call, count; plan 01 smoke tests TestMessageSavingTracer, TestPostSavingTracer, TestFilterSmoke.
|
||||
Demo classes (plan 02): DemoContext, ChannelContext, ChatAccess, ForumAccess with WithMembership and MemberFunc injection; chat_api.go bootstrap and members; chatbots.go demo and tour; moderation.go listeners; realtime_access.go guard.
|
||||
Layers (plans 03 and 04): vue-chat-layer createChatClient, useChat, linkify, registerEmbedRenderer, onUserEvent, slots; vue-chatvideo-layer callMachine (initialCallState, transition), createVideoClient, videoSessionChannel, useCall; createForumClient; createFriendsClient.
|
||||
Scripts: check-local.sh (visitor_jwt, throwaway registration helper, ALL_CHECKS, NODE_PATH="$(npm root -g)" for Playwright scripts), check-chat.cjs (plan 03 single-browser), check-all.sh (steps, "check-all: ok").
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: one banned word travels from the admin settings through the filter, the demo listener and the chat seam to a 422 in a member's request, under test</name>
|
||||
<precondition>`docker info` exits 0; the plan 05 SUMMARY exists.</precondition>
|
||||
<files>../sm-moderation-plugin/classes/filter_test.go, ../sm-demo-app/moderation_test.go</files>
|
||||
<read_first>../sm-moderation-plugin/classes/filter.go and filter_smoke_test.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/chat_smoke_test.go (TestDemoModerationSmoke), ../sm-demo-app/testmain_test.go, 20-VALIDATION.md (D-17 rows)</read_first>
|
||||
<action>Per D-17, D-18, SC-4.
|
||||
- sm-moderation-plugin classes/filter_test.go, table tests for the pure functions:
|
||||
- TestMatchWords: case-insensitive; a word inside a longer word does not match; phrases; Polish letters on both sides of a boundary (for example "żółw" against "żółwik" and "żółw!"); digits and underscores count as word characters; QuoteMeta'd specials such as "c++".
|
||||
- TestMatchLinks: a host entry matches the bare domain, www., http and https URLs, subdomains, upper case and trailing paths; "notbad.example" does not match "bad.example"; a URL-prefix entry matches only below its path.
|
||||
- TestParseList: trims, lowercases, drops empty lines and duplicates.
|
||||
- sm-demo-app moderation_test.go `TestDemoModeration` (Docker), end to end through HTTP:
|
||||
- after demo:seed, update golem15_moderation_settings with banned_words "blockedword" and banned_links "bad.example";
|
||||
- a member's message, reply and edit containing either answer 422 with errors.content, and the message count does not change;
|
||||
- a forum thread title answers 422 errors.title and a reply body 422 errors.body;
|
||||
- after changing the list to another word without restarting, the old word is accepted (cache refresh);
|
||||
- with enabled false, everything is accepted.
|
||||
|
||||
Commit per repository (`test: ...`).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-moderation-plugin test -count=1 ./... && go -C ../sm-demo-app test -count=1 -run '^TestDemoModeration$' -v . 2>&1 | grep -q -- '^--- PASS: TestDemoModeration'</automated>
|
||||
<fails_when>Non-zero exit from the moderation suite (a boundary, host or cache case wrong), or no "--- PASS: TestDemoModeration" line (skipped without Docker, banned content stored, or a list change not picked up).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func TestMatch' ../sm-moderation-plugin/classes/filter_test.go` prints at least 2.
|
||||
- `grep -c 'żółw' ../sm-moderation-plugin/classes/filter_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>The D-17/D-18 chain is pinned from the settings row to the HTTP answer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Seam and moderation plugin unit tests: parity without a listener, refusals on every write path</name>
|
||||
<files>../sm-chat-plugin/saving_test.go, ../sm-forum-plugin/saving_test.go, ../sm-moderation-plugin/models/settings_test.go, ../sm-moderation-plugin/plugin_test.go</files>
|
||||
<read_first>../sm-chat-plugin/saving_smoke_test.go, http_tracer_test.go, harness_test.go (memory driver), classes/attachments.go (ReplyTo), ../sm-forum-plugin/saving_smoke_test.go, http_tracer_test.go, ../sm-moderation-plugin/models/settings.go, plugin.go, updates/*.go, ../sm-sitemap-plugin/models/definition_test.go (settings test style)</read_first>
|
||||
<action>Per D-18, D-17.
|
||||
- sm-chat-plugin saving_test.go:
|
||||
- TestMessageSavingNoListener: the same send, reply, edit and ReplyTo requests answer the same status and body keys as before the seam, and MessageCreated and MessageUpdated still fire;
|
||||
- TestMessageSavingRejects: table over send, reply (POST replies), ReplyTo (service) and edit. Each gives 422 under the Rejection field (and "content" when the field is empty), no row inserted or changed, no MessageCreated or MessageUpdated fired (a counting listener), and no publication on the memory driver;
|
||||
- TestMessageSavingOtherError: a plain error from a listener gives 500 with the generic body and nothing stored;
|
||||
- the event carries Op, ConversationID, ActorID, RootID, MessageID and the trimmed Content.
|
||||
- sm-forum-plugin saving_test.go: TestPostSavingNoListener and TestPostSavingRejects over thread, reply and edit (field title or body, nothing stored, content_html unchanged on a refused edit, Op values).
|
||||
- sm-moderation-plugin:
|
||||
- models/settings_test.go TestSettingsLimits: too many entries, too long entries and a bad link entry refused with their lang keys; valid lists saved;
|
||||
- plugin_test.go TestBootPublishesFilter (Docker, with the sitemap-style harness): migrations create the singleton row, Boot publishes *classes.Filter, and Check on a clean text gives nil.
|
||||
Keep php_method_map_test.go and golden_test.go untouched in both content plugins. Commit per repository.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-chat-plugin test -count=1 ./... && go -C ../sm-forum-plugin test -count=1 ./... && go -C ../sm-moderation-plugin test -count=1 ./... && go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSaving' -v . 2>&1 | grep -c -- '^--- PASS: TestMessageSaving' | grep -qvx '[0-2]'</automated>
|
||||
<fails_when>Non-zero exit from any of the three suites (parity broken, a write path that bypasses the seam, an event fired after a refusal), or fewer than three passing TestMessageSaving* lines (tests skipped without Docker).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` and the same in ../sm-forum-plugin print nothing.
|
||||
- `grep -c 'ReplyTo' ../sm-chat-plugin/saving_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>The seams' contract (silent without listeners, total refusal with one) and the moderation plugin's settings and boot are pinned.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Demo host unit tests: context, adapter, bootstrap, DMs, moderator delete, commands, video start, realtime, seed and reset</name>
|
||||
<files>../sm-demo-app/plugins/demo/classes/chat_context_test.go, ../sm-demo-app/plugins/demo/chatbots_test.go, ../sm-demo-app/chat_test.go, ../sm-demo-app/video_test.go, ../sm-demo-app/seed_chat_test.go, ../sm-demo-app/reset_test.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/boot_test.go</files>
|
||||
<read_first>../sm-demo-app/plugins/demo/{chat.go,chat_api.go,chatbots.go,moderation.go,realtime_access.go,routes.go,classes/chat_context.go,classes/forum_access.go,console/seed_chat.go,console/seed_forum.go,console/seed_friends.go,console/reset.go}, ../sm-demo-app/{chat_smoke_test.go,demo_api_test.go,realtime_test.go,reset_test.go,seed_test.go,boot_test.go,testmain_test.go}, ../sm-demo-app/plugins/golem15/chatvideo/classes/livekit.go (RoomAPI, NewProductionRooms, FakeRooms) and boot.go, ../sm-bm-app/realtime_test.go, 20-VALIDATION.md</read_first>
|
||||
<action>Per D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-22, D-23, SC-1.
|
||||
- plugins/demo/classes/chat_context_test.go (pure, with WithMembership and MemberFunc stubs): constants, BroadcastChannel never empty for either alias, CanModerateMessage only for moderator-group members in non-DM conversations, RequireLiveMembership true, ChatAccess CanManageContext always false and CanAccessContext only for demo/1 members, ForumAccess read, post and manage rules.
|
||||
- plugins/demo/chatbots_test.go: TestDemoCommands. Triggers demo and tour, IsAvailable false for actor 0, Handle returns an ephemeral result whose text lists the site paths.
|
||||
- chat_test.go (Docker), with a seeded demo and members created in the test:
|
||||
- TestDemoChatContextMembership: a banned user and a deleted user are not members; a member banned after joining loses access at the next request (live membership);
|
||||
- TestDemoChannelAdapter: the conversation of each seeded channel resolves through the channel alias;
|
||||
- TestDemoChannelsSeededOnly: POST channels is refused, join and leave work;
|
||||
- TestDemoChatBootstrap: the JSON keys and types of /_demo/api/v1/chat for a member, {member: false} for a non-member, 401 without a JWT;
|
||||
- TestDemoChatMembers: excludes the caller and personas count as members;
|
||||
- TestDemoDMs: any two members open a DM, and opening it again returns the same id;
|
||||
- TestDemoModeratorDelete: a moderator-group member deletes another member's channel message (200) but not a DM message of others (404); a plain member gets 404;
|
||||
- TestDemoCommands over HTTP: GET /_chatbots/api/v1/commands lists demo and tour; POST dispatch "/demo" answers mode ephemeral; "/nope" answers 422 errors.input.
|
||||
- video_test.go (Docker) TestDemoVideoStart: an httptest server answers the Twirp RoomService calls chatvideo's RoomAPI makes, with livekit_admin_url pointing at it and test keys in the environment. POST /_chatvideo/api/v1/conversations/{dm}/video/start answers 2xx with token and livekit_url, the token's claims grant the room for that user, and a second start returns the same session. A start in a seeded channel conversation is not refused by type (D-23 UI-only); assert the status the plugin gives with the channel's participant count and max_participants 4, and record it.
|
||||
- realtime_test.go: TestRealtimeRoutes keeps the middleware list from plan 02. New TestRealtimeMemberToken (Docker): a member's JWT gives 200 with a token, a banned user's gives 403 (or the guard's documented status), no JWT gives 401.
|
||||
- seed_chat_test.go TestSeedChat: exact counts per EN and PL channel, threads with replies and summary rows consistent, reactions, pins, three forum categories with content_html, friendships, the group, the visitor's DM and pending request when the visitor is set, the moderator group, and idempotency (every new step reports exists on the second run).
|
||||
- reset_test.go: TestResetKeepsPlans also asserts that every chat-family table named in resetTables is empty after the wipe and repopulated after the reseed, and that golem15_moderation_settings and the userfriends context row are unchanged.
|
||||
- boot_test.go: the chat-family route prefixes, and jwt.auth on GET /_demo/api/v1/chat and /chat/members.
|
||||
Commit `test: demo chat family`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoChannelsSeededOnly|TestDemoVideoStart|TestSeedChat|TestResetKeepsPlans|TestRealtimeMemberToken)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit or a FAIL line from the full app suite, or fewer than five "--- PASS" lines for the named tests (skipped without Docker, a refused channel create that succeeds, a video start error against the fake RoomService, a seed count mismatch, or a reset leaving chat rows).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func Test' ../sm-demo-app/chat_test.go` prints at least 7.
|
||||
- `go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/...` passes without Docker.
|
||||
</acceptance_criteria>
|
||||
<done>Every demo host decision of the phase is pinned by a unit or database test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Layer and site unit tests: bm's chat tests on the layer, new store and client tests, the call machine and the app wiring</name>
|
||||
<files>../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/app/lib/chatEvents.test.ts, ../vue-chat-layer/app/lib/chatFormat.test.ts, ../vue-chat-layer/app/lib/chatText.test.ts, ../vue-chat-layer/app/lib/chatUnread.test.ts, ../vue-chat-layer/app/lib/realtime.test.ts, ../vue-chat-layer/app/lib/linkEmbeds.test.ts, ../vue-chat-layer/tests/client.test.ts, ../vue-chat-layer/tests/store.test.ts, ../vue-chatvideo-layer/tests/callMachine.test.ts, ../vue-chatvideo-layer/tests/videoClient.test.ts, ../vue-chatvideo-layer/tests/useCall.test.ts, ../vue-forum-layer/tests/client.test.ts, ../vue-userfriends-layer/tests/client.test.ts, ../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/lib/realtime.test.ts</files>
|
||||
<read_first>../sm-bm-app/frontend/src/lib/{chatEvents,chatFormat,chatText,chatUnread,realtime}.test.ts and stores/chat.test.ts, ../sm-bm-app/frontend/vite.config.ts (its test block), ../vue-chat-layer/app/lib/*.ts, app/composables/useChat.ts, tests/smoke.test.ts, ../vue-chatvideo-layer/app/lib/*.ts and app/composables/useCall.ts, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, vitest.config.ts, app/lib/realtime.test.ts</read_first>
|
||||
<action>Per D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23.
|
||||
- vue-chat-layer:
|
||||
- vitest.config.ts includes app/**/*.test.ts and tests/**/*.test.ts in the node environment, with aliases so bm's tests run with only import paths changed;
|
||||
- copy bm's chatEvents, chatFormat, chatText, chatUnread and realtime tests next to the ports;
|
||||
- linkEmbeds.test.ts: linkify escapes, only http(s), rel and target set, the registry order, max_embeds;
|
||||
- client.test.ts: every endpoint's URL and method, 401 refresh-and-retry once, unauthorized after a failed refresh, the 422 mapping, multipart upload;
|
||||
- store.test.ts (bm's chat.test.ts rewritten for the useState composable, with a fake client and a fake realtime):
|
||||
- the bootstrap fills channels and DMs; an empty hostApiBase starts nothing; a non-member bootstrap starts nothing (D-04);
|
||||
- opening a channel lazy-joins; a realtime message increments unread in other conversations and appends in the open one;
|
||||
- thread replies and counts, pins load, reactions toggle;
|
||||
- a "/" input dispatches instead of posting and the ephemeral answer stays local (D-05);
|
||||
- polling starts when realtime is unavailable;
|
||||
- onUserEvent receives a video:* event the store does not handle;
|
||||
- the conversation-actions slot renders only for DMs (mount ChatHeader with @vue/test-utils only if it is already in the workspace lockfiles, else test the computed visibility helper the header uses).
|
||||
- vue-chatvideo-layer:
|
||||
- callMachine.test.ts: every transition, including failure reasons;
|
||||
- videoClient.test.ts: URLs, auth, videoSessionChannel;
|
||||
- useCall.test.ts: vi.mock livekit-client with a fake Room; start, connect, a remote track event adds a tile, mute and camera toggles call the local participant, leave disconnects and posts leave, an ended event on the session channel ends the call, and a 422 start fails with the too-many reason.
|
||||
- vue-forum-layer and vue-userfriends-layer: extend the client tests to every method and error mapping.
|
||||
- vue-demo-app: move the hook factories from app/plugins/auth.ts unchanged into app/utils/communityAuth.ts (`createChatAuth`, `tokenGetter`, `friendsDirectory`). auth.ts imports them. communityAuth.test.ts covers the token read, refresh storing the new token, unauthorized clearing the cookie, and the directory mapping. app/lib/realtime.test.ts still passes through the re-export.
|
||||
Commit per repository (`test: ...`; the vue-demo-app extraction as `refactor: community auth hooks into app/utils` before its test commit).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../vue-chatvideo-layer test && pnpm --dir ../vue-forum-layer test && pnpm --dir ../vue-userfriends-layer test && 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 from any layer's test script, the vue-demo-app vitest suite, or the app build after the hook extraction.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls ../vue-chat-layer/app/lib/*.test.ts | wc -l` prints at least 6.
|
||||
- `diff <(sed -n '/^describe/,$p' ../sm-bm-app/frontend/src/lib/chatText.test.ts) <(sed -n '/^describe/,$p' ../vue-chat-layer/app/lib/chatText.test.ts)` prints nothing (only imports differ).
|
||||
- `grep -c "vi.mock('livekit-client'" ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
|
||||
- `grep -c 'from .*communityAuth' ../sm-demo-app/vue-demo-app/app/plugins/auth.ts` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>The four layers and the site's wiring are covered by unit tests, with bm's chat tests running unchanged on the port.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Two browsers locally: live channel messages and a DM call with fake media, and the phase gate in check-all</name>
|
||||
<precondition>`../sm-demo-app/scripts/dev.sh up` runs with LiveKit on 127.0.0.1:7890; the global Playwright resolves (`NODE_PATH="$(npm root -g)" node -e "require('playwright')"` exits 0).</precondition>
|
||||
<files>../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/check-all.sh, ../sm-demo-app/README.md</files>
|
||||
<read_first>../sm-demo-app/scripts/check-chat.cjs (plan 03), check-remote.cjs, lib/browser.cjs, check-local.sh (visitor_jwt, throwaway registration helper, check_remote), check-all.sh (steps and plugin dirs), README.md (checks section)</read_first>
|
||||
<action>Per SC-2, D-11, D-13, D-23.
|
||||
- check-chat.cjs: add `--two` and `--call` modes. Each launches Chromium with `--use-fake-ui-for-media-stream` and `--use-fake-device-for-media-stream` and opens two contexts with two JWTs (DEMO_JWT and DEMO_JWT2).
|
||||
- `--two`: both open /chat#general; A sends a unique text; B sees it within 10 s without reload; B reacts and A sees the reaction; B types and A sees the typing indicator.
|
||||
- `--call`: A opens a DM with B (POST /_chat/api/v1/contexts/demo/1/dm through page.request with A's token) and opens it on /chat. The DM header shows the call button and no call button shows in #general (D-23). A clicks it; B's page shows the incoming prompt within 10 s and accepts. Both call pages reach the connected state and each shows one remote tile whose video element has videoWidth above 0 within 20 s. A mutes and B sees the muted marker if the tile shows one. Both leave and the session ends (GET status).
|
||||
- Print `check-chat: ok two` or `check-chat: ok call`.
|
||||
- check-local.sh: `check_chatlive` and `check_callmedia` take the visitor JWT and a throwaway user's JWT, run the script in the matching mode, and join ALL_CHECKS and the header list.
|
||||
- check-all.sh, new steps after the existing plugin suites:
|
||||
- `go test ./...` in ../sm-chat-plugin, ../sm-forum-plugin and ../sm-moderation-plugin, and `go test -short ./...` in chatchannels, chatbots, chatvideo, herald and userfriends (directories overridable by env, as USER_PLUGIN_DIR is);
|
||||
- `pnpm test` in the four layer checkouts (overridable);
|
||||
- the local browser step runs `check-local.sh chatlive callmedia` alongside back and remote.
|
||||
Update the header comment and README's checks section.
|
||||
Commit `test: two-browser chat and call checks; phase gate covers the chat family`.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-local.sh chat chatlive callmedia forum friends call herald tour realtime remote</automated>
|
||||
<fails_when>The last line is not "check-local: ok ..." listing the checks; a "check-local: chatlive failed" or "callmedia failed" line (message not delivered live, no incoming prompt, no remote video within 20 s, or a call button in a channel) or any other named check failing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'use-fake-device-for-media-stream' ../sm-demo-app/scripts/check-chat.cjs` prints at least 1.
|
||||
- `grep -cE 'chatlive|callmedia' ../sm-demo-app/scripts/check-local.sh` prints at least 4.
|
||||
- `grep -c 'sm-moderation-plugin' ../sm-demo-app/scripts/check-all.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Two-browser realtime chat and a DM video call are proven automatically on the local stack, and the phase gate runs every new suite.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 6: Go-ahead to push the test commits (and to release fixes if the tests found bugs)</name>
|
||||
<read_first>Claude's table: per repository the unpushed commits (`git log origin/<branch>..HEAD --oneline`), which are test-only and which are fixes; for each fix, the proposed patch tag (next free number from the remote) and the pin bumps and redeploy it needs (plan 05 runbook); failures outside Task 7's fix budget are listed as todos, not as fixes</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Push the test commits, and tag and redeploy fixes if there are any?</decision>
|
||||
<context>Test-only commits get no tag (19.4.1 precedent). A production fix in a shared plugin or layer needs a new patch tag (one-way), a go.mod or package.json pin bump in the demo and a redeploy through DEPLOY.md; the table lists each case. If there are no fixes, this only pushes.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push (and release the listed fixes)</name>
|
||||
<pros>Tests are on origin; any fix reaches the live demo before the final check.</pros>
|
||||
<cons>Fix tags are permanent.</cons>
|
||||
</option>
|
||||
<option id="push-tests-only">
|
||||
<name>Push test commits only</name>
|
||||
<pros>No new tags.</pros>
|
||||
<cons>Listed fixes stay local and the live demo keeps the bug.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>check-all's suites still run locally, but origin lacks the tests.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push", "push-tests-only" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Push, release any fixes, and run the phase gate against the live demo</name>
|
||||
<reversibility rating="one-way">Only when fixes are released: their patch tags are cached by module proxies and lockfiles.</reversibility>
|
||||
<precondition>Task 6 answered "push" or "push-tests-only".</precondition>
|
||||
<files>../sm-demo-app/README.md</files>
|
||||
<read_first>The Task 6 table and answer, ../sm-demo-app/DEPLOY.md (Release, Verify)</read_first>
|
||||
<action>Per SC-2, SC-3, SC-4.
|
||||
- Push the test commits of every repository (plugins and layers first, then vue-demo-app, then sm-demo-app).
|
||||
- For "push" with fixes, in one release batch:
|
||||
- tag each fixed plugin or layer with the next patch (annotated, on the verified HEAD) and push;
|
||||
- bump the demo's go.mod (GOWORK=off go get) or package.json pins in one `chore: bump <name> to <tag>` commit each;
|
||||
- push vue-demo-app before the sm-demo-app pointer bump;
|
||||
- release on rome through DEPLOY.md.
|
||||
- Run `scripts/check-all.sh --local` (every suite, the browser checks, check-live). Fix a failure only within the fix budget below, then run it again. Do not loop past the budget.
|
||||
- Fix budget. It bounds the whole plan: fixes made in Tasks 1 to 5, in this task and for the Task 8 findings.
|
||||
- Source: a fix is allowed only for a failure found by this plan's own tests or checks (Tasks 1 to 5, check-all, check-live, the Task 8 UAT).
|
||||
- Size: each fix is small. It adds no feature, changes no schema or migration, and changes no API route, payload, event, config key, hook, prop or other published contract.
|
||||
- Release: at most one new patch tag per affected repository and at most one redeploy on rome in this plan. Every in-budget fix goes into that one release batch. Under "push-tests-only", fixes are committed but not tagged or deployed, and each one is recorded as a todo.
|
||||
- After the release: a fix that needs no tag or redeploy (a check script or a test in sm-demo-app) is still allowed if it is small. Anything that needs a second tag of the same repository or a second redeploy is outside the budget.
|
||||
- Outside the budget: do not fix it in this plan. Record it as a todo in `.planning/todos/pending/` with its g15office task (`.planning/G15OFFICE.md` rules). List it in the SUMMARY as a decimal-phase candidate (for example `/msd-phase --insert 20.1`). Leave the check-all result as it is, and stop at Task 8's checkpoint with the open failures listed for the user.
|
||||
- Record in README's checks section what the gate now covers.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-all.sh --local 2>&1 | tail -1 | grep -qx 'check-all: ok'</automated>
|
||||
<fails_when>The last line is not "check-all: ok" (a suite, the release build, check-pins, smoke, check-deploy, a browser check or check-live failed; the failing step is named in its own "check-all: <step> failed" line). A failure outside the fix budget keeps this red: it is recorded as a todo and the plan stops at Task 8 instead of fixing further.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- For each repository touched in this plan, `git ls-remote origin` shows the default branch at the local HEAD (or the SUMMARY records the "hold" answer).
|
||||
- Any fix tag equals its pushed HEAD and the demo pins it (`check-pins.sh` prints `check-pins: ok`).
|
||||
- The fix budget held. The SUMMARY lists every production fix with its commit and the failure that found it, every tag created in this plan (no repository twice), and at most one DEPLOY.md release. `git -C <repo> show --stat <fix>` for each listed fix touches no migration (`updates/`) and no parity test file (`php_method_map_test.go`, `golden_test.go`).
|
||||
- Either the last line of check-all is `check-all: ok`, or every remaining failure is recorded as a todo in `.planning/todos/pending/` with a `g15office:` ID and named as a decimal-phase candidate in the SUMMARY, and execution stopped at Task 8 with those failures shown to the user.
|
||||
</acceptance_criteria>
|
||||
<done>Every test is on origin, every in-budget fix is live, and the phase gate is green against the live demo, or each remaining failure is recorded as a todo and decimal-phase candidate for the user at Task 8.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 8: The user checks chat and a DM video call on the live demo with two browsers</name>
|
||||
<read_first>The Task 7 check-all output, ../sm-demo-app/DEPLOY.md (Verify), 20-VALIDATION.md (Manual-Only Verifications)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user replied "approved". Or each issue they listed was fixed within Task 7's fix budget (released in the plan's one release batch only if it is still unused, check-live green again) and the user approved. Or the issues outside the budget are recorded as todos and decimal-phase candidates in the SUMMARY and the user approved closing the plan with them open.
|
||||
</acceptance_criteria>
|
||||
<what-built>https://demo.summercms.io with chat (channels, DMs, threads, pins, reactions, attachments, slash commands, floating panel), forum, friends and DM video calls through the demo's own LiveKit, moderation of banned words and links, EN and PL, and the tour cards. Anonymous visitors get register prompts on /chat and /forum.</what-built>
|
||||
<how-to-verify>
|
||||
1. Browser A: log in as the visitor (DEMO_VISITOR_EMAIL; the password is SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD in /srv/summercms-demo/.env on rome). Browser B (another profile or device on a different network if possible, for example a phone on mobile data): register a new account.
|
||||
2. Both open /chat, then #general. Send a message from A and see it arrive in B without reloading. Reply in a thread, react, and pin if allowed. Type "/demo" and see the answer only in your own window.
|
||||
3. Add a banned word in the admin (log in at /smbackend with the admin credentials in /root/summercms-demo-admin.txt on rome, Settings › Moderation), then send a message containing it: it is refused with a message. Remove the word again.
|
||||
4. From A, open a DM with B's account and press the call button (it must not appear in #general). In B, accept the prompt. Both see and hear each other; mute, toggle the camera, then leave.
|
||||
5. Check /forum and /friends as a member and anonymously (register prompt), the floating chat panel on /blog, and the Polish language switch.
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved", or describe what is wrong.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test harnesses to Docker Postgres and local services | test data only, no production systems |
|
||||
| browser checks to the local and live demo | throwaway accounts, the visitor credentials from .env |
|
||||
| build machine to origin and rome (fix releases only) | tags and releases after the go-ahead |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-35 | Information disclosure | credentials in tests and check output | medium | mitigate | tests use generated users and test keys; check scripts read the visitor password from .env and never print it; the live UAT names the .env key, not the value |
|
||||
| T-20-36 | Tampering | regressions hiding behind skipped tests | medium | mitigate | verify commands count "--- PASS" lines so a Docker-less skip fails the gate; check-all runs full (non-short) suites |
|
||||
| T-20-37 | Denial of service | throwaway accounts on the live demo | low | accept | the live check registers no accounts (check-live is read-only); the human UAT creates one, removed by the reset job |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new package: tests use vitest 3.2.7 and the existing workspace pins; @vue/test-utils only if already in a lockfile (otherwise the helper is tested directly); Playwright is the existing global install |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: moderation suite and TestDemoModeration.
|
||||
- Task 2: chat, forum and moderation suites with Docker; parity tests unedited.
|
||||
- Task 3: sm-demo-app full suite plus the named database tests.
|
||||
- Task 4: four layer suites, vue-demo-app vitest and build.
|
||||
- Task 5: check-local with the two-browser chat and call checks.
|
||||
- Task 6: push and fix-release decision recorded.
|
||||
- Task 7: check-all --local prints check-all: ok against the live demo.
|
||||
- Task 8: the user approves the live demo.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
SC-4: the phase's new code is unit-tested in its last plan. SC-2 and SC-3 are proven live, automatically through check-all and check-live and by the user through a two-browser chat and a DM video call on https://demo.summercms.io.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md` when done: the test names per repository, bugs found and fixed (commits and tags), what TestDemoVideoStart recorded for a channel start, the check-all output, both checkpoint answers and the user's UAT notes.
|
||||
</output>
|
||||
@@ -0,0 +1,147 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Context
|
||||
|
||||
**Gathered:** 2026-10-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Phase 19.4.1 put `sm-demo-app` live on demo.summercms.io with the plugins of that time. This phase adds the chat family to that live instance and shows it working on the public Nuxt site: `chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald` and `userfriends` are mounted at tagged versions, seeded by `demo:seed`, deployed on rome, and given site UI through new shared Nuxt layers. Everything decided in 19.4.1 still holds (hosting on rome, own Centrifugo, `/backend` refused by nginx with the contact call to action, open registration behind Turnstile, the reset job, the demo banner, EN/PL, the summercms.io look). This phase does not repeat it.
|
||||
|
||||
New shared repos created by this phase: `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer`, `sm-moderation-plugin`.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Chat frontend
|
||||
- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
|
||||
- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
|
||||
- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
|
||||
- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
|
||||
- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
|
||||
|
||||
### Chat context and access
|
||||
- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
|
||||
- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
|
||||
- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
|
||||
- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
|
||||
|
||||
### Video calls
|
||||
- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
|
||||
- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
|
||||
- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
|
||||
- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
|
||||
|
||||
### Forum, friends, push
|
||||
- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
|
||||
- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
|
||||
- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
|
||||
|
||||
### Moderation
|
||||
- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
|
||||
- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
|
||||
|
||||
### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
|
||||
- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
|
||||
|
||||
### Plan-time decisions (2026-10-28, after research)
|
||||
- **D-20:** Six plans, as in `20-RESEARCH.md` § Suggested Plan Split: 01 shared plugin release (sibling fast-forward, go.mod fixes, chat and forum pre-save seams, `sm-moderation-plugin`, tags); 02 demo backend; 03 `vue-chat-layer` and its use in vue-demo-app; 04 the chatvideo, forum and userfriends layers, pages, tour cards, herald VAPID; 05 rome deploy (LiveKit, Vuurmuur rule as a checkpoint, nginx, `.env`, DEPLOY.md, check-deploy/check-live); 06 unit tests and live verification. 03 and 04 may run in parallel after 02.
|
||||
- **D-21:** Anonymous visitors on `/forum` see a description and a "register to join" prompt, as on `/chat` (D-04). No public forum read endpoint.
|
||||
- **D-22:** People-moderation uses a demo moderator user group (bm pattern) whose members can delete messages from the site UI; banning goes through the existing user admin. Chat-family admin screens are deferred.
|
||||
- **D-23:** chatvideo keeps about 4 participants per room (D-12). Calls are limited to DMs by the UI only: the call button appears only in DM conversations. The server does not refuse channel calls.
|
||||
|
||||
### Claude's Discretion
|
||||
- Seeded channel names and persona users, and how much EN/PL history to seed.
|
||||
- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
|
||||
- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
|
||||
- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
|
||||
- How `vue-chat-layer` exposes extension points for app-specific embeds.
|
||||
|
||||
### Folded Todos
|
||||
- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump <plugin> to <tag>` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Prior demo decisions
|
||||
- `.planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/19.4.1-CONTEXT.md` — hosting, accounts, reset job, banner, tour, EN/PL, look, Centrifugo, tagged-module go.mod (D-01..D-23)
|
||||
- `../sm-demo-app/DEPLOY.md` — rome layout, ports in use, supervisor programs, Centrifugo, release and rollback
|
||||
- `../sm-demo-app/README.md` — local run, `demo:seed`, credentials handling
|
||||
- `../sm-demo-app/summer.yaml`, `../sm-demo-app/go.mod` — the current plugin set and tagged requires
|
||||
|
||||
### Chat UI source (port)
|
||||
- `../sm-bm-app/frontend/src/components/chat/` — ChatPanel, MessageList, ChatMessage, ChatComposer, ChatHeader, DmList, MemberPicker, MentionPopup, ReactionBar, EmbedCard, EmbedPicker, StagingChips, DeleteDialog, Avatar
|
||||
- `../sm-bm-app/frontend/src/stores/chat.ts`, `../sm-bm-app/frontend/src/api/chat.ts`, `../sm-bm-app/frontend/src/lib/chat{Events,Format,Text,Unread}.ts` (with their tests)
|
||||
- `../sm-bm-app/plugins/jz/bm/classes/chat_context.go`, `../sm-bm-app/plugins/jz/bm/chat.go` — host context and access wiring to mirror in `golem15.demo`
|
||||
- `.planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/` plans for the presentations port (precedent for porting a bm Vite view into Nuxt)
|
||||
|
||||
### Plugins
|
||||
- `../sm-chat-plugin/README.md` — Context contract, Service, events, generic API, channel authorizer
|
||||
- `../sm-chatchannels-plugin/README.md` — channels, access resolver
|
||||
- `../sm-chatvideo-plugin/README.md` — LiveKit config keys, API, reconcile job, limits
|
||||
- `../sm-chatbots-plugin/README.md` — command catalog, dispatch, bot-auth seam
|
||||
- `../sm-forum-plugin/README.md`, `../sm-herald-plugin/README.md`, `../sm-userfriends-plugin/README.md`
|
||||
- `.planning/phases/19-chat-and-chatchannels/19-CONTEXT.md` — chat port contract (PHP parity, D-07 generic API mirrors WavePath, which must not drift)
|
||||
- `.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md` — satellites decisions
|
||||
- `.planning/notes/core-plugins-own-repos.md` — new shared plugin and layer repos, submodule mounting, commit order
|
||||
|
||||
### Existing layers (pattern)
|
||||
- `../vue-journal-layer`, `../vue-faq-layer`, `../vue-menu-layer`, `../vue-paymentgateway-layer`, `../vue-oauth-layer` — layer structure, tags, how `vue-demo-app` consumes them
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- bm chat SPA (about 4.6k lines with tests): the store, the API client and the event handling port almost directly; components need Nuxt/SSR guards (client-only realtime).
|
||||
- The demo's Centrifugo and `lighthouse` wiring from 19.4.1 plan 04 (speaker/remote sync) carries chat realtime too; chat authorizes `chat:conversation:{id}` and chatvideo `video:session:{id}`.
|
||||
- `golem15.demo` plugin and `demo:seed`, with the EN/PL seeding through translate (19.4.1 D-13).
|
||||
- The `realtime_test.go`, `seed_test.go`, `reset_test.go` and `go_mod_test.go` patterns in sm-demo-app for the new plugins.
|
||||
|
||||
### Established Patterns
|
||||
- Shared plugin and layer repos are their own git.golem15.com repos, mounted as submodules. Plugins are pushed and tagged first, then the pointer bump in the app goes in a separate commit.
|
||||
- `go.mod` requires tagged versions (19.4.1 D-03); `scripts/check-pins.sh` and `TestGoModRequiresTags` enforce checkouts at the tag.
|
||||
- chat/chatchannels APIs and events are PHP-parity contracts for WavePath (Phase 19 D-07, Phase 31). Changes are additive only.
|
||||
|
||||
### Integration Points
|
||||
- `golem15.demo`: chat `contracts.Context`, chatchannels `ChannelAccessResolver`, seed, moderation word lists, chatbots command registrations.
|
||||
- `vue-demo-app/nuxt.config.ts` `extends`: four new layers.
|
||||
- rome: new supervisor program for LiveKit, firewall and nginx changes, `.env` keys (LiveKit, VAPID).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "There is a simple chat view in sm-bm-app": port it rather than build chat UI from scratch.
|
||||
- The video UI starts small but as a reusable layer ("make it as initial vue-chatvideo-layer"), so later sites and the WavePath work can grow it.
|
||||
- The user prefers reusable shared pieces over demo-only code: a layer for each satellite with UI, and a shared moderation plugin rather than a filter inside `golem15.demo`.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
|
||||
- User-created channels (public or private) and ad-hoc group DMs with group calls.
|
||||
- A live bot persona answering in channels.
|
||||
- A read-only `/chat` preview for anonymous visitors.
|
||||
- Admin screens for chat-family plugins (deleting messages from the backend); D-22 uses a site-side moderator group for now.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
|
||||
- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 20-demo-instance-with-every-shared-plugin*
|
||||
*Context gathered: 2026-10-28*
|
||||
@@ -0,0 +1,137 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-28
|
||||
**Phase:** 20-demo-instance-with-every-shared-plugin
|
||||
**Areas discussed:** Chat frontend source, Chat context & access, Video calls hosting, Abuse & satellites scope
|
||||
|
||||
The user corrected the opening premise: "There is simple chat view in sm-bm-app". bm's Vite SPA chat (about 4.6k lines) became the port source.
|
||||
|
||||
Folded todo: "Commit sm-demo-app's leftover journal and oauthserver submodule moves" (Fold it in).
|
||||
|
||||
---
|
||||
|
||||
## Chat frontend source
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| New vue-chat-layer | Shared Nuxt layer; bm-specific bits stripped or slots | ✓ |
|
||||
| Demo-only pages | Port into vue-demo-app as 19.4.1 did for presentations | |
|
||||
| Layer, but later | Demo-only now, extraction as backlog todo | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Full bm feature set | Channels, DMs, threads, mentions, reactions, typing, unread, attachments | ✓ |
|
||||
| Core only | Channels and DMs with realtime, unread, typing | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Dedicated /chat page | Full-page chat plus tour card | |
|
||||
| Page + floating panel | /chat plus floating DM/unread widget | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Read-only #general preview | Live read-only seeded channel | |
|
||||
| Login prompt only | As quizzes D-18 | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Chat context & access
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One demo community | Single context like bm/1 | ✓ |
|
||||
| Per-user workspaces | Users create contexts | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Seeded public + user private | Users create private channels | |
|
||||
| Anyone, any channel | | |
|
||||
| Seeded only | Join seeded channels, DM each other | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Seeded users + history, EN/PL | Persona users with history | ✓ |
|
||||
| Plus a live bot | Bot answers slash commands | |
|
||||
| Empty channels | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Any member of the community | userfriends does not gate DMs | ✓ |
|
||||
| Friends only | | |
|
||||
|
||||
---
|
||||
|
||||
## Video calls hosting
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Self-host on rome | LiveKit under supervisor, own keys | ✓ |
|
||||
| LiveKit Cloud free tier | | |
|
||||
| Decide in research | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| DMs and private groups | No calls in public channels | ✓ |
|
||||
| Any conversation | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| LiveKit components in the chat layer | Call view on livekit-client | |
|
||||
| Minimal call page | Bare-bones join page | ✓ (as the initial vue-chatvideo-layer) |
|
||||
|
||||
**Notes:** "make it as initial vue-chatvideo-layer". Since channels are seeded only and chat has no ad-hoc group DMs, calls are effectively 1:1 DMs.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Short calls, few people | ~15 min, ~4 participants | ✓ |
|
||||
| Plugin defaults | | |
|
||||
|
||||
---
|
||||
|
||||
## Abuse & satellites scope
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Rate limits + report + reset | No content filter | |
|
||||
| Plus a word filter | Banned words/link filter | ✓ |
|
||||
| Posting needs approval | | |
|
||||
|
||||
Follow-up, filter home: Hook in plugins + list in demo / Demo plugin only / **New shared plugin** ✓
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Forum page + friends in chat + /commands | Built in demo or chat layer | |
|
||||
| A layer for each | vue-forum-layer, vue-userfriends-layer | ✓ |
|
||||
| Forum only | | |
|
||||
|
||||
Follow-up, which layers: **forum + userfriends; commands in chat layer** ✓ / Also a vue-chatbots-layer
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Push for DMs and mentions | Opt-in notifications | |
|
||||
| Mounted, no UI | | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Backend first, then layers | 5-plan split | ✓ |
|
||||
| You decide at plan time | | |
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Seeded channel names, personas, history volume
|
||||
- LiveKit port/TURN exposure on rome
|
||||
- Shape of the pre-save validator seam in chat and forum
|
||||
- Demo chatbots commands
|
||||
- vue-chat-layer extension points for embeds
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Web Push subscribe UI
|
||||
- User-created channels and ad-hoc group DMs/group calls
|
||||
- Live bot persona
|
||||
- Anonymous read-only chat preview
|
||||
@@ -0,0 +1,288 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-28
|
||||
**Files analyzed:** 34 (grouped by repo)
|
||||
**Analogs found:** 31 / 34
|
||||
|
||||
Paths are relative to the summercms.go repo root. The analogs are tracked source in their own repos. bm's `plugins/golem15/*` trees are nested submodules of sm-bm-app, and `plugins/jz/bm` is its own repo. Edit the shared plugins only in their `../sm-*-plugin` checkouts, after fast-forwarding them (RESEARCH Pitfall 1). Read the bm copies for reference only.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `../sm-chat-plugin/events/events.go` (+`MessageSaving`) | event type | event-driven (pre-save) | `sm-user-plugin/classes/events.go` (DeactivatingEvent, lines 60-90) | role-match |
|
||||
| `../sm-chat-plugin/contracts/rejection.go` (new) | contract/error | request-response | none (new type); errors pattern in `chat/api/errors.go:35-45` | partial |
|
||||
| `../sm-chat-plugin/classes/service.go` (fire seam in `SendMessageWith`, `EditMessage`) | service | CRUD | itself, lines 287-345 | exact |
|
||||
| `../sm-chat-plugin/api/errors.go` (map Rejection to 422) | api helper | request-response | itself, `writeValidation` lines 39-44 | exact |
|
||||
| `../sm-forum-plugin/events/events.go` (new pkg, `PostSaving`) | event type | event-driven | chat `events/events.go` | role-match |
|
||||
| `../sm-forum-plugin/classes/forum_service.go` (fire in CreateThread/Reply/EditPost) | service | CRUD | itself, lines 263-300 (`Bus` field line 57-58, unused) | exact |
|
||||
| `../sm-forum-plugin/api/errors.go` | api helper | request-response | itself, `writeValidation` line 28, `writeForumError` line 44 | exact |
|
||||
| `../sm-chatvideo-plugin/go.mod`, `../sm-herald-plugin/go.mod`, `../sm-userfriends-plugin/go.mod` | config | n/a | `sm-bm-app/plugins/golem15/chatbots/go.mod` (commit d0b9fb1 "drop sibling replaces") | exact |
|
||||
| `../sm-moderation-plugin/plugin.go` | plugin entry | boot | `../sm-sitemap-plugin/plugin.go` lines 1-80 | role-match |
|
||||
| `../sm-moderation-plugin/admin.go` (HasSettings) | admin | CRUD (settings singleton) | `../sm-sitemap-plugin/admin.go` lines 40-72 | exact |
|
||||
| `../sm-moderation-plugin/models/settings.go` + `models/settings/fields.yaml` | model | CRUD | `../sm-sitemap-plugin/models/settings.go`, `models/settings/fields.yaml` | exact |
|
||||
| `../sm-moderation-plugin/classes/filter.go` | service | transform | none | no analog |
|
||||
| `../sm-moderation-plugin/updates/`, `lang/`, `config/`, `README.md` | migration/lang/config | n/a | `../sm-sitemap-plugin/{updates,lang,config,README.md}` | exact |
|
||||
| `../sm-demo-app/plugins/demo/chat.go` | host wiring | boot/event-driven | `../sm-bm-app/plugins/jz/bm/chat.go` lines 1-95 | exact |
|
||||
| `../sm-demo-app/plugins/demo/classes/chat_context.go` (DemoContext, ChannelContext adapter, access resolvers) | model/contract impl | request-response | `../sm-bm-app/plugins/jz/bm/classes/chat_context.go` | exact |
|
||||
| `../sm-demo-app/plugins/demo/realtime_access.go` | middleware | request-response | `../sm-bm-app/plugins/jz/bm/realtime_access.go` | exact |
|
||||
| `../sm-demo-app/plugins/demo/routes.go` (mountRealtime guard, chat bootstrap routes) | route | request-response | itself, lines 60-87 | exact |
|
||||
| `../sm-demo-app/plugins/demo/chat_api.go` (`GET /_demo/api/v1/chat`, `/chat/members`) | controller | request-response | `../sm-bm-app/plugins/jz/bm/controllers/api/chat.go`; style `plugins/demo/quizzes_api.go` | exact |
|
||||
| `../sm-demo-app/plugins/demo/chatbots.go` (demo commands) | service | request-response | RESEARCH Code Examples "Chatbots command"; contracts `chatbots/contracts/slash_command.go` | partial |
|
||||
| `../sm-demo-app/plugins/demo/plugin.go` (Requires, Middlewares, Boot calls registerChat) | plugin entry | boot | itself, lines 33-100 | exact |
|
||||
| `../sm-demo-app/plugins/demo/console/seed_chat.go`, `seed_forum.go`, `seed_friends.go` | console/seed | batch | `plugins/demo/console/seed.go` (`seedQuiz` 309-340, `insertID`/`exists` 177-189) | exact |
|
||||
| `../sm-demo-app/plugins/demo/console/reset.go` (`resetTables`) | console | batch | itself, lines 21-75 | exact |
|
||||
| `../sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules}` | config | n/a | existing entries (summer.yaml plugins list) | exact |
|
||||
| `../sm-demo-app/go_mod_test.go`, `boot_test.go`, `realtime_test.go`, `reset_test.go`, `seed_test.go` (extend) + new `chat_test.go`, `seed_chat_test.go` | test | n/a | the same files (`TestGoModRequiresTags` :22, `TestBootAllPlugins` :31, `TestRealtimeRoutes` :124) | exact |
|
||||
| `../sm-demo-app/deploy/centrifugo.json.example` (chat/user/video namespaces) | config | pub-sub | itself lines 41-46 + `../sm-bm-app/deploy/centrifugo/bm-config.json` lines 39+ | exact |
|
||||
| `../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf`, `deploy/livekit.yaml.example` | config | n/a | `deploy/supervisor/summercms-demo-centrifugo.conf`; RESEARCH supervisor block | exact |
|
||||
| `../sm-demo-app/deploy/nginx/demo.summercms.io.conf`, `scripts/{dev.sh,check-deploy.sh,check-live.sh,check-local.sh}`, `DEPLOY.md` | config/script | n/a | the same files (Centrifugo `/ws` sections) | exact |
|
||||
| `../vue-chat-layer/nuxt.config.ts`, `package.json` | config | n/a | `../vue-faq-layer/nuxt.config.ts`, `package.json` | exact |
|
||||
| `../vue-chat-layer/app/composables/useChatApi.ts` (auth hook `chatAuth`) | composable | request-response | `../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts` | exact |
|
||||
| `../vue-chat-layer/app/composables/useChat.ts` (store) | store | event-driven | `../sm-bm-app/frontend/src/stores/chat.ts` (Pinia setup store, convert) | role-match |
|
||||
| `../vue-chat-layer/app/lib/realtime.ts` (+`onUserEvent`) | utility | pub-sub | `../sm-demo-app/vue-demo-app/app/lib/realtime.ts` (`openChannel` :58, dynamic import :59) | exact |
|
||||
| `../vue-chat-layer/app/lib/chat{Events,Format,Text,Unread}.ts` + tests, `app/components/chat/*` | utility/component | transform | `../sm-bm-app/frontend/src/lib/chat*.ts`, `frontend/src/components/chat/*` | exact (port) |
|
||||
| `../vue-chat-layer/app/components/chat/{ChannelList,ThreadPane,PinsPanel,CommandPicker}.vue` | component | request-response | none in bm (bm has one channel, no threads/pins) | no analog |
|
||||
| `../vue-chatvideo-layer/*` (call page on livekit-client) | component/composable | streaming | none (bm has no video UI) | no analog |
|
||||
| `../vue-forum-layer/*`, `../vue-userfriends-layer/*` | layer | request-response | `../vue-faq-layer` (structure) + `../vue-paymentgateway-layer` (authenticated client) | role-match |
|
||||
| `../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts}` | config/provider | n/a | itself: `extends` line 51, layer deps lines 19-22, auth.ts | exact |
|
||||
| `../sm-demo-app/vue-demo-app/app/pages/{chat.vue,forum.vue,friends.vue,call/[id].vue}` | page | request-response | `app/pages/quizzes/index.vue` (anonymous register prompt, lines 46, 68) | exact |
|
||||
| `../sm-demo-app/vue-demo-app/app/pages/index.vue` (tour cards) | page | n/a | itself, `<TourCard` line 40 | exact |
|
||||
|
||||
Edit `sm-demo-app/vue-demo-app` (the submodule), not the stale `../vue-demo-app` (RESEARCH Anti-Patterns).
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Chat / forum pre-save seam (D-18)
|
||||
|
||||
**Event type analog:** `sm-user-plugin/classes/events.go` (read here at `../sm-demo-app/plugins/golem15/user/classes/events.go`), lines 13-17 and 74-86:
|
||||
```go
|
||||
const DeactivatingEventName = "user.Deactivating"
|
||||
// ... A listener error rolls the deactivation back.
|
||||
type DeactivatingEvent struct {
|
||||
User *models.User
|
||||
Tx *gorm.DB
|
||||
}
|
||||
```
|
||||
`MessageSaving` and `PostSaving` follow the same doc-comment style and add a `<Name>EventName` const. They fire **before** the transaction, so they carry no `Tx`.
|
||||
|
||||
**Fire site, chat:** `../sm-chat-plugin/classes/service.go` `SendMessageWith` (lines 287-345 at v0.1.3). Insert the call after the `ErrEmptyMessage` check (line ~311) and before `lagoon.Transaction`. Copy the existing nil-bus guard:
|
||||
```go
|
||||
if s.bus != nil {
|
||||
if err := s.bus.Fire(ctx, &events.MessageCreated{MessageEvent: events.MessageEvent{
|
||||
MessageID: msg.ID, ConversationID: conversationID, ActorID: senderID, RootID: rootID, Tx: tx,
|
||||
}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
Do the same in `EditMessage` after `CanEditMessage`. `Fire` returns `errors.Join`, so callers use `errors.As`.
|
||||
|
||||
**Fire site, forum:** `../sm-forum-plugin/classes/forum_service.go` `CreateThread` lines 263-281. Fire between the `CanPost` check and `html := breakwater.Render(body)` (line 281). Do the same in `Reply` (line 316, before the Render at 330) and in `EditPost`. Use `s.Bus` (field at line 57-58), guarded for nil.
|
||||
|
||||
**422 mapping:** `../sm-chat-plugin/api/errors.go` lines 39-44 (forum: `api/errors.go:28`):
|
||||
```go
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": errValidation,
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
```
|
||||
In the service-error mapper (`writeChatError` / `writeForumError` line 44), add `var rej contracts.Rejection; if errors.As(err, &rej) { writeValidation(w, map[string][]string{rej.Field: {rej.Message}}); return }` before the 500 default. With no listener registered, no existing branch changes.
|
||||
|
||||
**Tests:** keep `php_method_map_test.go` and `golden_test.go` green. Add the seam tests next to `tombstone_test.go` / `upload_limit_test.go`, using the `harness_test.go` fixture.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-moderation-plugin` (new shared plugin)
|
||||
|
||||
**Plugin entry analog:** `../sm-sitemap-plugin/plugin.go` lines 1-60. Copy the package doc comment, the interface assertions and the embedded config:
|
||||
```go
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
)
|
||||
//go:embed config
|
||||
var configFS embed.FS
|
||||
type Plugin struct { app *backpack.App }
|
||||
func (p *Plugin) ID() string { return "golem15.sitemap" }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
|
||||
```
|
||||
Publish `*classes.Filter` from an `lagoon.OnDatabase` hook in Boot, the way chat publishes its Service (`app.Lookup[*chatclasses.Service]()` in bm chat.go:58). The plugin must not import chat or forum.
|
||||
|
||||
**Settings analog:** `../sm-sitemap-plugin/admin.go` lines 54-72:
|
||||
```go
|
||||
{
|
||||
Code: "sitemap_settings",
|
||||
Label: "golem15.sitemap::lang.settings.label",
|
||||
Description: "golem15.sitemap::lang.settings.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "settings",
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
},
|
||||
// ...
|
||||
_ pact.HasSettings = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
```
|
||||
**Model:** `../sm-sitemap-plugin/models/settings.go` uses a singleton row id 1 on `golem15_<plugin>_settings`, `*string` columns, and validation in `BeforeValidate` with a lang-key error const (`errFrontendURL`). The moderation model gets `banned_words` and `banned_links` (textarea, one per line). **fields.yaml:** copy the header block and the `label/comment/type/span` shape from `../sm-sitemap-plugin/models/settings/fields.yaml`.
|
||||
|
||||
**Filter (no analog):** a stdlib-only `Check(ctx, text) *Violation`: case-insensitive word-boundary match for words; links matched by scheme, host or bare domain. Use table tests.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-demo-app/plugins/demo/chat.go` (host wiring)
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/chat.go` lines 1-95.
|
||||
|
||||
Imports (lines 3-15). Swap the bm classes for `sm-demo-app/plugins/demo/classes` and add the forum, chatbots, moderation and chat events imports:
|
||||
```go
|
||||
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
|
||||
chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts"
|
||||
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
```
|
||||
Core (lines 35-50, 72-87). Add a second `reg.Add` for `chanmodels.ChannelAlias` (RESEARCH Pattern 1):
|
||||
```go
|
||||
if app == nil || !app.HasPlugin("golem15.chat") { return nil }
|
||||
reg, ok := app.Lookup[*chatcontracts.Registry]()
|
||||
if !ok || reg == nil { return errors.New("jz.bm: golem15.chat must be listed before jz.bm in summer.yaml") }
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext}); err != nil { return err }
|
||||
...
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
|
||||
svc, ok := app.Lookup[*chanclasses.ChannelService]()
|
||||
if !ok || svc == nil { return errors.New("... must be listed before ...") }
|
||||
svc.SetAccessResolver(classes.NewChatAccess(p.database, p.chatGroup(), nil))
|
||||
return nil
|
||||
})
|
||||
```
|
||||
Copy the config-key const block (lines 17-28) for `golem15.demo.chat.moderator_group` and the limits. The moderation listeners follow RESEARCH Pattern 2 (`app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)`).
|
||||
|
||||
### `../sm-demo-app/plugins/demo/classes/chat_context.go`
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/classes/chat_context.go`. Copy:
|
||||
- The interface assertions (lines 12-17).
|
||||
- The const block (lines 21-35): `ChatContextType = "demo"`, `ChatContextID = 1`, `ChatBroadcastChannel = "chat:context:1"`. The broadcast channel must never be empty.
|
||||
- `MemberFunc` injection and `WithMembership` for tests.
|
||||
- The five one-line methods (`ID/Type/ChatEnabled/GroupName/BroadcastChannel`).
|
||||
- `CanActFor` → `chatcontracts.DefaultCanActFor`; `CanOverseeChats` false.
|
||||
- `CanModerateMessage` limited to `conversationType == "group"` plus the moderator group (D-22).
|
||||
- `RequireLiveMembership` true.
|
||||
|
||||
Difference from bm: `HasMember` means "registered, not deleted, not banned". There is no group check (D-06), so drop `ChatMemberIDs`'s group join and query `users` directly. Add a `ChannelContext` with the same methods, loaded by channel id, and `BroadcastChannel() = "chat:channel:{id}"`.
|
||||
|
||||
### `../sm-demo-app/plugins/demo/realtime_access.go`
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/realtime_access.go` lines 1-70. Copy `MiddlewareRealtimeAccess` (name it `demo.realtime-access`), the `presentationsOwnerGuard` name const, the `sync.Once` owner-guard lookup, and the 401/503/member-pass flow (`bouncer.User(r.Context())`, `writeRealtimeGuard`). Then change `../sm-demo-app/plugins/demo/routes.go:78-86`. Mount when presentations **or** chat is active, and replace `"presentations.realtime-owner"` with the new guard:
|
||||
```go
|
||||
return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{
|
||||
UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime),
|
||||
ServerToServer: surf.Use(),
|
||||
Middleware: surf.Use(),
|
||||
})
|
||||
```
|
||||
`plugin.go` gains `pact.HasMiddleware` (`Middlewares() map[string]pact.Middleware`, bm realtime_access.go:24-29).
|
||||
|
||||
### `../sm-demo-app/plugins/demo/chat_api.go`
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/controllers/api/chat.go` (bootstrap JSON shape: channels with unread and mentions, DMs, members, limits). Mount it the way `routes.go:60-62` does: `r.Group(APIPrefix, surf.Use("jwt.auth", "throttle:120,1"), func(g pact.Router) { g.Get("/chat", ...) })`. Write errors with `wire.WriteJSON(w, status, map[string]string{"error": ...})` (routes.go:98).
|
||||
|
||||
### Seed and reset
|
||||
|
||||
**Seed analog:** `../sm-demo-app/plugins/demo/console/seed.go`:
|
||||
- Helpers at lines 177-189 (`exists`, `insertID` with `RETURNING id`).
|
||||
- `seedQuiz` (309-340) is the idempotent `(bool, error)` step shape, registered through `created(...)` in `runSeed` (40-80).
|
||||
- Use raw SQL only. Do not go through `chat.Service` or a `CreateChannel` gated by the resolver (RESEARCH Anti-Patterns).
|
||||
- Locale variants follow `seed_locales.go`.
|
||||
|
||||
**Reset analog:** `../sm-demo-app/plugins/demo/console/reset.go:21-40`, `resetTables []struct{ table, where string }`, children first. Insert the chat-family tables before `users` in the order from RESEARCH Pitfall 10. Keep `golem15_moderation_settings`.
|
||||
|
||||
### Tests (sm-demo-app)
|
||||
|
||||
Extend `go_mod_test.go:22 TestGoModRequiresTags` (its `wantPlugins` list), `boot_test.go:31 TestBootAllPlugins` (route prefixes), `realtime_test.go:124 TestRealtimeRoutes` (any member gets 200) and `reset_test.go`. New `chat_test.go` follows `demo_api_test.go`, and `seed_chat_test.go` follows `seed_test.go`, Docker-gated through `testmain_test.go`.
|
||||
|
||||
### Deploy config
|
||||
|
||||
- **Centrifugo:** `../sm-demo-app/deploy/centrifugo.json.example:41-46` uses the `{"name": ..., "subscribe_proxy_enabled": true}` format. Add `chat` (presence and join_leave options from `../sm-bm-app/deploy/centrifugo/bm-config.json` namespaces, line 39+), `user` and `video`. Keep the demo's option names, not bm's named proxies (RESEARCH A6).
|
||||
- **Supervisor:** copy `deploy/supervisor/summercms-demo-centrifugo.conf` to `summercms-demo-livekit.conf` using the RESEARCH "Supervisor program" block. **LiveKit yaml:** use the RESEARCH block (ports 7890/7891/7892).
|
||||
- **nginx:** add the `location ^~ /livekit/` block next to the existing `/ws` block in `deploy/nginx/demo.summercms.io.conf`, and extend the Go path allow-list with `/_chat/ /_chatchannels/ /_chatvideo/ /_chatbots/ /_forum/ /_userfriends/`.
|
||||
|
||||
---
|
||||
|
||||
### `../vue-chat-layer` (and the structure of the other three layers)
|
||||
|
||||
**Config analog:** `../vue-faq-layer/nuxt.config.ts`. Copy:
|
||||
- The header comment ("An app consumes it with `extends: [...]`").
|
||||
- `const currentDir = fileURLToPath(new URL('.', import.meta.url))` with absolute css paths.
|
||||
- `runtimeConfig.<x>.apiBase` (server, `NUXT_<X>_API_BASE`) and `runtimeConfig.public.<x>.apiBase` (same-origin).
|
||||
|
||||
Leave out the faq-specific `cachePurge`/`siteCache` keys. Add `public.chat.hostApiBase` (default `/_demo/api/v1` must NOT be hard-coded with an app name; default `''` and let the app set it) and `routeRules: { '/chat/**': { ssr: false } }`.
|
||||
|
||||
**package.json analog:** `../vue-faq-layer/package.json`: `"private": true, "type": "module", "main": "./nuxt.config.ts"`, `engines.node >=22.18`, `peerDependencies.nuxt 4.4.8`, devDeps nuxt 4.4.8 / typescript 5.9.3 / vue 3.5.35. For vue-chat-layer, replace the `node --test` script with vitest 3.2.7 and add `centrifuge@5.7.3`.
|
||||
|
||||
**Auth-hook client analog:** `../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts` lines 1-55. Copy the doc comment with the `defineNuxtPlugin` provide example, the hook lookup through `useNuxtApp()`, `Authorization: Bearer`, and the server-vs-browser base split:
|
||||
```ts
|
||||
type AuthHook = () => string | null | undefined
|
||||
const auth = (useNuxtApp() as unknown as { $paymentGatewayAuth?: AuthHook }).$paymentGatewayAuth
|
||||
const authHeaders = (): Record<string, string> => {
|
||||
const token = typeof auth === 'function' ? auth() : null
|
||||
return token ? { authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
```
|
||||
For chat, the hook becomes `$chatAuth: { token(), refresh(), unauthorized() }` (RESEARCH Pattern 4). The pure client goes in `app/utils/client.ts` (as in faq/paymentgateway `utils/client.ts`) so `tests/*.test.ts` can test it without Nuxt.
|
||||
|
||||
**App wiring:** `../sm-demo-app/vue-demo-app/app/plugins/auth.ts`:
|
||||
```ts
|
||||
export default defineNuxtPlugin(() => ({
|
||||
provide: { paymentGatewayAuth: () => useCookie<string | null>(DEMO_TOKEN_COOKIE).value ?? null },
|
||||
}))
|
||||
```
|
||||
Add `chatAuth` (and forum/friends/video hooks) to the same `provide`. Append the layers to `nuxt.config.ts:51` `extends`, and add `package.json` deps in the `git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z` form (lines 19-22).
|
||||
|
||||
**Store port:** `../sm-bm-app/frontend/src/stores/chat.ts`. It imports `defineStore` from 'pinia' plus `@/api/chat`, `@/lib/chatEvents`, `@/stores/toast`. Convert it to a module-level composable that uses `useState` (no Pinia, 19.4.1). Replace `general`/`generalId` with `channels[]`. Strip the bm `embeds` lib (`deadline|class|reading`) and add a slot or renderer registry for it.
|
||||
|
||||
**Realtime analog:** `../sm-demo-app/vue-demo-app/app/lib/realtime.ts`: `REALTIME_WS_PATH = '/ws'` (:7), `UNAVAILABLE = new Set([401,403,404])` (:19), `tokenFailureIsFinal` (:22), `wsURL` (:42), `openChannel` with `await import('centrifuge')` (:58-59). Move it into the layer as one shared client and add `onUserEvent(handler)` (Pattern 3). vue-chatvideo-layer reuses it and must not open a second client.
|
||||
|
||||
**Pages, anonymous prompt analog:** `../sm-demo-app/vue-demo-app/app/pages/quizzes/index.vue`. The doc comment (line 4) covers the anonymous prompt; `registerLink` (line 46) is `{ path: '/register', query: { next } }`; line 68 renders `<NuxtLink class="button button--small" :to="registerLink(...)">{{ t('quiz.registerToTake') }}</NuxtLink>`. Apply it to `/chat` (D-04) and `/forum` (D-21). Tour cards follow `<TourCard` in `app/pages/index.vue:40`. Wrap the floating panel in `<ClientOnly>` in the app layout.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Host wiring through the service registry plus OnDatabase
|
||||
**Source:** `../sm-bm-app/plugins/jz/bm/chat.go:55-87`
|
||||
**Apply to:** demo chat.go (chat limits, chatchannels resolver, forum resolver, chatbots registry, moderation filter lookup). Use the guard `if !app.HasPlugin(id) { return nil }`, then `app.Lookup[*T]()` inside `lagoon.OnDatabase`. A missing service returns the error "must be listed before … in summer.yaml".
|
||||
|
||||
### Festival events with listener-error rollback
|
||||
**Source:** `sm-user-plugin/classes/events.go:60-110`, fire sites `classes/access_revoked.go:50`, `backend_links.go:358` (`app.Events.Fire(ctx, &X{...})`)
|
||||
**Apply to:** the chat and forum seams and the demo listeners.
|
||||
|
||||
### Error envelope
|
||||
**Source:** `../sm-chat-plugin/api/errors.go:35-45`, `../sm-forum-plugin/api/errors.go:24-44`: `{"error": msg}` and the 422 `{"error":"Validation failed","errors":{field:[msg]}}` through `wire.WriteJSON`.
|
||||
**Apply to:** the seam rejection and the demo chat bootstrap API.
|
||||
|
||||
### Layer conventions
|
||||
**Source:** `../vue-faq-layer` (config, package, `tests/*.test.ts`, `app/utils/client.ts` + `app/composables/use*Api.ts`), `../vue-paymentgateway-layer` (auth hook).
|
||||
**Apply to:** all four new layers. They name no app and have no Pinia.
|
||||
|
||||
### Release hygiene
|
||||
**Source:** `sm-bm-app/plugins/golem15/chatbots/go.mod` (commit d0b9fb1, no sibling replaces). Requires are tags only. `../sm-demo-app/scripts/check-pins.sh` and `go_mod_test.go:22`.
|
||||
**Apply to:** every plugin go.mod and the demo pointer bumps. Make one `chore: bump <plugin> to <tag>` commit per plugin.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `../sm-moderation-plugin/classes/filter.go` | service | transform | No text filter exists. Use RESEARCH D-17 test map (word boundary, links). |
|
||||
| `../vue-chat-layer` ChannelList / ThreadPane / PinsPanel / CommandPicker | component | request-response | bm UI has one channel, no threads or pins. Build on chatchannels pins and chat replies APIs (RESEARCH OQ 4). |
|
||||
| `../vue-chatvideo-layer` call page | component | streaming | No video UI anywhere. Use livekit-client (human-verify checkpoint first), loaded with dynamic `import()` like centrifuge. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** sm-demo-app (plugins/demo, deploy, scripts, vue-demo-app), sm-bm-app (plugins/jz/bm, plugins/golem15/{chat,chatchannels,chatbots}, frontend/src), sm-forum-plugin, sm-sitemap-plugin, sm-user-plugin (via demo submodule), vue-faq-layer, vue-paymentgateway-layer
|
||||
**Files scanned:** ~40
|
||||
**Pattern extraction date:** 2026-10-28
|
||||
@@ -0,0 +1,694 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Research
|
||||
|
||||
**Researched:** 2026-10-28
|
||||
**Domain:** Mounting the chat family (chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends) into `sm-demo-app`, new shared Nuxt layers, a self-hosted LiveKit on rome, a new shared moderation plugin
|
||||
**Confidence:** HIGH for the backend and repo state (read and compiled this session), MEDIUM for LiveKit deployment, MEDIUM for the layer port effort
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Chat frontend
|
||||
- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
|
||||
- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
|
||||
- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
|
||||
- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
|
||||
- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
|
||||
|
||||
#### Chat context and access
|
||||
- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
|
||||
- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
|
||||
- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
|
||||
- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
|
||||
|
||||
#### Video calls
|
||||
- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
|
||||
- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
|
||||
- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
|
||||
- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
|
||||
|
||||
#### Forum, friends, push
|
||||
- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
|
||||
- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
|
||||
- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
|
||||
|
||||
#### Moderation
|
||||
- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
|
||||
- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
|
||||
|
||||
#### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
|
||||
- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
|
||||
|
||||
#### Folded Todos
|
||||
- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump <plugin> to <tag>` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
|
||||
|
||||
### Claude's Discretion
|
||||
- Seeded channel names and persona users, and how much EN/PL history to seed.
|
||||
- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
|
||||
- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
|
||||
- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
|
||||
- How `vue-chat-layer` exposes extension points for app-specific embeds.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
|
||||
- User-created channels (public or private) and ad-hoc group DMs with group calls.
|
||||
- A live bot persona answering in channels.
|
||||
- A read-only `/chat` preview for anonymous visitors.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
|
||||
- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (ROADMAP: "Requirements: TBD"). The success criteria and D-01..D-19 are the source of truth.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | `sm-demo-app` builds with every shared plugin incl. chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends; `demo:seed` fills each | §Repo state and tagging, §Demo host wiring, §Seeding |
|
||||
| SC-2 | Public Nuxt pages show chat/channels with realtime, video calls, presentations, journal, others through shared layers | §Layers, §Realtime, §LiveKit |
|
||||
| SC-3 | Deployed publicly; frontend open; `/backend` unreachable; "contact Golem15" CTA | §rome deployment, Open Question 1 |
|
||||
| SC-4 | New code unit-tested in the last plan | §Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
The backend half is in better shape than the repo layout suggests, but every chat-family plugin needs release work before `sm-demo-app` can require it. Only `sm-chat-plugin` has tags (v0.1.0-v0.1.3 on origin). `chatchannels`, `chatbots`, `chatvideo`, `forum`, `herald` and `userfriends` have none. Three sibling checkouts (`../sm-chat-plugin`, `../sm-chatchannels-plugin`, `../sm-chatbots-plugin`) are **behind origin**; the current code lives in `sm-bm-app/plugins/golem15/*`. `chatvideo` still requires `sm-chat-plugin v0.0.0`, and `chatvideo` and `herald` keep sibling `replace` lines. I copied all seven trees to a scratch workspace, set their requires to tags (chat v0.1.3, user v0.1.13, summercms v0.5.1) and dropped the sibling replaces. With that, all seven `go build`, `go vet` and `go test -short` pass against framework **v0.5.1**, the version `sm-demo-app` pins. Without the go.mod fix, module resolution fails with `unknown revision v0.0.0`. So the gap is release hygiene, not framework drift.
|
||||
|
||||
The frontend half is larger than D-01's "port about 4.6k lines" implies. bm's chat SPA is built around **one** channel (`# ogólny`) plus DMs. Its store has `general`/`generalId` throughout. It has **no thread UI, no pins, no channel list**, and its embeds are bm entity types (`'deadline' | 'class' | 'reading'`), not link embeds. It also depends on a bm host bootstrap endpoint (`/_bm/api/v1/chat` and `/chat/members`). D-02 and D-07 (several seeded channels, threads, pins) therefore need new UI: a multi-channel store, a thread pane and a pins view. The demo also needs its own bootstrap endpoints in `golem15.demo`.
|
||||
|
||||
The backend gaps are small but each one blocks something:
|
||||
- `chatchannels` does not register the `channel` chat alias. Messages in channels made with `CreateChannel` resolve through a host "channel-as-context adapter", which `golem15.demo` must register.
|
||||
- The demo's realtime token route is limited to presentation owners. Chat needs every member to get a token.
|
||||
- Centrifugo needs the `chat`, `user` and `video` namespaces.
|
||||
- No chat-family plugin has admin screens. "Admins deleting messages from the backend" (D-17) has no UI today.
|
||||
|
||||
On rome, a WavePath LiveKit 1.13.1 already runs on 127.0.0.1:7880, 7881/tcp and 50000-60000/udp. Its media ports are defined in Vuurmuur but **not applied in the firewall rules**. The demo needs its own instance on free ports and a Vuurmuur rule.
|
||||
|
||||
**Primary recommendation:** Plan in three stages:
|
||||
1. Release work in the shared plugin repos: fast-forward the siblings, fix the go.mod files, add the pre-save seams, create `sm-moderation-plugin`, tag v0.1.x.
|
||||
2. Demo host wiring and seed: the context, the channel adapter, bootstrap endpoints, the realtime guard, Centrifugo namespaces, local LiveKit.
|
||||
3. Layers, with the realtime client living in `vue-chat-layer` and reused by `vue-chatvideo-layer`, then the rome deploy and the tests plan.
|
||||
|
||||
Set `golem15.chatvideo.max_participants: 2` so the server itself enforces D-11 (DM-only calls).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Chat persistence, permissions, realtime publish | API / Backend (`golem15.chat`) | Database | Shared plugin; host only supplies context |
|
||||
| Demo community membership, channel adapter, channel/forum access policy | API / Backend (`golem15.demo`) | — | Host-specific policy (D-06); plugins stay neutral |
|
||||
| Chat bootstrap (channels with unread/mentions, DMs, members, limits) | API / Backend (`golem15.demo` `/_demo/api/v1/chat*`) | — | bm keeps this host-side (`/_bm/api/v1/chat`); generic APIs lack a "my conversations across contexts" call |
|
||||
| Pre-save content filter | API / Backend (`sm-moderation-plugin` service, wired by `golem15.demo` into chat/forum seams) | — | Must run before persistence; must not couple moderation to chat/forum modules |
|
||||
| Slash commands | API / Backend (`golem15.chatbots` registry, demo registers commands) | Browser (composer picker) | Dispatch runs as JWT actor server-side |
|
||||
| Video room lifecycle, tokens | API / Backend (`golem15.chatvideo`) | LiveKit SFU (rome) | Plugin mints tokens; media never touches Go |
|
||||
| Media transport | LiveKit SFU on rome (UDP mux / ICE-TCP) | Browser (`livekit-client`) | WebRTC needs direct ports, not nginx/Cloudflare |
|
||||
| Realtime fan-out | Centrifugo (demo instance) | API (subscribe proxy authorizers) | Already deployed in 19.4.1 |
|
||||
| Chat/forum/friends/video UI | Browser / Client (layers, client-only) | Frontend Server (SSR shell, `/forum` may SSR) | Chat and video touch window/WebSocket; SSR only the shell |
|
||||
| Anonymous prompts, tour cards | Frontend Server (vue-demo-app) | — | App-specific copy, EN/PL |
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- **Stdlib first.** A new Go dependency needs a research doc or decision note naming it. No new Go deps are needed beyond what the chat family already carries (`livekit/server-sdk-go/v2` and `SherClockHolmes/webpush-go` come in with `chatvideo` and `herald`). `sm-moderation-plugin` should be stdlib-only (+ `gorm`, framework modules).
|
||||
- **YAML** is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- **Framework code and shared plugins/layers never name a consuming application.** READMEs use neutral names (`blog`, `acme`). The pre-save seam, `sm-moderation-plugin` and every `vue-*-layer` follow this. `golem15.demo` may name itself.
|
||||
- **Core plugins: no breaking changes** (user, blog, pages, payment… and here chat/forum: WavePath parity, Phase 31). Seams are additive and ship as patch tags (D-18).
|
||||
- **Submodule commit order:** commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. One `chore: bump <plugin> to <tag>` per plugin.
|
||||
- **go.mod requires tags only** (`TestGoModRequiresTags`, `scripts/check-pins.sh`); `go.work` handles local checkouts.
|
||||
- **Module/plugin READMEs** follow the standard structure (H1, summary, import, `**Name:**` mnemonic, Overview, Features, Usage, API reference, Configuration, CLI, Dependencies, Testing); every identifier must exist (docs checker for framework; plugin READMEs by hand).
|
||||
- **Lean planning:** few, large plans; present the plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** STATE.md via `msd_run query state.*` handlers. g15office sync on phase advance.
|
||||
- **Commits:** no co-author tags (user's global CLAUDE.md overrides the harness attribution); stage files by explicit path; planning docs and code in separate commits. Don't push, tag or deploy without the user's go-ahead.
|
||||
- **Pushed release tags `vX.Y.Z`** of the framework get a changelog post on summercms.io the same day (only if this phase cuts a framework tag — not expected).
|
||||
- **Memory:** tag only at phase completion for the framework (interim plugin tags are fine). Add new shared plugins to the demo (this phase's whole point).
|
||||
- **Docker tests:** if the Docker socket is denied, run through `sudo -u golem`.
|
||||
|
||||
## Repo State and Tagging (verified this session)
|
||||
|
||||
| Plugin | Where current code lives | Origin `main` | Tags on origin | go.mod problems for module mode |
|
||||
|--------|-------------------------|---------------|----------------|----------------------------------|
|
||||
| `sm-chat-plugin` | `sm-bm-app/plugins/golem15/chat` (51c9e17 = v0.1.3) | 51c9e17 | v0.1.0, v0.1.1, v0.1.2, v0.1.3 | none (requires user v0.1.2, summercms v0.4.0) |
|
||||
| `sm-chatchannels-plugin` | `sm-bm-app/plugins/golem15/chatchannels` (a30f04a) | a30f04a | **none** | none (requires chat v0.1.0) |
|
||||
| `sm-chatbots-plugin` | `sm-bm-app/plugins/golem15/chatbots` (d0b9fb1 "drop sibling replaces") | d0b9fb1 | **none** | none (requires chat v0.1.0) |
|
||||
| `sm-chatvideo-plugin` | `../sm-chatvideo-plugin` (be688fe) | be688fe | **none** | `require sm-chat-plugin v0.0.0`; `replace sm-chat-plugin => ../sm-chat-plugin`; `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
|
||||
| `sm-forum-plugin` | `../sm-forum-plugin` (2779341) | 2779341 | **none** | none |
|
||||
| `sm-herald-plugin` | `../sm-herald-plugin` (74c889c) | 74c889c | **none** | `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
|
||||
| `sm-userfriends-plugin` | `../sm-userfriends-plugin` (57db2ba) | 57db2ba | **none** | requires chat pseudo-version `v0.0.0-20261017200243-4d0e764ca443` |
|
||||
|
||||
[VERIFIED: `git ls-remote origin` per repo; `git -C … log`; go.mod files read]
|
||||
|
||||
- **Sibling checkouts are stale:** `../sm-chat-plugin` is at 5fea654 (origin 51c9e17). `../sm-chatchannels-plugin` is at f3ac5d1 (origin a30f04a). `../sm-chatbots-plugin` is at 9dfe8b7 (origin d0b9fb1). Fast-forward (`git pull --ff-only`) before any edit.
|
||||
- **Compatibility proof:** in a scratch `go.work` I used framework v0.5.1 (from `git archive v0.5.1`) and user v0.1.13 (the demo's submodule), set every chat-family go.mod to `sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`, and removed the sibling replaces. All seven pass `go build ./...`, `go vet ./...` and `go test -short ./...`. Before the go.mod fix, every build failed with `git.golem15.com/golem15/sm-chat-plugin@v0.0.0: reading … go.mod at revision v0.0.0: unknown revision v0.0.0`. [VERIFIED: scratch build output]
|
||||
- **Suggested tags** (one-way, D-03 of 19.4.1):
|
||||
|
||||
| Plugin | Tag |
|
||||
|--------|-----|
|
||||
| chat | v0.1.4: pre-save seam, additive (patch) |
|
||||
| chatchannels | v0.1.0 |
|
||||
| chatbots | v0.1.0 |
|
||||
| chatvideo | v0.1.0, after the go.mod fix |
|
||||
| forum | v0.1.0, with the seam (its first tag) |
|
||||
| herald | v0.1.0, after dropping the replace |
|
||||
| userfriends | v0.1.0, chat requirement → v0.1.3 or v0.1.4 |
|
||||
| sm-moderation-plugin | v0.1.0 |
|
||||
|
||||
Raising the minimal `summercms` / `sm-user-plugin` requires in plugin go.mod files is optional (MVS picks the demo's v0.5.1 / v0.1.13). Keep the `replace git.golem15.com/golem15/summercms => ../summercms.go` line, the standard plugin pattern.
|
||||
- **bm is unaffected:** `sm-bm-app` pins chatchannels and chatbots as `v0.0.0` with local `replace` lines. It keeps building whatever the new tags are.
|
||||
- **Folded todo (oauthserver):** the `sm-demo-app` submodule is at b6a9586 (`v0.1.2-7-gb6a9586`, uncommitted move). The index has 8bbec72 = v0.1.2, and go.mod requires v0.1.2. Tag v0.1.3 = df2eddd ("require SummerCMS v0.5.0 and sm-user-plugin v0.1.11") contains b6a9586. Move the checkout to v0.1.3, run `GOWORK=off go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` then `go mod tidy`, and commit `chore: bump sm-oauthserver-plugin to v0.1.3`. The journal half of the todo is already done (`11d485f chore: bump journal to v0.2.5`). [VERIFIED: `git submodule status`, `git log`]
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (backend, all already chosen in Phases 19/19.1)
|
||||
| Library / module | Version | Purpose | Why Standard |
|
||||
|------------------|---------|---------|--------------|
|
||||
| `git.golem15.com/golem15/summercms` | v0.5.1 (demo pin; v0.5.2 exists) | Framework | Demo already on it; chat family proven on it this session |
|
||||
| `sm-chat-plugin` | v0.1.3 → v0.1.4 (seam) | Conversations, messages, reactions, attachments | Phase 19 port |
|
||||
| `sm-chatchannels-plugin` | first tag v0.1.0 | Channels, pins, mentions | Phase 19 port |
|
||||
| `sm-chatvideo-plugin` | first tag v0.1.0 | LiveKit sessions, tokens, reconcile | Phase 19.1 |
|
||||
| `sm-chatbots-plugin` | first tag v0.1.0 | Slash command registry/dispatch | Phase 19.1 |
|
||||
| `sm-forum-plugin` | first tag v0.1.0 (seam) | Forum | Phase 19.1 |
|
||||
| `sm-herald-plugin` | first tag v0.1.0 | Web Push (mounted, no UI) | Phase 19.1 |
|
||||
| `sm-userfriends-plugin` | first tag v0.1.0 | Friends, groups | Phase 19.1 |
|
||||
| `github.com/livekit/server-sdk-go/v2` | v2.19.0 (transitive via chatvideo) | RoomService client | Already in chatvideo go.mod |
|
||||
| LiveKit server | 1.13.1 (installed on rome `/usr/local/bin/livekit-server` and locally) | SFU | Same binary WavePath runs; no install needed |
|
||||
| Centrifugo | v6.5.1 (rome and local) | Realtime | Already the demo's (D-17) |
|
||||
|
||||
### Frontend
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `nuxt` | 4.4.8 (pinned across layers) | Layers and app | All layers `peerDependencies: { nuxt: 4.4.8 }` |
|
||||
| `vue` | 3.5.35 | — | — |
|
||||
| `centrifuge` | 5.7.3 (already pinned in vue-demo-app; audited in 19.4.1) | Centrifugo client | Move/duplicate into `vue-chat-layer` deps at the same pin |
|
||||
| `livekit-client` | 2.23.0 latest (published 2026-10-16) `[WARNING: flagged as suspicious — verify before using.]` | WebRTC client for `vue-chatvideo-layer` | Pin an exact version after the human-verify checkpoint |
|
||||
| `vitest` | 3.2.7 (vue-demo-app's pin) | Layer unit tests for the ported bm tests | bm's chat tests are vitest |
|
||||
|
||||
**No Pinia.** 19.4.1 established "Pinia stores become `useState` composables" (19.4.1-PATTERNS.md:259; 19.4.1-04-PLAN.md:134). `@pinia/nuxt` 1.0.3 peers `pinia ^4.1.0` and depends on `@nuxt/kit ^4.6.0`, against the repo's `nuxt` 4.4.8. bm's store is a setup store (`defineStore('chat', () => {…})`), so converting it to a module-level factory or composable is mechanical. [VERIFIED: npm view @pinia/nuxt peerDependencies dependencies]
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Own LiveKit instance on rome | Add a key pair to WavePath's LiveKit (`/etc/livekit/livekit.yaml`) | Any API key can list/delete every room on that server; cross-tenant blast radius; D-10 says own keys → use own instance |
|
||||
| UDP mux (`rtc.udp_port`) | Port range (`port_range_start/end`) | Range needs a wide firewall hole and overlaps WavePath's 50000-60000; mux needs one port |
|
||||
| Embedded TURN/TLS on 443 via nginx `stream` `ssl_preread` | No TURN, ICE-TCP fallback (`rtc.tcp_port`) | `ssl_preread` would restructure every 443 vhost on a shared host; ICE-TCP covers most NAT'd browsers |
|
||||
| Festival "-ing" event as pre-save seam | Validator registry on the services (`Service.AddValidator`) | Both additive; festival mirrors `user.Deleting`/`user.Deactivating` and needs no new type on the service |
|
||||
|
||||
**Installation (layers):**
|
||||
```bash
|
||||
# vue-chat-layer
|
||||
pnpm add centrifuge@5.7.3
|
||||
pnpm add -D vitest@3.2.7 nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
|
||||
# vue-chatvideo-layer (after checkpoint:human-verify on livekit-client)
|
||||
pnpm add livekit-client@<pinned>
|
||||
```
|
||||
|
||||
**Version verification:** `npm view livekit-client version` → 2.23.0 (2026-10-16). `npm view centrifuge version` → 5.8.0 (2026-10-20); the demo stays on the audited 5.7.3. [VERIFIED: npm registry]
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age (latest) | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| `livekit-client` | npm | latest 12 days | 5.67M/wk | github.com/livekit/client-sdk-js | [SUS] reason `too-new` | Flagged — planner must add checkpoint:human-verify; pin an exact version |
|
||||
| `centrifuge` | npm | latest 8 days | 1.61M/wk | github.com/centrifugal/centrifuge-js | [SUS] reason `too-new` (latest only) | Keep the already-audited 5.7.3 pin from 19.4.1; no new install decision |
|
||||
| `pinia`, `@pinia/nuxt` | npm | — | — | github.com/vuejs/pinia | [SUS] `too-new` | Not recommended (see above) |
|
||||
|
||||
No postinstall scripts on `livekit-client` or `centrifuge` (`npm view … scripts.postinstall` empty). `centrifuge` pulls `protobufjs`, whose postinstall is already disabled in `pnpm-workspace.yaml` (`protobufjs: false`).
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** `livekit-client`: the planner inserts checkpoint:human-verify before the install. The "too-new" verdict is about the latest release, not the package. It is LiveKit's official JS SDK, but its name came to me from training and the chatvideo README, not from Context7, so treat it as `[ASSUMED]` until verified.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Browser (vue-demo-app + layers, client-only chat/video)
|
||||
│ JWT (DEMO cookie) as Bearer
|
||||
├─ GET /_demo/api/v1/chat ─────────────► golem15.demo bootstrap ──► chat.Service.GetConversationsForUser
|
||||
│ chatchannels.ChannelService (channels, mention counts)
|
||||
├─ /_chat/api/v1/... (send/edit/react/replies/upload) ──► chat.Service
|
||||
│ └─ [NEW] Fire MessageSaving (pre-save) ──► demo listener ──► moderation.Filter.Check ──► 422 or continue
|
||||
│ └─ tx: insert ─► Fire MessageCreated ─► chatchannels mentions; lighthouse Bind ─► broadcasts queue ─► Centrifugo publish
|
||||
├─ /_chatchannels/api/v1/contexts/demo/1/channels/... (join, pins, mentions)
|
||||
├─ /_chatbots/api/v1/commands | /dispatch ─► chatbots Registry (demo-registered commands, ephemeral answers)
|
||||
├─ /_forum/api/v1/contexts/demo/1/... ─► forum.Service ─► [NEW] Fire PostSaving ─► moderation ─► persist (breakwater render)
|
||||
├─ /_userfriends/api/v1/...
|
||||
├─ /_chatvideo/api/v1/conversations/{dm}/video/start ─► chatvideo (participants ≤ max_participants=2 ⇒ DMs only)
|
||||
│ └─ RoomAPI ─► http://127.0.0.1:<lk-signal> (loopback, TrustedMode) ; returns {token, livekit_url}
|
||||
├─ GET /api/realtime/token ─► demo guard (any member) ─► lighthouse token
|
||||
├─ wss /ws ─► nginx ─► Centrifugo (demo) ──subscribe proxy──► Go authorizers: chat / user / video / presentations
|
||||
└─ wss /livekit/rtc ─► Cloudflare ─► nginx ─► LiveKit (demo instance) signal
|
||||
media: UDP <mux port> / TCP <ice-tcp port> direct to rome public IP (bypasses Cloudflare)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
sm-moderation-plugin/ # new shared plugin (golem15.moderation)
|
||||
├── plugin.go admin.go # pact.HasSettings, HasMigrations, HasConfig, HasLang
|
||||
├── classes/filter.go # Filter.Check(ctx, text) -> *Violation (words, links)
|
||||
├── models/settings.go + models/settings/fields.yaml
|
||||
├── updates/ lang/ config/
|
||||
sm-demo-app/plugins/demo/
|
||||
├── chat.go # registry aliases "demo" + "channel", resolvers, limits, seam listeners, chatbots commands
|
||||
├── classes/ (or chat_context.go) # DemoContext, ChannelContext adapter, access resolvers (chatchannels, forum)
|
||||
├── chat_api.go # GET /_demo/api/v1/chat, /chat/members (bootstrap)
|
||||
├── console/seed_chat.go seed_forum.go seed_friends.go
|
||||
vue-chat-layer/ # new
|
||||
├── app/components/chat/… # ported + ChannelList, ThreadPane, PinsPanel, CommandPicker
|
||||
├── app/composables/useChat.ts # ported store (no Pinia)
|
||||
├── app/lib/realtime.ts # shared Centrifuge client + onUserEvent hook (used by chatvideo layer)
|
||||
├── app/lib/chat{Events,Format,Text,Unread}.ts (+ tests)
|
||||
├── app/pages/chat.vue # optional; or app provides page and renders <ChatPage>
|
||||
vue-chatvideo-layer/ # extends vue-chat-layer
|
||||
vue-forum-layer/ vue-userfriends-layer/
|
||||
```
|
||||
|
||||
### Pattern 1: Host context and channel-as-context adapter (golem15.demo)
|
||||
**What:** Register two aliases on `*chatcontracts.Registry`:
|
||||
- `demo`: one context, id 1. Every registered, non-deleted, non-banned user is a member.
|
||||
- `channel`: loads a `chatchannels` channel by id. Its members are the demo community's members, since every channel is public (D-07).
|
||||
|
||||
Install `chatchannels` `SetAccessResolver` (any member may access `demo/1`, nobody manages it) and `forum.SetAccessResolver`.
|
||||
**Why the `channel` alias is required:** `CreateChannel` writes the backing conversation with `ContextType: &alias` where `alias := models.ChannelAlias` (`"channel"`) [VERIFIED: sm-bm-app/plugins/golem15/chatchannels/classes/channel_service.go:168-174]. No production code registers that alias. The only `Alias: models.ChannelAlias` registration is the test fixture [VERIFIED: chatchannels/fixture_plugin_test.go:172]. Without it, chat resolves the conversation's context to `ErrUnknownContextType` (404).
|
||||
**Lazy join:** `JoinChannel` only upserts a pref row (`row.HasLeft = false`) and adds no chat participant [VERIFIED: channel_service.go:959-969]. Sending needs `isParticipant` [VERIFIED: chat/classes/service.go:463-468], while reading falls back to `host.HasMember` [VERIFIED: service.go:630-640]. The client gets participant rows by calling `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, where `GetGroupChat` "still lazy-joins a context member" (chat README). Alternatively, the demo bootstrap adds the participant. Choose one and test it.
|
||||
**Example (mirror bm):**
|
||||
```go
|
||||
// Source: sm-bm-app/plugins/jz/bm/chat.go:35-90 (pattern), adapted
|
||||
func (p *Plugin) registerChat(app *backpack.App) error {
|
||||
reg, ok := app.Lookup[*chatcontracts.Registry]()
|
||||
if !ok || reg == nil { return errors.New("golem15.demo: golem15.chat must boot first") }
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: "demo", Loader: p.loadDemoContext}); err != nil { return err }
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, Loader: p.loadChannelContext, LegacyPHPClass: chanmodels.ChannelMorphName}); err != nil { return err }
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
|
||||
ch, _ := app.Lookup[*chanclasses.ChannelService]()
|
||||
ch.SetAccessResolver(p.channelAccess) // CanAccessContext: member && demo/1; CanManageContext: false
|
||||
f, _ := app.Lookup[*forum.Plugin]()
|
||||
f.SetAccessResolver(p.forumAccess) // CanRead/CanPost: member; CanManage: moderator group
|
||||
// chatbots registry is published inside its own OnDatabase hook (chatbots/boot.go bootService)
|
||||
bots, _ := app.Lookup[*botclasses.Registry]()
|
||||
return registerDemoCommands(bots)
|
||||
})
|
||||
}
|
||||
```
|
||||
`BroadcastChannel()` must be non-empty for both contexts ("an empty host channel silences the conversation channels too", bm chat_context.go:31-35). Use names no client can subscribe to, for example `chat:context:1` and `chat:channel:{id}`. `AuthorizeChat` admits only `chat:conversation:{id}`.
|
||||
Implement `contracts.LiveMembership` (`RequireLiveMembership() true`) and `contracts.MessageModerator`, as bm does, so that banned users lose access at the next check and a moderator group can delete messages in group conversations.
|
||||
|
||||
### Pattern 2: Additive pre-save seam (D-18), recommended shape
|
||||
**What:** Use a festival "-ing" event, fired **before** the write transaction, mirroring `user.Deleting` / `user.Deactivating` (sm-user-plugin/classes/events.go: "A listener error rolls the deactivation back").
|
||||
- chat: `events.MessageSaving{ConversationID, ActorID, RootID, Content, Metadata, Op}`, where `Op` is `"create"` or `"update"`. Fire it in `SendMessageWith` after the participant and empty checks, and in `EditMessage` after `CanEditMessage`.
|
||||
- forum: `events.PostSaving{ContextType, ContextID, ActorID, ThreadID, PostID, Title, Body, Op}`. Fire it in `CreateThread`, `Reply` and `EditPost` before `breakwater.Render`.
|
||||
- A listener rejects with a typed error, for example `contracts.Rejection{Field, Message}`, implementing `error`. The services return it wrapped. The API maps `errors.As(err, &contracts.Rejection{})` to the **existing** 422 envelope `{"error":"Validation failed","errors":{field:[message]}}` (chat api/errors.go:39-44, forum api/errors.go). Every existing route, payload and status stays the same. Only a request a listener rejects gets the new 422.
|
||||
- With no listener, behaviour is byte-identical (WavePath parity; the PHP method map tests stay green).
|
||||
|
||||
**Why not the existing events:** `MessageCreated` fires inside the transaction after `tx.Create(msg)` (service.go SendMessageWith). A listener error there rolls back, but it maps to 500 (the `writeChatError` default) and burns an id. **Forum fires no festival events at all.** `grep "Fire("` over sm-forum-plugin finds none, and `ForumService` has a `Bus` field (classes/forum_service.go:57-58) that is never used. CONTEXT's "chat and forum fire their festival events after the save" is accurate only for chat.
|
||||
|
||||
**Coupling:** `sm-moderation-plugin` must not import chat or forum. It publishes a `*classes.Filter`, and `golem15.demo` (which already imports both) wires it:
|
||||
```go
|
||||
// Source: pattern from festival bus.go:43 (Listen[T]) and user events
|
||||
app.Events.Listen[*chatevents.MessageSaving](p.ID(), func(ctx context.Context, ev *chatevents.MessageSaving) error {
|
||||
if v := filter.Check(ctx, ev.Content); v != nil {
|
||||
return chatcontracts.Rejection{Field: "content", Message: v.Message}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
Note: `festival.Bus.Fire` returns `errors.Join` of the listener errors (bus.go:74-75), so callers must use `errors.As`, not `==`.
|
||||
|
||||
### Pattern 3: One shared realtime connection across layers
|
||||
bm's `lib/realtime.ts` keeps a single Centrifuge client (`connect`, `subscribe`, `presence`). The chat store subscribes to `user:{me}` for the whole session. chatvideo publishes `video:call:incoming` to `user:{id}` (chatvideo README). One client cannot hold two subscriptions to the same channel, so `vue-chatvideo-layer` must **not** open its own `user:{me}` subscription. Put the realtime client in `vue-chat-layer` and add `onUserEvent(handler)` so unknown `user:` events (`video:*`) reach other layers. `vue-chatvideo-layer` `extends` `vue-chat-layer` and subscribes to `video:session:{id}` through the same client.
|
||||
|
||||
### Pattern 4: Layer conventions (from vue-faq-layer, vue-paymentgateway-layer, vue-oauth-layer)
|
||||
- `nuxt.config.ts` builds absolute paths from `fileURLToPath(new URL('.', import.meta.url))`.
|
||||
- runtimeConfig has a server `apiBase` (`NUXT_<X>_API_BASE`) and a public same-origin `apiBase`.
|
||||
- `peerDependencies: { nuxt: 4.4.8 }` and `engines.node >=22.18`.
|
||||
- Tests run with `node --test --test-reporter=tap tests/*.test.ts`.
|
||||
- Each layer is consumed as `"vue-x-layer": "git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z"`.
|
||||
- **Auth hook:** the app provides a token getter through a Nuxt plugin (`provide: { paymentGatewayAuth: () => useCookie(DEMO_TOKEN_COOKIE).value ?? null }`, vue-demo-app/app/plugins/auth.ts). The chat layer needs more than a getter: a 401 refresh (bm's client calls `/_user/api/v1/fetch` to renew) and an `onUnauthorized`. Define a `chatAuth` hook with `token()`, `refresh()` and `unauthorized()`, or let the app inject the whole HTTP client.
|
||||
- **Host bootstrap contract:** the layer reads `public.chat.hostApiBase` (for example `/_demo/api/v1`) and documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the layer's contract. Any host implements them, so the layer names no app.
|
||||
- **SSR:** `routeRules` `ssr: false` for `/chat/**` and the call page. The floating panel goes in `<ClientOnly>` in the app's layout, because a layer cannot inject into the app's layout.
|
||||
|
||||
### Pattern 5: DM-only calls enforced server-side (D-11)
|
||||
`VideoService.Start` checks only that the actor is a participant and that the conversation's participant count is at most `golem15.chatvideo.max_participants` (`max := s.intKey("golem15.chatvideo.max_participants", 8); if max > 0 && len(memberIDs) > max { return nil, ErrTooMany }`) [VERIFIED: sm-chatvideo-plugin/classes/video_service.go:109-112]. Conversation type is not checked, so **the plugin does not restrict calls to DMs.** `GetOrCreateDm` creates two-person DMs only. With `max_participants: 2` and every seeded channel holding three or more participants, `Start` on any channel or group conversation answers 422 `errTooMany` [VERIFIED: chatvideo/api/errors.go:42-43]. That is server-side enforcement of D-11 with no plugin change. The UI shows the call button only in DMs. LiveKit's `room.max_participants: 4` is a second cap. `Join` does not check counts (video_service.go:171-195), but only conversation participants can get a session (`sessionForActor`).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Seeding channels through `CreateChannel` with the demo's resolver.** It is gated by `CanManageContext` [VERIFIED: channel_service.go:136], which the demo answers false (D-07). Seed with a separate trusted instance instead, `chanclasses.New(chanclasses.Options{DB: tx, Resolver: seedResolver})`, or with raw SQL in the existing seed style.
|
||||
- **Seeding chat messages through `chat.Service`.** Its `SendMessageWith` emits realtime and enqueues jobs inside the tx. The seed already uses raw SQL (`insertID`, `exists`) and stays idempotent.
|
||||
- **Announce-mode chatbots commands.** HTTP dispatch passes `classes.CommandContext{}` [VERIFIED: chatbots/api/commands.go:57], so `announce` never persists (`cmdCtx.ConversationID == 0` returns early, dispatcher.go:52-56). Demo commands must be `Ephemeral`.
|
||||
- **Opening a second Centrifuge client** in the video layer (see Pattern 3).
|
||||
- **Editing `../vue-demo-app`.** That sibling checkout is stale (32049f7, no remote). The real app is the submodule `sm-demo-app/vue-demo-app` (44a5229, origin `golem15/vue-demo-app`).
|
||||
- **Putting the moderation filter in `golem15.demo`.** The user asked for a shared plugin (CONTEXT specifics).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Chat persistence, permissions, unread, reactions, tombstones | Demo-side message tables or endpoints | `sm-chat-plugin` generic API `/_chat/api/v1` | PHP-parity contracts; WavePath target |
|
||||
| Channel pins, mentions, joins | Custom pin/mention tables | `/_chatchannels/api/v1/contexts/demo/1/channels/{id}/pins`, `/mentions` | Already ported, with mention accrual listeners |
|
||||
| Video tokens / room cleanup | Own JWT minting or room GC | `chatvideo` `Start/Join/Leave/End` + `chatvideo:reconcile-sessions` (every 5 min) + LiveKit `room.empty_timeout` | HS256 golden tested |
|
||||
| WebRTC | Raw `RTCPeerConnection` | `livekit-client` | SFU signalling, reconnection, simulcast |
|
||||
| Realtime | Own WebSocket server | Centrifugo + lighthouse authorizers | Already deployed |
|
||||
| Markdown → safe HTML for forum | Client-side markdown rendering of user input | Server `content_html` from `breakwater.Render` (stored on write) | Sanitized server-side; render with `v-html` only from that field |
|
||||
| Web Push | Custom VAPID | `herald` + `herald:vapid-generate` | — |
|
||||
| Settings screen for word lists | Custom admin page | `pact.HasSettings` + `fields.yaml` singleton (pattern: sm-feedback-plugin admin.go) | cabana renders it |
|
||||
| Firewall changes on rome | Raw `iptables` | Vuurmuur (`vuurmuur_conf`/`vuurmuur_script`): rome's INPUT chain is Vuurmuur-managed (`vrmr:` NFLOG prefixes) | Raw rules get overwritten on Vuurmuur reload |
|
||||
|
||||
**Key insight:** The chat family is complete server-side. The work is host glue (context, adapter, resolvers, bootstrap), release hygiene and UI. Adding parallel endpoints or tables would fork the WavePath contract.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Stale sibling checkouts
|
||||
**What goes wrong:** Editing `../sm-chat-plugin` (13+ commits behind) and tagging an old tree, or a seam patch that conflicts with v0.1.3.
|
||||
**How to avoid:** `git -C ../sm-chat-plugin pull --ff-only` (same for chatchannels and chatbots) as task 1, then assert `git rev-parse HEAD == $(git ls-remote origin refs/heads/main)`.
|
||||
|
||||
### Pitfall 2: `v0.0.0` and pseudo-version requires break module mode
|
||||
**What goes wrong:** `GOWORK=off go build` in sm-demo-app fails with `unknown revision v0.0.0` (verified).
|
||||
**How to avoid:** Fix chatvideo and userfriends go.mod before tagging. `TestGoModRequiresTags` checks only the app's own go.mod, not transitive ones, so also build `GOWORK=off` in a clean temp dir.
|
||||
|
||||
### Pitfall 3: Missing `channel` alias, so messages in seeded channels 404
|
||||
**How to avoid:** Pattern 1. Test sending to a seeded channel conversation through the HTTP API.
|
||||
|
||||
### Pitfall 4: Realtime token route limited to presentation owners
|
||||
**What goes wrong:** Chat users get 403 from `/api/realtime/token`. The demo mounts `UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime)` and mounts nothing unless presentations is active [VERIFIED: sm-demo-app/plugins/demo/routes.go:78-87]. The vue-demo-app realtime client treats 401/403/404 as final (`UNAVAILABLE = new Set([401, 403, 404])`, app/lib/realtime.ts) and stops for good.
|
||||
**How to avoid:** Port bm's `jz.bm.realtime-access` guard (sm-bm-app/plugins/jz/bm/realtime_access.go): a demo member passes, otherwise the presentations owner guard decides.
|
||||
|
||||
### Pitfall 5: Centrifugo namespaces missing
|
||||
**What goes wrong:** Subscribes to `chat:…`, `user:…` and `video:…` fail. The demo config has only `presentations` (deploy/centrifugo.json.example).
|
||||
**How to avoid:** Add `chat` (`presence`, `join_leave`, `force_push_join_leave`, `allow_presence_for_subscriber`, subscribe proxy), `user` and `video` (subscribe proxy), copying bm's namespace block (sm-bm-app/deploy/centrifugo/bm-config.json). "`centrifugo checkconfig` does not catch misspelt option names" (sm-bm-app DEPLOY.md:395). Compare against `centrifugo defaultconfig`. The `user` authorizer comes from sm-user-plugin v0.1.13 (`RegisterUserAuthorizer`, classes/authorizer.go:35-47). Restart `summercms-demo-centrifugo` after changing `centrifugo.json`.
|
||||
|
||||
### Pitfall 6: LiveKit media ports not open on rome
|
||||
**What goes wrong:** Signalling works but the call shows no remote video (ICE fails). rome's INPUT policy is DROP under Vuurmuur. A Vuurmuur service `Livekit` exists (`RANGE="6;7881*1024:65535"`, `RANGE="17;50000:60000*1024:65535"`) but no rule in `/etc/vuurmuur/rules/rules.conf` uses it, and `iptables -S` shows no rule for 7881 or 50000-60000 [VERIFIED: read-only ssh probe 2026-10-28].
|
||||
**How to avoid:** Define a demo service in Vuurmuur (TCP ICE port plus UDP mux port) and a rule allowing it from the internet to the firewall, then apply it through Vuurmuur. This needs the user's go-ahead (shared production host). Verify with `ss -lunp` and an external `nc -u` / a real two-browser call.
|
||||
|
||||
### Pitfall 7: Port collisions with WavePath's LiveKit
|
||||
**What goes wrong:** LiveKit defaults (7880, 7881, 50000-60000) collide with the running `wavepath-livekit` program.
|
||||
**How to avoid:** Use distinct ports: for example signal `127.0.0.1:7890`, `rtc.tcp_port: 7891`, `rtc.udp_port: 7892` (mux, no `port_range_*`). Check them free with `ss -ltnup` at deploy time. The ss probe today showed 7890-7892 unused. Extend `scripts/check-deploy.sh` port agreement checks to LiveKit.
|
||||
|
||||
### Pitfall 8: LiveKit behind Cloudflare and nginx
|
||||
**What goes wrong:** The signal WebSocket drops or the URL path is wrong.
|
||||
**How to avoid:**
|
||||
- Keep the signal on the same origin under a prefix, for example `livekit_url: wss://demo.summercms.io/livekit`. livekit-client appends paths to the existing pathname ("`wss://host/livekit` becomes `wss://host/livekit/rtc`", client-sdk-js src/api/utils.ts `appendUrlPath`) [CITED: github.com/livekit/client-sdk-js/blob/main/src/api/utils.ts].
|
||||
- nginx: `location ^~ /livekit/ { proxy_pass http://127.0.0.1:7890/; proxy_http_version 1.1; Upgrade/Connection headers; proxy_read_timeout 7d; proxy_buffering off; }`, as lk.wavepath.org does.
|
||||
- Set `livekit_admin_url: http://127.0.0.1:7890` so the Go RoomService calls stay on loopback. fetchguard TrustedMode allows loopback.
|
||||
- Media goes direct to the public IP, so ICE candidates reveal rome's IP (54.36.173.176) despite Cloudflare. It is accepted risk, since rome already exposes game servers on that IP.
|
||||
- Newer livekit-client tries `/rtc/v1` and falls back to `/rtc`. Server 1.13.1 support is `[ASSUMED]`. Test locally against the same 1.13.1 binary.
|
||||
|
||||
### Pitfall 9: Floating panel and SSR
|
||||
**What goes wrong:** Hydration mismatches or `window` errors.
|
||||
**How to avoid:** Render the floating panel in `<ClientOnly>`. Set `ssr: false` for `/chat/**` and the call route. Load `livekit-client` with a dynamic `import()`, as `centrifuge` is in realtime.ts.
|
||||
|
||||
### Pitfall 10: Reset leaves LiveKit rooms and chat rows behind
|
||||
**What goes wrong:** `demo:reset` deletes `users`. Chat tables cascade from users in parts only, `golem15_chatchannels_*` rows have no FK to messages, and active LiveKit rooms survive.
|
||||
**How to avoid:** Add every chat-family table to `resetTables` in FK order, children first, before `users`:
|
||||
- chatvideo participants and sessions
|
||||
- chat reactions, thread mutes, thread summaries, messages, participants and conversations
|
||||
- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels and categories
|
||||
- forum reads, posts, threads and categories
|
||||
- userfriends bans, invites, group members, groups and friendships (keep the singleton context id=1)
|
||||
- herald subscriptions
|
||||
- chatbots bot flags
|
||||
|
||||
Keep `golem15_moderation_settings`. Set LiveKit `room.empty_timeout` and `departure_timeout` so orphaned rooms close. `TestResetKeepsPlans` must grow to cover the new tables.
|
||||
|
||||
### Pitfall 11: robots.txt and allow-lists drift
|
||||
**What goes wrong:** New API prefixes are not routed by nginx, or they show up in robots.txt.
|
||||
**How to avoid:** Add the new paths in three places:
|
||||
- nginx Go allow-list: `/_chat/`, `/_chatchannels/`, `/_chatvideo/`, `/_chatbots/`, `/_forum/`, `/_userfriends/`, and `/livekit/` to LiveKit.
|
||||
- `vue-demo-app` `goPaths`: dev proxy and robots.
|
||||
- The SEO plugin's robots.txt in the admin (DEPLOY.md "SEO": the Disallow lines are copied by hand).
|
||||
|
||||
`/_herald/` stays unrouted: there is no UI (D-16).
|
||||
|
||||
### Pitfall 12: Forum and friends APIs are JWT-only
|
||||
Every forum route is behind `jwt.auth` (forum README). An anonymous `/forum` cannot list threads without a new public endpoint. See Open Question 3.
|
||||
|
||||
### Pitfall 13: No admin screens for chat/forum content
|
||||
None of the seven plugins implements `HasAdminControllers` or `HasSettings` (grep). D-17's "admins deleting messages … from the backend" has no backend UI. See Open Question 2.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Demo context (verbatim interface members bm implements)
|
||||
```go
|
||||
// Source: sm-bm-app/plugins/jz/bm/classes/chat_context.go:12-17,101-110
|
||||
var (
|
||||
_ chatcontracts.Context = (*ChatContext)(nil)
|
||||
_ chatcontracts.MessageModerator = (*ChatContext)(nil)
|
||||
_ chatcontracts.LiveMembership = (*ChatContext)(nil)
|
||||
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
|
||||
)
|
||||
func (c *ChatContext) ID() uint { return ChatContextID }
|
||||
func (c *ChatContext) Type() string { return ChatContextType }
|
||||
func (c *ChatContext) ChatEnabled() bool { return true }
|
||||
func (c *ChatContext) GroupName() string { return ChatGeneralName }
|
||||
func (c *ChatContext) BroadcastChannel() string { return ChatBroadcastChannel }
|
||||
// + CanActFor, CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members
|
||||
```
|
||||
|
||||
### Chatbots command (ephemeral)
|
||||
```go
|
||||
// Source: contracts in sm-bm-app/plugins/golem15/chatbots/contracts/slash_command.go
|
||||
type demoCommand struct{}
|
||||
func (demoCommand) Trigger() string { return "demo" }
|
||||
func (demoCommand) Description() string { return "What this demo shows" }
|
||||
func (demoCommand) IsAvailable(actor uint, _ contracts.CommandContext) bool { return actor != 0 }
|
||||
func (demoCommand) Handle(args string, actor uint, _ contracts.CommandContext) contracts.CommandResult {
|
||||
return classes.Ephemeral("This is the SummerCMS demo: chat, channels, calls, forum …")
|
||||
}
|
||||
```
|
||||
Catalog JSON: `GET /_chatbots/api/v1/commands` → `{"commands":[{"trigger","description"}]}`. Dispatch: `POST /dispatch {"input":"/demo"}` → `{"mode","payload"}`; unknown → 422 `errors.input` [VERIFIED: chatbots/api/commands.go:21-66; classes/dispatcher.go:89-99].
|
||||
|
||||
### Chatvideo config for the demo
|
||||
```yaml
|
||||
# config/golem15.chatvideo.yaml (keys verbatim from sm-chatvideo-plugin/config/config.yaml:4-11)
|
||||
livekit_url: "wss://demo.summercms.io/livekit"
|
||||
livekit_admin_url: "http://127.0.0.1:7890"
|
||||
max_duration: 900 # D-12 ≈15 min (reconcile runs every 5 min, so up to ~20)
|
||||
max_participants: 2 # DM-only calls server-side (Pattern 5)
|
||||
token_ttl: 900
|
||||
screen_share: false # minimal call page (D-13)
|
||||
# secrets: SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY / __LIVEKIT_API_SECRET in .env (or PHP names LIVEKIT_API_KEY/SECRET)
|
||||
```
|
||||
|
||||
### LiveKit config for the demo instance (keys from LiveKit config-sample)
|
||||
```yaml
|
||||
# /srv/summercms-demo/livekit.yaml (root:summercms-demo 0640)
|
||||
port: 7890
|
||||
bind_addresses: [127.0.0.1]
|
||||
rtc:
|
||||
tcp_port: 7891
|
||||
udp_port: 7892 # UDP mux; port_range_start/end must NOT be set
|
||||
use_external_ip: true
|
||||
room:
|
||||
max_participants: 4
|
||||
empty_timeout: 120
|
||||
departure_timeout: 20
|
||||
keys:
|
||||
CHANGE_ME_KEY: CHANGE_ME_SECRET
|
||||
```
|
||||
[CITED: raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml]. The `udp_port` comment there says "port_range_start & end must not be set for this config to take effect". `bind_addresses` is used by rome's working WavePath config [VERIFIED: /etc/livekit/livekit.yaml on rome].
|
||||
|
||||
### Supervisor program (pattern from rome's wavepath-livekit.conf)
|
||||
```ini
|
||||
[program:summercms-demo-livekit]
|
||||
command=/usr/local/bin/livekit-server --config /srv/summercms-demo/livekit.yaml
|
||||
directory=/srv/summercms-demo
|
||||
user=summercms-demo
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
stopsignal=INT
|
||||
stopwaitsecs=10
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/supervisor/summercms-demo-livekit.log
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| bm SPA: one channel + DMs, host bootstrap | Multi-channel sidebar over chatchannels + host bootstrap | This phase | New store shape (`channels[]` replaces `general`) |
|
||||
| Pinia stores | `useState`/module composables | 19.4.1 | No Pinia in layers |
|
||||
| LiveKit port range | UDP mux single port | LiveKit ≥1.x | One firewall hole |
|
||||
| livekit-client `/rtc` | `/rtc/v1` with v0 fallback | client 2.17+ (per community reports) | Test against server 1.13.1 |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- The sibling `../sm-chat-plugin`, `../sm-chatchannels-plugin` and `../sm-chatbots-plugin` trees are behind origin. Treat origin `main` as the truth.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `livekit-client` (latest 2.x) connects to LiveKit server 1.13.1 through `/livekit` prefix behind Cloudflare | Pitfall 8 | Calls fail; fallback: dedicated `lk.demo.summercms.io` host (DNS + cert) |
|
||||
| A2 | UDP mux + ICE-TCP without TURN is enough for "typical NAT'd browsers" | Alternatives | Some corporate networks fail; fallback: embedded TURN on UDP 3478 / TLS 5349 with a DNS-only hostname |
|
||||
| A3 | Chat `GetGroupChat` on alias `channel` lazy-joins a channel member as participant (README statement; not traced through the HTTP handler this session) | Pattern 1 | Users cannot post in channels; fallback: bootstrap adds participants |
|
||||
| A4 | Forum `PostSaving` and chat `MessageSaving` names/shape | Pattern 2 | Discretion area; any additive shape is fine |
|
||||
| A5 | Persona users without passwords are acceptable (no login) | Seeding | None material |
|
||||
| A6 | Centrifugo v6 namespace option names copied from bm's running config are correct for the demo's config format (`channel.proxy.subscribe` + `subscribe_proxy_enabled`, unlike bm's named `proxies`) | Pitfall 5 | Subscribes denied; `centrifugo checkconfig` + `defaultconfig` comparison catches most |
|
||||
| A7 | `livekit-client` is the official LiveKit package (name from chatvideo README/training, registry repo github.com/livekit/client-sdk-js) | Legitimacy | Human-verify checkpoint covers it |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
Resolved 2026-10-28: Q1 by the 19.4.1 decision (admin at `/smbackend`, `/backend` 404; restated in plan 05's deploy checkpoint); Q2 by D-22 (moderator group, admin screens deferred); Q3 by D-21 (register prompt); Q4 by plans 03/04 (threads, pins, linkified URLs and an embed slot).
|
||||
|
||||
1. **Is the admin public or not?**
|
||||
- What we know: ROADMAP's goal says "The admin backend is not public". 19.4.1-02's DECISION CHANGE (user, 2026-10-20) made the admin public at `/smbackend` with a login required, throttled to 5/min, while `/backend` returns 404. SC-3 says "`/backend` is not reachable". That holds literally.
|
||||
- Recommendation: keep `/smbackend` as the user decided, and confirm in the plan's checkpoint.
|
||||
2. **How do admins delete chat messages and forum posts (D-17)?**
|
||||
- What we know: no chat-family plugin has admin screens.
|
||||
- Options:
|
||||
- (a) A demo moderator user group. `DemoContext.CanModerateMessage` (delete in group conversations) and the forum resolver's `CanManage` grant deletion. bm's `DeleteDialog` already supports moderators, and admins add users to the group in the user admin.
|
||||
- (b) cabana list screens in `golem15.demo` or `sm-moderation-plugin` over chat messages and forum posts, with delete actions. That puts chat/forum coupling into moderation, or app code into demo.
|
||||
- Recommendation: (a) now, and (b) as a backlog item.
|
||||
3. **Anonymous `/forum`.** D-14 does not say whether anonymous visitors see the forum, and the forum API is JWT-only. Recommendation: use the D-04/D-18 pattern (description plus "register to read and post") unless the user wants a public read endpoint in `golem15.demo`.
|
||||
4. **D-02 scope vs. bm reality.** bm has no threads, pins, multi-channel list or link embeds. Recommendation:
|
||||
- Threads: replies through `/messages/{id}/replies`, plus `PUT /threads/{rootId}/mute`.
|
||||
- Pins: chatchannels pins API.
|
||||
- Link embeds: linkified URLs. No unfurl backend exists, so no preview card. Add a slot or extension point for host embed renderers (bm's entity embeds become a host extension).
|
||||
|
||||
Confirm with the user that link embeds without previews is enough.
|
||||
5. **New git.golem15.com repos.** `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` and `sm-moderation-plugin` must be created on git.golem15.com (as for vue-oauth-layer in 32.1). The layers must be public or pnpm-installable from the build machine (existing layers install via `git+https`). The plan should start with a user-run or token-backed repo creation step.
|
||||
6. **Firewall change on a shared production host.** Opening the LiveKit ports through Vuurmuur needs the user's go-ahead. Plan it as a `checkpoint:human-action`.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.2 | — |
|
||||
| Docker | testcontainers tests | ✓ | 29.9.1 | `-short` tests |
|
||||
| Node | layers, vue-demo-app | ✓ | v24.22.0 local (rome site runs Node 22.22.0) | — |
|
||||
| pnpm | layers | ✓ | 12.14.0 | — |
|
||||
| livekit-server (local) | dev.sh video, tests | ✓ | 1.13.1 | `classes.FakeRooms` in Go tests |
|
||||
| livekit-server (rome) | D-10 | ✓ binary present | 1.13.1 | — |
|
||||
| centrifugo (local + rome) | realtime | ✓ | v6.5.1 | null driver |
|
||||
| rome free ports 7890-7892 | demo LiveKit | ✓ (not listening today) | — | other free ports |
|
||||
| rome firewall opening | media | ✗ (Vuurmuur rule needed) | — | none for media; blocks calls |
|
||||
| ssh rome | deploy | ✓ (`debian@10.8.3.118 -p 2222`, passwordless sudo) | — | — |
|
||||
|
||||
**Missing dependencies with no fallback:** the Vuurmuur rule for the demo LiveKit media ports (user-approved action).
|
||||
**Missing with fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (Go) | `go test` (testcontainers Postgres for full runs; `-short` skips Docker) |
|
||||
| Framework (layers) | `node --test` (existing layers); **vitest 3.2.7** recommended for `vue-chat-layer` (bm's chat tests are vitest, `vi.mock`) |
|
||||
| Framework (app site) | vitest 3.2.7 (`sm-demo-app/vue-demo-app/vitest.config.ts`) |
|
||||
| Quick run (plugin) | `go vet ./... && go test -short ./...` in the plugin checkout |
|
||||
| Quick run (demo) | `cd ../sm-demo-app && go vet ./... && go test -short ./...` |
|
||||
| Full suite (demo) | `scripts/check-all.sh` (Go app + summercms_mcp tag, user/journal plugin tests, vitest, sync-roadmap, check-pins, build release, smoke, check-deploy, browser checks, check-live) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | go.mod requires tags for all chat-family modules and summer.yaml lists them | unit | `go test -run TestGoModRequiresTags .` (sm-demo-app) | ✅ (extend wantPlugins) |
|
||||
| SC-1 | checkouts on required tags | script | `scripts/check-pins.sh` | ✅ |
|
||||
| SC-1 | every plugin activates, routes mounted (`/_chat/api/v1`, `/_chatchannels/api/v1`, `/_chatvideo/api/v1`, `/_chatbots/api/v1`, `/_forum/api/v1`, `/_userfriends/api/v1`, `/_herald/api/v1`, `/_demo/api/v1/chat`) | unit | `go test -short -run TestBootAllPlugins .` | ✅ extend |
|
||||
| SC-1 | release builds without go.work | script | `scripts/build.sh release` | ✅ |
|
||||
| SC-1 | demo:seed fills chat/channels/threads/reactions/pins/forum/friends EN+PL, idempotent | integration (Docker) | `go test -run 'TestSeed' .` | ❌ Wave 0: `seed_chat_test.go` |
|
||||
| SC-1 | reset wipes chat family and reseeds | integration | `go test -run TestResetKeepsPlans .` | ✅ extend |
|
||||
| D-06 | demo context membership, live membership, banned user excluded; channel adapter resolves | unit/integration | `go test -run 'TestDemoChat' ./plugins/demo/...` | ❌ Wave 0 |
|
||||
| D-07 | members cannot create channels (403/404 on POST channels) | integration | `go test -run TestDemoChannelsSeededOnly .` | ❌ |
|
||||
| D-11 | `video/start` on a channel conversation → 422; on DM → 201 with FakeRooms | integration | `go test -run TestDemoCallsDMOnly .` | ❌ |
|
||||
| Pitfall 4 | `/api/realtime/token` 200 for any member, 401 without JWT | unit | `go test -run TestRealtimeRoutes .` | ✅ extend |
|
||||
| D-18 | seam: no listener → identical behaviour; rejecting listener → 422 errors.content / errors.body, nothing persisted, no event | unit + Docker | `go test -short ./...` + `go test -run 'TestMessageSaving|TestPostSaving' .` in sm-chat-plugin / sm-forum-plugin | ❌ |
|
||||
| D-18 | PHP parity untouched | unit | existing `php_method_map_test.go`, `golden_test.go` | ✅ |
|
||||
| D-17 | filter: words (case-insensitive, word boundary), links (scheme/host/bare domain), settings round-trip | unit | `go test ./...` in sm-moderation-plugin | ❌ new repo |
|
||||
| D-05 | demo commands listed and dispatched ephemeral | unit | `go test -run TestDemoCommands ./plugins/demo/...` | ❌ |
|
||||
| Centrifugo | namespaces chat/user/video/presentations valid | script | `scripts/check-deploy.sh` (`centrifugo checkconfig`) | ✅ extend |
|
||||
| LiveKit | config valid, ports agree across supervisor/nginx/config | script | `scripts/check-deploy.sh` | ❌ extend |
|
||||
| D-01/02 | store: channels, DMs, threads, unread, mentions, reactions, typing, events parsing | unit | `pnpm test` in vue-chat-layer (ported `chat.test.ts`, `chatEvents/Format/Text/Unread.test.ts`) | ❌ port |
|
||||
| D-13 | call page state machine (join/leave/mute/camera, token errors) with a mocked Room | unit | `pnpm test` in vue-chatvideo-layer | ❌ |
|
||||
| D-14/15 | forum and friends API clients (URL building, error envelopes) | unit | `pnpm test` in each layer | ❌ |
|
||||
| SC-2/3 | live: /chat prompt anonymous, /forum, CTA, /backend 404, /_chat 401 without JWT, `/livekit/rtc/validate` reachable, /ws 101 | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend |
|
||||
| SC-2 | two-browser chat realtime + DM call | manual (browser) | `scripts/check-local.sh chat` (Playwright, as check-remote.cjs) + human UAT on live | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick run of the repo touched (`go test -short ./...` or `pnpm test`)
|
||||
- **Per wave merge:** sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
|
||||
- **Phase gate:** `scripts/check-all.sh` green, then `/msd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, adapter, resolvers, DM-only calls, bootstrap JSON)
|
||||
- [ ] `sm-chat-plugin` seam tests; `sm-forum-plugin` seam tests
|
||||
- [ ] `sm-moderation-plugin` package + tests (new repo)
|
||||
- [ ] `vue-chat-layer` vitest config + ported bm tests (rewrite `setActivePinia(createPinia())` setup for the composable store)
|
||||
- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`
|
||||
- [ ] `scripts/dev.sh`: start a local `livekit-server` (dev keys, ports 7890-7892 or `--dev`) like Centrifugo; `scripts/check-local.sh chat`
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | `jwt.auth` on every chat-family route; Turnstile on register (existing) |
|
||||
| V3 Session Management | yes | user JWT + refresh; realtime tokens (lighthouse, TTL 3600); LiveKit join tokens (HS256, `token_ttl` 900) |
|
||||
| V4 Access Control | yes | Host resolvers (demo context membership, live membership), chat participant checks, IDOR→404 in plugins, DM-only calls via `max_participants: 2` |
|
||||
| V5 Input Validation | yes | Plugin validators + new pre-save moderation seam; forum Markdown sanitized on write (`breakwater.Render`); chat content rendered as text (bm `chatText`), never `v-html` of raw content |
|
||||
| V6 Cryptography | yes | No hand-rolled crypto: LiveKit keys and VAPID keys generated (`openssl rand`, `herald:vapid-generate`), stored in `.env` 0640 |
|
||||
| V12 Files | yes | Chat attachments private, served only to participants; upload body limit 1 MiB default (keep, nginx `client_max_body_size 2m`) |
|
||||
|
||||
### Known Threat Patterns
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Spam/abuse in public chat and forum | Tampering / DoS | Moderation filter (pre-save), plugin buckets (`chat-api` 120/min, `chat-dm` 10/min, `chat-upload` 20/min, `forum-api`, `video-initiate` 5/min), Turnstile, reset job, user ban |
|
||||
| Stored XSS via messages/posts/names | Tampering | Text rendering for chat; server-sanitized `content_html` only for forum; no `v-html` on user fields |
|
||||
| Subscribing to others' conversations | Information disclosure | Centrifugo subscribe proxy → `AuthorizeChat` (participants only), `AuthorizeVideo`, user authorizer (`user:{self}`) |
|
||||
| LiveKit key co-tenancy with WavePath | Elevation | Separate LiveKit instance and keys (not a second key on WavePath's server) |
|
||||
| Origin IP disclosure via ICE | Information disclosure | Accepted (rome already exposes services on its IP); document in DEPLOY.md |
|
||||
| Call abuse (long or large rooms) | DoS | `max_duration` 900 + reconcile, `room.max_participants` 4, `empty_timeout`, `video-initiate` bucket |
|
||||
| Upload abuse | DoS | chat pending quota (10 files), prune job hourly, 1 MiB body limit |
|
||||
| Secrets in logs/payloads | Information disclosure | Same discipline as 19.4.1: keys only by name; never in `NUXT_PUBLIC_*` except public ones (VAPID public key) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Repo reads this session:
|
||||
- `sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots}` (README, classes, api, boot, events, fixture tests)
|
||||
- `sm-{chatvideo,forum,herald,userfriends}-plugin` (README, config, classes, api)
|
||||
- `sm-bm-app/plugins/jz/bm/{chat.go,classes/chat_context.go,classes/chat_sync.go,controllers/api/chat.go,realtime_access.go}`
|
||||
- `sm-bm-app/frontend/src/{api/chat.ts,api/types.ts,stores/chat.ts,lib/chatEvents.ts,components/chat/*}`
|
||||
- `sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules,DEPLOY.md,README.md,scripts/check-pins.sh,go_mod_test.go,boot_test.go,plugins/demo/*,deploy/*,config/*}`
|
||||
- `vue-{faq,paymentgateway,oauth}-layer`
|
||||
- `sm-demo-app/vue-demo-app`
|
||||
- `summercms.go/modules/{festival,lighthouse,fetchguard,party,conga}` README and source
|
||||
- Scratch compile and tests of all seven plugins against framework v0.5.1 (go build/vet/test -short)
|
||||
- `git ls-remote` per plugin repo; npm registry (`npm view`); msd package-legitimacy seam
|
||||
- Read-only ssh probe of rome (ports, supervisor programs, LiveKit config with keys redacted, Vuurmuur service, iptables)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- LiveKit config sample: https://raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml
|
||||
- livekit-client URL handling: https://raw.githubusercontent.com/livekit/client-sdk-js/main/src/api/utils.ts
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- [upd.dev patch notes on /rtc/v1 fallback](https://upd.dev/slopus/happy/compare/4533ef5666..c97ea347f1.patch)
|
||||
- [LiveKit community: v1.13.1 signalling drops behind nginx ssl_preread](https://community.livekit.io/t/livekit-v1-13-1-signaling-ws-drops-mid-call-with-client-request-leave-full-reconnect-media-udp-unaffected-behind-nginx-ssl-preread/1481)
|
||||
- [client-sdk-js PR #1785 (new rtc path)](https://app.semanticdiff.com/gh/livekit/client-sdk-js/pull/1785/overview)
|
||||
- [livekit-client 2.18.4 release notes](https://newreleases.io/project/npm/livekit-client/release/2.18.4)
|
||||
- [LiveKit firewall tips](https://kb.livekit.io/articles/1724892785-establishing-media-connection-firewall-troubleshooting)
|
||||
|
||||
## Suggested Plan Split (for the lean-planning confirmation)
|
||||
|
||||
| # | Scope (one line) | Repos |
|
||||
|---|------------------|-------|
|
||||
| 01 | Shared plugin release: fast-forward siblings; fix chatvideo/herald/userfriends go.mod; chat v0.1.4 and forum pre-save seams; new `sm-moderation-plugin`; tag all eight (with go-ahead) | 8 plugin repos |
|
||||
| 02 | Demo backend: oauthserver bump (folded todo); mount 8 plugins (submodules, go.work, go.mod, summer.yaml); demo context + channel adapter + resolvers; bootstrap endpoints; realtime guard; chatbots commands; moderation wiring; seed + reset; Centrifugo namespaces; local LiveKit in dev.sh | sm-demo-app |
|
||||
| 03 | `vue-chat-layer` v0.1.0 (port, multi-channel, threads, pins, command picker, shared realtime) and its use in vue-demo-app (`/chat`, floating panel, Chat tour card, EN/PL) | vue-chat-layer, vue-demo-app |
|
||||
| 04 | `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` v0.1.0 and their pages and tour cards in vue-demo-app; herald VAPID config | 3 layers, vue-demo-app, sm-demo-app |
|
||||
| 05 | rome deploy: LiveKit program, Vuurmuur rule (checkpoint), nginx locations, `.env` keys (LiveKit, VAPID), Centrifugo restart, release, DEPLOY.md, check-deploy and check-live extensions | sm-demo-app, rome |
|
||||
| 06 | Unit tests (Go, layers, site) and live verification incl. a two-browser chat and DM call | all |
|
||||
|
||||
Plans 03 and 04 can run in parallel after 02. 05 can merge into 04 if the user prefers fewer plans.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from go.mod files and the registry, and compatibility was compiled this session.
|
||||
- Architecture: HIGH for the backend glue (code read), MEDIUM for the layer design (new multi-channel work).
|
||||
- LiveKit deployment: MEDIUM. rome was probed, but the client path behaviour on server 1.13.1 behind Cloudflare is assumed.
|
||||
- Pitfalls: HIGH for repo, realtime and seed issues, MEDIUM for the network ones.
|
||||
|
||||
**Research date:** 2026-10-28
|
||||
**Valid until:** 2026-11-27 for the repo state (re-check `git ls-remote` before tagging); 7 days for the livekit-client version pick
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: "20"
|
||||
slug: "demo-instance-with-every-shared-plugin"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 20 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | go test (plugins, sm-demo-app; testcontainers Postgres on full runs); vitest 3.2.7 (vue-demo-app, vue-chat-layer); node --test or vitest (other layers) |
|
||||
| **Config file** | `../sm-demo-app/vue-demo-app/vitest.config.ts`; new layers: none — Wave 0 installs |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer |
|
||||
| **Full suite command** | `cd ../sm-demo-app && scripts/check-all.sh` |
|
||||
| **Estimated runtime** | ~30 seconds quick, ~300 seconds full |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick run command of the repo touched
|
||||
- **After every plan wave:** Run sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
|
||||
- **Before `/msd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner from the plans; source map in `20-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 20-01-01 | 01 | 1 | D-18, SC-1 | T-20-01, T-20-02 | a rejecting MessageSaving listener gives 422 and stores nothing; no listener changes nothing | integration (Docker) | `go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v .` | ❌ created in task (saving_smoke_test.go) | ⬜ pending |
|
||||
| 20-01-02 | 01 | 1 | D-18 | T-20-01 | forum thread, reply and edit refused before render | integration (Docker) | `go -C ../sm-forum-plugin test -count=1 -run '^TestPostSavingTracer$' -v .` | ❌ created in task | ⬜ pending |
|
||||
| 20-01-03 | 01 | 1 | D-17 | T-20-03, T-20-04 | word and link matching; list caps; settings behind a permission | unit | `go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterSmoke$' -v ./classes` | ❌ new repo | ⬜ pending |
|
||||
| 20-01-04 | 01 | 1 | SC-1 | T-20-05 | modules resolve by tag only | build | `for r in …8 repos; do go -C ../$r vet ./... && go -C ../$r test -short ./...; done` + `GOWORK=off go build` of 5 dependants | ✅ | ⬜ pending |
|
||||
| 20-01-05 | 01 | 1 | — | T-20-05 | go-ahead before one-way tags | checkpoint | — | — | ⬜ pending |
|
||||
| 20-01-06 | 01 | 1 | SC-1 | T-20-05 | remote tags equal verified HEADs | script | `git ls-remote origin refs/tags/<tag>^{}` loop | ✅ | ⬜ pending |
|
||||
| 20-02-01 | 02 | 2 | SC-1, D-06, D-07 | T-20-06, T-20-07 | member posts in #general; channel creation refused | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer\|TestGoModRequiresTags\|TestBootAllPlugins)$' -v .` + `scripts/check-pins.sh` | ❌ created in task (chat_smoke_test.go) | ⬜ pending |
|
||||
| 20-02-02 | 02 | 2 | D-05, D-08, D-12, D-14, D-17, D-22, D-23 | T-20-09, T-20-10 | banned words and links refused in chat and forum | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^TestDemoModerationSmoke$' -v .` + `scripts/build.sh dev` | ❌ created in task | ⬜ pending |
|
||||
| 20-02-03 | 02 | 2 | D-09 | T-20-08, T-20-13 | personas cannot log in; reset empties every chat-family table | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke\|TestResetKeepsPlans\|TestSeedRoadmapUpsert)$' -v .` | ❌ / ✅ | ⬜ pending |
|
||||
| 20-02-04 | 02 | 2 | D-06, D-10 | T-20-11, T-20-12 | realtime token only for live members; no secret committed | unit + script | `go -C ../sm-demo-app test -short .` + `centrifugo checkconfig` + `scripts/dev.sh up` + `scripts/check-local.sh realtime` | ✅ | ⬜ pending |
|
||||
| 20-03-01 | 03 | 3 | D-01, D-04, D-07, SC-2 | T-20-15 | anonymous prompt; Bearer only to same-origin APIs | unit + browser | `pnpm --dir ../vue-chat-layer test` + vue-demo-app build + `scripts/check-local.sh chat` | ❌ created in task | ⬜ pending |
|
||||
| 20-03-02 | 03 | 3 | D-02, SC-2 | T-20-14, T-20-19 | text escaped; one realtime connection | unit + browser | layer and vue-demo-app tests + `scripts/check-local.sh chat realtime remote` | ✅ (app) | ⬜ pending |
|
||||
| 20-03-03 | 03 | 3 | D-02, D-05, D-22, D-23 | T-20-14, T-20-16, T-20-17 | linkify on escaped HTML; authorised downloads; ephemeral answers stay local | unit + build | `pnpm --dir ../vue-chat-layer test` + `build:playground` + `scripts/check-local.sh chat` | ✅ | ⬜ pending |
|
||||
| 20-03-04 | 03 | 3 | D-03, D-04 | — | floating panel hidden for anonymous visitors | browser | `scripts/check-local.sh tour chat` | ✅ extend | ⬜ pending |
|
||||
| 20-03-05 | 03 | 3 | D-01 | — | go-ahead before the public tag | checkpoint | — | — | ⬜ pending |
|
||||
| 20-03-06 | 03 | 3 | D-01 | T-20-18 | layer pinned by tag with a committed lockfile | script | remote tag equals HEAD + pin grep + vue-demo-app test and build | ✅ | ⬜ pending |
|
||||
| 20-04-01 | 04 | 4 | D-14, D-21, D-22 | T-20-20 | posts render only server content_html | unit + script | `pnpm --dir ../vue-forum-layer test` + build + `scripts/check-local.sh forum` | ❌ created in task | ⬜ pending |
|
||||
| 20-04-02 | 04 | 4 | D-15 | T-20-25 | friend refusals stay generic | unit + script | `pnpm --dir ../vue-userfriends-layer test` + `scripts/check-local.sh friends forum chat` | ❌ created in task | ⬜ pending |
|
||||
| 20-04-03 | 04 | 4 | D-13 | T-20-SC | livekit-client legitimacy confirmed before install | checkpoint (blocking-human) | — | — | ⬜ pending |
|
||||
| 20-04-04 | 04 | 4 | D-11, D-12, D-13, D-23 | T-20-21, T-20-22, T-20-23, T-20-24 | server-minted tokens; call button only in DMs; media only after start or accept | unit + script | `pnpm --dir ../vue-chatvideo-layer test` + build + `scripts/check-local.sh call chat` | ❌ created in task | ⬜ pending |
|
||||
| 20-04-05 | 04 | 4 | D-16 | T-20-26 | VAPID keys only in .env | script | `scripts/dev.sh up` + `scripts/check-local.sh herald tour` | ❌ check added in task | ⬜ pending |
|
||||
| 20-04-06 | 04 | 4 | D-14, D-15, D-13 | — | go-ahead before public tags | checkpoint | — | — | ⬜ pending |
|
||||
| 20-04-07 | 04 | 4 | SC-2 | T-20-SC | layers pinned by tag | script | remote tags equal HEAD + vue-demo-app test, build + `scripts/check-local.sh forum friends call chat tour` | ✅ | ⬜ pending |
|
||||
| 20-05-01 | 05 | 5 | D-10, D-12, SC-3 | T-20-31 | exact Go allow-list; /_herald unrouted; ports and caps agree | script | `scripts/check-deploy.sh` (last line `check-deploy: ok`) | ✅ extend | ⬜ pending |
|
||||
| 20-05-02 | 05 | 5 | SC-1 | T-20-33 | release build from tags, smoke clean | script | `scripts/check-pins.sh` + `scripts/build.sh release` + `scripts/smoke.sh` | ✅ | ⬜ pending |
|
||||
| 20-05-03 | 05 | 5 | SC-3 | T-20-27 | go-ahead before rome changes | checkpoint | — | — | ⬜ pending |
|
||||
| 20-05-04 | 05 | 5 | SC-2, SC-3, D-10, D-16 | T-20-27, T-20-28, T-20-34 | WavePath untouched; secrets generated on rome | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend | ⬜ pending |
|
||||
| 20-05-05 | 05 | 5 | D-10 | T-20-29, T-20-32 | go-ahead before the firewall change | checkpoint | — | — | ⬜ pending |
|
||||
| 20-05-06 | 05 | 5 | D-10 | T-20-29, T-20-32 | only 7891/tcp and 7892/udp opened, through Vuurmuur | live | `ssh … iptables -S` count + `nc -zv … 7891` | ✅ | ⬜ pending |
|
||||
| 20-06-01 | 06 | 6 | D-17, D-18, SC-4 | T-20-10 | settings to filter to listener to 422 | unit + integration | `go -C ../sm-moderation-plugin test ./...` + `-run '^TestDemoModeration$'` | ❌ created in task | ⬜ pending |
|
||||
| 20-06-02 | 06 | 6 | D-18, D-17 | T-20-01, T-20-02 | parity without listeners; refusal on every write path | unit + integration | chat, forum and moderation full suites | ❌ created in task | ⬜ pending |
|
||||
| 20-06-03 | 06 | 6 | D-05 to D-09, D-11, D-12, D-22, D-23, SC-1 | T-20-06, T-20-07, T-20-09, T-20-11 | membership, refused channel create, moderator scope, member-only tokens | integration | `go -C ../sm-demo-app test -count=1 . ./plugins/demo/...` + named tests | ❌ created in task | ⬜ pending |
|
||||
| 20-06-04 | 06 | 6 | D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23 | T-20-14 | escaping and DM-only slot pinned | unit | `pnpm test` in four layers + vue-demo-app test and build | ❌ created in task | ⬜ pending |
|
||||
| 20-06-05 | 06 | 6 | SC-2, D-11, D-13, D-23 | — | two-browser realtime; DM call with fake media | browser E2E | `scripts/check-local.sh chat chatlive callmedia forum friends call herald tour realtime remote` | ❌ created in task | ⬜ pending |
|
||||
| 20-06-06 | 06 | 6 | — | — | go-ahead before pushes and fix tags | checkpoint | — | — | ⬜ pending |
|
||||
| 20-06-07 | 06 | 6 | SC-2, SC-3, SC-4 | T-20-36 | full gate including check-live | gate | `scripts/check-all.sh --local` (last line `check-all: ok`) | ✅ extend | ⬜ pending |
|
||||
| 20-06-08 | 06 | 6 | SC-2, SC-3 | — | two-browser chat and DM call on the live demo | manual (UAT) | — | — | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
Each gap is closed inside the plan that creates the code (a smoke test or check in the same task), and the full tests come in plan 06 (project rule: unit tests are the last plan).
|
||||
|
||||
- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, channel adapter, resolvers, DM calls, bootstrap JSON): smoke in 20-02 (`chat_smoke_test.go`), full in 20-06 Task 3
|
||||
- [ ] `sm-chat-plugin` and `sm-forum-plugin` pre-save seam tests: smoke in 20-01 Tasks 1-2, full in 20-06 Task 2
|
||||
- [ ] `sm-moderation-plugin` package and tests (new repo): smoke in 20-01 Task 3, full in 20-06 Tasks 1-2
|
||||
- [ ] `vue-chat-layer` vitest config and ported bm chat tests (composable store setup): config and smoke in 20-03 Task 1, ported tests in 20-06 Task 4
|
||||
- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`: smoke in 20-04 Tasks 1, 2 and 4, full in 20-06 Task 4
|
||||
- [ ] `scripts/dev.sh` local livekit-server (20-02 Task 4); `scripts/check-local.sh chat` (20-03 Task 1), `forum`, `friends`, `call`, `herald` (20-04), `chatlive` and `callmedia` (20-06 Task 5)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Two-browser chat realtime and a DM video call on the live demo | SC-2, D-13 | Real media over NAT'd browsers and the rome firewall | Two accounts on https://demo.summercms.io: send a message, see it arrive live; start a DM call, see and hear both tiles, mute, leave |
|
||||
| Vuurmuur firewall rule on rome | D-10 | Shared production host change | Apply with the user's go-ahead; confirm with `/livekit/rtc/validate` and a call |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies (checkpoints excepted)
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags (vitest run, node --test)
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
375
.planning/phases/32-seo-foundation/32-06-SUMMARY.md
Normal file
375
.planning/phases/32-seo-foundation/32-06-SUMMARY.md
Normal file
@@ -0,0 +1,375 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 06
|
||||
subsystem: release
|
||||
tags: [release, apisurface, additive-gate, compat-gate, unit-tests, vitest, sm-seo-plugin, sm-journal-plugin, sm-demo-app, changelog]
|
||||
|
||||
requires:
|
||||
- phase: 32-01
|
||||
provides: cross-plugin admin form extensions (pact.AdminFormExtension, cabana compile and save)
|
||||
- phase: 32-02
|
||||
provides: record events (pact.RecordSaved/RecordDeleted, cabana.EmitRecord*), content descriptors (pact.ContentDescriptor, signpost lookups), Journal adoption
|
||||
- phase: 32-03
|
||||
provides: sm-seo-plugin core (entries, rules, settings, resolver, history)
|
||||
- phase: 32-04
|
||||
provides: redirects, 404 log, robots.txt
|
||||
- phase: 32-05
|
||||
provides: management API, MCP tools, batches, undo, CSV, check-phase32.sh, 32-05-demo-mount.patch
|
||||
provides:
|
||||
- "scripts/check-phase32.sh stages additive (scripts/apisurface API-surface comparison with the previous release) and compat (three consumers tested, every other sibling built), with self-tests"
|
||||
- "Framework unit matrices for form extensions, record events and content descriptors"
|
||||
- "sm-seo-plugin unit tests (classes) and 52 vitest element tests; sm-journal-plugin descriptor, journal-tag and translated-slug tests"
|
||||
- "SummerCMS v0.5.1 (patch, strictly additive), sm-journal-plugin v0.2.5, sm-seo-plugin v0.1.0 (first release), all pushed"
|
||||
- "sm-demo-app mounts golem15.seo v0.1.0 (pushed, not deployed)"
|
||||
- "Changelog post https://summercms.io/changelog/summercms-0-5-1"
|
||||
affects: [33, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, vue-seo-layer]
|
||||
|
||||
actuals:
|
||||
tokens: 51000
|
||||
tasks: 5
|
||||
commits: 4
|
||||
plan_head_before: 49502e8ccb98e29a067961641dd08e2d39e42f27
|
||||
plan_head_after: a9a7f58eedaf70edd3472b6aebe01b2db4cbb498
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Release gate: the API surface of every changed framework package is compared with the previous release tag (tags containing HEAD skipped); removed or changed elements and methods added to existing interfaces are refused"
|
||||
- "Compat gate reads sibling repositories through temporary-index snapshots or detached worktrees and builds them in a symlinked scratch sibling layout"
|
||||
- "Release steps are logged as they complete (32-06-release-log.md) so a resumed run never re-tags, force-pushes or re-posts"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/apisurface/main.go
|
||||
- modules/cabana/form_extensions_test.go
|
||||
- modules/cabana/record_events_test.go
|
||||
- modules/signpost/content_test.go
|
||||
- .planning/phases/32-seo-foundation/32-06-release-log.md
|
||||
- .planning/phases/32-seo-foundation/deferred-items.md
|
||||
- ../sm-seo-plugin/classes/*_test.go (resolver, redirect_graph, normalize, robots_value, robots, templates, csv, owner, issues, helpers)
|
||||
- ../sm-seo-plugin/tests/elements/{text,robots,serp,robotstxt,transfer}.test.ts, tests/elements/css.ts
|
||||
- ../sm-journal-plugin/content_descriptors_test.go
|
||||
- ../sm-demo-app/config/golem15.seo.yaml, seo_mount_test.go, seo_mcp_test.go
|
||||
modified:
|
||||
- scripts/check-phase32.sh
|
||||
- .planning/phases/32-seo-foundation/32-VALIDATION.md
|
||||
- ../sm-seo-plugin/go.mod, README.md, classes/redirect_graph.go, classes/issues.go, classes/robots.go, assets/src/elements/measure.ts, assets/dist/seo.js
|
||||
- ../sm-journal-plugin/go.mod, README.md, menu_item_types_test.go
|
||||
- ../sm-demo-app/go.mod, go.sum, go.work, .gitmodules, summer.yaml, plugins.gen.go, boot_test.go, DEPLOY.md, deploy/nginx/demo.summercms.io.conf, plugins/golem15/journal, plugins/golem15/seo
|
||||
|
||||
key-decisions:
|
||||
- "Release per Jakub's go-ahead: SummerCMS v0.5.1, sm-journal-plugin v0.2.5, sm-seo-plugin v0.1.0, all pushed with their branches; sm-demo-app's three commits pushed; nothing deployed"
|
||||
- "The additive gate compares with the newest tag that does not contain HEAD, so a run on the tagged release commit compares it with the previous release, not with itself"
|
||||
- "The demo Journal bump replaces the other session's uncommitted journal pointer drift on the same path (b129902, an ancestor of v0.2.5) with the v0.2.5 pointer; the oauthserver drift is left untouched"
|
||||
- "check-pins is proven on the committed demo tree (clean detached worktree), because the live tree carries another session's oauthserver drift"
|
||||
- "The optional app Journal bumps and per-plugin changelog posts were not named and are deferred"
|
||||
|
||||
patterns-established:
|
||||
- "Post-release gate runs pick the previous release as the additive baseline (git tag --no-contains HEAD)"
|
||||
|
||||
requirements-completed: [SC-5, SC-1, SC-2, SC-3, SC-4, Q-01, D-106, D-90, D-91, D-92, D-93, D-98, D-22, D-25, D-09]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Additive and compat gate stages: the v0.5.1 API surface keeps every v0.5.0 element (cabana +2, pact +87, signpost +8 lines, nothing removed or changed, no interface method added); consumers build and test unchanged"
|
||||
requirement: "D-106"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase32.sh --additive (release commit 15b0d0d vs v0.5.0); bash scripts/check-phase32.sh --self-test"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase32.sh --all (compat stage: 3 consumers tested, 20 siblings built)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Framework unit matrices for form extensions, record events and content descriptors"
|
||||
requirement: "SC-5"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test ./modules/cabana ./modules/signpost (form_extensions_test.go, record_events_test.go, content_test.go)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "sm-seo-plugin class unit tests and element tests; Journal descriptor, journal-tag and translated-slug tests"
|
||||
requirement: "SC-5"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go -C ../sm-seo-plugin test ./... (TestResolverPrecedence, TestRedirectGraph, TestNormalizeMatrix, TestRobotsValueRoundTrip, TestRobotsMatcherGoogleExamples, TestTemplates, TestCSV, TestOwnerResolution, TestIssues)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "npm --prefix ../sm-seo-plugin run test (52 element tests)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "go -C ../sm-journal-plugin test ./... (content_descriptors_test.go, menu_item_types_test.go)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Releases tagged and pushed: SummerCMS v0.5.1, sm-journal-plugin v0.2.5, sm-seo-plugin v0.1.0"
|
||||
requirement: "Q-01"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --tags origin v0.5.1 / v0.2.5 / v0.1.0 (aa6e0a5, 16d9326, 9cf0447)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "sm-demo-app mounts golem15.seo v0.1.0 with tagged requires; SEO tab and MCP catalog proven"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "go -C ../sm-demo-app test -count=1 -tags summercms_mcp -run 'TestSEOToolsCatalog|TestGoModRequiresTags|TestSEOTabMounted|TestBootAllPlugins' . (Docker)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "scripts/check-pins.sh in a clean worktree of 9451b31: check-pins: ok (17 modules)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Full phase gate green with the demo mounted and no skips"
|
||||
requirement: "D-90"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase32.sh --all (exit 0, Phase 32 gate passed)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Changelog post for v0.5.1 on summercms.io reads well and says the right things to users"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "https://summercms.io/changelog/summercms-0-5-1 answers 200; API shows published at the tag date"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Wording and emphasis of public release notes are an editorial call"
|
||||
|
||||
duration: 1h 25m
|
||||
completed: 2026-10-27
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32 Plan 06: Unit tests, additive release gate and the v0.5.1 release Summary
|
||||
|
||||
**The phase is proven and released. An API-surface gate shows the framework change is strictly additive against v0.5.0, and a compat gate builds every sibling consumer unchanged. Unit tests cover the framework seams, the SEO plugin (Go and elements) and Journal. SummerCMS v0.5.1, sm-journal-plugin v0.2.5 and sm-seo-plugin v0.1.0 are tagged and pushed. sm-demo-app mounts golem15.seo v0.1.0 and is pushed, not deployed. The changelog post is at https://summercms.io/changelog/summercms-0-5-1.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 1h 25m in two executor contexts (Tasks 1 to 3, then Task 5 after the Task 4 checkpoint)
|
||||
- **Started:** 2026-10-27T11:51Z (plan base 49502e8)
|
||||
- **Completed:** 2026-10-27T13:20Z
|
||||
- **Tasks:** 5 (4 executed, 1 decision checkpoint)
|
||||
- **Files modified:** 6 in this repository, 23 in sm-seo-plugin, 4 in sm-journal-plugin, 14 in sm-demo-app
|
||||
|
||||
## Task 4 answer (Jakub)
|
||||
|
||||
- **Decision:** release. Versions confirmed: v0.5.1, v0.2.5 and v0.1.0.
|
||||
- **Approved pushes:**
|
||||
- framework master and v0.5.1 (master carried 64 commits, including other sessions' work; accepted)
|
||||
- sm-journal-plugin main and v0.2.5
|
||||
- sm-seo-plugin main (first push) and v0.1.0
|
||||
- sm-demo-app master with three commits
|
||||
- **Changelog:** a post for v0.5.1.
|
||||
- **Not named:** the optional app Journal bumps. **Nothing deployed.**
|
||||
- **Gate:** the full run with the demo mounted and no test skips. The fixture is readable after the chown.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `check-phase32.sh` has two new stages:
|
||||
- **additive:** `scripts/apisurface` compares the exported API with the previous release and refuses removed or changed elements and methods added to existing interfaces. The self-test plants each case.
|
||||
- **compat:** sm-sitemap-plugin, sm-journal-plugin and sm-demo-app are tested; every other sibling that requires the framework is built.
|
||||
- **Unit tests:**
|
||||
- framework matrices for form extensions, record events and content descriptors
|
||||
- sm-seo-plugin classes (resolver, redirect graph, normalisation, robots value and matcher, templates, CSV, owner, issues) and 52 vitest element tests
|
||||
- Journal: descriptors, the journal-tag type and translated slugs
|
||||
- **Releases:** v0.5.1, v0.2.5 and v0.1.0 are tagged and pushed. Both plugins require v0.5.1.
|
||||
- **Demo:** sm-demo-app requires v0.5.1, bumps Journal to v0.2.5 and mounts golem15.seo v0.1.0 (the 32-05 patch, the submodule and go.work).
|
||||
- **Changelog:** v0.5.1 is announced.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**summercms.go**
|
||||
1. **Task 1: additive and compat stages** - `2b300ec` (test)
|
||||
2. **Task 2: framework unit matrices** - `8c961f5` (test)
|
||||
3. **Task 3D: validation map** - `15b0d0d` (docs). This is the release commit, tagged v0.5.1.
|
||||
4. **Task 5 fix: additive baseline after tagging** - `a9a7f58` (fix). Local only, after the tag.
|
||||
|
||||
**sm-seo-plugin**
|
||||
- Task 3 fixes and refactors:
|
||||
- `e957c00` refactor: run the redirect simulation over a rule list
|
||||
- `79567b3` fix: a loop through the site's absolute URL lists each address once
|
||||
- `6d54fc7` refactor: build the issue report from loaded data
|
||||
- `9461c74` fix: the result preview truncates at the last word boundary
|
||||
- Task 3A: `9e45ddc` test: unit tests for the SEO classes
|
||||
- Task 3B: `dcfbdc0` test: element backstops
|
||||
- Task 5: `b19cd06` chore: require SummerCMS v0.5.1. Tagged v0.1.0.
|
||||
|
||||
**sm-journal-plugin**
|
||||
- Task 3C: `fea88f8` test: content descriptors, journal-tag type and translated slugs
|
||||
- Task 5: `f5a0394` chore: require SummerCMS v0.5.1. Tagged v0.2.5.
|
||||
|
||||
**sm-demo-app** (Task 5)
|
||||
- `11f64bf` chore: require SummerCMS v0.5.1 (go.mod, go.sum)
|
||||
- `11d485f` chore: bump journal to v0.2.5 (go.mod, go.sum, plugins/golem15/journal)
|
||||
- `9451b31` feat: mount golem15.seo v0.1.0 (13 paths, all from this plan)
|
||||
|
||||
**Plan metadata:** the commit that adds this SUMMARY (docs).
|
||||
|
||||
`commits: 4` is measured in this repository with `git rev-list --count 49502e8..HEAD` before this SUMMARY's commit. Sibling commits are listed above.
|
||||
|
||||
## Release steps (from 32-06-release-log.md)
|
||||
|
||||
- **Pre-checks:**
|
||||
- framework: newest tag v0.5.0; master was 64 ahead of origin/master and 0 behind.
|
||||
- admin/: only `admin/tests/form/FormView.test.ts` changed, so no dist rebuild was needed.
|
||||
- **Step 1, framework:**
|
||||
- `go vet` and `go test -short` green.
|
||||
- Annotated tag v0.5.1: tag object aa6e0a5 on commit 15b0d0d.
|
||||
- `git push origin master`: 223f87e..15b0d0d, fast-forward checked after `git fetch`.
|
||||
- `git push origin v0.5.1`; `ls-remote` confirms it.
|
||||
- **Step 2, sm-journal-plugin:**
|
||||
- `go get summercms@v0.5.1` with `GOWORK=off` and `GOPRIVATE`.
|
||||
- README dependency line updated to v0.5.1 (signpost is now a runtime dependency).
|
||||
- Build, vet and `test -short` green.
|
||||
- Commit f5a0394; tag v0.2.5 (tag object 16d9326).
|
||||
- Pushed main 7fc07d8..f5a0394 and the tag.
|
||||
- **Step 3, sm-seo-plugin:**
|
||||
- The same require, plus the README dependency line "SummerCMS v0.5.1 modules".
|
||||
- Build, vet and `test -short` green.
|
||||
- Commit b19cd06; tag v0.1.0 (tag object 9cf0447).
|
||||
- `git push -u origin main` (a new branch on the empty remote) and the tag.
|
||||
- **Step 4, sm-demo-app:**
|
||||
- Found at the start: ` M plugins/golem15/journal` and ` M plugins/golem15/oauthserver`.
|
||||
- Commit 11f64bf: require v0.5.1.
|
||||
- The journal submodule HEAD (b129902) is an ancestor of v0.2.5, so it was moved to v0.2.5. Commit 11d485f: only the v0.2.5 pointer was staged, and it replaces the foreign journal drift on that path.
|
||||
- Mount:
|
||||
- `git apply` the 32-05 patch
|
||||
- `git submodule add -b main` sm-seo-plugin, checked out at v0.1.0
|
||||
- go.work `use ./plugins/golem15/seo`
|
||||
- `go get sm-seo-plugin@v0.1.0` and `go mod tidy` (GOWORK=off)
|
||||
- `summer build` printed ✓ Built bin/demo
|
||||
- Build (plain and summercms_mcp), vet and `test -short` green. With Docker, TestBootAllPlugins, TestGoModRequiresTags, TestSEOToolsCatalog and TestSEOTabMounted pass.
|
||||
- Commit 9451b31.
|
||||
- check-pins: the live tree fails only on the foreign oauthserver drift. A clean worktree of 9451b31 prints `check-pins: ok (17 modules)`, and `GOWORK=off go build ./...` builds from the published tags (plain and mcp).
|
||||
- Pushed master 8fcdbdd..9451b31. Not deployed.
|
||||
- **Step 5, gate:** `check-phase32.sh --all` passes (details under Gate output).
|
||||
- **Step 6, changelog:**
|
||||
- `changelog-post.py source v0.5.1`, then the notes (## Highlights, ## Added).
|
||||
- `publish` returned post id 45: https://summercms.io/changelog/summercms-0-5-1. It is published at the tag date and the page answers 200.
|
||||
- The script read the token itself; it was never printed.
|
||||
- **Step 7, deferred items:** deferred-items.md is written.
|
||||
|
||||
## Gate output (step 5)
|
||||
|
||||
`bash scripts/check-phase32.sh --all` ran with Docker, the demo mounted, no `PHASE32_*` overrides and no test skips. It exited 0 with "Phase 32 gate passed".
|
||||
|
||||
- **Stages:** PASS for self-test, go, plugins (TestNoTideJournalFixtures included), consumers, admin, docs, marker, additive, compat and docker.
|
||||
- **compat:** sm-sitemap-plugin, sm-journal-plugin and sm-demo-app are tested. fonoteka.go, sm-bm-app, sm-grzybyfunkcjonalne-app and every sibling plugin are built.
|
||||
- **docker:** TestSEOTabMounted and TestSEOToolsCatalog pass in the demo.
|
||||
- **No failure lines:** no FAIL, `refuse:`, NOT MOUNTED or additive refusal.
|
||||
- **Additive baseline:** the additive line of that run compared v0.5.1 with itself, which led to fix a9a7f58. Re-run on the release commit, it compared against v0.5.0: cabana +2, pact +87, signpost +8 lines.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Behaviour-neutral refactors so TestRedirectGraph and TestIssues run under -short**
|
||||
- **Found during:** Task 3A
|
||||
- **Fix:** the redirect simulation runs over a rule list, and the issue report is built from loaded data.
|
||||
- **Commits:** sm-seo-plugin e957c00, 6d54fc7
|
||||
|
||||
**2. [Rule 1 - Bug] A loop notice listed an address twice**
|
||||
- **Found during:** Task 3A
|
||||
- **Fix:** a loop through the site's absolute URL lists each address once.
|
||||
- **Commit:** sm-seo-plugin 79567b3
|
||||
|
||||
**3. [Rule 1 - Bug] The result preview did not truncate per UI-SPEC E-1**
|
||||
- **Found during:** Task 3B
|
||||
- **Fix:** the preview truncates at the last word boundary plus " …", and the dist bundle was rebuilt.
|
||||
- **Commit:** sm-seo-plugin 9461c74
|
||||
|
||||
**4. [Rule 3 - Blocking] Extra test support files**
|
||||
- **Found during:** Task 3
|
||||
- **Fix:** classes/helpers_test.go and tests/elements/css.ts were added, and gorm.io/driver/postgres became a direct require in sm-seo-plugin's go.mod.
|
||||
- **Commits:** 9e45ddc, dcfbdc0
|
||||
|
||||
**5. [Rule 3 - Blocking] No layout in happy-dom**
|
||||
- **Found during:** Task 3B
|
||||
- **Fix:** the 320 and 360 px element rows check the stylesheet's layout contract.
|
||||
- **Commit:** dcfbdc0
|
||||
|
||||
**6. [Rule 3 - Blocking] The compat stage has to read other repositories without touching their trees**
|
||||
- **Found during:** Task 1
|
||||
- **Fix:** it reads them through temporary-index snapshots or detached worktrees and builds in a symlinked scratch sibling layout. There is a new PHASE32_SKIP_TESTS knob.
|
||||
- **Commit:** 2b300ec
|
||||
|
||||
**7. No separate RED commits**
|
||||
- **Found during:** Tasks 2 and 3
|
||||
- **Reason:** the green-at-every-commit rule in CLAUDE.md.
|
||||
|
||||
**8. Element tests: 52, against the plan's minimum of 20.**
|
||||
|
||||
**9. [Rule 1 - Bug] The additive stage compared the release with itself once HEAD was tagged**
|
||||
- **Found during:** Task 5, step 5
|
||||
- **Issue:** `last_tag` picked the newest tag, which was v0.5.1 on HEAD.
|
||||
- **Fix:** `git tag --sort=-v:refname --no-contains HEAD`. The re-run against v0.5.0 passes, and so does `--self-test`.
|
||||
- **Commit:** a9a7f58 (local; it is after the pushed tag and was not pushed)
|
||||
|
||||
**10. [Rule 3 - Blocking] The live demo tree fails check-pins**
|
||||
- **Found during:** Task 5, step 4
|
||||
- **Issue:** another session's uncommitted oauthserver drift (b6a9586 against v0.1.2, 8bbec72).
|
||||
- **Fix:** none in the live tree; the drift was left alone. check-pins is proven on a clean detached worktree of the pushed commit.
|
||||
|
||||
**11. [Rule 2 - Docs] Journal README dependency line**
|
||||
- **Found during:** Task 5, step 2
|
||||
- **Issue:** the plan named only the sm-seo-plugin README.
|
||||
- **Fix:** the Journal README now names SummerCMS v0.5.1, and signpost moved from "in tests" to a runtime dependency (CLAUDE.md documentation rule).
|
||||
- **Commit:** f5a0394
|
||||
|
||||
**12. [Rule 3 - Blocking] The demo's TestSEO* tests skip under -short (they need Docker)**
|
||||
- **Fix:** they were run without -short with Docker. Both pass, tagged and untagged.
|
||||
|
||||
**13. `go mod tidy` in the demo**
|
||||
- **Why:** to make sm-seo-plugin a direct require.
|
||||
- **Effect:** go.sum gained grobotstxt's test-dependency checksums and dropped the obsolete v0.5.0 and v0.2.3 lines.
|
||||
- **Commit:** 9451b31
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 13 (5 Rule 1, 1 Rule 2, 5 Rule 3, 2 notes). **Impact:** the fixes are bugs in plugin code or in the gate, plus test scaffolding. No scope creep and no API change.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **g15office comment not posted.** This executor has no g15office tool. The orchestrator posts the release comment on Phase 32's milestone task `8gldeain` (`.planning/G15OFFICE.md`).
|
||||
- **Retracted dependency in the demo.** sm-demo-app pins `github.com/gabriel-vasile/mimetype v1.4.15`, which upstream has retracted. This was there before the plan; it is recorded in deferred-items.md.
|
||||
|
||||
## Deferred (deferred-items.md)
|
||||
|
||||
- Journal bumps in sm-grzybyfunkcjonalne-app (with `golem15.journal.tag_pages: false`) and sm-summercmsio-app. Not named in the go-ahead.
|
||||
- Changelog posts of their own for sm-journal-plugin v0.2.5 and sm-seo-plugin v0.1.0.
|
||||
- Deploying the demo.
|
||||
- The demo's oauthserver drift (another session's) and the retracted mimetype pin.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 33 can build on the tagged v0.5.1 seams and on sm-seo-plugin v0.1.0.
|
||||
- The demo is pushed but not deployed. Deploy per `../sm-demo-app/DEPLOY.md` when Jakub says so.
|
||||
- Local commits after the release: a9a7f58 and this SUMMARY's commit. They go out with the next framework push.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: scripts/apisurface/main.go, modules/cabana/form_extensions_test.go, modules/cabana/record_events_test.go, modules/signpost/content_test.go, 32-06-release-log.md and deferred-items.md exist.
|
||||
- Commits: 2b300ec, 8c961f5, 15b0d0d and a9a7f58 are in this repository. e957c00, 79567b3, 6d54fc7, 9e45ddc, 9461c74, dcfbdc0 and b19cd06 are in sm-seo-plugin; fea88f8 and f5a0394 in sm-journal-plugin; 11f64bf, 11d485f and 9451b31 in sm-demo-app.
|
||||
- Remote tags: v0.5.1 (aa6e0a5), v0.2.5 (16d9326) and v0.1.0 (9cf0447).
|
||||
- Changelog URL answers 200.
|
||||
|
||||
---
|
||||
*Phase: 32-seo-foundation*
|
||||
*Completed: 2026-10-27*
|
||||
29
.planning/phases/32-seo-foundation/32-06-release-log.md
Normal file
29
.planning/phases/32-seo-foundation/32-06-release-log.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Plan 32-06 release log
|
||||
|
||||
Resume record of Task 5 (one-way). A resumed run confirms each `step <n> done` line and skips it: no tag is created twice, no push is forced, the changelog is posted once.
|
||||
|
||||
## Task 4 answer (Jakub, 2026-10-27)
|
||||
|
||||
- **Decision:** release (full Task 5).
|
||||
- **Versions:** framework `v0.5.1`, sm-journal-plugin `v0.2.5`, sm-seo-plugin `v0.1.0`.
|
||||
- **Pushes approved:** framework master + v0.5.1 (master carries 64 commits incl. other sessions' work, accepted); sm-journal-plugin main + v0.2.5; sm-seo-plugin main (first push, `-u`) + v0.1.0; sm-demo-app master (three commits: framework pin, Journal bump, golem15.seo mount).
|
||||
- **Changelog:** post v0.5.1 on summercms.io.
|
||||
- **Not named:** optional Journal bumps in sm-grzybyfunkcjonalne-app and sm-summercmsio-app (deferred).
|
||||
- **Nothing is deployed.**
|
||||
- **Gate:** full `check-phase32.sh --all` with the demo mounted, no PHASE32_ALLOW_UNMOUNTED, no TestNoTideJournalFixtures skip (fixture readable after the chown).
|
||||
|
||||
## Pre-checks
|
||||
|
||||
- framework: `git fetch --tags origin`; newest tag v0.5.0; master is 64 ahead, 0 behind origin/master (fast-forward).
|
||||
- admin/: only `admin/tests/form/FormView.test.ts` changed since v0.5.0; the embedded dist is unaffected, no rebuild.
|
||||
|
||||
## Steps
|
||||
|
||||
- step 1 done: framework `go vet ./...` + `go test -short ./...` green; annotated tag v0.5.1 (tag object aa6e0a5, commit 15b0d0d) created; `git push origin master` 223f87e..15b0d0d (fast-forward checked after `git fetch origin`); `git push origin v0.5.1` new tag; `git ls-remote --tags origin v0.5.1` prints aa6e0a5.
|
||||
- step 2 done: sm-journal-plugin `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/summercms@v0.5.1` (v0.5.0 => v0.5.1; go.sum unchanged, the module's replace points at ../summercms.go), README dependency line v0.5.1 (signpost now a runtime dependency); `GOWORK=off go build ./... && go vet ./... && go test -short ./...` green; commit f5a0394 `chore: require SummerCMS v0.5.1` (go.mod, README.md); annotated tag v0.2.5 (tag object 16d9326, commit f5a0394); `git push origin main` 7fc07d8..f5a0394 (fast-forward checked); `git push origin v0.2.5` new tag; ls-remote prints 16d9326.
|
||||
- step 3 done: sm-seo-plugin `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/summercms@v0.5.1` (v0.5.0 => v0.5.1; go.sum unchanged, replace to ../summercms.go), README dependency line "SummerCMS v0.5.1 modules"; `GOWORK=off go build ./... && go vet ./... && go test -short ./...` green; commit b19cd06 `chore: require SummerCMS v0.5.1` (go.mod, README.md); annotated tag v0.1.0 (tag object 9cf0447, commit b19cd06); `git push -u origin main` new branch (first push, origin was empty); `git push origin v0.1.0` new tag; ls-remote prints main b19cd06 and v0.1.0 9cf0447.
|
||||
- step 4 done: sm-demo-app (pre-existing drift ` M plugins/golem15/journal` (4ab94da recorded, b1299021 checked out) and ` M plugins/golem15/oauthserver` (8bbec72 recorded, b6a9586 checked out) found at start). Commit 11f64bf `chore: require SummerCMS v0.5.1` (go.mod, go.sum; build, mcp build, vet, test -short green). Journal submodule HEAD b129902 confirmed an ancestor of v0.2.5, checked out at v0.2.5 (f5a0394); commit 11d485f `chore: bump journal to v0.2.5` (go.mod, go.sum, plugins/golem15/journal: 4ab94da -> f5a0394; this pointer replaces the other session's journal drift on the same path, only the intended v0.2.5 pointer staged). Mount: `git apply 32-05-demo-mount.patch`, `git submodule add -b main git@git.golem15.com:golem15/sm-seo-plugin.git plugins/golem15/seo` checked out at v0.1.0 (b19cd06), `./plugins/golem15/seo` in go.work, `GOWORK=off go get sm-seo-plugin@v0.1.0` + `GOWORK=off go mod tidy` (seo a direct require), `go run ../summercms.go/cmd/summer build` (✓ Built bin/demo; plugins.gen.go as in the patch, main.go unchanged); `go build ./...`, `go build -tags summercms_mcp ./...`, `go vet ./...`, `go test -short ./...` green; Docker runs: TestBootAllPlugins, TestGoModRequiresTags, TestSEOToolsCatalog, TestSEOTabMounted PASS (tagged), TestBootAllPlugins, TestSEOTabMounted PASS (untagged); commit 9451b31 `feat: mount golem15.seo v0.1.0` (13 paths, this plan's only). `scripts/check-pins.sh` in the live tree fails only on the other session's oauthserver drift; in a detached worktree of 9451b31 with `git submodule update --init plugins/` it prints `check-pins: ok (17 modules)`, and `GOWORK=off go build ./...` (plain and summercms_mcp) builds from the published tags; scratch worktree removed. `git push origin master` 8fcdbdd..9451b31 (fast-forward checked). Not deployed. ` M plugins/golem15/oauthserver` left as found.
|
||||
- step 6a: `scripts/changelog-post.py source v0.5.1` (from ../sm-summercmsio-app) printed the tag message and 64 commit subjects since v0.5.0; user-facing notes written to a scratch file (## Highlights, ## Added; no Changed/Fixed entries in this release).
|
||||
- step 5 done: `bash scripts/check-phase32.sh --all` (no PHASE32_* overrides, Docker, demo mounted at 9451b31, no test skips) exit 0, "Phase 32 gate passed": PASS self-test, go, plugins (TestNoTideJournalFixtures included), consumers, admin, docs, marker, additive, compat (sm-sitemap-plugin, sm-journal-plugin, sm-demo-app tested; every other sibling built), docker (TestSEOTabMounted, TestSEOToolsCatalog in sm-demo-app). No FAIL, refuse:, NOT MOUNTED or additive refusal lines. The additive stage of that run compared v0.5.1 with itself (last_tag picked the tag on HEAD); fixed in a9a7f58 `fix(32-06): the additive stage compares with the previous release once HEAD is tagged` (last_tag uses `--no-contains HEAD`); `check-phase32.sh --additive` on the release commit then compared with v0.5.0: cabana +2, pact +87, signpost +8 lines, nothing removed or changed, no interface method added; `--self-test` passes. a9a7f58 is local (after the tag, not pushed).
|
||||
- step 6 done: `scripts/changelog-post.py publish v0.5.1 <notes> --excerpt "Plugins can extend each other's admin forms, react to record writes and map URLs to records. Strictly additive."` posted id=45, https://summercms.io/changelog/summercms-0-5-1 (title "SummerCMS 0.5.1", published_at 2026-10-27T13:00:42Z, the tag's date). Token read by the script from ~/.config/summercms/journal-token; never printed or copied.
|
||||
- step 7 done: deferred-items.md lists the unnamed app Journal bumps (sm-grzybyfunkcjonalne-app, sm-summercmsio-app), plugin release posts, the deploy, the demo's oauthserver drift and the retracted mimetype pin. Nothing deployed.
|
||||
278
.planning/phases/32-seo-foundation/32-REVIEW-DISPOSITION.md
Normal file
278
.planning/phases/32-seo-foundation/32-REVIEW-DISPOSITION.md
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 32
|
||||
review: 32-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "[CR-A01] `required: true` on a form extension field makes every save of the host form fail (also for administrators who cannot see the extension)"
|
||||
- id: CR-02
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "[CR-A02] Plugin field-type upload route ignores the extension's Permissions/Visible, so administrators without them can run its `Upload`"
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-A01] Admin writes that change or delete records fire no record event (hasMany link/unlink, deferred child unlink, deferred child commit)"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-A02] `RecordSaved.Locale` is the admin's UI language or the raw `Accept-Language` header, not a content locale, and reorder reports something different"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: skipped
|
||||
title: "[WR-A03] A failing extension `Load` (or plugin-type `Load`) after a successful save rolls back the whole save"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-A04] Journal API writes can commit changes (including a slug) and fire no RecordSaved"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-A05] Journal `postSavedEvent` reports `Old: nil` for a translated `content_html` that existed, because the body is mutated between the snapshot and the event"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-A06] Tags auto-created by the Journal post API fire no RecordSaved although `journal-tag` is now a content type"
|
||||
- id: WR-07
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-A07] `Hide` leaves the owner's relation-manager and partial endpoints of a hidden field reachable"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-A01] Dead check in `BindWritableFields`"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-A02] Scoped API tokens never see an extension that declares no Permissions, and their posted values are dropped silently"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-A03] `resolveExtensionLocales` wraps a nil error"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-A04] `tab: default` on an extension field is refused although `default` names the default tab elsewhere"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-A05] Journal `requestLocale` drops the region, so translated slugs in region-coded locales never match"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-A06] The extension's client assets are served to every administrator of the host form"
|
||||
- id: CR-03
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "[CR-B01] Exact redirects with a trailing slash or upper-case query characters never match at runtime"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B01] A query-specific exact redirect is silently shadowed by the path-only rule"
|
||||
- id: WR-09
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B02] `AddressChanged` writes redirects without validation or the loop simulation"
|
||||
- id: WR-10
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B03] The save-time loop check races with concurrent writes"
|
||||
- id: WR-11
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B04] Undo is not idempotent under concurrent requests"
|
||||
- id: WR-12
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B05] Public resolve and 404 report answer 500 for NUL or invalid UTF-8 paths"
|
||||
- id: WR-13
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B06] The runtime cycle log grows without bound and is keyed by attacker-controlled URLs"
|
||||
- id: WR-14
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B07] Export with a large `page` panics the handler"
|
||||
- id: WR-15
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B08] Public throttle buckets key on the SSR server's IP, capping the whole site"
|
||||
- id: WR-16
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B09] Social image values bypass validation on every non-admin write path"
|
||||
- id: WR-17
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B10] Invalid redirect status, match or query values are silently coerced on API, MCP and import"
|
||||
- id: WR-18
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B11] `set_url_seo`, batch `set_seo` and the records import accept any locale"
|
||||
- id: WR-19
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-B12] Entry generation overwrites `entry.URL`, which can swallow an address change and its 301"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B01] The `regexCache` / `globCache` maps are never evicted"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B02] CSV `unescapeCell` changes legitimate values that start with an apostrophe"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B03] The transfer token has no expiry or user binding, and the fallback key is per process"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B04] Uniqueness checks swallow query errors inside the save transaction"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B05] `RenderTemplate` trims separators that belong to the content"
|
||||
- id: IN-12
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B06] Body caps do not account for JSON escaping"
|
||||
- id: IN-13
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B07] `/robots.txt` serves allow-all without the noindex header when the service is nil, even outside production"
|
||||
- id: IN-14
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-B08] Self canonical and `url` are built from the decoded path"
|
||||
- id: CR-04
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "[CR-C01] The SERP preview and the robots.txt tester deadlock: their first action is dropped by the host and `inflight` is never cleared"
|
||||
- id: WR-20
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-C01] The social image preview accepts `/\\host/...`, so the admin can load an image from an attacker's host"
|
||||
- id: WR-21
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-C02] The CSV export/import round trip changes cells that already start with an apostrophe followed by a formula character"
|
||||
- id: WR-22
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-C03] The 404 \"Open/Resolved\" filter and the redirect \"Chains\" filter disagree with the runtime matcher"
|
||||
- id: WR-23
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-C04] The URL rule address is uncapped but sits in a btree UNIQUE index, so long values return 500 instead of 422"
|
||||
- id: WR-24
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-C05] The robots element's grammar drifts from the Go grammar, and an unparseable value is silently wiped on the next edit"
|
||||
- id: WR-25
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "[WR-C06] The element tests never exercise the host protocol; the tester and the image element have no tests"
|
||||
- id: IN-15
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C01] The regex length refusal puts an untranslated English phrase into the Polish UI"
|
||||
- id: IN-16
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C02] A migration comment is stale"
|
||||
- id: IN-17
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C03] The SERP preview's template rendering is not single-pass like the Go renderer"
|
||||
- id: IN-18
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C04] `plural()` can throw on an unusual `ui-locale`"
|
||||
- id: IN-19
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C05] Two test names do not describe what they assert"
|
||||
- id: IN-20
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C06] The inner controls' accessible name is the machine field name"
|
||||
- id: IN-21
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C07] The README test instructions skip the vitest suite, and the test script hides an empty run"
|
||||
- id: IN-22
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C08] Three partials are maintained twice"
|
||||
- id: IN-23
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "[IN-C09] The `disabled` branches in the widget elements are dead, and would be broken if reached"
|
||||
open: 23
|
||||
total: 52
|
||||
recorded: 2026-10-27T14:47:23.755Z
|
||||
---
|
||||
|
||||
# Phase 32: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| CR-01 | critical | fixed | 32-REVIEW-FIX.md |
|
||||
| CR-02 | critical | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-01 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-02 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-03 | warning | skipped | 32-REVIEW-FIX.md |
|
||||
| WR-04 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-05 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-06 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-07 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| IN-01 | info | open | - |
|
||||
| IN-02 | info | open | - |
|
||||
| IN-03 | info | open | - |
|
||||
| IN-04 | info | open | - |
|
||||
| IN-05 | info | open | - |
|
||||
| IN-06 | info | open | - |
|
||||
| CR-03 | critical | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-08 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-09 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-10 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-11 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-12 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-13 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-14 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-15 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-16 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-17 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-18 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-19 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| IN-07 | info | open | - |
|
||||
| IN-08 | info | open | - |
|
||||
| IN-09 | info | open | - |
|
||||
| IN-10 | info | open | - |
|
||||
| IN-11 | info | open | - |
|
||||
| IN-12 | info | open | - |
|
||||
| IN-13 | info | open | - |
|
||||
| IN-14 | info | open | - |
|
||||
| CR-04 | critical | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-20 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-21 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-22 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-23 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-24 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| WR-25 | warning | fixed | 32-REVIEW-FIX.md |
|
||||
| IN-15 | info | open | - |
|
||||
| IN-16 | info | open | - |
|
||||
| IN-17 | info | open | - |
|
||||
| IN-18 | info | open | - |
|
||||
| IN-19 | info | open | - |
|
||||
| IN-20 | info | open | - |
|
||||
| IN-21 | info | open | - |
|
||||
| IN-22 | info | open | - |
|
||||
| IN-23 | info | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
496
.planning/phases/32-seo-foundation/32-REVIEW-FIX.md
Normal file
496
.planning/phases/32-seo-foundation/32-REVIEW-FIX.md
Normal file
@@ -0,0 +1,496 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
fixed_at: 2026-10-27T14:47:00Z
|
||||
review_path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32-seo-foundation/32-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 29
|
||||
fixed: 28
|
||||
skipped: 1
|
||||
status: partial
|
||||
---
|
||||
|
||||
# Phase 32: Code Review Fix Report
|
||||
|
||||
Two fixer lanes ran in parallel on disjoint repositories. CR-04 was split into a framework half (WidgetField.vue element ordering) and a plugin half (element action queue with dropped-action retry); both are fixed.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: [CR-A01] `required: true` on a form extension field makes every save of the host form fail (also for administrators who cannot see the extension)
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 676ca53
|
||||
**Files modified:** `modules/cabana/crud.go`, `modules/cabana/form_extensions.go`, `modules/cabana/form_extensions_postgres_test.go`, `docs/backend/admin-controllers.md`
|
||||
**Applied fix:**
|
||||
- `mergedRules` skips extension fields, so the owner's model never gets a rule for them.
|
||||
- `liftExtensionValues` checks `required` on the lifted value, and only for visible extensions:
|
||||
- On create, the value must be sent and not blank. Blank means null or whitespace; for a translatable field it means the default locale's text.
|
||||
- On update, the value is checked only when it is sent.
|
||||
- New Postgres test `TestFormExtensionRequired` covers both cases:
|
||||
- An administrator who sees the extension gets a 422 when the value is missing, blank or null, a 201 when it is filled, and a 200 on a partial update.
|
||||
- An administrator who does not see the extension can create and update freely.
|
||||
- Docs bullet added.
|
||||
|
||||
### CR-02: [CR-A02] Plugin field-type upload route ignores the extension's Permissions/Visible, so administrators without them can run its `Upload`
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 5471b66
|
||||
**Files modified:** `modules/cabana/field_type_upload.go`, `modules/cabana/form_extensions_postgres_test.go`, `docs/backend/admin-controllers.md`
|
||||
**Applied fix:** `fieldUpload` answers 404 when the field belongs to an extension that `extensionVisible` refuses. New Postgres test `TestFormExtensionUpload`:
|
||||
- A visible upload gets a 201 on both create (id 0) and update.
|
||||
- An administrator without the extension's permission gets a 404 on both, and `Upload` never runs.
|
||||
|
||||
The docs now say the extension's "field type uploads" answer 404 too. I found no other route that resolves an extension field by name without a visibility check.
|
||||
|
||||
### CR-04: [CR-C01] The SERP preview and the robots.txt tester deadlock: their first action is dropped by the host and `inflight` is never cleared
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed (framework half only; the sm-seo-plugin element changes belong to the other fixer)
|
||||
**Repo:** summercms.go
|
||||
**Commit:** d660cd0
|
||||
**Files modified:**
|
||||
- `admin/src/components/form/fields/WidgetField.vue`
|
||||
- `admin/tests/form/WidgetField.test.ts`
|
||||
- `docs/backend/partials-and-widgets.md`
|
||||
- `modules/boardwalk/dist/index.html`
|
||||
- `modules/boardwalk/dist/assets/index-*.js` and `editor-*.js` (renamed by the rebuild)
|
||||
|
||||
**Applied fix:**
|
||||
- `element = created` now runs before `mountPoint.value.append(created)`.
|
||||
- New vitest case (`an action from connectedCallback (CR-04)`) mounts attached to the document. It uses an element that dispatches `summer-action` with a payload from `connectedCallback`, then checks that the POST is sent with that payload, that `busy` is set and then cleared, and that `summer-result` is dispatched. I confirmed the case fails on the old ordering.
|
||||
- Dist rebuilt and committed; `check-admin-dist.sh` passes.
|
||||
- Docs note that an action dispatched from `connectedCallback` is taken, and that one dispatched while `busy` is set is dropped.
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed (plugin half; `WidgetField.vue` was not touched, as instructed)
|
||||
**Commit:** 3ecbd1f
|
||||
**Files:** `assets/src/elements/actions.ts` (new), `serp.ts`, `robotstester.ts`, `labels.ts`, `assets/dist/seo.js`, `tests/elements/host.ts` (new), `tests/elements/serp.test.ts`, `tests/elements/robotstester.test.ts` (new)
|
||||
**Applied fix:** Both elements now share a new `ActionQueue`:
|
||||
- **Drop detection:** after `summer-action` is dispatched, a missing `busy` attribute means the host dropped the action. `inflight` is cleared and the payload is retried once on the next task.
|
||||
- **Second drop:** reported as a failure. The SERP preview shows `contextFailed`; the tester shows a new `testFailed` label (en/pl).
|
||||
- **First request:** `{op:'context'}` is sent on a microtask after `connectedCallback`.
|
||||
- **Disconnect:** clears the request in flight and any pending retry. On reconnect the context is requested again, or the duplicate check is re-armed.
|
||||
|
||||
**Tests:**
|
||||
- `tests/elements/host.ts` is a fake of `WidgetField.onAction`. It sets `busy` synchronously, settles asynchronously, sets `state=error` on failure and removes `busy`. It supports both wiring orders (`append-first` is the v0.5.1 host), dropping N actions, and an unwired host.
|
||||
- The SERP and tester suites cover:
|
||||
- the append-first host;
|
||||
- a dropped retry;
|
||||
- a host that never takes the action;
|
||||
- a queue behind `busy`;
|
||||
- `state=error` unblocking the next request;
|
||||
- a disconnect while a request is in flight.
|
||||
- Against the old `serp.ts`, 6 of the 7 new SERP host tests fail.
|
||||
|
||||
### WR-01: [WR-A01] Admin writes that change or delete records fire no record event (hasMany link/unlink, deferred child unlink, deferred child commit)
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 468af06
|
||||
**Files modified:**
|
||||
- `modules/cabana/relation.go`
|
||||
- `modules/cabana/deferred.go`
|
||||
- `modules/cabana/crud.go`
|
||||
- `modules/cabana/http.go`
|
||||
- `modules/cabana/relation_child.go`
|
||||
- `modules/cabana/record_events_postgres_test.go`
|
||||
- `modules/cabana/README.md`
|
||||
- `docs/backend/admin-controllers.md`
|
||||
|
||||
**Applied fix:**
|
||||
- New `RelationService.saveLinkedChild`: snapshot, mutate, `Save`, then `recordChanges` and `emitChildSaved`. It is used by:
|
||||
- hasMany `linkRelated` and `unlinkRelated`;
|
||||
- the deferred commit's attach of children created in the session (`applyRelationBinding`).
|
||||
- `unlinkDeferred` fires `RecordDeleted` after it deletes a child the session created.
|
||||
- `CRUDService` gains an unexported `reg *Registry` field, set in `crud()`, so the deferred commit's `RelationService` can name the child's controller. It also now carries `app`. The change is additive and unexported.
|
||||
- New subtests `hasMany link and unlink` and `deferred children` in `TestRecordEvents`.
|
||||
- README and docs list the new event sources.
|
||||
|
||||
### WR-02: [WR-A02] `RecordSaved.Locale` is the admin's UI language or the raw `Accept-Language` header, not a content locale, and reorder reports something different
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed (requires human verification of the chosen meaning)
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 1583651
|
||||
**Files modified:**
|
||||
- `modules/cabana/record_events.go`
|
||||
- `modules/cabana/record_events_test.go`
|
||||
- `modules/cabana/relation_child.go`
|
||||
- `modules/cabana/reorder.go`
|
||||
- `modules/pact/record_events.go`
|
||||
- `modules/pact/README.md`
|
||||
- `docs/plugins/extending.md`
|
||||
|
||||
**Applied fix:**
|
||||
- New `eventLocale(ctx)` returns `primaryLanguage(towel.Locale)`. Admin saves, child writes and reorder now all report it, so a raw header like `pl-PL,pl;q=0.9` becomes `pl-PL`, and reorder no longer reports a different, defaulted code.
|
||||
- The pact doc now defines `Locale` as the primary tag of the request locale. It states that an admin save writes the row in the default content locale and that per-locale work should key on `RecordChange.Locale`.
|
||||
- New unit test `TestRecordEventLocale`.
|
||||
|
||||
I deliberately did not take the reviewer's alternative of switching `Locale` to the default content locale: that changes a field's meaning inside a patch release. No consumer reads `ev.Locale` today; I checked sm-seo-plugin, sm-sitemap-plugin and sm-journal-plugin. A human should confirm that "request locale, normalised" is the meaning we want.
|
||||
|
||||
### WR-04: [WR-A04] Journal API writes can commit changes (including a slug) and fire no RecordSaved
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** sm-journal-plugin
|
||||
**Commit:** 62c75b1
|
||||
**Files modified:** `controllers/api/posts.go`, `controllers/api/record_events.go`, `record_events_test.go`, `README.md`
|
||||
**Applied fix:**
|
||||
- `Store` and `Update` run `Create`/`Save`, `applyPostAssocs` and `loadPostAssocs` in one `lagoon.Transaction`, with `cabana.EmitRecordSaved(ctx, app, tx, ...)` inside it. The event now follows the commit, and a failure rolls back the row.
|
||||
- The site-cache purge still runs after the commit.
|
||||
- New test step: a PUT with a new slug and an invalid `tags` value returns 500, fires no event and leaves the slug unchanged. It fails without the fix: the slug was `api-lost`.
|
||||
- README updated.
|
||||
|
||||
### WR-05: [WR-A05] Journal `postSavedEvent` reports `Old: nil` for a translated `content_html` that existed, because the body is mutated between the snapshot and the event
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** sm-journal-plugin
|
||||
**Commit:** 7a03640
|
||||
**Files modified:** `controllers/api/record_events.go`, `record_events_test.go`
|
||||
**Applied fix:**
|
||||
- `submittedTranslations` lists `content_html` whenever a translation sends a non-blank `content`. This mirrors `classes.FormatTranslation`, so the snapshot and the event cover the same fields whether or not the body has been mutated yet.
|
||||
- New test step: updating a pl `content` reports the pl `content_html` change with the old HTML as `Old`. It failed before the fix, with `Old` nil.
|
||||
|
||||
### WR-06: [WR-A06] Tags auto-created by the Journal post API fire no RecordSaved although `journal-tag` is now a content type
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** sm-journal-plugin
|
||||
**Commit:** a6fc7cd
|
||||
**Files modified:** `controllers/api/posts.go`, `controllers/api/posts_helpers.go`, `controllers/api/record_events.go`, `record_events_test.go`, `README.md`
|
||||
**Applied fix:**
|
||||
- `resolveTagIDs` and `applyPostAssocs` now return the tags created by name. These are unexported signature changes.
|
||||
- The handlers emit `tagSavedEvent` (`Created`, with name and slug changes) for each new tag inside the post write's transaction.
|
||||
- New test step: a PUT with a new tag name fires two events, the post and a `journal-tag` `Created`; reusing that tag fires only the post event.
|
||||
- README updated.
|
||||
|
||||
### WR-07: [WR-A07] `Hide` leaves the owner's relation-manager and partial endpoints of a hidden field reachable
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 0e187cf
|
||||
**Files modified:** `modules/cabana/form_extensions.go`, `modules/cabana/form_extensions_smoke_test.go`, `modules/pact/capabilities.go`, `docs/backend/admin-controllers.md`
|
||||
**Applied fix:**
|
||||
- `dropHiddenFields` now receives the owner's original field list and works on copies of the maps. It drops:
|
||||
- the relation whose `fieldName` is hidden. This closes the panel, link, unlink, child CRUD and deferred-commit routes, because `relationOf` and `commitDeferred` read `cc.Relations`.
|
||||
- a hidden partial field's path, from `formPartials` and `partials`. A path that is still used by the list header partial, the preview header partial or another owner partial field keeps rendering.
|
||||
- New smoke test `TestFormExtensionHideEndpoints` checks that `relationOf` refuses the hidden relation-manager relation and that the hidden partial answers 404. With no `Hide`, both still work. It fails without the fix.
|
||||
- The pact `Hide` doc and the docs updated.
|
||||
|
||||
### CR-03: [CR-B01] Exact redirects with a trailing slash or upper-case query characters never match at runtime
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 2d81c26
|
||||
**Files:** `updates/202610280001_add_golem15_seo_redirects_source_key.go` (new migration), `classes/redirects.go`, `redirects_test.go`
|
||||
**Applied fix:** The new migration `202610280001_add_golem15_seo_redirects_source_key` adds `source_key`, a stored generated column. It is the SQL form of `exactKey`: the path lowercased, one trailing slash dropped (the root stays `/`), and the query kept as written. A partial index covers it `WHERE match = 'exact'`. `LoadRedirectSet` now fetches with `source_key IN keys`. Postgres computes the column, so every write path fills it (admin, API, batch, import, undo, address changes) and no released migration was edited. The column is not in the GORM model.
|
||||
**Test:** the DB-backed `TestExactRedirectKeys`:
|
||||
- `/old-page/` fires for both `/old-page` and `/old-page/`.
|
||||
- `/list?Page=2` fires for its own query and not for `/list?page=2`.
|
||||
- An ignore-case `/Mixed/Case/` fires for `/mixed/case`.
|
||||
- The generated keys are asserted for each source, including `/` and `/A/?Q=1`.
|
||||
|
||||
With the old `lower(source)` fetch the test fails.
|
||||
|
||||
### WR-08: [WR-B01] A query-specific exact redirect is silently shadowed by the path-only rule
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed (requires human verification: matching-order logic)
|
||||
**Commit:** 58b2504
|
||||
**Files:** `classes/redirects.go`, `classes/redirect_graph_test.go`, `README.md`
|
||||
**Applied fix:** `exactKeys` now returns `path?query` before `path`, so the more specific exact rule wins. A unit test covers both insertion orders, and the README states the precedence.
|
||||
|
||||
### WR-09: [WR-B02] `AddressChanged` writes redirects without validation or the loop simulation
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed (requires human verification: redirect-graph logic)
|
||||
**Commit:** cb614cb
|
||||
**Files:** `classes/addresses.go`, `redirects_test.go`, `README.md`
|
||||
**Applied fix:**
|
||||
- **Address form:** absolute addresses on the site URL become paths, and the candidate `old → new` must pass `Problems`. A new address that is not a path on the site creates no redirect and logs a warning.
|
||||
- **Shadowing rules:** step 1 now deletes every exact rule that `exactMatches` the new address with its case and slash options, not only an exact literal match.
|
||||
- **Loop check:** steps 2 to 4 (create or update, then re-point) are simulated in memory with `checkRedirectRules` before anything is written. On a loop, no redirect is written, a warning is logged, and only the entry URL moves.
|
||||
- **Entry URL:** still stored as the descriptor wrote it, so later address diffs keep working.
|
||||
|
||||
**Test:** the DB test `TestAddressChangeGuards` covers:
|
||||
- the pattern-loop case;
|
||||
- a case or slash variant that is removed;
|
||||
- an absolute old address;
|
||||
- a `//evil` new address.
|
||||
|
||||
### WR-10: [WR-B03] The save-time loop check races with concurrent writes
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** c2f136a
|
||||
**Files:** `classes/redirect_graph.go`, `classes/addresses.go`, `classes/batch.go`, `redirects_test.go`, `README.md`
|
||||
**Applied fix:** `lockRedirectWrites` runs `SELECT pg_advisory_xact_lock(...)` and is taken in three places:
|
||||
- in `CheckRedirect` before it reads the rules (this covers `SaveRedirect`, the admin extension, undo and the 404 actions);
|
||||
- in `applyAddressRedirects`;
|
||||
- once at the start of every batch, which also covers imports.
|
||||
|
||||
**Test:** `TestConcurrentRedirectWrites` holds A → B uncommitted and checks that a concurrent B → A waits, then is refused as a loop. With the lock removed the test fails.
|
||||
|
||||
### WR-11: [WR-B04] Undo is not idempotent under concurrent requests
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** d20f14f
|
||||
**Files:** `classes/undo.go`, `redirects_test.go`
|
||||
**Applied fix:**
|
||||
- `Undo` loads the revision `FOR UPDATE`.
|
||||
- `UndoBatch` locks `batch_id = ? AND undone_at IS NULL` rows `FOR UPDATE`.
|
||||
- The final mark is `WHERE id = ? AND undone_at IS NULL`. When `RowsAffected == 0` it returns `UndoRefusal{already_undone}`.
|
||||
|
||||
**Test:** `TestConcurrentUndo` checks that the second concurrent undo gets `already_undone`, the redirect is re-created once, and there is one undo history row. Before the fix the second undo hit a unique violation.
|
||||
|
||||
### WR-12: [WR-B05] Public resolve and 404 report answer 500 for NUL or invalid UTF-8 paths
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 98fbeb6
|
||||
**Files:** `classes/resolver.go`, `classes/notfound.go`, `redirects_test.go`
|
||||
**Applied fix:**
|
||||
- **Resolve:** `parseTarget` answers `ErrBadURL` (422) when the decoded path, the raw query or a decoded query key or value is invalid UTF-8 or holds NUL. `safeText` is the new helper for this check.
|
||||
- **404 report:** `NotFoundPath` applies the same check to the logged address. The query stays escaped there, so it is safe text.
|
||||
- **Referer:** cleaned of NUL and invalid UTF-8.
|
||||
|
||||
**Test:** in `TestNotFoundLog`, `/%00`, `/%FF` and query variants get 422 from resolve. The report refuses the path cases with 422 and never answers 5xx.
|
||||
|
||||
### WR-13: [WR-B06] The runtime cycle log grows without bound and is keyed by attacker-controlled URLs
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** c2ba33e
|
||||
**Files:** `classes/redirects.go`, `classes/redirect_graph_test.go`, `README.md`
|
||||
**Applied fix:** A mutex-guarded map replaces the global `sync.Map`:
|
||||
- it is keyed by the sorted rule IDs of the cycle, not the request URL;
|
||||
- each cycle is logged again at most once an hour;
|
||||
- the map is reset when it reaches 1024 keys.
|
||||
|
||||
**Test:** `TestCycleLogBounded` sends 5000 URLs through one cycle, which leaves one key, and checks that the map never exceeds the cap.
|
||||
|
||||
### WR-14: [WR-B07] Export with a large `page` panics the handler
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 59c37a8
|
||||
**Files:** `classes/transfer.go`, `controllers/api/auth.go`, `transfer_test.go`
|
||||
**Applied fix:**
|
||||
- **Export:** a page past `Pages` is empty. The page is compared before multiplying, so an empty last page keeps the old semantics.
|
||||
- **List endpoints:** `listParams` caps `page` at `maxPage = 1_000_000`.
|
||||
|
||||
**Test:** `page=9223372036854777` on export and `page=MaxInt64` on a list both answer 200 with no rows.
|
||||
|
||||
### WR-15: [WR-B08] Public throttle buckets key on the SSR server's IP, capping the whole site
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed (documentation only)
|
||||
**Commit:** cab1b9a
|
||||
**Files:** `README.md`, `routes.go` (comment)
|
||||
**Applied fix:** The README has a new paragraph, "Behind the frontend server". It says the SSR host must be in `http.trusted_proxies` and must forward `X-Forwarded-For`. Without that, `seo-resolve` and `seo-notfound` become caps for the whole site, 600 requests a minute each. The `Buckets` doc comment says the same.
|
||||
**Not done (needs a decision):** an authenticated resolve call that skips the throttle or gets a higher limit. It needs a new token or config key and a framework way for a surf `Bucket` to skip or raise its limit per request; today `FixedWindowLimiter` has no such hook. This is a design and framework change, not a plugin patch.
|
||||
|
||||
### WR-16: [WR-B09] Social image values bypass validation on every non-admin write path
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** c8e0961
|
||||
**Files:** `classes/owner.go`, `classes/rules.go`, `classes/settings_write.go`, `mcp_test.go`
|
||||
**Applied fix:** The following now refuse a non-empty value that is not a path or an http(s) URL, with `golem15.seo::lang.image.url_invalid`:
|
||||
- `SetURLSEO` (`og_image`), which covers the API, MCP, batch `set_seo` and the records import;
|
||||
- `SaveRule` (`og_image`);
|
||||
- `SaveSettings` (`default_og_image`, only when that column is sent).
|
||||
|
||||
**Test:** `javascript:`, `data:`, `//` and a value with spaces get 422 on all three MCP routes.
|
||||
|
||||
### WR-17: [WR-B10] Invalid redirect status, match or query values are silently coerced on API, MCP and import
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 24e6f38
|
||||
**Files:** `classes/batch.go`, `classes/transfer.go`, `controllers/api/redirects.go`, `mcp_test.go`, `batch_test.go`, `transfer_test.go`
|
||||
**Applied fix:** The new `RedirectFields.Problems()` refuses values outside {301, 302, 410}, {exact, pattern, regex} and {ignore, pass} with `api.value_invalid`. An empty match or query still means the default. It is checked before `Apply` in:
|
||||
- API create and update (422 `validation_failed`);
|
||||
- batch create and update redirect;
|
||||
- the CSV redirects import (the row is refused with `transfer.row_invalid`).
|
||||
|
||||
**Tests:** MCP `status: 308`, `match: Regex` and `query: keep` get 422; a batch with 307 gets 422; an import with `glob` or `keep` gives 2 refused rows.
|
||||
|
||||
### WR-18: [WR-B11] `set_url_seo`, batch `set_seo` and the records import accept any locale
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** cff2d29
|
||||
**Files:** `classes/owner.go`, `controllers/api/seo.go` (jsonschema text), `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `mcp_test.go`
|
||||
**Applied fix:** When a locale other than the matched one is given, `SetURLSEO` requires it to be one of the locales the record's descriptor describes (existing `recordLocales`). Otherwise it refuses with the new `api.locale_unknown` (en/pl). This covers the API, MCP, batch and the records import.
|
||||
**Test:** `xx` and `EN ` get 422 on `locale`; `pl` is written.
|
||||
|
||||
### WR-19: [WR-B12] Entry generation overwrites `entry.URL`, which can swallow an address change and its 301
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed (requires human verification: address-change logic in the job)
|
||||
**Commit:** d8aa989
|
||||
**Files:** `classes/generate.go`, `seo_tab_test.go`, `README.md`
|
||||
**Applied fix:** `refreshEntry` (published records only) behaves as follows:
|
||||
- an empty URL is filled;
|
||||
- a URL that differs goes through `AddressChanged` (via auto), which creates the 301 and moves the URL;
|
||||
- it never overwrites the URL silently.
|
||||
|
||||
**Test:** `TestGenerateEntries` covers a slug changed by a direct DB write: the job now creates `/pages/one → /pages/one-renamed` and `/pl/pages/one → /pl/pages/one-renamed`. A third run adds nothing.
|
||||
|
||||
### WR-20: [WR-C01] The social image preview accepts `/\host/...`, so the admin can load an image from an attacker's host
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 882d4ee
|
||||
**Files:** `assets/src/elements/image.ts`, `assets/dist/seo.js`, `classes/field_types.go`, `classes/urlref_test.go` (new), `tests/elements/image.test.ts` (new)
|
||||
**Applied fix:** `safeImageURL` (`/^\/(?![\/\\])/`) and `ValidURLRef` both refuse a path that starts with `/\`. `ValidURLRef` is also the canonical validator, so canonicals are stricter in the same way.
|
||||
**Tests:** a Go table test and a TS test.
|
||||
|
||||
### WR-21: [WR-C02] The CSV export/import round trip changes cells that already start with an apostrophe followed by a formula character
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** ed2d919
|
||||
**Files:** `classes/csv.go`, `classes/csv_test.go`, `README.md`
|
||||
**Applied fix:**
|
||||
- A new `escapable(s)` helper checks for a formula character after any leading apostrophes.
|
||||
- `escapeCell` prefixes one more apostrophe to such values.
|
||||
- `unescapeCell` strips exactly one apostrophe, so it is the inverse of `escapeCell`.
|
||||
- The round-trip rows now include `'=x`, `''-)` and `'plain`.
|
||||
- The unit expectation for `unescapeCell("''=x")` changed to `'=x`.
|
||||
|
||||
This also resolves IN-08 (IN-B02), which is out of scope.
|
||||
|
||||
### WR-22: [WR-C03] The 404 "Open/Resolved" filter and the redirect "Chains" filter disagree with the runtime matcher
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed (requires human verification: SQL matcher logic). Pattern and regex rules remain a documented gap.
|
||||
**Commits:** 7677d8d, ad419e9 (gofmt follow-up)
|
||||
**Files:** `models/redirect.go`, `models/notfound.go`, `controllers/api/notfound.go` (jsonschema), `redirects_test.go`
|
||||
**Applied fix:** A new `coversSQL(alias, addr)` is the SQL form of `exactMatches`:
|
||||
- the source path is compared with its `ignore_case` and `trailing_slash` options, and the query exactly when the source has one;
|
||||
- the indexed `source_key` from CR-03 serves as a prefilter.
|
||||
|
||||
`coveredSQL` (Open/Resolved, also MCP `list_not_found?status=`) and `chainSQL` (a path target that another rule's source names literally or an exact rule matches) both use it. The MCP `status` schema text now says that an address only a pattern or regex rule answers stays open in the filter but has `resolved: true` in its row.
|
||||
**Test:** `TestCoverAndChainFilters` covers:
|
||||
- `/missing/`, `/missing?utm=x`, `/case/` and `/q?id=1` are resolved;
|
||||
- `/MISSING`, `/other` and `/q?id=2` are open;
|
||||
- each row's badge agrees with the filter;
|
||||
- `/c1 → /c2/` is a chain next to `/c2`.
|
||||
|
||||
### WR-23: [WR-C04] The URL rule address is uncapped but sits in a btree UNIQUE index, so long values return 500 instead of 422
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 4ca26e9
|
||||
**Files:** `models/rule.go`, `mcp_test.go`
|
||||
**Applied fix:** `models.MaxRuleURL = 2048`, and `Rule.Problems` refuses longer addresses with `rules.url_invalid`. The check covers the admin form, the API, MCP, batches and imports.
|
||||
**Test:** a 3001-byte rule URL gets 422 on `url`.
|
||||
|
||||
### WR-24: [WR-C05] The robots element's grammar drifts from the Go grammar, and an unparseable value is silently wiped on the next edit
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commit:** 923d0f9
|
||||
**Files:** `assets/src/elements/robots.ts`, `labels.ts`, `styles.css`, `assets/dist/seo.js`, `assets/dist/seo.css`, `tests/elements/robots.test.ts`, `classes/robots_value_test.go`
|
||||
**Applied fix:**
|
||||
- **Dates:** the new `validDate` checks a real calendar date and handles leap years. `2027-02-30` is refused, as Go's `time.Parse` refuses it.
|
||||
- **Numbers:** an optional sign is accepted like `strconv.Atoi`, so `+5` reads as 5. The Go side was left unchanged so existing API callers keep working.
|
||||
- **Unparseable stored value:** the element keeps the raw value and shows a note (`robotsUnparsed`, en/pl) in place of the Inherit display. All controls are disabled, `commit` is blocked, and an explicit Reset button emits `''`.
|
||||
|
||||
**Tests:** the Feb-30, Apr-31 and day-00 dates are in the TS malformed list. A leap day and `+5` are accepted. An element test covers keep, disable and Reset. A Go test checks that `+5` normalises to `max-snippet:5`.
|
||||
|
||||
### WR-25: [WR-C06] The element tests never exercise the host protocol; the tester and the image element have no tests
|
||||
|
||||
_Lane: sm-seo-plugin_
|
||||
|
||||
**Status:** fixed
|
||||
**Commits:** 3ecbd1f (fake host, SERP host-protocol tests, `robotstester.test.ts`), 882d4ee (`image.test.ts` `safeImageURL`), 5c2ad6a (transfer host tests, image element tests, README test gate)
|
||||
**Files:** `tests/elements/host.ts`, `serp.test.ts`, `robotstester.test.ts`, `transfer.test.ts`, `image.test.ts`, `README.md`
|
||||
**Applied fix:**
|
||||
- The fake widget host drives serp, robotstester and transfer. The tests cover queueing behind `busy`, `state=error` unblocking, the append-first ordering and dropped actions.
|
||||
- `golem15-seo-image` is tested for safe and unsafe previews, upload id matching (a foreign id is ignored), the error and the `imageFailed` fallback, Remove, and the disabled state.
|
||||
- The README test gate now includes `npm test`.
|
||||
|
||||
|
||||
|
||||
None.
|
||||
|
||||
|
||||
|
||||
- **No schema edits to released migrations:** CR-03 adds the new migration `202610280001_add_golem15_seo_redirects_source_key`. v0.1.0's migrations are untouched.
|
||||
- **Framework half of CR-04:** the `WidgetField.vue` change belongs to the other fixer. The plugin now works with either wiring order.
|
||||
- **Needs human verification of logic:** WR-08, WR-09, WR-19 and WR-22 change matching, redirect-graph or SQL semantics. Syntax checks and DB tests pass, but the logic should be reviewed by a person.
|
||||
- **Behaviour now stricter for callers:**
|
||||
- unknown redirect status, match or query values are refused (WR-17);
|
||||
- unknown locales are refused (WR-18);
|
||||
- invalid social images are refused (WR-16);
|
||||
- `/\` paths are refused as image or canonical URLs (WR-20);
|
||||
- rule URLs over 2048 bytes are refused (WR-23).
|
||||
|
||||
These are worth a changelog line when the plugin is tagged.
|
||||
- **README sections updated:** redirects precedence, address changes, the loop lock, the runtime cycle log, "Behind the frontend server", entry generation, CSV escaping, and the Testing gate.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-27T14:45:18Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
### WR-03: [WR-A03] A failing extension `Load` (or plugin-type `Load`) after a successful save rolls back the whole save
|
||||
|
||||
_Lane: summercms.go / sm-journal-plugin_
|
||||
|
||||
**Status:** skipped
|
||||
**File:** `modules/cabana/crud.go:980`, `modules/cabana/form_extensions.go` (`hydrateFormExtensions`)
|
||||
**Reason:** Both suggested fixes have problems that need a design decision:
|
||||
- **Log and omit the extension's keys.** This risks data loss. The admin SPA's `editablePayload` always sends translatable fields (`localeRecord(undefined)`), so a response without them makes the next save post empty locale maps to the extension's `Save`.
|
||||
- **Run hydration after the commit.** This stops the rollback, but the request still answers 500 after a committed write. Show still fails for the same `Load`, so editing stays blocked either way.
|
||||
|
||||
A proper fix needs an "extension unavailable" state in the schema/record (for example, the failing extension's fields marked read-only or failed, so the SPA does not post them). That is beyond a v0.5.2 patch.
|
||||
|
||||
**Original issue:** `hydrateFormExtensions` runs inside the save transaction, so one plugin's read-side failure turns a successful save into a 500 and rolls it back. `ShowRecord` likewise fails the whole record.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-27T15:55:00Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
719
.planning/phases/32-seo-foundation/32-REVIEW.md
Normal file
719
.planning/phases/32-seo-foundation/32-REVIEW.md
Normal file
@@ -0,0 +1,719 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
reviewed: 2026-10-27T13:31:23Z
|
||||
depth: standard
|
||||
files_reviewed: 221
|
||||
lanes: [framework-and-journal (A, 50 files), seo-plugin-core-go (B, 68 files), seo-plugin-rest (C, 103 files)]
|
||||
findings:
|
||||
critical: 4
|
||||
warning: 25
|
||||
info: 23
|
||||
total: 52
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 32: Code Review Report
|
||||
|
||||
The scope (221 files across summercms.go, sm-journal-plugin and sm-seo-plugin) was split into three parallel reviewer lanes at standard depth; each lane's report follows verbatim, with finding IDs prefixed A-, B- and C-. Per-lane file lists are in each section's header block.
|
||||
|
||||
**Critical:** CR-01 (CR-A01) (extension `required:` field blocks every host save), CR-02 (CR-A02) (field-type upload ignores extension Permissions/Visible), CR-03 (CR-B01) (exact redirects with trailing slash or upper-case query never match at runtime), CR-04 (CR-C01) (widget host assigns `element` after `append`, so actions sent from `connectedCallback` are dropped: SERP preview and robots tester never load; orchestrator confirmed in `admin/src/components/form/fields/WidgetField.vue:161-162`).
|
||||
|
||||
---
|
||||
|
||||
## Lane A: framework and Journal
|
||||
|
||||
```yaml
|
||||
phase: 32-seo-foundation
|
||||
reviewed: 2026-10-27T13:28:44Z
|
||||
depth: standard
|
||||
files_reviewed: 50
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 7
|
||||
info: 6
|
||||
total: 15
|
||||
status: issues_found
|
||||
```
|
||||
|
||||
### Phase 32: Code Review Report (scope A: framework seams + Journal adoption)
|
||||
|
||||
**Reviewed:** 2026-10-27T13:28:44Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 50
|
||||
**Status:** issues_found
|
||||
|
||||
### Summary
|
||||
|
||||
I reviewed the D-91 form extensions (`cabana/form_extensions.go` and its hooks in crud, http, actions, partial_render, field_ml and field_type), the D-92 record events (`cabana/record_events.go`, `pact/record_events.go` and the emit sites in crud, reorder and relation_child), the D-93 content descriptors (`pact/content.go`, `signpost/content.go`), the apisurface/check-phase32 release gate, and the Journal adoption.
|
||||
|
||||
Parts that hold up:
|
||||
- Transaction boundaries are correct. Every emit site runs inside `lagoon.Transaction`, through `CRUDService.transaction`, `RelationService.transaction` or the `reorderPost` transaction. So events are buffered until the commit and dropped on a rollback, including when a later step such as `projectFullRecord` or hydrate fails. After-commit listeners receive the outer ctx, not the tx ctx.
|
||||
- Extension values are lifted out of the body before any owner path reads it.
|
||||
- Invisible extensions are filtered consistently in the schema, show, save, widget-action and partial routes.
|
||||
|
||||
Problems found:
|
||||
- **Required extension fields (CR-01 (CR-A01)).** A `required: true` field in an extension is turned into a model validation rule on the owner's record. That rule can never pass, so every save of the host form fails.
|
||||
- **Upload route (CR-02 (CR-A02)).** The plugin field-type upload route does not check extension visibility. Administrators who are not allowed to see an extension can still call its `Upload`.
|
||||
- **Record-event coverage and locale (warnings).** Some admin writes fire no event: hasMany link/unlink and the delete of a deferred child. `RecordSaved.Locale` is not a content locale.
|
||||
- **Journal MCP writes (warnings).** These can commit a slug change and fire no event, and they report a wrong `Old` value for derived `content_html` translations.
|
||||
|
||||
### Narrative Findings (AI reviewer)
|
||||
|
||||
### Critical Issues
|
||||
|
||||
### CR-01: [CR-A01] `required: true` on a form extension field makes every save of the host form fail (also for administrators who cannot see the extension)
|
||||
|
||||
**File:** `modules/cabana/crud.go:1353-1361` (mergedRules), `modules/cabana/form_extensions.go:405-443` (compileExtensionKinds accepts `Required`)
|
||||
**Issue:** `compileFormExtensions` places extension fields into `cc.Form.Fields`. `mergedRules` loops over `cc.Form.Fields` and adds `required` for every `field.Required && valueFormField(field)` field, extension fields included. Then `lagoon.Validate` checks those rules against the owner's model. It reads the values through `virtualValuesForRules`/`valuesForRules`, which come from the model's columns. The extension field is not a column, and its value has already been lifted out of the body by `liftExtensionValues`, so the value is always nil and the rule always fails. I confirmed this in a scratch copy of the package. `parseExtensionDoc` accepts `required: true` with no error, and `mergedRules` returns `map[seo_title:required]` → `msgs=map[seo_title:[The seo title field is required.]]`. Nothing in `compileExtensionKinds` refuses or strips `Required`. The impact:
|
||||
- Any extension that marks a field required, which is a natural thing to do for an SEO title or a slug override, bricks create/update of the host controller for everyone.
|
||||
- `mergedRules` does not look at extension visibility, so it also hits administrators who do not hold the extension's `Permissions`.
|
||||
- The 422 names a field that those administrators cannot see in the schema.
|
||||
|
||||
If the extension field's name matches a model column that is not on the owner's form, the rule silently validates the owner's column instead.
|
||||
**Fix:** Keep owner validation away from extension fields. Then either refuse `required` at boot, or check it against the lifted value for visible extensions only:
|
||||
```go
|
||||
// crud.go mergedRules
|
||||
for _, field := range cc.Form.Fields {
|
||||
if cc.extensionOf(field.Name) != nil {
|
||||
continue // D-91: an extension validates its own values (Save / AdminFormErrors)
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
and in `liftExtensionValues`, for a visible extension, add a "The X field is required." detail when `field.Required && contextAllows(...)` and the value is absent or empty (on create; on update only when the key is sent). If you choose to refuse it instead, add to `compileExtensionKinds`:
|
||||
```go
|
||||
if field.Required {
|
||||
return extErr(pluginID, controller, "field %s: required is not supported in a form extension; refuse the value in Save with pact.AdminFormErrors", field.Name)
|
||||
}
|
||||
```
|
||||
Add a Postgres test with a required extension field, saved by an administrator who sees the extension and by one who does not.
|
||||
|
||||
### CR-02: [CR-A02] Plugin field-type upload route ignores the extension's Permissions/Visible, so administrators without them can run its `Upload`
|
||||
|
||||
**File:** `modules/cabana/field_type_upload.go:32-126` (`fieldUpload`), `uploadField` at line 130
|
||||
**Issue:** The other per-field routes of an extension gate on `extensionVisible`: `widgetAction` (actions.go), `partial` (partial_render.go), `formSchema` (http.go) and save/show. `POST .../{id}/fields/{field}/upload` does not. `uploadField` finds the field in `cc.Form.Fields`, which includes the extension's fields, and checks only `contextAllows`. `protect()` checks only the host controller's permissions. So an administrator with access to, say, `acme.blog.posts` but without `golem15.seo.manage` can still run the SEO plugin's `seoimage` `Upload` (D-100). The plugin stores a file in the bucket, inside a transaction on the loaded record, with no admin UI showing it. This is exactly the access that `Permissions` and `Visible` are documented to deny ("Its widget actions and partials answer 404 for them"), and the docs never list the upload route as an exception. It is an authorization gap on a write path.
|
||||
**Fix:**
|
||||
```go
|
||||
field, ok := uploadField(cc, r.PathValue("field"), id)
|
||||
if !ok {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ext := cc.extensionOf(field.Name); ext != nil {
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
if !extensionVisible(r.Context(), principal, ext) {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
Add the upload route to the permission tests in `form_extensions_postgres_test.go`. Also audit any other route that resolves a field by name from `cc.Form.Fields` (`uploadField` is the only one I found that accepts extension types today).
|
||||
|
||||
### Warnings
|
||||
|
||||
### WR-01: [WR-A01] Admin writes that change or delete records fire no record event (hasMany link/unlink, deferred child unlink, deferred child commit)
|
||||
|
||||
**File:** `modules/cabana/relation.go:1013` (linkRelated hasMany `Save(related)`), `modules/cabana/relation.go:1316` (unlinkRelated hasMany `Save(child)` with FK nulled + ClearOnChange), `modules/cabana/relation.go:1130` (unlinkDeferred `Delete(child)`), `modules/cabana/deferred.go` (commitDeferred binding children to the new parent)
|
||||
**Issue:** D-92 says "Admin, MCP and imports all go through cabana and emit it", and `pact.RecordSaved` says "The admin framework fires it for every admin write". The SEO plugin relies on it for 301s and for deleting entries with their record. Some admin paths still write records with no event:
|
||||
- `unlinkDeferred` deletes a child that `CreateChild` created. `CreateChild` already fired `RecordSaved{Created: true}` for it after its commit, so subscribers learn about a record that later disappears without a `RecordDeleted`. The SEO entry (or any derived state) is orphaned.
|
||||
- hasMany link and unlink rewrite the child's foreign key and clear its `ClearOnChange` columns (a parent change that can change the child's URL), with no `RecordSaved`.
|
||||
**Fix:** In `unlinkDeferred`, after the delete, call `EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{Controller: s.reg.controllerForRecord(child), Model: recordMorphType(tx, child), ID: recordIDText(child)})`. In linkRelated and unlinkRelated (hasMany), take a snapshot before `setModelColumn` and call `s.emitChildSaved(ctx, tx, child, false, changes)` after `Save`. Do the same for the hasMany children `commitDeferred` attaches. Alternatively, narrow the `pact.RecordSaved` doc and `docs/backend/admin-controllers.md` to the paths that actually fire.
|
||||
|
||||
### WR-02: [WR-A02] `RecordSaved.Locale` is the admin's UI language or the raw `Accept-Language` header, not a content locale, and reorder reports something different
|
||||
|
||||
**File:** `modules/cabana/record_events.go:65`, `modules/cabana/relation_child.go:490`, `modules/cabana/reorder.go:318,337`
|
||||
**Issue:** `pact.RecordSaved.Locale` is documented as "the request's content locale". `emitRecordSaved` and `emitChildSaved` copy `towel.Locale(ctx)`. In the admin that value is set by `surf.Router.locale` to the raw `Accept-Language` header when no `LocaleResolver` is published, for example `pl-PL,pl;q=0.9,en-US;q=0.8`, or by `surf.LocaleFromPrincipal` to the administrator's `PreferredLocale` (the UI language). Journal's `requestLocale` has to parse exactly this raw header, which shows it happens. `reorderPost` instead sets `towel.WithLocale(ctx, schemaLocale(...))`, so reorder events carry a normalised UI code. Admin saves write the default content locale to the row and report the other locales per change, so the event-level Locale has no defined content meaning. A subscriber that keys on it (an SEO 301 per locale) will mis-key.
|
||||
**Fix:** Pick one meaning and enforce it at the emit site. For admin saves, set `Locale` to the default content locale (from `TranslationWriter.DefaultLocale`, or `""` without translate) and leave per-locale changes to `RecordChange.Locale`. At the very least, normalise with `primaryLanguage` the way `schemaLocale` does. Then document it in `pact/record_events.go`.
|
||||
|
||||
### WR-03: [WR-A03] A failing extension `Load` (or plugin-type `Load`) after a successful save rolls back the whole save
|
||||
|
||||
**File:** `modules/cabana/crud.go:980`, `modules/cabana/form_extensions.go:1431-1435`
|
||||
**Issue:** `hydrateFormExtensions` runs as the last statement inside the save transaction closure, and its error is returned from it. The owner row, translations, deferred bindings and every extension's `Save` have all succeeded by then, but a read-side failure in third-party plugin code (an SEO `Load` hitting a missing row, a field type's `Load`) turns the save into a 500 and rolls everything back. `ShowRecord` likewise fails the whole host record when any one extension's `Load` fails. One contributing plugin can block editing of another plugin's records.
|
||||
**Fix:** Run the response hydration after the transaction commits, in a short read transaction, or log the error and omit that extension's keys from `result.Data` instead of returning it. Keep `Save` errors as the rollback path; hydration is presentation only.
|
||||
|
||||
### WR-04: [WR-A04] Journal API writes can commit changes (including a slug) and fire no RecordSaved
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/api/posts.go:223-236` (Store), `:281-294` (Update)
|
||||
**Issue:** The API handlers write without a transaction: `Save(post)`, then `applyPostAssocs` (categories, tags, and `syncTranslations`, which writes translated slugs), then `loadPostAssocs`. `cabana.EmitRecordSaved` is called only after all of them succeed. If `applyPostAssocs` or `loadPostAssocs` fails, the post row (and possibly some translations) is already committed but no event fires. A slug changed over MCP then gets no 301 (D-102), which is exactly what the change was for.
|
||||
**Fix:** Wrap the write in `lagoon.Transaction(ctx, db, ...)` and call `cabana.EmitRecordSaved(ctx, app, tx, ...)` inside it, so the event follows the commit and a failure rolls back the row too. If that is not possible, emit as soon as `Save` succeeds and emit again (or emit the translation changes) after `applyPostAssocs`.
|
||||
|
||||
### WR-05: [WR-A05] Journal `postSavedEvent` reports `Old: nil` for a translated `content_html` that existed, because the body is mutated between the snapshot and the event
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/api/record_events.go:54,91`, `../sm-journal-plugin/controllers/api/posts_helpers.go:789`
|
||||
**Issue:** `snapshotPost` reads the old translations of the fields `submittedTranslations(body)` lists *before* the write. `syncTranslations` then calls `classes.FormatTranslation(attrs)` on the same `attrs` map inside `body`, which adds `attrs["content_html"]` whenever `content` is sent. `postSavedEvent` calls `submittedTranslations(body)` again, now sees `content_html`, finds no snapshot for it and reports `Old: nil`. Per `pact.RecordChange`, nil means "a translation that did not exist before", so the event is wrong for every update that sends a translated `content` without `content_html`.
|
||||
**Fix:** Compute the submitted set once, before the write, and pass it to both functions. Or include `content_html` in the snapshot whenever `content` is submitted (mirror `FormatTranslation`). Or copy the body's translation maps before `syncTranslations` mutates them.
|
||||
|
||||
### WR-06: [WR-A06] Tags auto-created by the Journal post API fire no RecordSaved although `journal-tag` is now a content type
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/api/posts_helpers.go:732-770` (`resolveTagIDs` → `db.Create(&tag)`)
|
||||
**Issue:** D-102 adds the `journal-tag` content descriptor so that tags get SEO entries. The MCP post write creates new `models.Tag` rows on the fly and fires nothing for them. SEO and sitemap subscribers only learn about those tags from a later bulk job.
|
||||
**Fix:** Collect the tags created in `resolveTagIDs` and emit `cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{Model: models.Tag{}.MorphName(), ID: ..., Created: true, Changes: ...})` for each, ideally inside the transaction suggested in WR-04 (WR-A04).
|
||||
|
||||
### WR-07: [WR-A07] `Hide` leaves the owner's relation-manager and partial endpoints of a hidden field reachable
|
||||
|
||||
**File:** `modules/cabana/form_extensions.go:624-641` (`dropHiddenFields`)
|
||||
**Issue:** `dropHiddenFields` removes a hidden field from `Writable`, `files`, `dates`, `permissions` and `FieldRelations`, but not from `cc.Relations` (relation-manager panels and their link, unlink and child CRUD routes, plus `commitDeferred`, which loops over `cc.Relations` and calls `contextAllows`; that returns true for a field no longer in the form) or from `cc.partials`/`cc.formPartials`. The pact doc says hidden fields "leave the schema, show and save", but writes through the relation routes of a hidden relation-manager field still go through, and the partial still renders.
|
||||
**Fix:** For hidden names, also drop the relation whose `fieldName` matches from `cc.Relations`, and drop the names from `cc.partials` and `cc.formPartials` (copy the maps first). Or refuse `Hide` of `relation-manager`/`partial` owner fields at boot.
|
||||
|
||||
### Info
|
||||
|
||||
### IN-01: [IN-A01] Dead check in `BindWritableFields`
|
||||
|
||||
**File:** `modules/cabana/crud.go:259-263`
|
||||
**Issue:** `BindWritableFields` runs in `compileRegistryWithTypes`, before `compileFormExtensions` fills `cc.extensionFields`, and again for relation child forms, which never have extensions. So `cc.extensionOf(field.Name) != nil` is always false there. The comment suggests it protects something it does not.
|
||||
**Fix:** Remove it, or move the guard to where it would matter and say so.
|
||||
|
||||
### IN-02: [IN-A02] Scoped API tokens never see an extension that declares no Permissions, and their posted values are dropped silently
|
||||
|
||||
**File:** `modules/cabana/form_extensions.go:1092-1097`, `modules/cabana/token_scopes.go:246-252`
|
||||
**Issue:** `Allows` returns false under a non-full token scope when `required` is empty. An extension without `Permissions` is therefore invisible to scoped MCP tokens, and a save from such a token drops the extension keys and still answers 200. That is fail-closed, but it is surprising and undocumented in `pact.AdminFormExtension`.
|
||||
**Fix:** Document it next to `Permissions`, or treat an empty `Permissions` as "inherit the host controller's access".
|
||||
|
||||
### IN-03: [IN-A03] `resolveExtensionLocales` wraps a nil error
|
||||
|
||||
**File:** `modules/cabana/form_extensions.go:1129-1131`
|
||||
**Issue:** When `DefaultLocale` returns `("", nil)`, `fmt.Errorf("cabana: default locale: %w", err)` produces `%!w(<nil>)`. The same pattern exists in `hydrateMLRecord`.
|
||||
**Fix:** Use `errors.New("cabana: default locale is empty")` when `err == nil`.
|
||||
|
||||
### IN-04: [IN-A04] `tab: default` on an extension field is refused although `default` names the default tab elsewhere
|
||||
|
||||
**File:** `modules/cabana/form_extensions.go:932-936`
|
||||
**Issue:** The docs say "`default` names the tab of fields without a `tab:`". Anchors and `after:` accept it, but a field with `tab: default` fails with "unknown tab" because `place` does not map it through `tabAnchorKey`.
|
||||
**Fix:** Normalise `field.Tab` with `tabAnchorKey` before the lookup, or say in the docs that a field omits `tab:` for the default tab.
|
||||
|
||||
### IN-05: [IN-A05] Journal `requestLocale` drops the region, so translated slugs in region-coded locales never match
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/api/posts_helpers.go:806-819`
|
||||
**Issue:** `pt-BR` and `en_GB` become `pt` and `en`. If translate stores region codes, the translated-slug lookup in `findPostBySlugOrID` misses.
|
||||
**Fix:** Try the full normalised code first, then the primary language.
|
||||
|
||||
### IN-06: [IN-A06] The extension's client assets are served to every administrator of the host form
|
||||
|
||||
**File:** `modules/cabana/form_extensions.go:508-543`, `modules/cabana/http.go:851` (`view.Assets = s.controllerAssets(cc)`)
|
||||
**Issue:** The plugin's `AdminClientAssets` JS/CSS is added to `cc.scripts`/`cc.styles` and loaded for every administrator, including those the extension is hidden from. This is not an authorization leak, because the scripts are already served, but it does run the plugin's script on forms where none of its elements exist.
|
||||
**Fix:** Optional. Filter `controllerAssets` by visible extensions, or note the behaviour in the docs.
|
||||
|
||||
|
||||
_Reviewed: 2026-10-27T13:28:44Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
||||
---
|
||||
|
||||
## Lane B: sm-seo-plugin core Go
|
||||
|
||||
```yaml
|
||||
phase: 32-seo-foundation
|
||||
reviewed: 2026-10-27T13:28:36Z
|
||||
depth: standard
|
||||
files_reviewed: 68
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 12
|
||||
info: 8
|
||||
total: 21
|
||||
status: issues_found
|
||||
```
|
||||
|
||||
### Phase 32: Code Review Report (scope B: sm-seo-plugin core)
|
||||
|
||||
**Reviewed:** 2026-10-27T13:28:36Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 68
|
||||
**Status:** issues_found
|
||||
|
||||
### Summary
|
||||
|
||||
Reviewed the sm-seo-plugin v0.1.0 core: resolver and precedence, the redirect engine with loop and chain detection, normalisation and strict URLs, the 404 log, robots.txt, verification files, history and undo, batches, CSV transfer, the issue report, the `/_seo/api/v1` management API with its MCP tool table, and the admin controllers and extensions.
|
||||
|
||||
Auth is consistent. Every management route goes through `manage()` (`cabana.ResolveBackend` bearer-only, no cookie, so no CSRF surface, then `cabana.Allows(golem15.seo.manage)`). Every admin controller, action, bulk action, widget action and form extension declares `golem15.seo.manage`. The public endpoints (`/robots.txt`, `/resolve`, `/notfound`, `/verification/{name}`) are throttled. The 404 report token is compared in constant time. Open-redirect hardening on substituted targets (`SafeTarget`, `escapeCapture`) and the normalisation host pinning (`sameSite`) hold up under tracing. Absolute-URL building never uses the Host header. Verification files carry `nosniff` and `sandbox` CSP. Batches and API undo run in one transaction with savepoints. Admin record and bulk actions run inside cabana's action transaction (verified in `cabana/crud.go`).
|
||||
|
||||
Main concerns:
|
||||
|
||||
1. **Exact redirects whose source ends in `/` or has upper-case letters in its query never fire at runtime.** The resolver's on-demand DB lookup compares a different key than the in-memory set the save-time check uses. This also breaks "Create redirect…" from the 404 log on sites that use trailing slashes.
|
||||
2. **The loop guarantee has holes.** The save-time simulation is not serialised, `AddressChanged` skips it entirely, and undo is not idempotent under concurrency.
|
||||
3. **The public resolve endpoint can be driven to 500s and unbounded memory.** NUL or invalid UTF-8 paths cause the 500s; the never-evicted "logged cycle" map grows without bound.
|
||||
4. **Validation differs between paths.** Social images, invalid statuses and locales are checked or rejected in the admin but accepted or silently coerced through the API, MCP, batches and imports.
|
||||
5. **An overflowing `page` on export panics the handler.**
|
||||
|
||||
### Narrative Findings (AI reviewer)
|
||||
|
||||
### Critical Issues
|
||||
|
||||
### CR-03: [CR-B01] Exact redirects with a trailing slash or upper-case query characters never match at runtime
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/redirects.go:119-137, 230-242, 277-289`
|
||||
**Issue:** `LoadRedirectSet`, used by the resolver (`resolver.go:375`), the 404 log's "resolved" state (`controllers/notfound.go:70,205`, `controllers/api/notfound.go:123`) and `ResolveNotFound` (`notfound.go:161`), loads exact rules on demand with
|
||||
`WHERE match = 'exact' AND lower(source) IN (keys)`. The keys come from `exactKeys(path, rawQuery)`, which strips one trailing slash from the path (`trimSlash`) and keeps the query in its original case. The stored `source` is never normalised: `models.Redirect.Normalize` only trims spaces. So:
|
||||
|
||||
- source `/old-page/` gives `lower(source)` = `/old-page/`, but every request key is `/old-page`. The row is never fetched, and the redirect is dead for both `/old-page` and `/old-page/`.
|
||||
- source `/list?Page=2` gives `lower(source)` = `/list?page=2`, but the key is `/list?Page=2`. The row is never fetched.
|
||||
|
||||
I verified both mismatches with a scratch program. The save-time check (`CheckRedirect`, which uses `NewRedirectSet` over all rows in memory with no fetch), the issue report and the list's SQL `coveredSQL` filter all see these rules. So the admin saves them without complaint, the redirects list's "Resolved" SQL filter says the 404 entry is covered, the row badge says it is open, and visitors get a 404.
|
||||
|
||||
This hits real usage directly. On a site with `url_slash: add`, every 404 the frontend reports ends in `/`. **Create redirect…** / `redirect_not_found` stores `source = "/old/"`, which never fires. The next report of the same URL also finds no cover, so the admin can be offered "Create redirect" again, and that fails with `source_taken` instead of `has_rule`. Existing unit tests only exercise `NewRedirectSet` in memory (`classes/redirect_graph_test.go`), never the DB fetch path.
|
||||
**Fix:** Make the DB lookup use the same key function as the in-memory set. Either normalise the stored lookup key at write time, or match on an expression index:
|
||||
|
||||
```go
|
||||
// LoadRedirectSet fetch: compare the same normalised key exactKey() builds
|
||||
err := db.Where(`match = ? AND (
|
||||
rtrim(lower(split_part(source, '?', 1)), '/') ||
|
||||
CASE WHEN position('?' in source) > 0 THEN '?' || substr(source, position('?' in source) + 1) ELSE '' END
|
||||
) IN ?`, models.RedirectExact, keys).Order("id ASC").Find(&out).Error
|
||||
```
|
||||
|
||||
A cleaner option is a stored `source_key TEXT` column, filled from `exactKey(r.Source)` in `SaveRedirect`, `AddressChanged` and the admin `FormBefore*`, with an index, and `WHERE source_key IN ?`. Keep `rtrim` semantics for `/` (the root stays `/`). Add a DB-backed test that resolves `/old/`, `/old` and `/list?Page=2` against stored exact sources.
|
||||
|
||||
### Warnings
|
||||
|
||||
### WR-08: [WR-B01] A query-specific exact redirect is silently shadowed by the path-only rule
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/redirects.go:130-137, 281-289`
|
||||
**Issue:** `exactKeys` returns `[path, path?query]`, and `Match` returns the first hit in that order. With `/products → /shop` and `/products?id=5 → /shop/item-5` both stored, `/products?id=5` always goes to `/shop`, so the more specific rule can never fire. `checkRedirectRules` does not report this: the probe's first hop is the other rule, so the `Hops[0].Rule.ID == r.ID` branches are skipped and the rule is saved without any notice.
|
||||
**Fix:** Try the `path?query` key before the path-only key in `Match`. Alternatively, have `CheckRedirect` refuse or warn when the probe of `r` is answered by a different rule (a "shadowed" problem).
|
||||
|
||||
### WR-09: [WR-B02] `AddressChanged` writes redirects without validation or the loop simulation
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/addresses.go:58-120`
|
||||
**Issue:** Steps 1–4 use raw `tx.Create`, `tx.Save` and `tx.Delete` and never call `Problems()` or `CheckRedirect`. Only exact redirects whose source equals the new address are removed (step 1). Pattern and regex rules, case variants (`ignore_case`) and trailing-slash variants that match the new address survive. The new `old → new` 301 then forms a loop with them. For example, with a pattern `/a/* → /b/*` in place, a record moved from `/b/x` to `/a/x` gets `/b/x → /a/x → /b/x`. The resolver turns this into `not_found` for the record's live address. Re-pointing (step 3) can likewise close a cycle with pattern rules. Also, the descriptor URL is stored as an exact `source` unvalidated: an absolute or `//` URL from a descriptor would be stored and never match.
|
||||
**Fix:** After steps 1–4, run `s.CheckRedirect(ctx, tx, redirect)` and the same for every re-pointed rule. On `Loop`, either skip creating the 301 and log, or delete the conflicting shadowing rule. Validate `res.Old` and `res.New` with `Problems(tx)` before creating.
|
||||
|
||||
### WR-10: [WR-B03] The save-time loop check races with concurrent writes
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/redirect_graph.go:168-177, 276-310`; `controllers/api/auth.go:172-180`
|
||||
**Issue:** `CheckRedirect` reads all rules inside the writer's transaction at READ COMMITTED with no lock. Two concurrent writes (two MCP agents, an import and an admin save, or a batch and a slug change) can each simulate without the other's uncommitted rule. For example, one creates `A → B` while the other creates `B → A`. Both commit, and the stored set contains the loop D-19 promises to refuse. The runtime guard turns it into a 404 for both addresses.
|
||||
**Fix:** Serialise redirect writes. Take `SELECT pg_advisory_xact_lock(<const>)` at the start of `SaveRedirect`, `DeleteRedirect`, `AddressChanged` and `runBatch` before `LoadRedirects`, or run those transactions at `SERIALIZABLE` and retry on 40001.
|
||||
|
||||
### WR-11: [WR-B04] Undo is not idempotent under concurrent requests
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/undo.go:71-83, 291-343`
|
||||
**Issue:** `Undo` reads the revision with `undone_at IS NULL` without locking. It then restores and marks the revision with `UPDATE ... WHERE id = ?`, with no `undone_at IS NULL` condition and no `RowsAffected` check. Two simultaneous undo calls (a double-click in the admin plus a retry, or an MCP agent retrying) can both pass the "already undone" check and both restore. For "deleted" subjects that means two re-created rules, redirects or 404 rows (or a unique-violation 500). In every case it writes two `via undo` history rows. `UndoBatch` has the same pattern.
|
||||
**Fix:** Lock the revision first, `tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Find(&rows)`. For batches, lock `WHERE batch_id = ? AND undone_at IS NULL FOR UPDATE`. Also make the final update conditional:
|
||||
|
||||
```go
|
||||
res := tx.Model(&models.Revision{}).Where("id = ? AND undone_at IS NULL", rev.ID).Updates(updates)
|
||||
if res.Error == nil && res.RowsAffected == 0 { return &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"} }
|
||||
```
|
||||
|
||||
### WR-12: [WR-B05] Public resolve and 404 report answer 500 for NUL or invalid UTF-8 paths
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/resolver.go:198-219`; `classes/notfound.go:59-80`; `controllers/api/resolve.go:31-40`
|
||||
**Issue:** `parseTarget` and `NotFoundPath` return `u.Path`, which is percent-decoded. `/%00` or `/%FF` yields a path with a NUL or invalid UTF-8 byte. That path reaches Postgres as a text parameter (`lower(source) IN ?` in the redirect fetch, `url = ?` in `ReportNotFound`), and Postgres rejects it ("invalid byte sequence for encoding UTF8"). The anonymous resolve endpoint then answers 500 and writes a `log.Error` line per request: 600 per minute per IP, more with many IPs. The token-gated 404 report fails the same way for every such URL a visitor hits.
|
||||
**Fix:** In `parseTarget` and `NotFoundPath`, refuse a decoded path that is not `utf8.ValidString` or contains `\x00`, returning `ErrBadURL` / `ErrNotFoundURL` (422). Apply the same check to `rawQuery`.
|
||||
|
||||
### WR-13: [WR-B06] The runtime cycle log grows without bound and is keyed by attacker-controlled URLs
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/redirects.go:529-537`; `classes/resolver.go:358, 385, 635`
|
||||
**Issue:** `redirectHopsLogged` is a process-global `sync.Map` that is never evicted. Its key is `self = site + path?rawQuery`, and the raw query comes straight from the anonymous request (up to 2048 bytes). Once any looping or over-10-hop chain exists (which WR-09 (WR-B02) and WR-10 (WR-B03) make reachable), an unauthenticated client can request `/looping?x=1`, `?x=2`, … Each request stores a new entry and writes a new warning line. Memory grows without limit, and the log is flooded despite the "once per URL" intent.
|
||||
**Fix:** Key the de-duplication on the rule IDs of the cycle (or `npath` without the query), and bound it, for example with a small LRU or a time-based reset like `capWarned` in `notfound.go`.
|
||||
|
||||
### WR-14: [WR-B07] Export with a large `page` panics the handler
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/transfer.go:159-168`; `controllers/api/transfer.go:88-89`
|
||||
**Issue:** `start := (page - 1) * perPage` overflows for large `page` values. `page=9223372036854777` gives `start = -9223372036854775616`, which passes the `start > len(rows)` check, and `rows[start:end]` panics with "slice bounds out of range". I reproduced this. It is reachable from `GET /_seo/api/v1/export?page=…` and from the `seo_export_csv` MCP tool for any holder of `golem15.seo.manage`.
|
||||
**Fix:** Clamp before multiplying:
|
||||
|
||||
```go
|
||||
if page < 1 { page = 1 }
|
||||
if page > out.Pages { page = out.Pages }
|
||||
start := (page - 1) * perPage
|
||||
```
|
||||
|
||||
Apply the same clamp in `listParams` (`controllers/api/auth.go:201-216`), where the multiplication can overflow into a negative offset.
|
||||
|
||||
### WR-15: [WR-B08] Public throttle buckets key on the SSR server's IP, capping the whole site
|
||||
|
||||
**File:** `../sm-seo-plugin/routes.go:91-111`
|
||||
**Issue:** The README says the frontend server calls `/resolve` for every page it renders and reports every 404. Both calls are server-to-server, so `surf.ClientIP` resolves to the SSR host unless that host is configured as a trusted proxy and forwards `X-Forwarded-For`. `seo-resolve` (600/min) and `seo-notfound` (600/min) then become site-wide caps: about 10 rendered pages per second. Beyond that, resolve answers 429 and the frontend either fails or renders without the redirect decision and robots. Nothing in the plugin documents or enforces the trusted-proxy requirement.
|
||||
**Fix:** Let the frontend server authenticate resolve calls with the existing `notfound_token` (or a dedicated `resolve_token`) and exempt or raise the bucket for authenticated calls. At minimum, document in the README that the SSR host must be in the trusted proxies and must forward the visitor IP.
|
||||
|
||||
### WR-16: [WR-B09] Social image values bypass validation on every non-admin write path
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/owner.go:129-150`; `classes/rules.go:33-52`; `classes/settings_write.go:104-105`
|
||||
**Issue:** The admin `seoimage` field type refuses anything but a path or an http(s) URL (`decodeImage` → `ValidImageURL`). `SetURLSEO` (API, MCP, batch `set_seo`, records import), `SaveRule` (API, MCP, batch, rules import) and `SaveSettings` (`default_og_image`) validate `canonical` and `robots` but not `og_image`. Values such as `javascript:alert(1)`, `data:…`, `//evil.example/x.png` or text with spaces are stored. `AbsoluteURL` (`urls.go:125-137`) passes any `IsAbs()` value through, so they come back from `/resolve` as `seo.og.image`. That value goes into `og:image` / `twitter:image` meta content, where crawlers and frontends trust it.
|
||||
**Fix:** Add `case column == "og_image" && v != "" && !ValidImageURL(v):` to `SetURLSEO`. Add the same check for `r.OGImage` in `SaveRule` and for `default_og_image` in `SaveSettings`, using the `golem15.seo::lang.image.url_invalid` problem.
|
||||
|
||||
### WR-17: [WR-B10] Invalid redirect status, match or query values are silently coerced on API, MCP and import
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/batch.go:53-78`; `models/redirect.go:109-129`
|
||||
**Issue:** `RedirectFields.Apply` copies any status, match or query, and `Normalize` replaces unknown values with defaults. A client sending `"status": 308` or `307` gets a 301. `"match": "Regex"` or `"glob"` becomes `exact`, which then fails with a misleading `source_invalid`. `"query": "keep"` becomes `ignore`. All of this is answered with 201. The CSV import has the same behaviour for the `match` and `query` columns. An MCP agent cannot tell that its request was changed.
|
||||
**Fix:** In `RedirectFields.Apply` (or a `Validate` step before `Normalize` on API, batch and import paths), refuse values outside {301, 302, 410}, {exact, pattern, regex} and {ignore, pass} with a `ProblemsError` (`value_invalid`). Leave the defaulting only to fields the caller omitted.
|
||||
|
||||
### WR-18: [WR-B11] `set_url_seo`, batch `set_seo` and the records import accept any locale
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/owner.go:154-173`; `classes/entries.go:176-223`; `classes/transfer.go:536-568`
|
||||
**Issue:** `in.Locale` (or the CSV `locale` cell) overrides the matched locale without checking that the record has that locale. `SaveRecord` creates an entry for `"xx"` or `"EN "` or any string, with an empty URL (`described[locale]` is missing), and records a history row. The answer says `written_to: record`, but the resolver never reads that row. The SEO tab never shows it either, so the agent's change is lost while it is reported as success.
|
||||
**Fix:** In `SetURLSEO`, when `in.Locale` is set, require it to be one of `recordLocales(...)` (or the enabled locales from `Locales()`). Otherwise refuse with `locale_unknown` as a `ProblemsError`.
|
||||
|
||||
### WR-19: [WR-B12] Entry generation overwrites `entry.URL`, which can swallow an address change and its 301
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/generate.go:111-131`; `listeners.go:46-70`
|
||||
**Issue:** Address-change detection (`AddressChanges`) depends on `entry.URL` holding the last address seen. `refreshEntry` sets `url` to the current descriptor URL unconditionally, and the job is queued after every settings save (`settings_write.go:292`) or from the CLI. If a record's slug changes and the job runs before the `RecordSaved` listener's transaction, the listener sees `entry.URL == url` and no 301 is created. This happens with an after-commit listener racing a worker, or with any write path that does not emit `RecordSaved`. The old URL then 404s with no trace in history.
|
||||
**Fix:** In `refreshEntry`, when an existing entry's URL differs from `l.URL` and the record is published, call `AddressChanged` (with via auto) instead of overwriting, or leave `url` untouched and let the listener or SEO tab own it. Only fill `url` when it is empty.
|
||||
|
||||
### Info
|
||||
|
||||
### IN-07: [IN-B01] The `regexCache` / `globCache` maps are never evicted
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/redirects.go:45-72`; `classes/urls.go:13-29`
|
||||
**Issue:** Every distinct source compiled during a save simulation, a dry-run batch, an import preview or a refused (looping) save stays cached for the life of the process. The input is admin/MCP-controlled (up to 512-character regexes), so this is bounded by trusted users, but a long-running MCP agent can grow it steadily.
|
||||
**Fix:** Cache only stored rules (key by rule ID plus `updated_at`), or use a bounded LRU.
|
||||
|
||||
### IN-08: [IN-B02] CSV `unescapeCell` changes legitimate values that start with an apostrophe
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/csv.go:50-57`
|
||||
**Issue:** A stored title such as `'-Mix` is exported unescaped (it does not start with a formula character) and imported back as `-Mix`, because import strips any apostrophe followed by a formula character. Round-trips are therefore not lossless.
|
||||
**Fix:** In `escapeCell`, also prefix values that already start with `'` (so `'x` exports as `''x`), and strip exactly one apostrophe on import.
|
||||
|
||||
### IN-09: [IN-B03] The transfer token has no expiry or user binding, and the fallback key is per process
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/transfer.go:700-788`
|
||||
**Issue:** The apply token carries the file and a fingerprint but no issue time or admin ID, so it can be replayed by any SEO manager at any later time while the data is unchanged. Without an app key, `fallbackKey` is random per process, so with several instances behind a load balancer a preview and its apply land on different keys and fail with `token_invalid`.
|
||||
**Fix:** Add `iat` and `admin_id` to the token and check a TTL (for example 1 h) and the current principal on apply. Refuse to issue tokens when there is no app key, rather than using a per-process key.
|
||||
|
||||
### IN-10: [IN-B04] Uniqueness checks swallow query errors inside the save transaction
|
||||
|
||||
**File:** `../sm-seo-plugin/models/redirect.go:165-174`; `models/rule.go:110-119`
|
||||
**Issue:** `if err := q.Count(&count).Error; err == nil && count > 0` ignores errors. On Postgres a failed statement aborts the transaction, so the next statement fails with "current transaction is aborted", which hides the real cause. If the check is skipped, the unique constraint later surfaces as a 500 instead of `source_taken` / `url_taken`.
|
||||
**Fix:** Return the error, or map a unique-violation (23505) on create or save to the same problem key.
|
||||
|
||||
### IN-11: [IN-B05] `RenderTemplate` trims separators that belong to the content
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/templates.go:29-40`
|
||||
**Issue:** `strings.Trim(out, " \t|-–—·:,")` runs on the whole rendered string. A template that is just `%title%`, or a title such as `Q&A:` or `-20% sale`, loses its own leading or trailing characters (`Q&A`, `20% sale`).
|
||||
**Fix:** Trim only the separators adjacent to placeholders that rendered empty. For example, render each placeholder with a sentinel, collapse `sentinel+separator` runs, then remove the sentinels.
|
||||
|
||||
### IN-12: [IN-B06] Body caps do not account for JSON escaping
|
||||
|
||||
**File:** `../sm-seo-plugin/controllers/api/robots.go:103`; `controllers/api/transfer.go:105-112`
|
||||
**Issue:** `decode` truncates the body at the limit. A 500 KB robots.txt or a 900 KB CSV with many quotes or newlines exceeds the JSON body limit after escaping, so the caller gets a generic `422 The request body is invalid.` instead of `robots.too_large` or `413 too_large`.
|
||||
**Fix:** Detect truncation (read `limit+1` and compare) and answer 413 with the documented code, or size the limits from the worst-case escaped size.
|
||||
|
||||
### IN-13: [IN-B07] `/robots.txt` serves allow-all without the noindex header when the service is nil, even outside production
|
||||
|
||||
**File:** `../sm-seo-plugin/controllers/api/robots.go:20-46`
|
||||
**Issue:** When `service()` is nil (before boot), `staging` stays false and the body is `FallbackRobots` (allow all). A staging instance answering during startup gives crawlers an allow-all file, which contradicts D-22's "outside production always Disallow".
|
||||
**Fix:** When `svc == nil`, read the environment from the app config directly, or default to `NonProductionRobots` with `X-Robots-Tag: noindex, nofollow`.
|
||||
|
||||
### IN-14: [IN-B08] Self canonical and `url` are built from the decoded path
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/resolver.go:358, 476-480`
|
||||
**Issue:** `self := site + path` uses `u.Path`, which is decoded. A page at `/a%3Fb` gets canonical `https://site/a?b` (a different URL), and spaces or non-ASCII characters end up unescaped in `canonical`, `og.url` and `url`. Location headers already use `escapePath`, so canonical and redirect forms disagree.
|
||||
**Fix:** Build `self` from `escapePath(path)` in both places.
|
||||
|
||||
|
||||
_Reviewed: 2026-10-27T13:28:36Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
||||
---
|
||||
|
||||
## Lane C: sm-seo-plugin models, config, partials, elements, tests
|
||||
|
||||
```yaml
|
||||
phase: 32-seo-foundation
|
||||
reviewed: 2026-10-27T13:28:38Z
|
||||
depth: standard
|
||||
files_reviewed: 103
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 6
|
||||
info: 9
|
||||
total: 16
|
||||
status: issues_found
|
||||
```
|
||||
|
||||
### Phase 32: Code Review Report (scope C: sm-seo-plugin models, migrations, admin YAML, partials, elements, lang, tests, README)
|
||||
|
||||
**Reviewed:** 2026-10-27T13:28:38Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 103
|
||||
**Status:** issues_found
|
||||
|
||||
### Summary
|
||||
|
||||
I reviewed the remaining sm-seo-plugin v0.1.0 files: GORM models and migrations, the admin list/form/filter YAML, the server-rendered `.htm` partials, the seven light-DOM custom elements, the en/pl phrasebooks, the Go and vitest suites, the build config and the README.
|
||||
|
||||
**What holds up:**
|
||||
- **Partials.** They render through cabana's `html/template` renderer (`modules/cabana/partial_render.go`). It autoescapes, refuses trusted `template.HTML*` types in view models and sanitises the output. No partial interpolates raw HTML, so I found no XSS path in the `.htm` files.
|
||||
- **Elements.** All of them build DOM with `textContent`, `createTextNode` and `setAttribute`. `innerHTML` is never used. Server-supplied links in the SERP duplicates are limited to single-slash paths.
|
||||
- **Lang files.** Both phrasebooks have the same key set (pl adds only CLDR `few`/`many` forms) and the same `:param` sets. Every `golem15.seo::lang.*` key the Go, YAML and `.htm` files reference exists.
|
||||
|
||||
**The one blocker is cross-module.** The SERP preview widget and the robots.txt tester widget both send their first `summer-action` from `connectedCallback`. The framework's `WidgetField.vue` drops that event, because it assigns `element` only after `append()`. Both elements then keep `inflight` set forever, and every later request sits in the queue. Neither widget ever works in the real admin. The plugin's vitest suite does not catch this: it dispatches `summer-result` by hand and never goes through the host. I confirmed the timing with happy-dom from `admin/node_modules`: the listener sees `element === null`.
|
||||
|
||||
The warnings cover:
|
||||
- inconsistent URL safety checks for the social image
|
||||
- a CSV round trip that changes some values
|
||||
- list filters that disagree with the runtime redirect matcher
|
||||
- an uncapped indexed rule URL
|
||||
- grammar drift between the TS and Go robots parsers
|
||||
- gaps in the element tests
|
||||
|
||||
### Critical Issues
|
||||
|
||||
### CR-04: [CR-C01] The SERP preview and the robots.txt tester deadlock: their first action is dropped by the host and `inflight` is never cleared
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/serp.ts:172-178, 217-224`; `../sm-seo-plugin/assets/src/elements/robotstester.ts:77-82, 110-117`. Root cause in the framework: `admin/src/components/form/fields/WidgetField.vue:82, 161-162`.
|
||||
|
||||
**Issue:**
|
||||
|
||||
1. Both elements call `this.request({ op: 'context' })` in `connectedCallback`. `request()` sets `this.inflight = payload` and then dispatches `summer-action` synchronously.
|
||||
2. `WidgetField.vue` registers its listener before mounting, but it runs `mountPoint.value.append(created)` (line 161) before `element = created` (line 162). `connectedCallback` runs inside `append()`.
|
||||
3. `onAction` therefore reads `const target = element` as `null` (line 82) and returns without posting, without setting `busy` and without ever dispatching `summer-result` or setting `state=error`.
|
||||
|
||||
`inflight` is only cleared in `onResult` or on `state=error`, so it stays set for the element's lifetime:
|
||||
- **`golem15-seo-serp`** (SEO tab and URL rule form) shows the skeleton with `aria-busy` forever. `scheduleDuplicates` exits early because there is no context, so no duplicate check is ever sent.
|
||||
- **`golem15-seo-robotstester`** puts every **Test URL** click into `queued`. `flush()` only runs when `busy` is removed, and `busy` is never set. The tester never answers.
|
||||
|
||||
Reproduced with happy-dom (`admin/node_modules/happy-dom`) using the same create, listen, append, assign order: the listener observes `element === null`.
|
||||
|
||||
The same deadlock happens if the element is disconnected while a request is in flight: the `summer-result` listener is removed in `disconnectedCallback`, the result is lost, and reconnecting queues behind the stale `inflight`.
|
||||
|
||||
**Fix:** Do both. In the framework, assign the element before connecting it:
|
||||
|
||||
```ts
|
||||
// WidgetField.vue onMounted
|
||||
element = created
|
||||
created.addEventListener(WIDGET_EVENT, listener)
|
||||
mountPoint.value.append(created)
|
||||
status.value = 'ready'
|
||||
```
|
||||
|
||||
In the elements, do not trust that an emitted action was taken. The host sets `busy` synchronously inside `onAction` before its first `await`, so after `dispatchEvent` returns, a missing `busy` attribute means the action was dropped:
|
||||
|
||||
```ts
|
||||
private request(payload: Payload): void {
|
||||
if (this.hasAttribute('busy') || this.inflight) { this.queued = payload; return }
|
||||
this.inflight = payload
|
||||
emit(this, 'summer-action', { payload })
|
||||
if (!this.hasAttribute('busy')) { // host did not take it
|
||||
this.inflight = null
|
||||
this.queued = payload
|
||||
setTimeout(() => this.flush(), 0) // retry once the host is wired
|
||||
}
|
||||
}
|
||||
// and defer the first request:
|
||||
connectedCallback(): void { /* ... */ queueMicrotask(() => this.request({ op: 'context' })) }
|
||||
// and reset on disconnect:
|
||||
disconnectedCallback(): void { /* ... */ this.inflight = null }
|
||||
```
|
||||
|
||||
Add a vitest case that mounts the element through a fake host with the same ordering, and a `robotstester.test.ts`. There is none today.
|
||||
|
||||
### Warnings
|
||||
|
||||
### WR-20: [WR-C01] The social image preview accepts `/\host/...`, so the admin can load an image from an attacker's host
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/image.ts:6-8`. Server side: `../sm-seo-plugin/classes/field_types.go:157-163` (`ValidURLRef`, used by `ValidImageURL`).
|
||||
|
||||
**Issue:**
|
||||
- `safeImageURL` only refuses `//`: `/^\/(?!\/)/`.
|
||||
- `ValidURLRef` likewise only refuses `//`.
|
||||
- A stored `og_image` of `/\evil.example/x.png` passes both. Browsers normalise `\` to `/` in http(s) URLs, so `<img src="/\evil.example/x.png">` in the admin loads `https://evil.example/x.png`. That leaks each viewing administrator's IP and session timing to a third party.
|
||||
- Any holder of `golem15.seo.manage`, an MCP caller or a CSV import can set the value.
|
||||
- `models.validTarget` (`models/redirect.go:192-193`) already refuses `/\`, so the two validators are inconsistent.
|
||||
|
||||
**Fix:** Refuse `/\` in both places:
|
||||
|
||||
```ts
|
||||
export function safeImageURL(url: string): boolean {
|
||||
return /^\/(?![\/\\])/.test(url) || /^https?:\/\//i.test(url)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
if strings.HasPrefix(v, "/") {
|
||||
return !strings.HasPrefix(v, "//") && !strings.HasPrefix(v, "/\\")
|
||||
}
|
||||
```
|
||||
|
||||
### WR-21: [WR-C02] The CSV export/import round trip changes cells that already start with an apostrophe followed by a formula character
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/csv.go:43-57` (exercised by `classes/csv_test.go:60-70`; the claim is in `README.md:38`)
|
||||
|
||||
**Issue:**
|
||||
- `escapeCell` adds `'` only when the cell starts with a formula character.
|
||||
- `unescapeCell` strips a leading `'` whenever the next character is a formula character.
|
||||
- A note, title or target whose real value is `'=x`, `'-)` or `'+48…` is therefore exported unchanged and comes back on import as `=x`, `-)` or `+48…`.
|
||||
- The README promises that the import drops "again" what the export added, and `TestCSVTransfer` asserts that re-importing an export is all `unchanged`. That holds only because no test value starts with `'`. `csv_test.go` checks `unescapeCell("''=x")` but never the round trip of `'=x`.
|
||||
|
||||
**Fix:** Escape an apostrophe-led value too, so `unescapeCell` is the exact inverse:
|
||||
|
||||
```go
|
||||
func escapeCell(s string) string {
|
||||
if formulaStart(s) || (strings.HasPrefix(s, "'") && formulaStart(s[1:])) {
|
||||
return "'" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
Then add `{"/ap", "/x", "'=x"}` to the round-trip rows in `TestCSV`.
|
||||
|
||||
### WR-22: [WR-C03] The 404 "Open/Resolved" filter and the redirect "Chains" filter disagree with the runtime matcher
|
||||
|
||||
**File:** `../sm-seo-plugin/models/notfound.go:44-61`, `../sm-seo-plugin/models/redirect.go:227-265`. The same scope backs MCP `list_not_found?status=` (`controllers/api/notfound.go:116`).
|
||||
|
||||
**Issue:**
|
||||
- **`coveredSQL`** counts an entry as resolved only when an exact redirect's `source` equals the logged `url`, optionally ignoring case. The runtime also matches:
|
||||
- the source with and without a trailing slash (`trailing_slash`, on by default)
|
||||
- a source without a query against a logged URL with one (`query = ignore`)
|
||||
- patterns and regexes
|
||||
|
||||
So `/missing/` or `/missing?utm=x` stays "Open" in the admin filter and in the MCP list after a redirect for `/missing` already answers it, while the row badge says "Resolved". The pagination totals are wrong too.
|
||||
- **`chainSQL`** compares `next.source = target` literally. It misses targets written as the site's absolute URL, trailing-slash or case variants, and pattern or regex next hops, all of which the save-time check treats as chains. The "Only chains" filter therefore under-reports.
|
||||
|
||||
The code comment admits the pattern/regex gap. The trailing-slash and query gaps are not documented, and the MCP tool presents "open" as "no redirect answers the address yet".
|
||||
|
||||
**Fix:** Pick one approach:
|
||||
- Compute the status from the compiled `RedirectSet` (as the badge does) and filter by IDs.
|
||||
- Add the slash and query cases to the SQL, for example `r.trailing_slash AND rtrim(r.source,'/') = rtrim(split_part(url,'?',1),'/')` plus `r.query = 'ignore' AND r.source = split_part(url,'?',1)`.
|
||||
- Persist a `resolved_by` column that is refreshed on redirect writes.
|
||||
|
||||
Also document the remaining gap in the MCP tool's `jsonschema` text.
|
||||
|
||||
### WR-23: [WR-C04] The URL rule address is uncapped but sits in a btree UNIQUE index, so long values return 500 instead of 422
|
||||
|
||||
**File:** `../sm-seo-plugin/models/rule.go:102-121`; `../sm-seo-plugin/updates/202610270005_create_golem15_seo_rules.go:28`
|
||||
|
||||
**Issue:**
|
||||
- `Rule.Problems` checks the prefix, whitespace and `*`, but not the length.
|
||||
- `UNIQUE (url, match)` is a btree, and Postgres refuses index rows over about 2704 bytes ("index row size exceeds btree version 4 maximum"). Short highly repetitive strings may compress under the limit; random content does not.
|
||||
- A long rule URL from the admin form, MCP `create_rule`/`set_url_seo` (which falls back to a rule) or a records CSV import (a row without a record becomes a rule) therefore fails at the database with a 500. A batch or import fails as a whole with an opaque error, not a per-row refusal.
|
||||
- Redirects cap their sources at `MaxRedirectURL = 2048`; rules have no equivalent.
|
||||
|
||||
**Fix:** Mirror the redirect cap:
|
||||
|
||||
```go
|
||||
const MaxRuleURL = 2048
|
||||
if len(url) > MaxRuleURL || !strings.HasPrefix(url, "/") || ... {
|
||||
out["url"] = []Problem{{Key: errRuleURLInvalid}}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
### WR-24: [WR-C05] The robots element's grammar drifts from the Go grammar, and an unparseable value is silently wiped on the next edit
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/robots.ts:39-98, 136-145`; test claim in `tests/elements/robots.test.ts:75-79`
|
||||
|
||||
**Issue:** `parseRobots` claims "the grammar is the same as `classes/robots_value.go`", but they differ in both directions:
|
||||
|
||||
| Input | TS `parseRobots` | Go `ParseRobots` |
|
||||
|---|---|---|
|
||||
| `unavailable_after:2027-02-30` | accepts (V8 `Date.parse` rolls it over to March) | refuses |
|
||||
| `max-snippet:+5` | refuses (`/^-?\d+$/`) | accepts (`strconv.Atoi`) |
|
||||
|
||||
When `set value` gets a string TS cannot parse, the element:
|
||||
- shows every group as "Inherit" (`parseRobots(next) ?? emptyRobots()`) while `current` still holds the stored value
|
||||
- serialises the empty model on the first click of any control, which silently drops every stored directive
|
||||
|
||||
Today the server normalises the values it writes, so this needs a value written outside those validators: a direct DB write or migrated legacy data. The UI still misrepresents the stored state with no warning. The vitest "refused like the Go grammar" list omits the Feb-30 case, so the drift is untested.
|
||||
|
||||
**Fix:**
|
||||
- Validate the date strictly: round-trip it through `new Date(Date.UTC(y, m-1, d))` and compare the parts.
|
||||
- Accept an optional `+` like Go, or make Go strict.
|
||||
- On a `null` parse, keep the raw value, render an "unrecognised value" note and disable the controls, or require an explicit reset, rather than showing "Inherit".
|
||||
- Add `unavailable_after:2027-02-30` to the TS malformed list.
|
||||
|
||||
### WR-25: [WR-C06] The element tests never exercise the host protocol; the tester and the image element have no tests
|
||||
|
||||
**File:** `../sm-seo-plugin/tests/elements/serp.test.ts:27-38`; no `robotstester.test.ts` or `image.test.ts` exists.
|
||||
|
||||
**Issue:**
|
||||
- The `preview()` helper appends the element, checks that `{op:'context'}` was emitted, then dispatches a `summer-result` itself. It never sets or clears `busy`, never sets `state=error`, and never models the host dropping or answering asynchronously.
|
||||
- The `inflight`/`queued`/`flush` logic in serp, robotstester and transfer is therefore untested, which is how CR-04 (CR-C01) shipped.
|
||||
- `golem15-seo-robotstester` (the queue, the `urlRequired` error, the context rebuild) and `golem15-seo-image` (upload id matching, error display, `safeImageURL`) have no tests at all.
|
||||
|
||||
**Fix:**
|
||||
- Add a small fake host to `tests/elements/` that mirrors `WidgetField.onAction`: synchronously set `busy`, resolve on a microtask, dispatch `summer-result`, then remove `busy`, with a variant that drops the first event.
|
||||
- Assert that a second request queued during `busy` is sent after `busy` clears, and that `state=error` unblocks the next request.
|
||||
- Add `robotstester.test.ts` and `image.test.ts`.
|
||||
|
||||
### Info
|
||||
|
||||
### IN-15: [IN-C01] The regex length refusal puts an untranslated English phrase into the Polish UI
|
||||
|
||||
**File:** `../sm-seo-plugin/models/redirect.go:156`
|
||||
|
||||
**Issue:** `Params{"error": "longer than 512 characters"}` is substituted into `redirects.regex_invalid`. The pl admin shows "To nie jest poprawne wyrażenie regularne: longer than 512 characters".
|
||||
|
||||
**Fix:** Add a `redirects.regex_too_long` phrase (en/pl) with `:max`, and use it here.
|
||||
|
||||
### IN-16: [IN-C02] A migration comment is stale
|
||||
|
||||
**File:** `../sm-seo-plugin/updates/202610270003_create_golem15_seo_settings.go:8-11`
|
||||
|
||||
**Issue:** The comment says "The robots.txt columns are added by a later migration", but robots.txt got its own table (`golem15_seo_robots`, migration 0008) and the settings table never gains such columns.
|
||||
|
||||
**Fix:** Delete the sentence.
|
||||
|
||||
### IN-17: [IN-C03] The SERP preview's template rendering is not single-pass like the Go renderer
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/serp.ts:44-53`
|
||||
|
||||
**Issue:** `renderTemplate` replaces placeholders one after another, so a title that contains `%site%` (or a site name that contains `%description%`) is expanded again. Go uses `strings.NewReplacer`, which runs in one pass. The preview can therefore differ from the served title, despite the comment saying "the way classes/templates.go does".
|
||||
|
||||
**Fix:** Use one pass: `tpl.replace(/%(title|description|site)%/g, (_, k) => vars[k] ?? '')`.
|
||||
|
||||
### IN-18: [IN-C04] `plural()` can throw on an unusual `ui-locale`
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/labels.ts:402-411`
|
||||
|
||||
**Issue:** `new Intl.PluralRules(locale)` is wrapped in `try`, but `new Intl.NumberFormat(locale)` in the same function is not. `uiLocaleOf` returns the first subtag of any `ui-locale` or `<html lang>` value, so an invalid tag (for example `1` or `x`) throws a `RangeError` during render and leaves the element blank.
|
||||
|
||||
**Fix:** Format the count through the existing `num()` helper, which already catches.
|
||||
|
||||
### IN-19: [IN-C05] Two test names do not describe what they assert
|
||||
|
||||
**File:** `../sm-seo-plugin/classes/owner_test.go:31`; `../sm-seo-plugin/classes/resolver_test.go:19-53`
|
||||
|
||||
**Issue:**
|
||||
- The owner case "double slashes are normalised first" asserts `ErrBadURL`, because `url.Parse("//pages//about")` reads `pages` as the host. Nothing is normalised.
|
||||
- The resolver "chains" subtests run `pick()` against layer orders the test defines itself. They do not exercise the resolver's real precedence, so a reordering in `resolver.go` would not fail them. The Postgres test covers the real path.
|
||||
|
||||
**Fix:** Rename the owner case. Either drop the synthetic chain loop or derive the chains from the resolver's actual candidate lists.
|
||||
|
||||
### IN-20: [IN-C06] The inner controls' accessible name is the machine field name
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/text.ts:85-88`; `../sm-seo-plugin/assets/src/elements/robotstxt.ts:174-177`
|
||||
|
||||
**Issue:** The host sets `field-name` to `props.field.name` (for example `seo_title`). The elements copy that into `aria-label`, so screen readers announce "seo_title" and the visible translated label is overridden.
|
||||
|
||||
**Fix:** Have the host pass a translated `field-label` attribute and use that, or rely on `aria-labelledby` pointing at the host's label id.
|
||||
|
||||
### IN-21: [IN-C07] The README test instructions skip the vitest suite, and the test script hides an empty run
|
||||
|
||||
**File:** `../sm-seo-plugin/README.md:227-236`; `../sm-seo-plugin/package.json:11`
|
||||
|
||||
**Issue:**
|
||||
- The Testing section lists `npm ci && npm run typecheck && bash scripts/check-dist.sh` but never `npm test`, so the element tests are not part of the documented gate.
|
||||
- `vitest run --passWithNoTests` makes a broken `include` glob pass silently.
|
||||
- `check-dist.sh` builds with whatever `node_modules` already exists, not the committed lockfile it claims to use.
|
||||
|
||||
**Fix:**
|
||||
- Add `npm test` to the README.
|
||||
- Drop `--passWithNoTests`.
|
||||
- Run `npm ci` unconditionally in `check-dist.sh`.
|
||||
|
||||
### IN-22: [IN-C08] Three partials are maintained twice
|
||||
|
||||
**File:** `../sm-seo-plugin/extension/_seo_effective.htm` / `controllers/rules/_seo_effective.htm`; `extension/_seo_status.htm` / `controllers/rules/_seo_status.htm`; `extension/_type_placeholders.htm` / `controllers/settings/_placeholders.htm`
|
||||
|
||||
**Issue:** These pairs are byte-identical copies, so a fix to one silently misses the other.
|
||||
|
||||
**Fix:** Keep one copy and resolve the other path to it, or add a test that asserts the pairs stay equal.
|
||||
|
||||
### IN-23: [IN-C09] The `disabled` branches in the widget elements are dead, and would be broken if reached
|
||||
|
||||
**File:** `../sm-seo-plugin/assets/src/elements/transfer.ts:265, 296, 378`; `robotstester.ts` `syncControls`; `styles.css:805-809`
|
||||
|
||||
**Issue:**
|
||||
- `WidgetField.vue` never sets a `disabled` attribute on widget elements (only `PluginField` does), so these branches never run.
|
||||
- If they did, `golem15-seo-transfer .seo-transfer__actions { display: flex }` would override the `hidden` attribute, and the Discard and Apply buttons would stay visible (though inert).
|
||||
|
||||
**Fix:** Remove the dead branches. If they are kept, add `[hidden] { display: none }` rules for the affected classes, as `.seo-robots__panel[hidden]` already has.
|
||||
|
||||
|
||||
_Reviewed: 2026-10-27T13:28:38Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
56
.planning/phases/32-seo-foundation/32-UAT.md
Normal file
56
.planning/phases/32-seo-foundation/32-UAT.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 32-seo-foundation
|
||||
source: [32-VERIFICATION.md]
|
||||
started: 2026-10-27T15:12:00.387Z
|
||||
updated: 2026-10-27T15:12:00.387Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: MVP user-flow walk-through in the demo admin, built from the CURRENT (unreleased) heads: framework 6472f28, sm-seo-plugin ad419e9, sm-journal-plugin a6fc7cd. Open a Journal post, then a category, then a tag; open the SEO tab; watch the SERP preview load; fill title, description and keyphrase; save; change the post's slug and save; open SEO > Redirects; open SEO > robots.txt, edit, and use the tester; open Settings > SEO and check the per-type tabs. Then GET /_seo/api/v1/resolve?url=<old and new post URL>.
|
||||
expected: |
|
||||
The SEO tab shows on posts, categories and tags. The SERP preview leaves its skeleton and shows the card, counters and duplicate warnings. Saving shows values back. The slug change shows the redirects.auto_created notice and a 301 old->new row appears. The robots tester answers allowed/blocked with the matching line. Settings shows General, one tab per content type, Indexing, Addresses, Verification. Resolve answers redirect 301 for the old URL and the effective SEO with sources for the new one. Also check the E1 320px and E2 360px layout items above.
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. MVP user-flow walk-through in the demo admin, built from the CURRENT (unreleased) heads: framework 6472f28, sm-seo-plugin ad419e9, sm-journal-plugin a6fc7cd. Open a Journal post, then a category, then a tag; open the SEO tab; watch the SERP preview load; fill title, description and keyphrase; save; change the post's slug and save; open SEO > Redirects; open SEO > robots.txt, edit, and use the tester; open Settings > SEO and check the per-type tabs. Then GET /_seo/api/v1/resolve?url=<old and new post URL>.
|
||||
expected: The SEO tab shows on posts, categories and tags. The SERP preview leaves its skeleton and shows the card, counters and duplicate warnings. Saving shows values back. The slug change shows the redirects.auto_created notice and a 301 old->new row appears. The robots tester answers allowed/blocked with the matching line. Settings shows General, one tab per content type, Indexing, Addresses, Verification. Resolve answers redirect 301 for the old URL and the effective SEO with sources for the new one. Also check the E1 320px and E2 360px layout items above.
|
||||
result: [pending]
|
||||
|
||||
### 2. MCP session from a real Claude client linked to an admin with golem15.seo.manage: get_url_seo for a post URL; set_url_seo noindex by URL; apply_batch with dry_run true (include a redirect that would create a loop), then without the loop; undo_batch from history.
|
||||
expected: The dry run returns the diff and conflicts and writes nothing. The loop is refused with redirect_loop. The applied batch is undone as a whole. A read-only grant lists only GET tools.
|
||||
result: [pending]
|
||||
|
||||
### 3. Release decision for the post-review fixes: framework v0.5.2 (7 code commits after v0.5.1, unpushed), sm-seo-plugin v0.1.1 (20 fix commits after v0.1.0 plus a new migration, unpushed), sm-journal-plugin v0.2.6 (3 commits after v0.2.5, unpushed), then the demo bump.
|
||||
expected: Jakub decides to release or hold. Until then, the released and mounted versions carry the 4 critical findings listed under 'Goal items that depend on unreleased fixes'.
|
||||
result: [pending]
|
||||
|
||||
### 4. Confirm the meaning chosen for pact.RecordSaved.Locale in WR-02: the primary tag of the request locale, not the default content locale.
|
||||
expected: Accept, or ask for the content-locale meaning before v0.5.2 freezes it
|
||||
result: [pending]
|
||||
|
||||
### 5. Review the logic of the fixes the fixer flagged: WR-08 (exact path?query wins over path-only), WR-09 (address-change redirect validation and in-memory loop simulation), WR-19 (generation job routes an unseen URL change through AddressChanged), WR-22 (coversSQL as the SQL form of exactMatches).
|
||||
expected: The matching and redirect-graph semantics are confirmed correct. DB tests pass: TestExactRedirectKeys, TestAddressChangeGuards, TestGenerateEntries, TestCoverAndChainFilters.
|
||||
result: [pending]
|
||||
|
||||
### 6. Decide WR-03 (skipped): a failing extension Load after a successful save rolls back the whole save, and show fails the whole record.
|
||||
expected: Either accept it as a known limitation for v0.5.x, or schedule an 'extension unavailable' state (a todo or a Phase 33 or later item).
|
||||
result: [pending]
|
||||
|
||||
### 7. Judgment-tier prohibitions of 32-06: (a) nothing was pushed, tagged, deployed or posted without Jakub's go-ahead, and no version was hard-coded before the checkpoint; (b) the changelog token was never printed, copied or minted.
|
||||
expected: Confirm, based on 32-06-release-log.md. The log records 'Task 4 answer (Jakub, 2026-10-27)' before step 1 and says the token was read by the script and never printed.
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 7
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 7
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
264
.planning/phases/32-seo-foundation/32-VERIFICATION.md
Normal file
264
.planning/phases/32-seo-foundation/32-VERIFICATION.md
Normal file
@@ -0,0 +1,264 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
verified: 2026-10-27T15:09:40Z
|
||||
status: human_needed
|
||||
score: 5/5 roadmap success criteria verified (plan must-haves: all automatable truths verified; 2 layout backstops and the UI-SPEC visual states routed to human)
|
||||
covered_files:
|
||||
- .planning/phases/32-seo-foundation/32-01-PLAN.md
|
||||
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md
|
||||
- .planning/phases/32-seo-foundation/32-02-PLAN.md
|
||||
- .planning/phases/32-seo-foundation/32-02-SUMMARY.md
|
||||
- .planning/phases/32-seo-foundation/32-03-PLAN.md
|
||||
- .planning/phases/32-seo-foundation/32-03-SUMMARY.md
|
||||
- .planning/phases/32-seo-foundation/32-04-PLAN.md
|
||||
- .planning/phases/32-seo-foundation/32-04-SUMMARY.md
|
||||
- .planning/phases/32-seo-foundation/32-05-PLAN.md
|
||||
- .planning/phases/32-seo-foundation/32-05-SUMMARY.md
|
||||
- .planning/phases/32-seo-foundation/32-06-PLAN.md
|
||||
- .planning/phases/32-seo-foundation/32-06-SUMMARY.md
|
||||
- admin/src/components/form/fields/WidgetField.vue
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/deferred.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/form_extensions.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/record_events.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/content.go
|
||||
- modules/pact/record_events.go
|
||||
- modules/signpost/content.go
|
||||
- modules/signpost/signpost.go
|
||||
- scripts/apisurface/main.go
|
||||
- scripts/check-phase32.sh
|
||||
covered_digest: "v2:sha256:3d160533886964f919d0b009ef28fe87e9b314c89b991e50bdff020197a3e7b3"
|
||||
behavior_unverified: 2
|
||||
overrides_applied: 0
|
||||
behavior_unverified_items:
|
||||
- truth: "E1 counter line at 320px container width: status text and count wrap onto two lines without overlap (32-06 backstop)"
|
||||
test: "Open a Journal post's SEO tab in the admin, narrow the form column to about 320px, type a long SEO title"
|
||||
expected: "The status text and the character/pixel count wrap onto two lines and do not overlap"
|
||||
why_human: "tests/elements/text.test.ts asserts the CSS contract (flex, wrap, gap, no fixed width) under happy-dom, which has no layout engine; actual wrapping is not observable in the test"
|
||||
- truth: "E2 at 360px: the two segmented groups stack and no segment label is clipped in either language (32-06 backstop)"
|
||||
test: "Open the SEO tab's robots control at about 360px width in en and in pl"
|
||||
expected: "Index and Follow groups stack on separate lines; no segment label is clipped"
|
||||
why_human: "tests/elements/robots.test.ts checks the wrap rules, not rendered geometry"
|
||||
human_verification:
|
||||
- test: "MVP user-flow walk-through in the demo admin, built from the CURRENT (unreleased) heads: framework 6472f28, sm-seo-plugin ad419e9, sm-journal-plugin a6fc7cd. Open a Journal post, then a category, then a tag; open the SEO tab; watch the SERP preview load; fill title, description and keyphrase; save; change the post's slug and save; open SEO > Redirects; open SEO > robots.txt, edit, and use the tester; open Settings > SEO and check the per-type tabs. Then GET /_seo/api/v1/resolve?url=<old and new post URL>."
|
||||
expected: "The SEO tab shows on posts, categories and tags. The SERP preview leaves its skeleton and shows the card, counters and duplicate warnings. Saving shows values back. The slug change shows the redirects.auto_created notice and a 301 old->new row appears. The robots tester answers allowed/blocked with the matching line. Settings shows General, one tab per content type, Indexing, Addresses, Verification. Resolve answers redirect 301 for the old URL and the effective SEO with sources for the new one. Also check the E1 320px and E2 360px layout items above."
|
||||
why_human: "Visual rendering and the real WidgetField host. CR-04 showed that the fake-host element tests missed a real-admin deadlock in the released v0.5.1 + v0.1.0, so the fix (framework d660cd0 and plugin 3ecbd1f) has never been observed in a real browser."
|
||||
- test: "MCP session from a real Claude client linked to an admin with golem15.seo.manage: get_url_seo for a post URL; set_url_seo noindex by URL; apply_batch with dry_run true (include a redirect that would create a loop), then without the loop; undo_batch from history."
|
||||
expected: "The dry run returns the diff and conflicts and writes nothing. The loop is refused with redirect_loop. The applied batch is undone as a whole. A read-only grant lists only GET tools."
|
||||
why_human: "External client (32-VALIDATION.md manual-only row). TestSEOToolsCatalog and TestMCPSetURLSeoTracer prove the catalog and the routes, not a live client session."
|
||||
- test: "Release decision for the post-review fixes: framework v0.5.2 (7 code commits after v0.5.1, unpushed), sm-seo-plugin v0.1.1 (20 fix commits after v0.1.0 plus a new migration, unpushed), sm-journal-plugin v0.2.6 (3 commits after v0.2.5, unpushed), then the demo bump."
|
||||
expected: "Jakub decides to release or hold. Until then, the released and mounted versions carry the 4 critical findings listed under 'Goal items that depend on unreleased fixes'."
|
||||
why_human: "Pushing and tagging need Jakub's go-ahead (CLAUDE.md)."
|
||||
- test: "Confirm the meaning chosen for pact.RecordSaved.Locale in WR-02: the primary tag of the request locale, not the default content locale."
|
||||
expected: "Accept, or ask for the content-locale meaning before v0.5.2 freezes it"
|
||||
why_human: "Public API semantics inside a patch release; the fixer flagged it for human confirmation."
|
||||
- test: "Review the logic of the fixes the fixer flagged: WR-08 (exact path?query wins over path-only), WR-09 (address-change redirect validation and in-memory loop simulation), WR-19 (generation job routes an unseen URL change through AddressChanged), WR-22 (coversSQL as the SQL form of exactMatches)."
|
||||
expected: "The matching and redirect-graph semantics are confirmed correct. DB tests pass: TestExactRedirectKeys, TestAddressChangeGuards, TestGenerateEntries, TestCoverAndChainFilters."
|
||||
why_human: "Semantic correctness of matching and graph rules beyond what the tests encode (32-REVIEW-FIX.md asks for a human review)."
|
||||
- test: "Decide WR-03 (skipped): a failing extension Load after a successful save rolls back the whole save, and show fails the whole record."
|
||||
expected: "Either accept it as a known limitation for v0.5.x, or schedule an 'extension unavailable' state (a todo or a Phase 33 or later item)."
|
||||
why_human: "Needs a design decision (32-REVIEW-FIX.md, Skipped Issues)."
|
||||
- test: "Judgment-tier prohibitions of 32-06: (a) nothing was pushed, tagged, deployed or posted without Jakub's go-ahead, and no version was hard-coded before the checkpoint; (b) the changelog token was never printed, copied or minted."
|
||||
expected: "Confirm, based on 32-06-release-log.md. The log records 'Task 4 answer (Jakub, 2026-10-27)' before step 1 and says the token was read by the script and never printed."
|
||||
why_human: "unverified-prohibition, human review recommended. The verifier's verdict (consistent with the release log) is a non-authoritative LLM judgment."
|
||||
---
|
||||
|
||||
# Phase 32: SEO foundation Verification Report
|
||||
|
||||
**Phase Goal:** As a site editor on any SummerCMS application, I want per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, so that every site ships correct, editable SEO without per-site work. The framework gains only generic seams; the SEO code lives in a new shared plugin.
|
||||
**Verified:** 2026-10-27T15:09:40Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No (initial verification)
|
||||
**Mode:** mvp
|
||||
|
||||
**MVP format note:** `user-story.validate` answers `valid: false` ("Story must include ', I want to [capability],'"). The goal reads "I want per-record and global SEO …" with no "to". The narrowing rule was still applied, using the `[outcome]` clause ("every site ships correct, editable SEO without per-site work"), because the roadmap SCs give a precise contract. To meet the canonical regex, reword the goal through `/msd mvp-phase 32`, for example "I want to manage per-record and global SEO …".
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
| # | Step of the story | Expected | Evidence in codebase | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | The editor opens a record of any registered content type | The SEO tab is there, and the content plugin does not import the SEO plugin | `extensions.go:27` builds one form extension per `reg.ContentTypes()`; Journal declares 3 `Content: &pact.ContentDescriptor` (`classes/menu_item_types.go:124,148,182`); marker stage: "journal: imports neither sm-seo-plugin nor sm-sitemap-plugin"; demo `TestSEOTabMounted` passes for posts (tabs Edit, Manage, Categories, SEO), categories and tags | ✓ |
|
||||
| 2 | The editor edits per-record and global SEO | Values stored per (model type, record id, locale); settings with per-type tabs | `TestSEOTabTracer`, `TestSEOTab`, `TestSettingsTypeTabs`, `TestSEOTabPermission` pass (Docker) | ✓ (visual: human) |
|
||||
| 3 | The frontend asks for the effective SEO of a URL | URL rule > record > global > derived, with sources | `GET /_seo/api/v1/resolve` → `Resolver.Resolve` (`controllers/api/resolve.go:31`); `TestResolverPrecedence` (unit, all layer masks, plus DB) passes | ✓ |
|
||||
| 4 | The editor changes a slug | 301 old→new, chains flattened, loops refused | `TestSlugChange301`, `TestRedirectGraph` (back-and-forth), `TestAddressChangeFromEvent`, `TestLoopRefused` (admin, MCP, batch, import) pass | ✓ |
|
||||
| 5 | The editor manages robots.txt and the AI bots | Served file, tester, staging lock, 11 bots incl. Claude-User | `TestRobotsServed`, `TestRobotsTester`, `TestRobotsMatcherGoogleExamples`, `classes/bots.go:27-37` | ✓ (tester in a real admin: human, CR-04) |
|
||||
| 6 | An agent does all of it through MCP | One tool per admin operation, dry run, undo | 29 tools in `mcp.go`; `TestEveryAdminOperationHasTool` derives the operations from the admin controllers; `TestBatch`, `TestUndo`, `TestConcurrentUndo`; demo `TestSEOToolsCatalog` passes | ✓ (live client: human) |
|
||||
| Outcome | "every site ships correct, editable SEO without per-site work" | The plugin mounts by `summer.yaml` alone; the framework stays generic | The demo mounts `golem15.seo` with no app code beyond the mount (`9451b31`, generated `plugins.gen.go`); marker: no SEO names under `modules/` or `docs/`. Frontend emission of the tags (Nuxt layer) is Phase 33 (D-97) by design | ✓ (backend side) |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (Roadmap Success Criteria)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| SC-1 | A plugin can add a field between two fields of another plugin's admin form (A, B plus C after A renders A, C, B), and add a tab between two existing tabs; unknown anchors, duplicate names and anchor cycles stop the boot with a clear error | ✓ VERIFIED | `compileFormExtensions(reg, plugins)` is wired at `modules/cabana/http.go:120`. I ran the following in this session: `TestFormExtensionPlacement` (subtests `C_after_A_gives_A_C_B`, `a_new_tab_between_two_tabs`, `a_tab-less_form_gains_the_default_tab_then_the_new_tab`), `TestFormExtensionBootErrors` (`unknown_field_anchor`, `unknown_tab_anchor`, `duplicate_field_name_*`, `duplicate_tab_key_*`, `an_anchor_cycle`, `an_anchor_outside_Requires()`, `a_fileupload_field`, zone refusals) and both matrices: all pass. `TestFormExtensionTracer` (Postgres) asserts the schema `[a c b]` and that the owner row is unchanged. The SPA backstop (FormView.test.ts, "form extensions in compiled order (UI-SPEC C1, D-91)") passes. |
|
||||
| SC-2 | Journal posts, categories and tags show the SEO tab without importing `sm-seo-plugin`, and the frontend gets the effective SEO of any URL with the precedence URL rule > record > global > derived | ✓ VERIFIED | Demo `TestSEOTabMounted` (tagged) passes for posts, categories and tags. The marker stage passes the Journal import check. `TestResolverPrecedence` enumerates every layer mask per value. Note: per P-14 (32-03-PLAN), the per-type and general templates form the "global" layer above content, while the site-wide default description and default image are last-resort fallbacks after content. The approved UI-SPEC copy `settings.default_description_comment` states this. It is a documented reading of D-02, not a deviation. |
|
||||
| SC-3 | Changing a record's slug creates a 301 and flattens existing chains; redirect loops are refused from the admin, MCP and imports | ✓ VERIFIED | Behavioral: `TestSlugChange301` (auto_created notice, 301 old→new, /pages/x flattened, pl locale), `TestAddressChangeFromEvent` (writers outside the admin), the back-and-forth case in `classes/redirect_graph_test.go:87` and `redirects_test.go:293`, and `TestLoopRefused` (admin 422 redirects.loop with nothing saved; MCP `redirect_loop`; MCP batch; CSV import preview `row_loop` and apply `import_refused`). Post-review hardening: `TestAddressChangeGuards` and `TestConcurrentRedirectWrites`. All pass. |
|
||||
| SC-4 | Every SEO operation in the admin is available as an MCP tool, and a batch change can be previewed with dry-run and undone from history | ✓ VERIFIED | `TestEveryAdminOperationHasTool` derives the required operations from the admin controllers' config, actions and extensions (at least 20 operations) and checks that each one maps to a declared tool bound to a registered `/_seo/api/v1/` route. `TestMCPReadToolsAreGET` passes. All 29 management handlers go through `manage()` (401 without an admin, 403 without `golem15.seo.manage`). `TestBatch` checks that a dry run leaves a snapshot of every table unchanged and that a loop refuses the whole batch. `TestUndo` covers stale, loop, gone and batch cases, and `TestConcurrentUndo` passes. |
|
||||
| SC-5 | The framework release with the seams is tagged, and the new code has unit tests, delivered in the phase's last plan | ✓ VERIFIED | `v0.5.1` (tag object aa6e0a5, commit 15b0d0d) is on origin (`git ls-remote` confirmed); `v0.1.0` (9cf0447) and `v0.2.5` are on origin; the demo go.mod requires all three and its submodules are at those tags. The 32-06 unit tests exist and pass: cabana matrices, `TestRecordEventDiff`, signpost `TestContent`, plugin `./classes` (resolver, graph, normalise, robots, templates, CSV, owner, issues) and 77 vitest element tests. |
|
||||
|
||||
**Score:** 5/5 roadmap success criteria verified (0 present-but-behavior-unverified among the SCs).
|
||||
|
||||
### Plan must-haves (summary)
|
||||
|
||||
| Plan | Truths | Result |
|
||||
|---|---|---|
|
||||
| 32-01 (D-91 seam) | 15 incl. 1 backstop | All verified by `TestFormExtension*` (unit and Postgres) and the FormView C1 backstop. "admin/src unchanged by this plan" holds at v0.5.1 (`git diff v0.5.0 v0.5.1 -- admin/src` is empty). WidgetField.vue changed later in the CR-04 fix, outside this plan. |
|
||||
| 32-02 (D-92, D-93, Journal) | 11 | Verified: `TestRecordEvents` (create, update, translated slug, rollback, failing listener, reorder, child write, hasMany link and unlink, deferred children, delete, bulk delete), `TestRecordSavedDescribesNewURL`, `TestAPIRecordEvents`, `TestContentDescriptors(Matrix)`, `TestJournalTagType(Pages)`, `TestTranslatedSlug`. |
|
||||
| 32-03 (plugin core) | 32 | Server-side truths verified (`TestSEOTabTracer`, `TestSEOTab`, `TestSEOTabPermission`, `TestSettingsTypeTabs`, `TestResolverPrecedence`, `TestHistoryRecordsAdminWrite`, `TestEntryDeletedWithRecord`, `TestGenerateEntries`, `TestRuleFormPreview`). The UI-SPEC visual states (skeletons, callouts, badges, truncation) go to the human walk-through. |
|
||||
| 32-04 (redirects, 404, robots) | 21 | Verified: `TestRedirectEngine`, `TestExactRedirectKeys`, `TestNormalize(Matrix)`, `TestStrictURL`, `TestOneHop`, `TestNotFoundLog`, `TestRobotsServed`, `TestRobotsTester`, `TestRobotsScreen`, `TestVerificationFile`, `TestCoverAndChainFilters`. No root route for verification files (`routes.go:36-41`). The staging lock reads only `cfg.Environment()` (`classes/resolver.go:143`). The UI list and screen visuals go to human. |
|
||||
| 32-05 (MCP, batch, CSV, gate) | 14 | Verified: `TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues` (8 kinds in `classes/issues.go:15-23`), `TestCSVTransfer`, `TestBatch`, `TestUndo`. The demo mount was later done for real (`9451b31`). `check-phase32.sh` has every stage. |
|
||||
| 32-06 (tests, release) | 8 plus 8 backstops | 6 backstops verified by passing vitest (E1 thresholds, E2 grammar, E3 title and truncation, E4 lint per rule, E6 gating, E3 stem matching). 2 layout backstops (E1 320px, E2 360px) are ⚠️ PRESENT_BEHAVIOR_UNVERIFIED. Release truths verified against git and the release log. |
|
||||
|
||||
### Prohibitions
|
||||
|
||||
| Prohibition | Tier | Disposition |
|
||||
|---|---|---|
|
||||
| No breaking framework API change (32-01, 32-02, 32-06) | test | ✓ `check-phase32.sh --additive` passes (HEAD vs v0.5.1: +0 lines in cabana, pact, signpost). The release log records v0.5.1 vs v0.5.0: +2, +87, +8 lines, nothing removed. The self-test proves the stage refuses removals, signature changes and new interface methods. `--compat`: 23 siblings OK. |
|
||||
| Extensions never write owner columns, translation rows or attachments | test | ✓ `TestFormExtensionTracer` (owner row asserted), `a_fileupload_field` boot error |
|
||||
| Framework and docs name no SEO concept or consuming app (D-90) | test | ✓ marker stage |
|
||||
| No record event for an uncommitted write | test | ✓ `TestRecordEvents/rollback` |
|
||||
| Content plugins never import sm-seo-plugin; SEO data not in content tables | test | ✓ marker stage; `golem15_seo_entries` table |
|
||||
| Resolver never answers unpublished SEO and never uses the Host header | test | ✓ `a_draft_is_not_found`; no `r.Host` use in the plugin |
|
||||
| Non-production lock not switchable from admin or MCP | test | ✓ `Staging()` reads only the environment; `the_staging_lock` |
|
||||
| Loops never saved (admin, MCP, import) | test | ✓ `TestLoopRefused` |
|
||||
| /robots.txt never 5xx | test | ✓ `TestRobotsServed` (see IN-13 in Anti-Patterns) |
|
||||
| Regex capture or query never makes an internal target external | test | ✓ `redirects_test.go:314-328`, `:423` |
|
||||
| Batch or import never partial | test | ✓ `TestBatch`, `TestLoopRefused/import` |
|
||||
| Read grant can't write; every management route needs golem15.seo.manage | test | ✓ `TestMCPReadToolsAreGET`; all 29 handlers wrap `manage()` |
|
||||
| Undo never silently overwrites a newer change | test | ✓ `TestUndo` (undo_stale), `TestConcurrentUndo` |
|
||||
| Nothing pushed, tagged or posted without Jakub's go-ahead | judgment | ⚠️ flagged (unverified-prohibition, human review recommended). The LLM verdict is that the release log is consistent with it. |
|
||||
| Changelog token never printed, copied or minted | judgment | ⚠️ flagged (same) |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|---|---|---|
|
||||
| `modules/pact/capabilities.go` (`Form *AdminFormExtension`) | ✓ VERIFIED | line 436; types at line 518 and onward |
|
||||
| `modules/cabana/form_extensions.go` | ✓ VERIFIED | 1591 lines; compile, placement, save and hydrate |
|
||||
| `modules/pact/record_events.go`, `content.go` | ✓ VERIFIED | RecordSaved, RecordDeleted, ContentDescriptor |
|
||||
| `modules/cabana/record_events.go` | ✓ VERIFIED | `EmitRecordSaved`; emitted at `crud.go:971` inside the save transaction (after commit) |
|
||||
| `modules/signpost/content.go` | ✓ VERIFIED | `MatchPath` and the rest; `TestContent` |
|
||||
| `../sm-journal-plugin/classes/content_descriptors.go` | ✓ VERIFIED | 3 descriptors wired in `menu_item_types.go` |
|
||||
| `../sm-seo-plugin/*` (plugin.go, extensions.go, classes/resolver.go, migrations, assets/dist/seo.js, mcp.go, operations.go, classes/batch.go, classes/undo.go, classes/redirect_graph.go, classes/addresses.go, classes/robots.go) | ✓ VERIFIED | All present and substantive; `check-dist.sh`: "assets/dist matches a fresh build" |
|
||||
| `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` and the STACK.md row | ✓ VERIFIED | D-99 |
|
||||
| `scripts/check-phase32.sh`, `scripts/apisurface/main.go` | ✓ VERIFIED | `--all` exit 0 in this session |
|
||||
| `32-05-demo-mount.patch`, `../sm-demo-app/seo_mcp_test.go` | ✓ VERIFIED | applied in `9451b31`; `TestSEOToolsCatalog` passes |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|---|---|---|---|
|
||||
| cabana `http.go` | `form_extensions.go` | `compileFormExtensions(reg, plugins)` (line 120) | ✓ WIRED |
|
||||
| cabana `crud.go` | the extension's Save | `saveFormExtensions` in the save transaction (line 965) → `ext.def.Save(ctx, pact.AdminFormSave{…})` (form_extensions.go:1394) | ✓ WIRED |
|
||||
| cabana `crud.go` | `record_events.go` | `emitRecordSaved(ctx, s.app, tx, …)` (line 971) | ✓ WIRED |
|
||||
| docs/backend/admin-controllers.md | example_form_extension_test.go | `src=` snippet (line 561); docs stage passes | ✓ WIRED |
|
||||
| Journal `controllers/api/posts.go` | `cabana.EmitRecord*` | lines 237, 239, 305, 307, 349 | ✓ WIRED |
|
||||
| Journal `menu_item_types.go` | `pact.ContentDescriptor` | `Content: &pact.ContentDescriptor` ×3 | ✓ WIRED |
|
||||
| seo `extensions.go` | signpost `ContentTypes()` | line 27 | ✓ WIRED |
|
||||
| seo `controllers/api/resolve.go` | `Resolver.Resolve` | line 31 | ✓ WIRED |
|
||||
| seo `listeners.go` | `*pact.RecordDeleted` / `*pact.RecordSaved` | lines 23, 30; `AddressChanged` at line 65 | ✓ WIRED |
|
||||
| seo `extensions.go` | `AddressChanged` | line 337 (in the save transaction) | ✓ WIRED |
|
||||
| seo `routes.go` | `/robots.txt` | line 33, throttled, with no backend middleware | ✓ WIRED |
|
||||
| seo `mcp.go` | `routes.go` | every tool's Method+Path is a registered route (parity test) | ✓ WIRED |
|
||||
| `check-phase32.sh` | apisurface / Journal worktree compat | additive and compat stages pass | ✓ WIRED |
|
||||
| `sm-demo-app/go.mod` | sm-seo-plugin | `sm-seo-plugin v0.1.0`; submodule at b19cd06 (v0.1.0) | ✓ WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|---|---|---|---|---|
|
||||
| SEO tab fields | entry values | extension Load/Save on `golem15_seo_entries` in the owner transaction | yes (`TestSEOTabTracer`) | ✓ FLOWING |
|
||||
| `/resolve` answer | effective SEO plus sources | rules, entries, type settings, settings and signpost Describe (DB) | yes (`TestResolverPrecedence` DB part) | ✓ FLOWING |
|
||||
| E3 SERP preview | context and duplicates | widget actions → plugin `Run` → DB | yes in tests through a fake host; real host per the CR-04 fix (human) | ✓ FLOWING (real-host: human) |
|
||||
| MCP tools | all SEO data | `/_seo/api/v1` handlers → classes.Service → DB | yes (`TestManagementAPI`) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks and Probe Execution (run in this session)
|
||||
|
||||
| Check | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| Seam unit tests | `go test -short ./modules/cabana ./modules/signpost ./modules/pact -run 'TestFormExtension\|TestRecordEventDiff\|TestRecordEventLocale\|TestContent'` | ok | ✓ PASS |
|
||||
| Seam Postgres tests | `go test ./modules/cabana -run 'TestFormExtensionTracer\|…Save\|…Required\|…Upload\|TestRecordEvents'` | 5 top-level, 19 subtests PASS | ✓ PASS |
|
||||
| Journal | `go test ./... -run 'TestContentDescriptors\|TestJournalTagType\|TestTranslatedSlug\|TestAPIRecordEvents\|TestRecordSavedDescribesNewURL'` | 8 PASS | ✓ PASS |
|
||||
| sm-seo-plugin full suite (Docker) | `go test -count=1 ./...` | 55 top-level PASS, 0 FAIL | ✓ PASS |
|
||||
| Plugin elements | `npm run test` + typecheck + `scripts/check-dist.sh` | 77/77; dist matches | ✓ PASS |
|
||||
| Admin SPA | `npm --prefix admin run test -- FormView WidgetField` | 84/84 | ✓ PASS |
|
||||
| Demo mount | `go test -tags summercms_mcp -run 'TestGoModRequiresTags\|TestSEOTabMounted\|TestSEOToolsCatalog' .` | 3 PASS | ✓ PASS |
|
||||
| Phase gate (probe) | `bash scripts/check-phase32.sh --all` | exit 0, "Phase 32 gate passed" (self-test, go, plugins, consumers, admin, docs, marker, additive vs v0.5.1, compat across 23 siblings, docker) | ✓ PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
REQUIREMENTS.md has no IDs for this phase. The plans declare 51 IDs, and each is accounted for against 32-DECISIONS-MAP.md:
|
||||
|
||||
| IDs | Source plans | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| SC-1..SC-5 | 32-01..32-06 | ✓ SATISFIED | table above |
|
||||
| D-90, D-91, D-92, D-93, D-98, D-100, D-101, D-104, D-106 | 32-01, 32-02, 32-06 | ✓ SATISFIED | seams, marker, additive and compat, boot errors (zone, fileupload), settings `AdminSingleton` (`controllers/settings.go:134`) |
|
||||
| D-94, D-95, D-96, D-102, D-58 | 32-02, 32-03 | ✓ SATISFIED | D-95's Phase 32 part: `Requires()` returns nil (`plugin.go:51`); Journal descriptors, journal-tag and API events; translated slug |
|
||||
| D-01..D-09, D-13, D-15, D-21, D-23 | 32-03 | ✓ SATISFIED | entries table, rules, settings, resolver, robots grammar, the one permission, SERP and counters (real-host rendering: human) |
|
||||
| D-05, D-06, D-08, D-16..D-20, D-20a/b/c, D-22, D-24, D-25, D-99, D-103, D-105 | 32-04 | ✓ SATISFIED | redirect engine, 404 log, normalise, strict, robots and tester, bots incl. Claude-User, verification API only |
|
||||
| D-10..D-14, D-19 | 32-05 | ✓ SATISFIED | 29 tools, owner resolution, batch, undo, CSV, issues |
|
||||
| Q-01 | 32-06 | ✓ SATISFIED | patch tag v0.5.1 computed at release |
|
||||
|
||||
Orphaned: none. Every map ID with a Phase 32 column entry appears in a plan's `requirements`. Phase-33-only IDs (D-30..D-34, D-40..D-46, D-50..D-57, D-70..D-72, D-80..D-86, D-97) are out of scope by design.
|
||||
|
||||
### Goal items that depend on unreleased fixes
|
||||
|
||||
The released and mounted set is framework v0.5.1, sm-seo-plugin v0.1.0, Journal v0.2.5 and sm-demo-app 9451b31. Everything above was verified at the local heads, which carry the review fixes (unpushed: framework 16 commits after v0.5.1, of which 7 are code; sm-seo-plugin 21 after v0.1.0; Journal 3 after v0.2.5). In the released versions:
|
||||
|
||||
| Goal item | Finding | Released behavior | Fixed in (unreleased) |
|
||||
|---|---|---|---|
|
||||
| SERP preview (D-09, E3) and robots.txt tester (D-22, E5) in the real admin | CR-04 | Deadlock: the skeleton shows forever and the tester never answers | framework d660cd0 (WidgetField.vue and the dist), plugin 3ecbd1f (ActionQueue with retry). Either release alone fixes it. |
|
||||
| Exact redirects (D-16, D-17, SC-3 runtime) | CR-03 | Sources with a trailing slash or a mixed-case query never fire | plugin 2d81c26 plus the new migration `202610280001_add_golem15_seo_redirects_source_key` |
|
||||
| Loops refused on every path (SC-3, D-19) | WR-09, WR-10 | Address-change 301s skip validation and the loop simulation; the loop check races | plugin cb614cb, c2f136a |
|
||||
| Slug change through Journal API or MCP fires the event (SC-3, D-102) | WR-04, WR-06 | A slug can commit with no RecordSaved on a failing association; auto-created tags fire nothing | Journal 62c75b1, a6fc7cd (WR-05: 7a03640) |
|
||||
| Generation job keeps the 301 (SC-3) | WR-19 | The job can overwrite entry.URL and swallow an address change | plugin d8aa989 |
|
||||
| Undo (SC-4) | WR-11 | Concurrent undo is not idempotent | plugin d20f14f |
|
||||
| MCP/import validation (SC-4) | WR-16, WR-17, WR-18, WR-23 | Invalid values are silently coerced or answer 500 | plugin c8e0961, 24e6f38, cff2d29, 4ca26e9 |
|
||||
| D-91 seam generality (SC-1) | CR-01, CR-02, WR-07 | `required` extension fields break host saves; uploads ignore extension visibility; hidden fields' relation and partial routes stay reachable | framework 676ca53, 5471b66, 0e187cf. The SEO plugin itself is not exposed: it declares no `required` fields, and its `seoimage` Upload checks `golem15.seo.manage` itself (32-03 deviation 6). |
|
||||
| D-92 coverage | WR-01, WR-02 | hasMany link/unlink and deferred children fire no events; Locale is inconsistent | framework 468af06, 1583651 |
|
||||
|
||||
The additive stage shows +0 exported lines since v0.5.1, so plugin v0.1.1 needs no new framework API. However, the CR-02, WR-01 and WR-07 fixes reach consumers only through v0.5.2.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Pattern | Severity | Impact |
|
||||
|---|---|---|---|
|
||||
| (all phase files in 4 repos) | TBD/FIXME/XXX | none found | — |
|
||||
| sm-seo-plugin, cabana | 23 open info findings (IN-01..IN-23, 32-REVIEW-DISPOSITION.md) | ℹ️ Info | Not blocking. Notable: IN-13 (`/robots.txt` serves allow-all without the noindex header when the service is nil, even outside production; this is an edge of the D-22 lock in a boot-failure state); IN-05 (Journal `requestLocale` drops the region, so translated slugs in region-coded locales never match, which narrows D-58); IN-09 (transfer token has no expiry or user binding) |
|
||||
| modules/cabana/crud.go:982 | WR-03 skipped | ⚠️ Warning | A failing extension Load turns a committed save into a rollback and a 500 (design decision pending) |
|
||||
| 32-VALIDATION.md | stale status | ℹ️ Info | Still `status: draft`, with 32-06-T4 and T5 rows "pending" although both completed and `--all` is green. Update it to validated. |
|
||||
| sm-seo-plugin README | WR-15 documentation-only fix | ℹ️ Info | The public throttle is site-wide unless the SSR host is in `http.trusted_proxies`; a deploy concern for Phase 33 layer integration |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
1. **MVP user-flow walk-through on the current heads.** Covers the SEO tab, SERP preview, slug-change 301, robots tester and settings tabs, plus the E1 320px and E2 360px layout. CR-04 proved that the fake-host tests can miss a real-admin failure.
|
||||
2. **Live MCP client session.** get/set by URL, dry-run batch with a loop, apply, undo the batch, check the read-only grant.
|
||||
3. **Release decision for the fixes** (v0.5.2, v0.1.1, v0.2.6, then the demo bump). The released set carries the 4 critical findings.
|
||||
4. **WR-02**: confirm the meaning of `RecordSaved.Locale`.
|
||||
5. **Logic review** of WR-08, WR-09, WR-19 and WR-22.
|
||||
6. **WR-03**: design decision.
|
||||
7. **Judgment-tier prohibitions** (release go-ahead, token handling): confirm against the release log.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No blocking gaps. Each roadmap success criterion is backed by passing behavioral tests run in this session against the local heads of the four repositories, and the phase gate `check-phase32.sh --all` passes again (exit 0). The framework carries only generic seams (marker and additive stages), and the SEO code lives in sm-seo-plugin. Status is `human_needed` for three reasons:
|
||||
- The visual and real-host behavior of the elements has never been observed in a browser, which matters because CR-04 slipped past the element tests.
|
||||
- A live MCP client session is still to be run.
|
||||
- The released versions (v0.5.1, v0.1.0, v0.2.5, mounted in the demo) predate 28 review fixes, including 4 critical ones. The goal is met in the codebase, but sites on the released tags get a broken SERP preview and robots tester and dead trailing-slash exact redirects until the fixes are released.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-27T15:09:40Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
23
.planning/phases/32-seo-foundation/deferred-items.md
Normal file
23
.planning/phases/32-seo-foundation/deferred-items.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Phase 32 deferred items
|
||||
|
||||
Work left out of plan 32-06 on purpose, each with its next command.
|
||||
|
||||
## Not named in the release go-ahead (Task 4)
|
||||
|
||||
1. **sm-grzybyfunkcjonalne-app: Journal bump.** The app requires sm-journal-plugin as a pseudo-version (`v0.0.0-00010101000000-000000000000`, through a replace) and SummerCMS v0.4.4. Journal v0.2.5 needs SummerCMS v0.5.1, and its layer has no tag pages, so the bump sets `golem15.journal.tag_pages: false`. Next, after Jakub names it:
|
||||
```sh
|
||||
cd ../sm-grzybyfunkcjonalne-app
|
||||
GOWORK=off GOPRIVATE='git.golem15.com/*' go get git.golem15.com/golem15/summercms@v0.5.1 git.golem15.com/golem15/sm-journal-plugin@v0.2.5
|
||||
git -C plugins/golem15/journal fetch --tags origin && git -C plugins/golem15/journal checkout v0.2.5
|
||||
# config: golem15.journal.tag_pages: false
|
||||
go build ./... && go test -short ./...
|
||||
git add go.mod go.sum plugins/golem15/journal <config file> && git commit -m "chore: bump journal to v0.2.5"
|
||||
```
|
||||
2. **sm-summercmsio-app: Journal bump.** The app requires sm-journal-plugin v0.1.0 and SummerCMS v0.5.0. Next, after Jakub names it: the same `go get` of `summercms@v0.5.1` and `sm-journal-plugin@v0.2.5`, the submodule checkout at v0.2.5, build and tests, commit `chore: bump journal to v0.2.5`. Decide whether the changelog site wants tag pages (`golem15.journal.tag_pages`).
|
||||
3. **Plugin release posts.** summercms.io/changelog also carries posts for plugin releases (for example "sm-sociallogin-plugin 0.1.5"). Only the framework post for v0.5.1 was named; sm-journal-plugin v0.2.5 and sm-seo-plugin v0.1.0 have no post of their own (both are listed under "Released together" in the v0.5.1 post). Next, if wanted: the same Journal API post by hand (`../sm-summercmsio-app/DEPLOY.md`, "Posting to the changelog", step 3).
|
||||
4. **Deploy.** Nothing was deployed. demo.summercms.io runs the previous demo build until a deploy per `../sm-demo-app/DEPLOY.md` (the new `location = /robots.txt` nginx block ships with it).
|
||||
|
||||
## Found during the release, not this plan's
|
||||
|
||||
- sm-demo-app: ` M plugins/golem15/oauthserver` (checked out b6a9586, recorded 8bbec72 = v0.1.2) is another session's uncommitted drift. It makes `scripts/check-pins.sh` fail in the live tree; the committed tree passes (`check-pins: ok (17 modules)` in a clean worktree). Left as found.
|
||||
- sm-demo-app: `github.com/gabriel-vasile/mimetype v1.4.15` (indirect) is retracted upstream; `go get` warns. Pre-existing; next: `GOWORK=off go get github.com/gabriel-vasile/mimetype@latest` in a dependency-bump commit.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
phase: 32.1
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/mcp.go
|
||||
- modules/pact/mcp_call_test.go
|
||||
- modules/pact/README.md
|
||||
- modules/pier/dispatch.go
|
||||
- modules/pier/prm.go
|
||||
- modules/pier/stateless.go
|
||||
- modules/pier/mcp_seam_test.go
|
||||
- modules/pier/README.md
|
||||
- modules/wristband/authorize.go
|
||||
- modules/wristband/server.go
|
||||
- modules/wristband/consent.go
|
||||
- modules/wristband/mcp_scopes_smoke_test.go
|
||||
- modules/wristband/README.md
|
||||
- docs/services/mcp.md
|
||||
- docs/services/oauth-server.md
|
||||
- docs/services/authentication.md
|
||||
autonomous: true
|
||||
requirements: []
|
||||
estimate:
|
||||
tokens: 52000
|
||||
raw_tokens: 52000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1: A wristband MCP server whose Options.ScopesSupported is empty advertises and accepts read, write, ai, offline_access and backend. A non-empty list is used as given, so DefaultOptions still refuses backend. OIDC discovery scopes stay openid, profile, email (D-02)."
|
||||
- "SC-1: pier protected-resource metadata copies pact.MCPAdvertisedScopes from the published verifier when that list is non-empty, and otherwise keeps PRMScopes (D-04). Pier does not read the raw member-token catalog and does not change the read/write ceiling (D-01)."
|
||||
- "SC-2: Every in-process MCP tool call carries pact.MCPCall (Subject, SessionID, Tool) set immediately before bouncer.WithDispatch. MCPCallFrom on a context without that value returns ok false (D-09, D-10, D-11)."
|
||||
- "PendingRequestView.OfflineAccess is filled from the pending row. IssueCode's signature is unchanged."
|
||||
- "pact, pier and wristband READMEs and docs/services/mcp.md, oauth-server.md and authentication.md name only identifiers this plan adds, and never a consuming application."
|
||||
artifacts:
|
||||
- path: modules/pact/mcp.go
|
||||
provides: "MemberTokenScopeCatalog, MCPAdvertisedScopes, MCPCall, WithMCPCall, MCPCallFrom"
|
||||
contains: "MemberTokenScopes"
|
||||
- path: modules/pact/capabilities.go
|
||||
provides: "FrontendTokenScope.Implies"
|
||||
contains: "Implies"
|
||||
- path: modules/pier/dispatch.go
|
||||
provides: "MCPCall set beside WithDispatch"
|
||||
contains: "WithMCPCall"
|
||||
- path: modules/pier/prm.go
|
||||
provides: "PRM scopes from the verifier or PRMScopes"
|
||||
contains: "MCPAdvertisedScopes"
|
||||
- path: modules/wristband/consent.go
|
||||
provides: "PendingRequestView.OfflineAccess"
|
||||
contains: "OfflineAccess"
|
||||
key_links:
|
||||
- from: modules/wristband/server.go Metadata
|
||||
to: modules/wristband/authorize.go allowedAuthorizeScopes
|
||||
via: one resolved scope slice
|
||||
pattern: resolvedScopes
|
||||
- from: modules/pier/prm.go
|
||||
to: pact.MCPAdvertisedScopes
|
||||
via: app.Lookup of MCPTokenVerifier then a type assert
|
||||
pattern: MCPScopesSupported
|
||||
- from: modules/pier/dispatch.go
|
||||
to: pact.MCPCallFrom
|
||||
via: WithMCPCall immediately before WithDispatch
|
||||
pattern: WithMCPCall
|
||||
---
|
||||
|
||||
<objective>
|
||||
Framework seam for SC-1 and SC-2 (D-01, D-02, D-03, D-04, D-05, D-09, D-10, D-11). Publish the catalog and advertised-scope interfaces, carry an MCP call on context, and make the wristband MCP metadata and authorize allow-list share one resolved scope list so an empty Options.ScopesSupported cannot fall back to the four-name map and drop backend.
|
||||
|
||||
Purpose: sm-user-plugin and sm-oauthserver-plugin need these types before they can advertise declared scopes or record who called a tool. The read/write ceiling and the OIDC scope list stay as they are.
|
||||
Output: pact, pier and wristband API plus the three docs pages and module READMEs. One tracer smoke. The broad unit-test pass is plan 05.
|
||||
|
||||
spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
Discretion recorded here, not left open:
|
||||
- Catalog interface is pact.MemberTokenScopeCatalog with MemberTokenScopes() []FrontendTokenScope. The method is not named FrontendTokenScopes, because HasFrontendTokenScopes already has that method (D-05).
|
||||
- Advertised list is a second interface pact.MCPAdvertisedScopes with MCPScopesSupported() []string. Do not add a method to pact.MCPTokenVerifier.
|
||||
- pact.MCPCall fields are Subject pact.MCPSubject, SessionID string, Tool string. Setter is pact.WithMCPCall. Accessor is pact.MCPCallFrom (D-10).
|
||||
- MCP flavour only: empty Options.ScopesSupported resolves to read, write, ai, offline_access, backend. Metadata and allowedAuthorizeScopes both use that slice. DefaultOptions stays the non-empty four-name list and still refuses backend. TestMCPScopesSmoke's empty-slice assertion is updated in the same commit. Do not edit modules/wristband/oidc_options.go.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
|
||||
@modules/pact/mcp.go
|
||||
@modules/pact/capabilities.go
|
||||
@modules/pier/dispatch.go
|
||||
@modules/pier/prm.go
|
||||
@modules/pier/stateless.go
|
||||
@modules/pier/session.go
|
||||
@modules/wristband/authorize.go
|
||||
@modules/wristband/server.go
|
||||
@modules/wristband/consent.go
|
||||
@modules/wristband/mcp_scopes_smoke_test.go
|
||||
@modules/bouncer/dispatch.go
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: one advertised scope list and one MCP call on the dispatch context</name>
|
||||
<reversibility rating="costly">D-03 and D-10 publish a scope list and a pact.MCPCall shape that later grants and callers will store and read.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/mcp.go, modules/pact/mcp_call_test.go, modules/wristband/authorize.go, modules/wristband/server.go, modules/wristband/mcp_scopes_smoke_test.go, modules/pier/dispatch.go, modules/pier/prm.go, modules/pier/stateless.go, modules/pier/mcp_seam_test.go</files>
|
||||
<read_first>
|
||||
modules/pact/capabilities.go (FrontendTokenScope at the struct that today has Name, Label, Description only)
|
||||
modules/pact/mcp.go (MCPSubject, MCPTokenVerifier, MCPGrant)
|
||||
modules/bouncer/dispatch.go (WithDispatch context key; do not copy its nil-to-background behaviour)
|
||||
modules/wristband/authorize.go (allowedAuthorizeScopes empty fallback)
|
||||
modules/wristband/server.go (Metadata copies opts.ScopesSupported; flavourMCP versus flavourOIDC)
|
||||
modules/wristband/mcp_scopes_smoke_test.go (empty slice currently expects four names and no backend)
|
||||
modules/pier/prm.go (PRMScopes and mountPRM)
|
||||
modules/pier/dispatch.go (dispatcher.run sets bouncer.WithDispatch at the credential assignment)
|
||||
modules/pier/session.go (toolHandler copies req.Session.ID into callInfo.session; ephemeralSession is the audit word)
|
||||
modules/pier/stateless.go (statelessPath.ServeHTTP)
|
||||
modules/pier/tracer_smoke_test.go (package pier_test mount helper to copy, not to rewrite)
|
||||
modules/pier/filter.go (read only: id.writes ceiling stays)
|
||||
</read_first>
|
||||
<action>
|
||||
Add the pact surface and wire one path through wristband and pier. Do not add a method to pact.MCPTokenVerifier. Do not edit modules/pier/filter.go or modules/wristband/oidc_options.go (D-01, D-02).
|
||||
|
||||
In modules/pact/capabilities.go add Implies string on FrontendTokenScope. Zero value means not implied. Update the comment that says the struct carries no permission patterns so it still says the route middleware is what enforces a member scope, and that Implies is only "", "read" or "write" for a later OAuth MCP check. Do not implement that check in this repo (D-01a is plan 02).
|
||||
|
||||
In modules/pact/mcp.go add MemberTokenScopeCatalog with MemberTokenScopes() []FrontendTokenScope, and MCPAdvertisedScopes with MCPScopesSupported() []string (D-05, D-04). Add MCPCall{Subject MCPSubject, SessionID string, Tool string}, WithMCPCall(ctx, call) context.Context and MCPCallFrom(ctx) (MCPCall, bool) (D-09, D-10). Use an unexported context key. If ctx is nil, WithMCPCall must not invent context.Background; the dispatch context is never nil. MCPCallFrom returns ok false when the value is absent, including a nil context (D-11). Leave MCPSubject's fields unchanged.
|
||||
|
||||
In wristband, add an unexported resolvedScopes on Server used by both Metadata's scopes_supported and allowedAuthorizeScopes. Non-empty Options.ScopesSupported is cloned as-is. Empty or nil, on the MCP flavour only (NewServer, flavour zero value, not flavourOIDC), resolves to exactly read, write, ai, offline_access, backend in that order. That is the fix for the four-name fallback dropping backend. Update TestMCPScopesSmoke so the empty-slice case expects those five names and accepts backend, and keep the DefaultOptions case that still refuses backend because that list is non-empty and has no backend. Do not change PKCE, client lookup, or redirect matching (Authorize still validates those before the /connect redirect).
|
||||
|
||||
In pier mountPRM, keep the issuer lookup. If the looked-up pact.MCPTokenVerifier is non-nil and also implements pact.MCPAdvertisedScopes and MCPScopesSupported() is non-empty, ScopesSupported is a clone of that slice. Otherwise keep a clone of PRMScopes (D-04). Do not import an OAuth plugin and do not read MemberTokenScopeCatalog here.
|
||||
|
||||
In dispatcher.run, immediately before the existing bouncer.WithDispatch line, set callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name}) where subject is the id.subjectFor(t.guardName) value already computed (D-11). sessionID is info.session, except the audit word ephemeralSession becomes "". Add a stateless bool on callInfo. statelessPath.ServeHTTP sets it true on the bearer token-info extraCall value, writing the struct back, before the handler runs. toolHandler copies req.Session.ID() into info.session only when that flag is false and the id is non-empty. Do not expand scopes in pier. Do not put a raw bearer or a code in a log line.
|
||||
|
||||
Tests, same commit: modules/pact/mcp_call_test.go TestMCPCallRoundTrip proves WithMCPCall then MCPCallFrom returns the same Subject.Guard, SessionID and Tool, and MCPCallFrom(context.Background()) is ok false. modules/pier/mcp_seam_test.go TestMCPSeam, package pier_test, does three things in one test: wristband.NewServer with nil ScopesSupported answers metadata scopes_supported equal to the five fixed names including backend; a server whose ScopesSupported is those five plus acme:read advertises that exact slice; a pier mounted with a fake MCPTokenVerifier that also implements MCPAdvertisedScopes advertises that slice on the protected-resource document, and one tools/call of a frontend tool reaches a handler where pact.MCPCallFrom is ok, Tool is the tool name, and bouncer dispatch is still set. A verifier that does not implement MCPAdvertisedScopes still yields PRMScopes. Commit in this repo only, staging the files listed on this task. Do not git add -A.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short -count=1 -run 'TestMCPCallRoundTrip|TestMCPSeam|TestMCPScopesSmoke|TestDiscoveryFields' ./modules/pact ./modules/pier ./modules/wristband</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPCallRoundTrip or TestMCPSeam has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- MCPTokenVerifier still has only MCPVerifyToken and MCPResource.
|
||||
- Empty MCP Options.ScopesSupported metadata and allow-list both include backend. DefaultOptions still refuses backend.
|
||||
- TestDiscoveryFields still expects scopes_supported openid, profile, email on the default OIDC document.
|
||||
- TestMCPSeam PASS shows a declared name on PRM only when the verifier advertises it, and one tool handler observes MCPCall.
|
||||
- filter.go and oidc_options.go have no diff in this commit.
|
||||
</acceptance_criteria>
|
||||
<done>One MCP tool call carries MCPCall, and an empty MCP scope list no longer drops backend. The commit is only the files of this task.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Expose offline_access on the pending consent view</name>
|
||||
<reversibility rating="reversible">OfflineAccess is an additive field on a view struct. IssueCode's signature stays.</reversibility>
|
||||
<files>modules/wristband/consent.go</files>
|
||||
<read_first>
|
||||
modules/wristband/consent.go (PendingRequestView and IssueCode)
|
||||
modules/wristband/authorize.go (offline_access is peeled onto the pending row, not stored in Scopes)
|
||||
</read_first>
|
||||
<action>
|
||||
Add OfflineAccess bool to PendingRequestView and set it in PendingRequest from the pending row's existing OfflineAccess flag. Do not change the IssueCode signature, MarkIssued's wristband parameters, or the redirect URL. Callers that ignore the new field stay valid. Do not treat offline_access as a scope inside wristband. Do not log codes.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short -count=1 ./modules/wristband</automated>
|
||||
<fails_when>non-zero exit, or the output contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- PendingRequestView has an exported OfflineAccess field set from the pending row.
|
||||
- IssueCode is still func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error).
|
||||
</acceptance_criteria>
|
||||
<done>Consent show can see the offline_access column without a new IssueCode argument.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Document the seam in READMEs and docs</name>
|
||||
<reversibility rating="reversible">Docs follow the API added in this plan and can be rewritten with it.</reversibility>
|
||||
<files>modules/pact/README.md, modules/pier/README.md, modules/wristband/README.md, docs/services/mcp.md, docs/services/oauth-server.md, docs/services/authentication.md</files>
|
||||
<read_first>
|
||||
modules/pact/README.md
|
||||
modules/pier/README.md
|
||||
modules/wristband/README.md
|
||||
docs/services/mcp.md
|
||||
docs/services/oauth-server.md
|
||||
docs/services/authentication.md
|
||||
modules/pact/mcp.go
|
||||
modules/pier/prm.go
|
||||
</read_first>
|
||||
<action>
|
||||
Document only identifiers this plan added: MemberTokenScopeCatalog, MemberTokenScopes, MCPAdvertisedScopes, MCPScopesSupported, MCPCall, WithMCPCall, MCPCallFrom, FrontendTokenScope.Implies, PendingRequestView.OfflineAccess, and the existing PRMScopes fallback. State the D-03 rule in pier README, docs/services/mcp.md and docs/services/oauth-server.md: fixed names read, write, ai, offline_access, backend are always on the MCP advertised set; an unset host filter means every declared member scope; a set filter means only those declared names; an empty filter means the five fixed names only. Name the host config key golem15.oauthserver.mcp_scopes as the plugin key plan 03 adds. Do not document it as a framework config key. State that pier copies MCPScopesSupported when non-empty and otherwise PRMScopes (D-04). State that Subject.TokenID is a member personal-token id only when ClientID is empty, and is the OAuth access-token row otherwise, and that Subject.Scopes is the grant, not an implied expansion (D-10, D-11). State that the read/write ceiling is unchanged (D-01) and that OIDC discovery stays openid, profile, email (D-02). State that an empty MCP Options.ScopesSupported resolves to the five fixed names, while a non-empty list is used as given. Framework docs and READMEs must not name a consuming application. Config keys are not checked by the docs checker; the prose must still match the key spelling above. Commit in this repo, staging only these doc files plus any code file this task had to touch (it should touch none). Do not git add -A.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./modules/pact ./modules/pier ./modules/wristband && go test -short -count=1 ./modules/pact ./modules/pier ./modules/wristband</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- TestDocsTree and docs:build --check pass.
|
||||
- The three READMEs and three docs pages mention MCPCallFrom and MCPAdvertisedScopes and do not mention a consuming application by name.
|
||||
- go test -short on pact, pier and wristband passes.
|
||||
</acceptance_criteria>
|
||||
<done>SC-4 docs for the framework seam are in the same release as the API they name.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP client to wristband authorize | Untrusted scope string and redirect cross here. This plan only changes which names are legal. |
|
||||
| pier dispatch to route handler | The handler receives MCPCall built from the already-resolved subject, not from tool arguments. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32.1-01 | Elevation of privilege | wristband resolvedScopes | high | mitigate | Empty MCP Options.ScopesSupported resolves to the five fixed names including backend. A non-empty list is not widened. OIDC discovery is untouched. |
|
||||
| T-32.1-02 | Elevation of privilege | pier filter.go read/write ceiling | high | mitigate | Do not edit Identity.writes or the ceiling in filter.go (D-01). Implies is data on the catalog only; pier does not expand scopes. |
|
||||
| T-32.1-03 | Spoofing | wristband Authorize redirect | high | mitigate | Do not edit client lookup, exact redirect match, PKCE or the /connect redirect. Scope resolution runs after those checks. |
|
||||
| T-32.1-04 | Information disclosure | pact.MCPCall.Subject.TokenID | medium | mitigate | README states TokenID is a user_api_tokens id only when ClientID is empty. Dispatch logs keep the existing audit line and must not gain the raw bearer or an authorization code. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
go test -short on pact, pier and wristband passes, TestDiscoveryFields still locks the OIDC scope list, and TestDocsTree passes after the README commit.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 and SC-2 have a framework path: one advertised slice drives MCP metadata and authorize, pier PRM reads it from the verifier, and one tool call exposes MCPCall.
|
||||
- D-01 ceiling and D-02 OIDC list are unchanged files.
|
||||
- Docs name the new identifiers and the mcp_scopes rule.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- pact.MemberTokenScopeCatalog, method MemberTokenScopes
|
||||
- pact.MCPAdvertisedScopes, method MCPScopesSupported
|
||||
- pact.MCPCall, pact.WithMCPCall, pact.MCPCallFrom
|
||||
- FrontendTokenScope.Implies
|
||||
- wristband Server resolvedScopes (unexported), used by Metadata and allowedAuthorizeScopes
|
||||
- PendingRequestView.OfflineAccess
|
||||
- callInfo.stateless (unexported) so MCPCall.SessionID is empty on the stateless path and never the audit word ephemeral
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
plan: 01
|
||||
subsystem: auth
|
||||
tags: [mcp, oauth, pact, pier, wristband, scopes]
|
||||
|
||||
requires:
|
||||
- phase: 19.7-generic-mcp-module
|
||||
provides: pier in-process dispatch, PRMScopes and the read/write ceiling
|
||||
- phase: 19.4-oidc-provider-on-wristband
|
||||
provides: OIDC discovery scope list left unchanged
|
||||
provides:
|
||||
- "pact.MemberTokenScopeCatalog, pact.MCPAdvertisedScopes, pact.MCPCall, pact.WithMCPCall, pact.MCPCallFrom"
|
||||
- "pact.FrontendTokenScope.Implies"
|
||||
- "wristband resolvedScopes shared by MCP metadata and authorize"
|
||||
- "wristband.PendingRequestView.OfflineAccess"
|
||||
- "pier PRM copies MCPScopesSupported when non-empty, else PRMScopes, and sets MCPCall beside WithDispatch"
|
||||
affects: [32.1-02, 32.1-03, 32.1-04, 32.1-05]
|
||||
|
||||
actuals:
|
||||
tokens: 17695
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 2cea3127cc2a2da5f8e0ea6ec47356bfaecc5c67
|
||||
plan_head_after: 876f0f09f2c4a42864941118d12d636123af1594
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "MCPAdvertisedScopes is a second interface; MCPTokenVerifier stays MCPVerifyToken and MCPResource"
|
||||
- "One resolvedScopes slice drives MCP metadata and the authorize allow-list"
|
||||
- "WithMCPCall is set on the dispatch context immediately before WithDispatch; a nil context is not replaced"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/pact/mcp_call_test.go
|
||||
- modules/pier/mcp_seam_test.go
|
||||
modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/mcp.go
|
||||
- modules/pact/README.md
|
||||
- modules/pier/dispatch.go
|
||||
- modules/pier/prm.go
|
||||
- modules/pier/session.go
|
||||
- modules/pier/stateless.go
|
||||
- modules/pier/README.md
|
||||
- modules/wristband/authorize.go
|
||||
- modules/wristband/server.go
|
||||
- modules/wristband/consent.go
|
||||
- modules/wristband/mcp_scopes_smoke_test.go
|
||||
- modules/wristband/README.md
|
||||
- docs/services/mcp.md
|
||||
- docs/services/oauth-server.md
|
||||
- docs/services/authentication.md
|
||||
|
||||
key-decisions:
|
||||
- "Empty MCP Options.ScopesSupported resolves to read, write, ai, offline_access, backend; a non-empty list, including DefaultOptions, is used as given"
|
||||
- "Advertised scopes are pact.MCPAdvertisedScopes, not a new method on pact.MCPTokenVerifier"
|
||||
- "Stateless tool calls set callInfo.stateless so MCPCall.SessionID stays empty"
|
||||
|
||||
patterns-established:
|
||||
- "Pattern: Metadata and allowedAuthorizeScopes both call Server.resolvedScopes"
|
||||
- "Pattern: pier mountPRM type-asserts MCPAdvertisedScopes and clones a non-empty MCPScopesSupported, else PRMScopes"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "An empty MCP Options.ScopesSupported advertises and accepts the five fixed names including backend; DefaultOptions still refuses backend; OIDC discovery stays openid, profile, email"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/wristband/mcp_scopes_smoke_test.go#TestMCPScopesSmoke
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: modules/wristband/oidc_discovery_test.go#TestDiscoveryFields
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "pier copies MCPScopesSupported onto protected-resource metadata when non-empty and otherwise keeps PRMScopes, and one frontend tools/call observes MCPCall beside the bouncer dispatch marker"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/pier/mcp_seam_test.go#TestMCPSeam
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "WithMCPCall then MCPCallFrom round-trips Subject.Guard, SessionID and Tool; a context without a call answers ok false"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/pact/mcp_call_test.go#TestMCPCallRoundTrip
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "PendingRequestView.OfflineAccess is set from the pending row; IssueCode's signature is unchanged"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: go test -short ./modules/wristband
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "No test asserts that PendingRequest copies OfflineAccess; the package suite only proves the field compiles and existing consent tests still pass"
|
||||
- id: D5
|
||||
description: "pact, pier and wristband READMEs and the three service docs name the new identifiers and the mcp_scopes rule without naming a consuming application"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 12min
|
||||
completed: 2026-10-28
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32.1 Plan 01: MCP scope list and call context Summary
|
||||
|
||||
**One resolved MCP scope list drives wristband metadata and authorize, pier copies it from the verifier, and every in-process tool call carries pact.MCPCall**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-10-28T13:25:44Z
|
||||
- **Completed:** 2026-10-28T13:37:30Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 18
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `pact` publishes `MemberTokenScopeCatalog`, `MCPAdvertisedScopes`, `MCPCall` (`WithMCPCall` / `MCPCallFrom`) and `FrontendTokenScope.Implies`. `MCPTokenVerifier` is still only `MCPVerifyToken` and `MCPResource`.
|
||||
- An empty or nil MCP `Options.ScopesSupported` resolves to `read`, `write`, `ai`, `offline_access`, `backend` for both metadata and the authorize allow-list. `DefaultOptions` stays the four-name list and still refuses `backend`. OIDC discovery stays `openid`, `profile`, `email`.
|
||||
- pier copies `MCPScopesSupported` into protected-resource metadata when that slice is non-empty, otherwise `PRMScopes`. `dispatcher.run` stores `MCPCall` immediately before `bouncer.WithDispatch`. Stateless calls leave `SessionID` empty.
|
||||
- `PendingRequestView.OfflineAccess` is the pending row's flag. `IssueCode`'s signature is unchanged.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Tracer — one advertised scope list and one MCP call on the dispatch context** - `63d05e8` (feat)
|
||||
2. **Task 2: Expose offline_access on the pending consent view** - `9e29d6f` (feat)
|
||||
3. **Task 3: Document the seam in READMEs and docs** - `876f0f0` (docs)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/pact/mcp.go` - catalog and advertised-scope interfaces, `MCPCall`, `WithMCPCall`, `MCPCallFrom`
|
||||
- `modules/pact/capabilities.go` - `FrontendTokenScope.Implies`
|
||||
- `modules/pact/mcp_call_test.go` - round-trip and absent-context cases
|
||||
- `modules/wristband/server.go` - unexported `resolvedScopes` used by metadata
|
||||
- `modules/wristband/authorize.go` - allow-list uses that same slice
|
||||
- `modules/wristband/consent.go` - `PendingRequestView.OfflineAccess`
|
||||
- `modules/wristband/mcp_scopes_smoke_test.go` - empty list expects the five names and accepts backend
|
||||
- `modules/pier/prm.go` - copy `MCPScopesSupported` or keep `PRMScopes`
|
||||
- `modules/pier/dispatch.go` - `WithMCPCall` immediately before `WithDispatch`; blank the ephemeral audit word
|
||||
- `modules/pier/session.go` - copy the SDK session id only when the call is not stateless
|
||||
- `modules/pier/stateless.go` - mark `callInfo.stateless` before the handler runs
|
||||
- `modules/pier/mcp_seam_test.go` - metadata, PRM and one tool call that observes `MCPCall`
|
||||
- `modules/pact/README.md`, `modules/pier/README.md`, `modules/wristband/README.md` - the new identifiers and the advertised-set rule
|
||||
- `docs/services/mcp.md`, `docs/services/oauth-server.md`, `docs/services/authentication.md` - the same rule, `MCPCallFrom`, and the unchanged ceiling and OIDC list
|
||||
|
||||
## Decisions Made
|
||||
|
||||
None beyond the plan's recorded discretion. `MemberTokenScopeCatalog.MemberTokenScopes` is not named `FrontendTokenScopes`. `MCPAdvertisedScopes` is a separate interface. Empty MCP options resolve to the five fixed names; a non-empty list is cloned as given. `filter.go` and `oidc_options.go` were not edited.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
The framework seam for SC-1 and SC-2 is in place. Later plans in this phase can publish the member-token catalog, build the advertised list from `golem15.oauthserver.mcp_scopes`, and read `MCPCallFrom` on a dispatched route. The read/write ceiling and the OIDC discovery list are unchanged.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/pact/mcp.go
|
||||
- FOUND: modules/pact/mcp_call_test.go
|
||||
- FOUND: modules/pier/mcp_seam_test.go
|
||||
- FOUND: modules/wristband/consent.go
|
||||
- FOUND: modules/pact/README.md
|
||||
- FOUND: docs/services/mcp.md
|
||||
- FOUND: 63d05e8
|
||||
- FOUND: 9e29d6f
|
||||
- FOUND: 876f0f0
|
||||
|
||||
---
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
*Completed: 2026-10-28*
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: 32.1
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 32.1-01
|
||||
files_modified:
|
||||
- ../sm-user-plugin/classes/api_token_scopes.go
|
||||
- ../sm-user-plugin/classes/mcp_guard.go
|
||||
- ../sm-user-plugin/classes/mcp_guard_test.go
|
||||
- ../sm-user-plugin/plugin.go
|
||||
- ../sm-user-plugin/README.md
|
||||
- ../sm-user-plugin/go.mod
|
||||
autonomous: true
|
||||
requirements: []
|
||||
estimate:
|
||||
tokens: 38000
|
||||
raw_tokens: 38000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1: On a branch from tag v0.1.14, an OAuth MCP subject (ClientID non-empty) whose grant holds read satisfies a declared scope with Implies read, in both MCPAllows and the credential MCPDispatch builds (D-01a). write does not satisfy Implies read."
|
||||
- "A personal-token subject (ClientID empty) is not expanded. Its credential ID stays subject.TokenID, the v0.1.14 rule (D-11)."
|
||||
- "The compiled catalog is published as pact.MemberTokenScopeCatalog. Implied names are not written onto the token row, the fingerprint, or subject.Scopes (D-05, D-01a)."
|
||||
- "The app submodule at sm-demo-app/plugins/golem15/user is not edited."
|
||||
artifacts:
|
||||
- path: ../sm-user-plugin/classes/mcp_guard.go
|
||||
provides: "OAuth-only Implies expansion shared by MCPAllows and MCPDispatch"
|
||||
contains: "ClientID"
|
||||
- path: ../sm-user-plugin/plugin.go
|
||||
provides: "Publish of MemberTokenScopeCatalog beside the concrete catalog"
|
||||
contains: "MemberTokenScopeCatalog"
|
||||
key_links:
|
||||
- from: classes/mcp_guard.go MCPAllows
|
||||
to: classes/mcp_guard.go MCPDispatch
|
||||
via: one helper that clones scopes only when ClientID is non-empty
|
||||
pattern: ClientID
|
||||
- from: plugin.go bootAPITokenScopes
|
||||
to: pact.MemberTokenScopeCatalog
|
||||
via: app.Publish of the interface
|
||||
pattern: MemberTokenScopeCatalog
|
||||
---
|
||||
|
||||
<objective>
|
||||
sm-user-plugin from tag v0.1.14 publishes the member-token catalog and applies Implies only to OAuth MCP subjects (D-01a, D-05, D-11). Personal tokens keep their scopes and their credential id.
|
||||
|
||||
Purpose: an OAuth grant of read and write can list and run the same tools as a personal token that carries the declared names the plugins marked as implied, without storing those names on the grant.
|
||||
Output: a worktree at the path below, on a branch from v0.1.14, plus one tracer test. The rest of the Implies matrix is plan 05.
|
||||
|
||||
Worktree, exact: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin
|
||||
Remote: git@git.golem15.com:golem15/sm-user-plugin.git
|
||||
Branch: phase-32.1-mcp-implied-scopes, created from tag v0.1.14 (commit a1b78b80). Do not use sm-demo-app/plugins/golem15/user or any other app submodule. That checkout is v0.1.13 and has the wrong credential-id body.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
|
||||
@modules/pact/capabilities.go
|
||||
@modules/pact/mcp.go
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: v0.1.14 worktree publishes the catalog and expands one implied scope</name>
|
||||
<precondition>git ls-remote git@git.golem15.com:golem15/sm-user-plugin.git refs/tags/v0.1.14 prints a commit, and /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin is either absent or already this phase's worktree on phase-32.1-mcp-implied-scopes. Halt if that path is any other checkout, including an app submodule.</precondition>
|
||||
<reversibility rating="costly">D-01a: once a plugin marks Implies read or write, live OAuth agents gain those tools. Removing it later shrinks their tool list.</reversibility>
|
||||
<files>../sm-user-plugin/go.mod, ../sm-user-plugin/classes/api_token_scopes.go, ../sm-user-plugin/classes/mcp_guard.go, ../sm-user-plugin/plugin.go, ../sm-user-plugin/classes/mcp_guard_test.go</files>
|
||||
<read_first>
|
||||
The worktree's classes/mcp_guard.go after the branch exists (v0.1.14 MCPDispatch sets credential.ID only when ClientID is empty). Do not read the v0.1.13 submodule as the body to edit.
|
||||
The worktree's classes/api_token_scopes.go CompileAPITokenScopes and builtInAPITokenScopeKeys.
|
||||
The worktree's plugin.go bootAPITokenScopes (today app.Publish of the concrete catalog and of pact.MCPPersonalTokenResolver).
|
||||
modules/pact/mcp.go MemberTokenScopeCatalog in the framework repo.
|
||||
../sm-oauthserver-plugin/go.mod replace line, as the pattern for a sibling replace.
|
||||
</read_first>
|
||||
<action>
|
||||
Fetch the tag and create the worktree with git clone or git worktree add of git@git.golem15.com:golem15/sm-user-plugin.git at /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, branch phase-32.1-mcp-implied-scopes from v0.1.14. Confirm HEAD's MCPDispatch comment says a personal-token credential ID is subject.TokenID and an OAuth credential keeps ID 0. If the comment still says the credential has ID 0 and never sets credential.ID, stop: that is v0.1.13.
|
||||
|
||||
Point the summercms replace at ../summercms.go so this worktree compiles against plan 01. If go.mod has no replace for git.golem15.com/golem15/summercms, add one. Commit that replace with the feature commit in the plugin repo. Do not retarget any app submodule.
|
||||
|
||||
On *APITokenScopeCatalog add MemberTokenScopes() []pact.FrontendTokenScope returning the compiled list (built-ins and declared). In bootAPITokenScopes, after the existing concrete app.Publish(catalog), also app.Publish the same value as pact.MemberTokenScopeCatalog (D-05). Do not remove the concrete publish or the MCPPersonalTokenResolver publish.
|
||||
|
||||
Extend CompileAPITokenScopes: reject Implies other than "", "read" or "write"; reject a declared name backend or offline_access. Built-in read, write and ai keep Implies empty. Existing rejections of read, write, ai as declared names, duplicates, and empty label or description stay.
|
||||
|
||||
Add one helper used by MCPAllows and MCPDispatch (D-01a). When subject.ClientID is empty, return subject.Scopes unchanged. Otherwise clone subject.Scopes and, for each catalog entry whose Implies is read or write, if the clone contains that stock scope and not the declared name, append the declared name. Never append a name the catalog does not mark. Never let write satisfy Implies read. Do not write the clone back onto subject.Scopes, a token row, or a fingerprint. MCPAllows checks req.Scopes against the helper's slice. MCPDispatch puts that slice on the in-memory models.APIToken.Scopes and still sets credential.ID = subject.TokenID only when ClientID is empty (D-11). Do not set APIToken.OAuthClientID.
|
||||
|
||||
Tracer test TestMCPImpliesOAuth in classes/mcp_guard_test.go: an OAuth subject with ClientID set and scopes read passes MCPAllows for a requirement servers:read when the catalog entry says Implies read, and the MCPDispatch credential HasScope servers:read while its ID stays 0. Commit only in the plugin repo, staging paths under that worktree. Do not git add -A. Do not push or tag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run '^TestMCPImpliesOAuth$' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- git -C the worktree rev-parse HEAD is a descendant of v0.1.14, not of the v0.1.13 submodule commit 07196a4 alone.
|
||||
- TestMCPImpliesOAuth PASS: OAuth read grant satisfies Implies read, credential ID is 0.
|
||||
- sm-demo-app/plugins/golem15/user has no diff from this task.
|
||||
</acceptance_criteria>
|
||||
<done>The v0.1.14 branch publishes the catalog and one OAuth implied scope works end to end through allow and dispatch.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Reject bad Implies and keep personal tokens unexpanded</name>
|
||||
<reversibility rating="costly">D-01a and D-03: boot errors and the personal-token exemption are part of the contract plugins and grants will rely on.</reversibility>
|
||||
<files>../sm-user-plugin/classes/api_token_scopes.go, ../sm-user-plugin/classes/mcp_guard.go, ../sm-user-plugin/classes/mcp_guard_test.go, ../sm-user-plugin/README.md</files>
|
||||
<read_first>
|
||||
The worktree classes/api_token_scopes.go and classes/mcp_guard.go after the tracer commit.
|
||||
The worktree README.md
|
||||
modules/pact/capabilities.go FrontendTokenScope.Implies comment
|
||||
</read_first>
|
||||
<action>
|
||||
Finish the helper's edges if the tracer left any call site on slices.Clone(subject.Scopes) for the scope check. MCPAllows and MCPDispatch must both call the helper. A personal token (ClientID empty) with only read must not gain a declared name. A grant of write must not satisfy Implies read. A second copy of an already-present declared name is not appended.
|
||||
|
||||
CompileAPITokenScopes error text names the plugin and the bad Implies value or the reserved name backend or offline_access, in the same style as the existing declared-name errors. Phrase-key checks in bootAPITokenScopes stay.
|
||||
|
||||
README: document FrontendTokenScope.Implies (empty, read, or write), that expansion happens only inside MCPAllows and MCPDispatch when ClientID is non-empty, that personal tokens are unchanged, and that the catalog is published as pact.MemberTokenScopeCatalog. Do not name a consuming application. Do not persist implied names. Commit in the plugin repo only. Do not push or tag. Do not edit the framework repo in this plan.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run 'TestMCP' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesOAuth has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- A personal-token test in this package shows scopes unchanged and credential ID equal to TokenID.
|
||||
- An Implies value other than empty, read, or write fails CompileAPITokenScopes.
|
||||
- README names MemberTokenScopeCatalog and Implies and names no consuming application.
|
||||
</acceptance_criteria>
|
||||
<done>D-01a holds on both call sites, personal tokens are not expanded, and the plugin README matches the API.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| OAuth grant scopes to MCPAllows / user.scope | The grant is the attacker's consented set. Implies may add only names the plugin marked. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32.1-05 | Elevation of privilege | MCPUserGuard Implies helper | high | mitigate | Expand only when ClientID is non-empty, only for a catalog Implies of read or write, only the declared name that says so. Do not write the expanded set onto the token, the fingerprint, or subject.Scopes. Personal tokens are returned unchanged (D-01a, D-11). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
go test -short -run TestMCP in the v0.1.14 worktree passes, including TestMCPImpliesOAuth, and the app submodule is untouched.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Catalog publication and OAuth-only Implies are on a branch from v0.1.14.
|
||||
- Personal-token credential ID behaviour from that tag is intact.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Worktree /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin branch phase-32.1-mcp-implied-scopes from tag v0.1.14
|
||||
- *APITokenScopeCatalog.MemberTokenScopes
|
||||
- app.Publish of pact.MemberTokenScopeCatalog
|
||||
- CompileAPITokenScopes rejection of bad Implies and of declared names backend and offline_access
|
||||
- Unexported effective-scope helper in classes/mcp_guard.go
|
||||
- TestMCPImpliesOAuth
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
plan: 02
|
||||
subsystem: auth
|
||||
tags: [mcp, oauth, scopes, implies, sm-user-plugin]
|
||||
|
||||
requires:
|
||||
- phase: 32.1-01
|
||||
provides: pact.MemberTokenScopeCatalog and FrontendTokenScope.Implies
|
||||
provides:
|
||||
- "sm-user-plugin on phase-32.1-mcp-implied-scopes publishes the compiled catalog as pact.MemberTokenScopeCatalog"
|
||||
- "OAuth MCP subjects expand declared scopes whose Implies is read or write inside MCPAllows and MCPDispatch"
|
||||
- "Personal tokens are not expanded and keep credential ID equal to TokenID"
|
||||
- "CompileAPITokenScopes rejects a bad Implies and the reserved names backend and offline_access"
|
||||
affects: [32.1-03, 32.1-05]
|
||||
|
||||
actuals:
|
||||
tokens: 10492
|
||||
tasks: 2
|
||||
commits: 0
|
||||
plan_head_before: 415a09333bf9bb2cc93ac8324aac794cc74ff818
|
||||
plan_head_after: 415a09333bf9bb2cc93ac8324aac794cc74ff818
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "One unexported helper, mcpEffectiveScopes, feeds MCPAllows and MCPDispatch"
|
||||
- "Expansion clones the grant only when ClientID is non-empty and never writes that clone back"
|
||||
- "The catalog is published twice: the concrete *APITokenScopeCatalog and pact.MemberTokenScopeCatalog"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-user-plugin/mcp_implies_test.go
|
||||
modified:
|
||||
- ../sm-user-plugin/classes/api_token_scopes.go
|
||||
- ../sm-user-plugin/classes/mcp_guard.go
|
||||
- ../sm-user-plugin/plugin.go
|
||||
- ../sm-user-plugin/go.mod
|
||||
- ../sm-user-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Implies tests live in the plugin root package so go test . runs them; classes/mcp_guard_test.go would report no tests to run"
|
||||
- "The tracer loads a principal from an in-memory database/sql driver so go test -short proves MCPAllows and MCPDispatch without Docker"
|
||||
- "The phase branch replace points at ../summercms.go, the sibling of this checkout, not the old submodule-relative path"
|
||||
|
||||
patterns-established:
|
||||
- "Pattern: OAuth effective scopes are a clone plus catalog names whose Implies is exactly the stock scope the grant holds"
|
||||
- "Pattern: a personal token returns subject.Scopes unchanged, including slice identity, and credential.ID stays TokenID"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "An OAuth subject with ClientID set and scopes read passes MCPAllows for servers:read when Implies is read, and the MCPDispatch credential HasScope servers:read with ID 0. The grant slice is not rewritten. The catalog is published as pact.MemberTokenScopeCatalog."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: mcp_implies_test.go#TestMCPImpliesOAuth
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "A personal token (ClientID empty) with only read does not gain servers:read, and the dispatch credential ID equals TokenID"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: mcp_implies_test.go#TestMCPImpliesPersonalUnchanged
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A grant of write does not satisfy Implies read, and a declared name already on the grant is not appended a second time. A catalog entry with empty Implies is not appended."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: mcp_implies_test.go#TestMCPImpliesWriteDoesNotGrantRead
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: mcp_implies_test.go#TestMCPImpliesAlreadyPresent
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "CompileAPITokenScopes fails for Implies other than empty, read, or write, and for declared names backend and offline_access. The error names the plugin and the bad value."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: mcp_implies_test.go#TestMCPImpliesCompileRejects
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The plugin README names FrontendTokenScope.Implies, the OAuth-only expansion, unchanged personal tokens, and pact.MemberTokenScopeCatalog, and names no consuming application"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: README.md MemberTokenScopeCatalog and Implies; rg found no consuming-application name
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 17min
|
||||
completed: 2026-10-28
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32.1 Plan 02: OAuth implied scopes Summary
|
||||
|
||||
**On a branch from sm-user-plugin v0.1.14, an OAuth MCP grant of read satisfies declared scopes marked Implies read, and a personal token keeps its scopes and credential id**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 17 min
|
||||
- **Started:** 2026-10-28T13:48:50Z
|
||||
- **Completed:** 2026-10-28T14:06:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 6
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- The checkout `/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin` is branch `phase-32.1-mcp-implied-scopes` from tag `v0.1.14` (`a1b78b8`). `MCPDispatch` still sets `credential.ID` only when `ClientID` is empty.
|
||||
- `*APITokenScopeCatalog.MemberTokenScopes` returns the compiled list. Boot publishes that value as `pact.MemberTokenScopeCatalog` beside the concrete catalog and still publishes `pact.MCPPersonalTokenResolver`.
|
||||
- `mcpEffectiveScopes` is the only expansion. It returns `subject.Scopes` unchanged when `ClientID` is empty. Otherwise it clones the grant and appends a catalog name only when `Implies` is `read` or `write`, the clone holds that stock scope, and the name is not already present. `MCPAllows` and `MCPDispatch` both use it. The clone is not written onto `subject.Scopes`, a token row, or a fingerprint. `APIToken.OAuthClientID` is not set.
|
||||
- `CompileAPITokenScopes` rejects `Implies` other than empty, `read`, or `write`, and rejects declared names `backend` and `offline_access`. Built-ins keep `Implies` empty. The summercms replace on this branch is `../summercms.go`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Plugin repo `sm-user-plugin` only. summercms.go had no code commits in this plan (`commits: 0` from `415a093..HEAD` at summary time).
|
||||
|
||||
1. **Task 1: Tracer — v0.1.14 worktree publishes the catalog and expands one implied scope** - `25fd981` (feat)
|
||||
2. **Task 2: Reject bad Implies and keep personal tokens unexpanded** - `193a381` (test)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-user-plugin/classes/api_token_scopes.go` - `MemberTokenScopes`, `Implies` on the compiled scope, rejection of bad `Implies` and of `backend` / `offline_access`
|
||||
- `../sm-user-plugin/classes/mcp_guard.go` - `mcpEffectiveScopes` shared by `MCPAllows` and `MCPDispatch`
|
||||
- `../sm-user-plugin/plugin.go` - second publish as `pact.MemberTokenScopeCatalog`
|
||||
- `../sm-user-plugin/go.mod` - replace `../summercms.go` so the branch compiles against plan 01
|
||||
- `../sm-user-plugin/mcp_implies_test.go` - OAuth, personal-token, write, duplicate, and compile-rejection tests
|
||||
- `../sm-user-plugin/README.md` - `Implies`, the OAuth-only expansion, and `pact.MemberTokenScopeCatalog`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The tests are in the plugin root package (`mcp_implies_test.go`), not `classes/mcp_guard_test.go`. The plan's verify is `go test .`, which does not compile `classes/`.
|
||||
- `MCPAllows` and `MCPDispatch` need a principal before they can return success. Docker-backed tests skip under `-short` (CLAUDE.md), and the verify command is `go test -short`, so the tests answer one user and no group grants from an in-memory `database/sql` driver. No new module dependency.
|
||||
- The existing `go.mod` replace pointed at `../../../../summercms.go` (a submodule layout). This checkout is a sibling of `summercms.go`, so the replace is `../summercms.go`, as the plan required. Do not tag that path as a release for app submodules.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Tests live in the plugin root package**
|
||||
- **Found during:** Task 1 (tracer)
|
||||
- **Issue:** The plan named `classes/mcp_guard_test.go`, but `go -C <plugin> test .` runs package `user`. A test under `classes/` makes that command print `[no tests to run]`.
|
||||
- **Fix:** `TestMCPImpliesOAuth` and the task 2 cases are in `mcp_implies_test.go` at the plugin root.
|
||||
- **Files modified:** `mcp_implies_test.go`
|
||||
- **Verification:** `go test -short -count=1 -run 'TestMCP' .` exits 0; `-v` shows `--- PASS: TestMCPImpliesOAuth`
|
||||
- **Committed in:** `25fd981` (task 1), `193a381` (task 2)
|
||||
|
||||
**2. [Rule 3 - Blocking] In-memory principal instead of testcontainers**
|
||||
- **Found during:** Task 1 (tracer)
|
||||
- **Issue:** A passing `MCPAllows` / `MCPDispatch` loads the user. The admin harness skips under `-short`, so a Docker test would not produce a PASS on the mandated command. CLAUDE.md says Docker-backed tests skip under `-short`.
|
||||
- **Fix:** The tests publish a `*gorm.DB` over a tiny in-memory driver that returns one user row and no group grants. Scope expansion does not read the database.
|
||||
- **Files modified:** `mcp_implies_test.go`
|
||||
- **Verification:** `TestMCPImpliesOAuth` PASS under `-short` with no container
|
||||
- **Committed in:** `25fd981`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (2 blocking)
|
||||
**Impact on plan:** Both keep the mandated `go test -short` command able to prove the behavior. No production path changed beyond the plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. STATE.md was updated with phase-scoped handlers where they exist, and was not committed.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 03 can `Lookup` `pact.MemberTokenScopeCatalog` from an app that boots this branch. The catalog includes built-ins (empty `Implies`) then declared scopes.
|
||||
|
||||
Plan 05 should extend `mcp_implies_test.go` in the plugin root, not add `classes/mcp_guard_test.go`, or its `go test .` verify will not see the new tests. These tests already exist and must not be redeclared in package `user`: `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesAlreadyPresent`, `TestMCPImpliesCompileRejects`. `TestMCPImpliesNotPersisted` is not a separate test yet; `TestMCPImpliesOAuth` already checks that `subject.Scopes` keeps its original slice header.
|
||||
|
||||
The app submodule `sm-demo-app/plugins/golem15/user` is still `07196a4` with no diff. This branch was not pushed or tagged. The `../summercms.go` replace is for this sibling checkout only.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../sm-user-plugin/classes/mcp_guard.go
|
||||
- FOUND: ../sm-user-plugin/classes/api_token_scopes.go
|
||||
- FOUND: ../sm-user-plugin/plugin.go
|
||||
- FOUND: ../sm-user-plugin/mcp_implies_test.go
|
||||
- FOUND: ../sm-user-plugin/README.md
|
||||
- FOUND: ../sm-user-plugin/go.mod
|
||||
- FOUND: 25fd981
|
||||
- FOUND: 193a381
|
||||
- Demo submodule HEAD 07196a4, status clean
|
||||
- `go test -short -count=1 -run 'TestMCP' .` in the plugin: exit 0
|
||||
|
||||
---
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
*Completed: 2026-10-28*
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
phase: 32.1
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 32.1-01
|
||||
- 32.1-02
|
||||
files_modified:
|
||||
- ../sm-oauthserver-plugin/config/config.yaml
|
||||
- ../sm-oauthserver-plugin/servers.go
|
||||
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
- ../sm-oauthserver-plugin/classes/mcp_store.go
|
||||
- ../sm-oauthserver-plugin/classes/mcp_scopes.go
|
||||
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
- ../sm-oauthserver-plugin/controllers/authorizations.go
|
||||
- ../sm-oauthserver-plugin/lang/en/lang.yaml
|
||||
- ../sm-oauthserver-plugin/lang/pl/lang.yaml
|
||||
- ../sm-oauthserver-plugin/README.md
|
||||
- ../sm-oauthserver-plugin/authorizations_smoke_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_scopes_test.go
|
||||
- ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
|
||||
autonomous: false
|
||||
requirements: []
|
||||
estimate:
|
||||
tokens: 64000
|
||||
raw_tokens: 64000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1: golem15.oauthserver.mcp_scopes unset advertises every declared member scope; a set list advertises only those names; an empty list advertises the five fixed names. Fixed names read, write, ai, offline_access, backend are always present. An unknown name fails boot (D-03, D-04)."
|
||||
- "SC-3: An MCP allow upserts golem15_oauthserver_consents for that user and client, replacing scopes. It does not call SaveConsent. A later allow of a smaller set drops the unticked names (D-08, D-12)."
|
||||
- "SC-3: Revoke takes lockUserGrants, deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens of the pair, then emits MCPSubjectRevoked with ClientID and a zero TokenID (D-12, D-15)."
|
||||
- "GET /oauth/authorizations entries include kind oidc or mcp as the last key. A POST of decision allow with no scopes field still grants the whole request (D-13, D-06)."
|
||||
- "offline_access stays a column flag. The pending view surfaces it. IssueCode's signature is unchanged (D-06)."
|
||||
artifacts:
|
||||
- path: ../sm-oauthserver-plugin/config/config.yaml
|
||||
provides: "golem15.oauthserver.mcp_scopes"
|
||||
contains: "mcp_scopes"
|
||||
- path: ../sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
provides: "MCPScopesSupported on the existing verifier value"
|
||||
contains: "MCPScopesSupported"
|
||||
- path: ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
provides: "scope_details, logo_url, consent replace"
|
||||
contains: "scope_details"
|
||||
- path: ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
|
||||
provides: "idempotent MCP consent backfill"
|
||||
contains: "golem15_oauthserver_consents"
|
||||
key_links:
|
||||
- from: servers.go NewServers
|
||||
to: classes/mcp_verifier.go MCPScopesSupported
|
||||
via: the same slice assigned to mcpOpts.ScopesSupported
|
||||
pattern: MCPScopesSupported
|
||||
- from: controllers/mcp_consent_api.go MCPConsentDecide
|
||||
to: golem15_oauthserver_consents
|
||||
via: replace upsert after IssueCode, not SaveConsent
|
||||
pattern: ON CONFLICT
|
||||
- from: controllers/authorizations.go revoke
|
||||
to: golem15_oauthserver_mcp_codes
|
||||
via: lockUserGrants then Delete of MCPCode
|
||||
pattern: lockUserGrants
|
||||
---
|
||||
|
||||
<objective>
|
||||
sm-oauthserver-plugin closes SC-1's host filter and SC-3's consent row (D-02, D-03, D-04, D-06, D-07, D-08, D-12, D-13, D-14, D-15). The MCP server's one scope slice is the fixed five plus declared names filtered by golem15.oauthserver.mcp_scopes. Consent JSON gains scope_details and logo_url. A decision replaces the stored scopes. Revoke deletes MCP codes under the grants lock. kind is additive. The backfill is one-way and waits for the checkpoint in this plan.
|
||||
|
||||
Purpose: an agent that consents over MCP can be revoked per client, and the protected-resource list cannot drift from the authorization server.
|
||||
Output: plugin code, phrases, README, the authorizations key-list update, and, after the checkpoint, the backfill migration. Behaviour proofs beyond the existing smoke tests are plan 05.
|
||||
|
||||
Discretion recorded here:
|
||||
- An opt-in name that no plugin declares fails boot. The error names the key golem15.oauthserver.mcp_scopes and the unknown name. A warning would start a server whose metadata disagrees with the file (D-03).
|
||||
- kind is always the string models.ClientKindOIDC or models.ClientKindMCP ("oidc" or "mcp"), never omitted, and it is the last JSON key (D-13).
|
||||
|
||||
Do not bump go.mod require versions to unreleased tags. The existing replace of summercms to ../summercms.go stays. Do not edit views/consent.html.tmpl. Do not edit wristband. Do not force read into a grant.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
|
||||
@../sm-oauthserver-plugin/servers.go
|
||||
@../sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
@../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
@../sm-oauthserver-plugin/controllers/authorizations.go
|
||||
@../sm-oauthserver-plugin/classes/oidc_store.go
|
||||
@../sm-oauthserver-plugin/classes/mcp_store.go
|
||||
@../sm-oauthserver-plugin/classes/mcp_backend_binding.go
|
||||
@../sm-oauthserver-plugin/config/config.yaml
|
||||
@modules/pier/config.go
|
||||
@modules/pact/mcp.go
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: one MCP scope slice from config to metadata and the verifier</name>
|
||||
<precondition>/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/go.mod exists and its replace points at ../summercms.go. Plan 01's pact.MCPAdvertisedScopes is in that tree.</precondition>
|
||||
<reversibility rating="costly">D-03: scope names published in metadata and later stored on grants are costly to retract.</reversibility>
|
||||
<files>../sm-oauthserver-plugin/config/config.yaml, ../sm-oauthserver-plugin/servers.go, ../sm-oauthserver-plugin/classes/mcp_scopes.go, ../sm-oauthserver-plugin/classes/mcp_verifier.go, ../sm-oauthserver-plugin/mcp_scopes_test.go</files>
|
||||
<read_first>
|
||||
../sm-oauthserver-plugin/servers.go NewServers (mcpOpts := opts then append backend)
|
||||
../sm-oauthserver-plugin/config/config.yaml header (SUMMER_ double-underscore env spelling) and the mcp: block
|
||||
../sm-oauthserver-plugin/classes/mcp_verifier.go MCPTokenVerifier
|
||||
modules/pier/config.go originsKey (the []any / []string / comma-string switch to copy)
|
||||
modules/compass config Has versus String (String is empty for both missing and empty)
|
||||
modules/pact/mcp.go MCPAdvertisedScopes
|
||||
</read_first>
|
||||
<action>
|
||||
Add config key mcp_scopes under the mcp block in config/config.yaml, with a comment that unset means every declared member scope, a YAML list means only those declared names, and an empty list means the five fixed names only. Document the env spelling SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES. Do not change mcp.enabled's default of false.
|
||||
|
||||
Add an unexported builder, called from NewServers, that returns the MCP slice and an error. Order is always read, write, ai, offline_access, backend, then declared names in catalog order. Declared names are MemberTokenScopes entries whose name is not read, write or ai. Lookup pact.MemberTokenScopeCatalog. A missing catalog means no declared names. When Config.Has("golem15.oauthserver.mcp_scopes") is false, include every declared name. When it is true, include only declared names the list contains. Read the value with Lookup, accepting []string, []any of strings, or a comma-separated string, the same three shapes originsKey accepts. An unknown name fails boot with an error that contains golem15.oauthserver.mcp_scopes and that name. Do not filter the five fixed names. Never return an empty slice.
|
||||
|
||||
Assign that slice only to mcpOpts.ScopesSupported. mcpOpts := opts is already a struct copy. Do not assign cfg.OIDC.ScopesSupported and do not append backend a second time (D-02). Store the same slice on the MCPTokenVerifier value NewServers already builds, and add MCPScopesSupported() []string so that value implements pact.MCPAdvertisedScopes (D-04). Publish stays the existing verifier publish; the extra method is on that concrete value, not a new method of pact.MCPTokenVerifier.
|
||||
|
||||
TestMCPAdvertisedScopes in mcp_scopes_test.go, no Docker: a fake catalog with acme:read, key unset, yields the five fixed names plus acme:read; key set to acme:read yields the same; key set to empty yields the five only; key set to not-declared returns an error naming the key; the OIDC server's scopes stay openid, profile, email. Hit Metadata on the MCP server from NewServers for the unset case and compare scopes_supported to MCPScopesSupported. Commit in the plugin repo only, those files. Do not git add -A. Do not push or tag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -short -count=1 -run '^TestMCPAdvertisedScopes$' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- TestMCPAdvertisedScopes PASS covers unset, set, empty, and unknown name.
|
||||
- OIDC discovery scopes in that test stay openid, profile, email.
|
||||
- mcpOpts.ScopesSupported is never empty, including the empty-catalog case.
|
||||
</acceptance_criteria>
|
||||
<done>Metadata and the verifier advertise one non-empty slice, and an unknown mcp_scopes name fails boot.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Consent JSON, replace-not-merge, offline_access, revoke, and kind</name>
|
||||
<precondition>docker info exits 0. The plugin tests use testcontainers Postgres and skip the database when Docker is down.</precondition>
|
||||
<reversibility rating="reversible">Consent JSON fields and the revoke delete list are code. The backfill that writes rows on every install is the next task, not this one.</reversibility>
|
||||
<files>../sm-oauthserver-plugin/controllers/mcp_consent_api.go, ../sm-oauthserver-plugin/controllers/authorizations.go, ../sm-oauthserver-plugin/classes/mcp_store.go, ../sm-oauthserver-plugin/lang/en/lang.yaml, ../sm-oauthserver-plugin/lang/pl/lang.yaml, ../sm-oauthserver-plugin/README.md, ../sm-oauthserver-plugin/authorizations_smoke_test.go</files>
|
||||
<read_first>
|
||||
../sm-oauthserver-plugin/controllers/mcp_consent_api.go (show body, grantedScopes, IssueCode call, WithConsentedBackend)
|
||||
../sm-oauthserver-plugin/classes/oidc_store.go SaveConsent (unions; do not call it) and TouchConsent
|
||||
../sm-oauthserver-plugin/classes/mcp_store.go MarkIssued (does not write offline_access today)
|
||||
../sm-oauthserver-plugin/classes/mcp_backend_binding.go WithConsentedBackend and lockUserGrants
|
||||
../sm-oauthserver-plugin/controllers/authorizations.go revoke and the authorization struct
|
||||
../sm-oauthserver-plugin/authorizations_smoke_test.go (len of keys is 6)
|
||||
../sm-oauthserver-plugin/mcp_consent_smoke_test.go the subtest that posts scopes backend and expects 422
|
||||
../sm-oauthserver-plugin/models/client.go ClientKindOIDC and ClientKindMCP
|
||||
modules/phrasebook translator Get
|
||||
</read_first>
|
||||
<action>
|
||||
Keep scopes as []string. Add scope_details objects with name, label, description, locked, and add logo_url on the client object (D-07). locked is true only for read when read was requested. logo_url is classes.ClientLogoURL, empty string when none. Labels: if the catalog text contains "::", resolve it with phrasebook.Translator.Get; otherwise use the literal. backend and offline_access are not in the member catalog. Add phrase keys under consent.mcp in lang/en/lang.yaml and lang/pl/lang.yaml. English: backend label "Also act as an administrator", description "The agent may use the administrator account this page names."; offline_access label "Stay connected", description "The agent may refresh its access without asking you to sign in again." Polish: backend label "Działaj też jako administrator", description "Agent może używać konta administratora, które wskazuje ta strona."; offline_access label "Pozostań połączony", description "Agent może odnawiać dostęp bez ponownego logowania."
|
||||
|
||||
When PendingRequestView.OfflineAccess is true, include offline_access in the show scopes array and in scope_details (locked false). grantedScopes intersects the submitted list with that augmented requested list, not only view.ScopesRequested. Strip the word offline_access before IssueCode. Do not change IssueCode's signature. A submit whose only remaining scope is offline_access still returns 422 no_grantable_scopes. Do not special-case read inside grantedScopes. The existing smoke subtest that posts scopes backend for a read-plus-backend request with no link must still get 422 no_grantable_scopes (D-06).
|
||||
|
||||
Follow WithConsentedBackend with a context value the MCP store reads in MarkIssued. When the POST body omits the scopes field, do not set the override, so the pending row's offline_access flag is kept (old all-or-nothing clients). When scopes is present, set the column from whether offline_access survived the intersection. Absent context means MarkIssued leaves the column as the pending row has it.
|
||||
|
||||
D-08 and D-12, after IssueCode returns nil: load the consent for (userID, clientID). Compare the requested set (data scopes plus offline_access when that flag was on the pending view) to the stored scopes. If a row exists and nothing requested is missing, call TouchConsent only and do not change scopes. If a row exists and something is missing, or there is no row, insert or replace scopes with the granted set, including the word offline_access when the flag was granted. Replace means the stored array becomes the granted set, not a union. Do not call oidc SaveConsent. Deny does not change the row. Add additive consent_required on the show body: false when a row exists and nothing requested is missing, true otherwise. Do not skip the authorize redirect; the user is known only on these jwt.auth routes.
|
||||
|
||||
A POST with decision allow and no scopes field still grants every requested data scope (D-06, old pages). Do not require scope_details or kind on the request.
|
||||
|
||||
revoke (D-12, D-15): inside the existing transaction, call classes.lockUserGrants for the user before the deletes. Then delete the consent, models.AuthCode, models.AccessToken, models.RefreshToken, and models.MCPCode for the same user_id and client_id. Keep 404 errNotFound when no consent exists. After commit, keep the existing MCPSubjectRevoked event with Guard user, ClientID set, and TokenID left zero. Do not add a second event or a second backend revoke. One delete of the access and refresh rows covers backend_user_id on those rows.
|
||||
|
||||
GET /oauth/authorizations: select c.kind, set authorization.Kind with json kind, last field. Always set it from the client kind, oidc or mcp. Update authorizations_smoke_test.go so the key list is client_id, name, logo_url, scopes, first_authorized_at, last_used_at, kind, in that order. The length check must match the new list. Existing OIDC rows in that test get kind oidc.
|
||||
|
||||
README: document mcp_scopes (unset, set, empty, fixed five always, boot error on an unknown name), scope_details and logo_url, consent replace versus the OIDC union, offline_access as a column, kind, and that revoke deletes MCP codes under lockUserGrants. Say that a non-nil DCR scope ceiling still drops names outside that ceiling and this phase does not rewrite old ceilings. Do not name a consuming application. Do not log bearers, codes, or refresh tokens. Commit in the plugin repo only. Do not push or tag. Do not write the backfill file in this task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 240s -run 'TestMCPConsentSmoke|TestAuthorizationsSmoke|TestMCPAdvertisedScopes' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestAuthorizationsSmoke has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Show JSON still has scopes as a string array, plus scope_details and client.logo_url.
|
||||
- MCPConsentDecide does not call SaveConsent.
|
||||
- authorizations_smoke_test.go expects seven keys ending in kind.
|
||||
- The backend-only consent POST still expects 422 no_grantable_scopes.
|
||||
- revoke deletes models.MCPCode after lockUserGrants, and the revoked event has no TokenID.
|
||||
</acceptance_criteria>
|
||||
<done>Consent, revoke, and the authorizations list behave as D-06 through D-13 and D-15, and the existing smokes pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="write-noop-rollback">
|
||||
<name>Confirm the one-way MCP consent backfill</name>
|
||||
<reversibility rating="one-way">D-14 writes consent rows into golem15_oauthserver_consents on every install. A later rollback cannot tell those rows from consents the new code created.</reversibility>
|
||||
<decision>Write the D-14 backfill that inserts one golem15_oauthserver_consents row per live MCP user and client, with Rollback as a no-op?</decision>
|
||||
<context>D-14 is already the chosen behaviour. This checkpoint is the one-way door before the migration file exists. Rollback must not delete every MCP consent, because that would also delete consents created after the migration. The next task writes the file only if write-noop-rollback is accepted. On resume, before that task creates or refuses the migration, write `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt` containing only the accepted option id, `write-noop-rollback` or `hold`. Do not create the migration in this task.</context>
|
||||
<options>
|
||||
<option id="write-noop-rollback">
|
||||
<name>Write the backfill, Rollback is a no-op</name>
|
||||
<pros>Live MCP grants become revocable. A second migrate inserts nothing. Rollback cannot destroy later consents.</pros>
|
||||
<cons>Rows stay after a down migration. Undo is a hand-written delete, not the migration Rollback.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Do not write the migration in this run</name>
|
||||
<pros>No rows are inserted on install until a later explicit decision.</pros>
|
||||
<cons>Grants that predate this release still have no consent row, so per-client revoke answers 404 for them. D-14 stays unmet.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Select write-noop-rollback or hold</resume-signal>
|
||||
<read_first>
|
||||
.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md D-14
|
||||
../sm-oauthserver-plugin/updates/202610230001_add_backend_user_id.go
|
||||
</read_first>
|
||||
<acceptance_criteria>
|
||||
- The accepted option is recorded before any backfill file is created.
|
||||
- That record is `32.1-03-backfill-decision.txt` in this phase directory and its entire text is `write-noop-rollback` or `hold`.
|
||||
- hold means the next task does not add a migration.
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Backfill MCP consents for live grants</name>
|
||||
<reversibility rating="one-way">D-14 inserts consent rows on every consuming install. Rollback is a no-op and does not delete those rows.</reversibility>
|
||||
<files>../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt</files>
|
||||
<read_first>
|
||||
../sm-oauthserver-plugin/updates/202610230001_add_backend_user_id.go (Register pattern)
|
||||
../sm-oauthserver-plugin/models/consent.go
|
||||
../sm-oauthserver-plugin/models/access_token.go
|
||||
../sm-oauthserver-plugin/models/refresh_token.go
|
||||
../sm-oauthserver-plugin/models/client.go ClientKindMCP
|
||||
</read_first>
|
||||
<action>
|
||||
Run this task only when the checkpoint option write-noop-rollback was accepted. If the accepted option is hold, stop without creating a migration file and say so in the summary. Do not rewrite `32.1-03-backfill-decision.txt` after this task starts changing the plugin tree. If you resumed and that record is not on disk yet, write the accepted option id into it before creating or deleting the migration, then leave the record unchanged. The verify reads that record and fails when it is absent or disagrees with the migration file.
|
||||
|
||||
Add updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go with migration ID 202610280001_backfill_golem15_oauthserver_mcp_consents, registered from init via Register, so it sorts after 202610230001_add_golem15_oauthserver_backend_user_id. Migrate, in Go on the transaction: one row per (user_id, client_id) where the client kind is mcp and the pair has at least one live access or refresh token. Live means revoked_at IS NULL and (expires_at IS NULL or expires_at is in the future). Skip the pair when any consent row already exists. scopes is the union of those tokens' scopes arrays; if any live token has offline_access true, include the string offline_access. created_at and updated_at are the earliest created_at among the live tokens. last_used_at stays null. A second run inserts nothing. Rollback returns nil and its comment says the writes are one-way and a down migration must not delete MCP consents. Do not delete token rows. Commit in the plugin repo only. Do not push or tag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python3 -c 'import pathlib,sys; rec=pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt"); mig=pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go");
|
||||
assert rec.is_file(), "checkpoint record absent"
|
||||
opt=rec.read_text().strip()
|
||||
exists=mig.is_file()
|
||||
assert opt in ("write-noop-rollback","hold"), "checkpoint record is not an option id"
|
||||
if opt=="hold":
|
||||
assert not exists, "hold but migration file exists"
|
||||
sys.exit(0)
|
||||
text=mig.read_text() if exists else ""
|
||||
assert exists, "write-noop-rollback but migration file missing"
|
||||
for s in ("202610280001_backfill_golem15_oauthserver_mcp_consents","Register","golem15_oauthserver_consents"):
|
||||
assert s in text, "migration missing "+s
|
||||
i=text.find("Rollback")
|
||||
assert i>=0, "Rollback missing"
|
||||
rest=text[i:]
|
||||
nl=rest.find("\nfunc ")
|
||||
body=rest if nl<0 else rest[:nl]
|
||||
assert ".Delete(" not in body and "DELETE FROM" not in body, "Rollback deletes"
|
||||
assert "return nil" in body, "Rollback does not return nil"'</automated>
|
||||
<fails_when>non-zero exit, stdout or stderr contains checkpoint record absent, checkpoint record is not an option id, hold but migration file exists, write-noop-rollback but migration file missing, migration missing, Rollback missing, Rollback deletes, or Rollback does not return nil</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- When write-noop-rollback was accepted, the migration ID sorts after 202610230001_add_golem15_oauthserver_backend_user_id, Rollback does not delete consents, and the file is committed in the plugin repo.
|
||||
- When hold was accepted, that file does not exist.
|
||||
</acceptance_criteria>
|
||||
<done>D-14 is either written as an idempotent no-op-rollback migration or explicitly held. It is not written before the checkpoint.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser POST /oauth/mcp/requests/{request} | Signed-in user submits decision and scopes. The body is untrusted. |
|
||||
| DELETE /oauth/authorizations/{client_id} | Signed-in user names a client. Another user's row must stay. |
|
||||
| Operator config mcp_scopes | Names enter metadata and the allow-list at boot. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32.1-06 | Elevation of privilege | MCP consent upsert | high | mitigate | Replace scopes on a real decision. Do not call SaveConsent, which unions. Silent re-grant only TouchConsent. Deny does not change the row (D-08). |
|
||||
| T-32.1-07 | Elevation of privilege | authorizations.revoke | high | mitigate | lockUserGrants exclusive inside the transaction before deletes. Delete models.MCPCode for the pair plus consent, auth codes, access and refresh. Event is MCPSubjectRevoked with ClientID and zero TokenID (D-12, D-15). |
|
||||
| T-32.1-08 | Information disclosure | PendingRequest / IssueCode | high | mitigate | Keep owner checks. Missing, foreign, expired and used stay wristband.ErrPendingNotFound. Do not add a lookup by request id alone. |
|
||||
| T-32.1-09 | Tampering | grantedScopes when scopes is absent | medium | mitigate | A POST of decision allow without scopes still grants the whole requested set. scope_details and kind are response fields. Old pages ignore them. |
|
||||
| T-32.1-10 | Information disclosure | consent and revoke logs | medium | mitigate | Do not log the bearer, the authorization code, or a refresh token. Errors stay the existing JSON codes. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
TestMCPAdvertisedScopes passes under -short. TestMCPConsentSmoke and TestAuthorizationsSmoke pass against Postgres. The backfill file exists only after write-noop-rollback.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- One scope slice drives the MCP server and the verifier.
|
||||
- Consent replace, offline_access, revoke-deletes-MCP-code, and kind are in the plugin.
|
||||
- D-14 is gated and, when accepted, idempotent with a no-op rollback.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Config key golem15.oauthserver.mcp_scopes and env SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES
|
||||
- Unexported advertised-scope builder used by NewServers
|
||||
- MCPTokenVerifier.MCPScopesSupported
|
||||
- Consent JSON fields scope_details (name, label, description, locked), client.logo_url, consent_required
|
||||
- Phrase keys consent.mcp.backend, consent.mcp.backend_description, consent.mcp.offline_access, consent.mcp.offline_access_description in en and pl
|
||||
- Context override read by mcpTx.MarkIssued for the offline_access column
|
||||
- Replace upsert into golem15_oauthserver_consents
|
||||
- authorization.kind json kind
|
||||
- revoke deletes golem15_oauthserver_mcp_codes under lockUserGrants
|
||||
- Migration ID 202610280001_backfill_golem15_oauthserver_mcp_consents when the checkpoint accepts it
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
plan: 03
|
||||
subsystem: auth
|
||||
tags: [mcp, oauth, consent, gormigrate, sm-oauthserver-plugin]
|
||||
|
||||
requires:
|
||||
- phase: 32.1-01
|
||||
provides: pact.MCPAdvertisedScopes and offline access on the pending consent view
|
||||
- phase: 32.1-02
|
||||
provides: pact.MemberTokenScopeCatalog from sm-user-plugin
|
||||
provides:
|
||||
- "One MCP scope slice from golem15.oauthserver.mcp_scopes drives metadata and MCPTokenVerifier.MCPScopesSupported"
|
||||
- "MCP consent replaces stored scopes, revoke deletes MCP codes under lockUserGrants, and authorizations carry kind"
|
||||
- "Migration 202610280001 backfills one consent per live MCP grant and its Rollback does not delete rows"
|
||||
affects: [32.1-05]
|
||||
|
||||
actuals:
|
||||
tokens: 21059
|
||||
tasks: 4
|
||||
commits: 0
|
||||
plan_head_before: 3f52b1ab495b487b03b8e534bdfc6b6676104b00
|
||||
plan_head_after: 3f52b1ab495b487b03b8e534bdfc6b6676104b00
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "MCP scope slice is built once in NewServers and stored on the verifier; OIDC scopes stay untouched"
|
||||
- "MCP consent replace writes golem15_oauthserver_consents directly and does not call SaveConsent"
|
||||
- "The D-14 backfill is one-way: Rollback returns nil and does not delete consents"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
|
||||
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt
|
||||
modified:
|
||||
- ../sm-oauthserver-plugin/classes/mcp_scopes.go
|
||||
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
- ../sm-oauthserver-plugin/controllers/authorizations.go
|
||||
- ../sm-oauthserver-plugin/README.md
|
||||
|
||||
key-decisions:
|
||||
- "Checkpoint write-noop-rollback: backfill live MCP grants into golem15_oauthserver_consents, and Rollback returns nil so a down migration cannot delete consents created later"
|
||||
- "Scope union walks live tokens by created_at (nulls last), then access before refresh, then id; offline_access is appended when any live refresh token has that column set"
|
||||
|
||||
patterns-established:
|
||||
- "Pattern: an unknown golem15.oauthserver.mcp_scopes name fails boot and names the key"
|
||||
- "Pattern: MCP revoke takes lockUserGrants, deletes the consent and MCP codes, and emits MCPSubjectRevoked with a zero TokenID"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Unset mcp_scopes advertises the five fixed names plus every declared member scope; a set list keeps only those declared names; an empty list is the five fixed names; an unknown name fails boot. OIDC discovery scopes stay openid, profile, email."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: mcp_scopes_test.go#TestMCPAdvertisedScopes
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "MCP consent show adds scope_details and client.logo_url, an allow replaces stored scopes instead of calling SaveConsent, revoke deletes MCP codes under lockUserGrants, and GET /oauth/authorizations ends with kind."
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: mcp_consent_smoke_test.go#TestMCPConsentSmoke
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: authorizations_smoke_test.go#TestAuthorizationsSmoke
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "The accepted checkpoint is write-noop-rollback. Migration 202610280001_backfill_golem15_oauthserver_mcp_consents is registered, names golem15_oauthserver_consents, and its Rollback returns nil and does not delete."
|
||||
verification:
|
||||
- kind: other
|
||||
ref: python3 checkpoint record and migration text gate in 32.1-03-PLAN.md task 4
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The plan gate checks the decision file and that Rollback returns nil without a delete. It does not insert a live token and migrate twice. Plan 05 TestMCPConsentBackfillIdempotent is the row-level proof."
|
||||
|
||||
duration: 24min
|
||||
completed: 2026-10-28
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32.1 Plan 03: MCP scope slice, consent replace, and one-way backfill Summary
|
||||
|
||||
**One MCP scope slice drives metadata and the verifier, consent replace and revoke delete MCP codes, and a no-op-rollback migration backfills consents for live grants**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 24 min
|
||||
- **Started:** 2026-10-28T14:18:42Z
|
||||
- **Completed:** 2026-10-28T14:43:00Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 17
|
||||
|
||||
The clock starts at the task 1 commit. This session resumed after the checkpoint.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `golem15.oauthserver.mcp_scopes` filters declared member scopes. The fixed names `read`, `write`, `ai`, `offline_access`, and `backend` are always advertised, in that order, then declared names. An unknown name fails boot and names the key. `MCPTokenVerifier.MCPScopesSupported` returns that same slice. OIDC scopes are not rewritten.
|
||||
- An MCP allow replaces `golem15_oauthserver_consents` for the user and client and does not call `SaveConsent`. `offline_access` stays a column, copied into the consent scope list when it was granted. Revoke takes `lockUserGrants`, deletes the consent, OIDC codes, MCP codes, access tokens, and refresh tokens, then emits `MCPSubjectRevoked` with `ClientID` set and `TokenID` left zero. `GET /oauth/authorizations` always ends with `kind` `oidc` or `mcp`.
|
||||
- Checkpoint option `write-noop-rollback` was accepted. The decision file was written before the migration existed. `202610280001_backfill_golem15_oauthserver_mcp_consents` inserts one consent per MCP user and client that has a live access or refresh token and no consent yet. Live means `revoked_at` is null and `expires_at` is null or still in the future. A second run inserts nothing. `Rollback` returns nil and does not delete consents.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Plugin repo `sm-oauthserver-plugin` on `master`. summercms.go had no commits in the measured range `3f52b1ab495b487b03b8e534bdfc6b6676104b00..HEAD` at summary time (`commits: 0`). The decision record is committed with this summary.
|
||||
|
||||
1. **Task 1: Advertise one MCP scope slice** - `644641a` (feat)
|
||||
2. **Task 2: MCP consent replace, revoke, and kind** - `52529c6` (feat)
|
||||
3. **Task 3: Confirm the one-way MCP consent backfill** - option `write-noop-rollback` recorded in `32.1-03-backfill-decision.txt` before the migration file was created. No plugin commit.
|
||||
4. **Task 4: Backfill MCP consents for live grants** - `a409074` (feat)
|
||||
|
||||
**Plan metadata:** this summary, the decision record, and the roadmap progress update.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-oauthserver-plugin/config/config.yaml` - `mcp_scopes` and the `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES` spelling
|
||||
- `../sm-oauthserver-plugin/classes/mcp_scopes.go` - the one advertised slice and the unknown-name boot error
|
||||
- `../sm-oauthserver-plugin/classes/mcp_verifier.go` - `MCPScopesSupported`
|
||||
- `../sm-oauthserver-plugin/servers.go` - assigns that slice only to the MCP server
|
||||
- `../sm-oauthserver-plugin/mcp_scopes_test.go` - unset, set, empty, and unknown name
|
||||
- `../sm-oauthserver-plugin/controllers/mcp_consent_api.go` - `scope_details`, `logo_url`, `offline_access`, consent replace
|
||||
- `../sm-oauthserver-plugin/controllers/authorizations.go` - `kind` and revoke of MCP codes under `lockUserGrants`
|
||||
- `../sm-oauthserver-plugin/classes/mcp_store.go` - `MarkIssued` reads the offline-access override
|
||||
- `../sm-oauthserver-plugin/classes/mcp_backend_binding.go` - context value for that override
|
||||
- `../sm-oauthserver-plugin/lang/en/lang.yaml` - backend and offline_access consent phrases
|
||||
- `../sm-oauthserver-plugin/lang/pl/lang.yaml` - the same phrases in Polish
|
||||
- `../sm-oauthserver-plugin/README.md` - scope filter, replace versus the OIDC union, kind, revoke, and old DCR ceilings
|
||||
- `../sm-oauthserver-plugin/authorizations_smoke_test.go` - seven keys ending in `kind`
|
||||
- `../sm-oauthserver-plugin/mcp_consent_smoke_test.go` - smoke expectations kept in step with the new JSON
|
||||
- `../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go` - idempotent one-way consent backfill
|
||||
- `32.1-03-backfill-decision.txt` - the entire text is `write-noop-rollback`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The checkpoint accepted `write-noop-rollback`. Rollback must not delete every MCP consent, because those rows cannot be told from consents the new code creates after the migration. Undo is a hand-written delete, not the migration Rollback.
|
||||
- The union walks live tokens from earliest `created_at` (nulls last), access before refresh on a tie, then id. Empty scope names are dropped. `offline_access` is added at the end when any live refresh token has the column set and the word is not already present. Access tokens have no such column. `created_at` and `updated_at` are that earliest token time, or null when every live token has none. `last_used_at` stays null. A pair with any existing consent row is skipped, and the insert also uses `WHERE NOT EXISTS`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. A phase-scoped decision and metric may be recorded in STATE.md. STATE.md and state.json are not committed.
|
||||
|
||||
`go.work` in sm-oauthserver-plugin was already dirty (one replace line). It was not staged, reverted, or committed.
|
||||
|
||||
The plugin commit is on `master`, which is where tasks 1 and 2 of this plan already landed. This resume required that. Nothing was pushed or tagged.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 05 can add `TestMCPConsentBackfillIdempotent` against this migration. A live MCP access token with no consent should gain one row. A refresh token with `offline_access` true should add that word. A pair that already has a consent must be left unchanged. A second migrate inserts nothing. `Rollback` returns nil.
|
||||
|
||||
Plan 04 (vue-oauth-layer) can read `scope_details`, `logo_url`, `consent_required`, and `kind`. It does not depend on the backfill having run.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
|
||||
- FOUND: ../sm-oauthserver-plugin/config/config.yaml
|
||||
- FOUND: ../sm-oauthserver-plugin/classes/mcp_scopes.go
|
||||
- FOUND: ../sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
- FOUND: ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
- FOUND: ../sm-oauthserver-plugin/controllers/authorizations.go
|
||||
- FOUND: ../sm-oauthserver-plugin/README.md
|
||||
- FOUND: 32.1-03-backfill-decision.txt (`write-noop-rollback`)
|
||||
- FOUND: 644641a
|
||||
- FOUND: 52529c6
|
||||
- FOUND: a409074
|
||||
- Python task-4 gate: exit 0
|
||||
- `go test -count=1 -timeout 240s -run 'TestAuthorizationsSmoke|TestMCPAdvertisedScopes'` exit 0
|
||||
- `go test -count=1 -timeout 240s -run 'TestMCPConsentSmoke|TestMCPAdvertisedScopes'` exit 0
|
||||
- `TestAuthorizationsSmoke` PASS against Postgres, so the new migration applies on an empty database
|
||||
|
||||
---
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
*Completed: 2026-10-28*
|
||||
@@ -0,0 +1 @@
|
||||
write-noop-rollback
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
phase: 32.1
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on:
|
||||
- 32.1-03
|
||||
files_modified:
|
||||
- ../vue-oauth-layer/package.json
|
||||
- ../vue-oauth-layer/nuxt.config.ts
|
||||
- ../vue-oauth-layer/app/pages/connect.vue
|
||||
- ../vue-oauth-layer/app/utils/connectConsent.ts
|
||||
- ../vue-oauth-layer/tests/connect.test.ts
|
||||
- ../vue-oauth-layer/README.md
|
||||
- ../vue-oauth-layer/.playground/nuxt.config.ts
|
||||
autonomous: true
|
||||
requirements: []
|
||||
estimate:
|
||||
tokens: 42000
|
||||
raw_tokens: 42000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-3: The layer page /connect renders the MCP consent show JSON as checkboxes. read is locked in the UI. The server is not asked to force read into the grant (D-06, D-17)."
|
||||
- "Client name, logo, scope label, scope description and the admin name are text, never raw HTML (D-07)."
|
||||
- "backend shows the also-act-as-admin line when backend_admin is set. A 409 backend_link_changed fetches the show again (D-17)."
|
||||
- "No application /connect page is edited, replaced, or deleted. The repo is not tagged or pushed in this plan."
|
||||
artifacts:
|
||||
- path: ../vue-oauth-layer/app/pages/connect.vue
|
||||
provides: "Nuxt layer page /connect"
|
||||
contains: "connectConsent"
|
||||
- path: ../vue-oauth-layer/app/utils/connectConsent.ts
|
||||
provides: "Pure mapping from show JSON to POST body"
|
||||
contains: "allowBody"
|
||||
- path: ../vue-oauth-layer/tests/connect.test.ts
|
||||
provides: "node:test coverage of the mapping"
|
||||
contains: "node:test"
|
||||
key_links:
|
||||
- from: app/pages/connect.vue
|
||||
to: app/utils/connectConsent.ts
|
||||
via: the page imports the pure functions the tests import
|
||||
pattern: allowBody
|
||||
- from: allowBody
|
||||
to: POST /oauth/mcp/requests/{request}
|
||||
via: JSON decision, scopes, backend_admin
|
||||
pattern: decision
|
||||
---
|
||||
|
||||
<objective>
|
||||
Initial public Nuxt layer vue-oauth-layer, one plan (D-17, D-06, D-07). It provides /connect for redirect consent from the MCP consent JSON plan 03 returns. Apps adopt it later. This plan does not migrate any app and does not tag or push.
|
||||
|
||||
Purpose: a redirect-mode application can render per-scope consent without copying a page, while an application that already has /connect keeps its own page because Nuxt project files win over a layer.
|
||||
Output: a new repo beside vue-menu-layer, with node --test and a README. The first tag is plan 05's checkpoint, not this plan.
|
||||
|
||||
Copy vue-menu-layer's skeleton (fileURLToPath, node --test, no extra runtime dependencies), not vue-journal-layer. Sibling path of the template: /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer. New repo path: /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
|
||||
@../vue-menu-layer/package.json
|
||||
@../vue-menu-layer/nuxt.config.ts
|
||||
@../vue-menu-layer/tests/menuLinks.test.ts
|
||||
@../sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: show JSON maps to a locked-read allow body</name>
|
||||
<precondition>/media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer/package.json exists, and /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer is either absent or already this phase's new repo. Halt if that path is some other project.</precondition>
|
||||
<reversibility rating="costly">D-17: the layer's /connect props and POST body become the contract apps will pin by tag.</reversibility>
|
||||
<files>../vue-oauth-layer/package.json, ../vue-oauth-layer/nuxt.config.ts, ../vue-oauth-layer/app/utils/connectConsent.ts, ../vue-oauth-layer/app/pages/connect.vue, ../vue-oauth-layer/tests/connect.test.ts, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer</files>
|
||||
<read_first>
|
||||
../vue-menu-layer/package.json (engines, peer nuxt 4.4.8, dev vue 3.5.35 and typescript 5.9.3, test script)
|
||||
../vue-menu-layer/nuxt.config.ts (fileURLToPath)
|
||||
../vue-menu-layer/tests/menuLinks.test.ts (node:test and node:assert/strict importing a ts util)
|
||||
../sm-oauthserver-plugin/controllers/mcp_consent_api.go show and decide bodies, after plan 03
|
||||
</read_first>
|
||||
<action>
|
||||
Create the repo with git init at /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer. Do not add a remote, do not push, do not tag.
|
||||
|
||||
package.json name vue-oauth-layer, version 0.1.0, private true, type module, main ./nuxt.config.ts, engines node >= 22.18, peerDependency nuxt 4.4.8, devDependencies nuxt 4.4.8, typescript 5.9.3, vue 3.5.35. Script test is node --test --test-reporter=tap tests/*.test.ts. No other runtime dependency. Do not add sanitize-html, an SEO module, or i18n.
|
||||
|
||||
nuxt.config.ts builds every path with fileURLToPath(new URL('.', import.meta.url)). No relative layer path. No Nuxt modules. compatibilityDate 2025-07-15. devtools disabled.
|
||||
|
||||
app/utils/connectConsent.ts exports pure functions the page and the tests both import. scopeBoxes(show) returns one box per scope_details entry, each checked to start, with locked copied from the detail. allowBody(show, clearedNames) returns decision allow. When show.consent_required is false, the body is decision allow and omits scopes, so the server keeps the stored set. Otherwise scopes is the checked names. A box with locked true cannot be cleared: read stays in scopes when it was requested. denyBody returns decision deny. backendAdminLogin(show) is show.backend_admin.login when backend_admin is non-null and backend is among the scopes, else absent. shouldRefetch(status, errorCode) is true only for HTTP 409 and error backend_link_changed.
|
||||
|
||||
app/pages/connect.vue reads the request query, calls those functions, and posts JSON to /oauth/mcp/requests/{request} on the same origin with credentials included. It does not add a Nitro proxy. Render label, description, client name and admin name as text. Do not use v-html. The tracer page must already import connectConsent.ts so the test and the page share one implementation. A 401 redirects to the loginPath prop, default /login.
|
||||
|
||||
tests/connect.test.ts: one show payload with scope_details for read (locked true) and write (locked false), clearing write, allowBody scopes are read only. consent_required false yields a body without a scopes key. The test reads app/pages/connect.vue and fails if the source contains v-html. Run pnpm install in the new repo so the test script's node can resolve the package, then commit only inside vue-oauth-layer. Do not git add -A. Do not edit any app.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
|
||||
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- pnpm test exits 0 and the locked-read case passes.
|
||||
- connect.vue imports the same module the test imports.
|
||||
- No v-html in app/pages/connect.vue.
|
||||
- No app repository has a diff from this task.
|
||||
</acceptance_criteria>
|
||||
<done>A show payload becomes an allow POST that keeps locked read, and the page uses that function.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Finish /connect and the layer README</name>
|
||||
<reversibility rating="costly">The page copy and the README install line are what a later tag publishes (D-17).</reversibility>
|
||||
<files>../vue-oauth-layer/app/pages/connect.vue, ../vue-oauth-layer/app/utils/connectConsent.ts, ../vue-oauth-layer/tests/connect.test.ts, ../vue-oauth-layer/README.md, ../vue-oauth-layer/.playground/nuxt.config.ts, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer</files>
|
||||
<read_first>
|
||||
../vue-oauth-layer/app/utils/connectConsent.ts
|
||||
../vue-oauth-layer/app/pages/connect.vue
|
||||
../vue-menu-layer/README.md install and extends example, as the shape to copy with this layer's name
|
||||
../sm-oauthserver-plugin/controllers/mcp_consent_api.go backend_link_changed and backend_admin
|
||||
</read_first>
|
||||
<action>
|
||||
Complete the page (D-17). Show the client name and, when logo_url is non-empty, an img whose alt is the client name as text. Every requested scope starts checked. read's checkbox is disabled and checked, and the POST from a shown form always includes read when it was requested. The server does not force read; do not send a flag that would make it do so. offline_access, backend and declared scopes can be cleared. When backend_admin is non-null and backend is requested, show the sentence "also act as admin" followed by the admin name as text, and send backend_admin set to backend_admin.login. Allow posts decision allow. Deny posts decision deny. On 409 with error backend_link_changed, fetch the show JSON again and render it. When consent_required is false, post decision allow immediately and do not render checkboxes.
|
||||
|
||||
Do not log the user in. Do not read a product name from an app. loginPath prop default remains /login, documented as the handoff when the JSON route answers 401.
|
||||
|
||||
Tests cover: locked read stays in the POST when the box cannot be cleared; a cleared write is absent; consent_required false omits scopes; 409 backend_link_changed is the refetch signal and a 422 is not; backend_admin.login is sent only when the line is shown. The source check that rejects v-html stays.
|
||||
|
||||
README: what the layer is, the install shape pnpm add git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z, and extends ['vue-oauth-layer']. Say the tag does not exist until a later release the user accepts. Say an application's own app/pages/connect.vue wins over this layer, and this repo does not edit those pages. Do not name a consuming application. .playground/nuxt.config.ts extends the layer via fileURLToPath of the parent directory so a local dev server resolves the layer. No new dependency. Commit in the layer repo only. Do not push or tag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
|
||||
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The page implements allow, deny, locked read, the admin line, and the 409 re-show.
|
||||
- README shows the git pin shape and extends, and does not claim a tag was pushed.
|
||||
- pnpm test passes.
|
||||
</acceptance_criteria>
|
||||
<done>D-17's /connect behaviour is in the layer, tested, and not tagged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP consent JSON to the browser | Client name, labels and admin name are attacker-influenced (DCR name, phrase text). |
|
||||
| Page POST back to the consent route | The browser can omit fields. The server intersection is the control. This page must not widen it. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32.1-11 | Tampering | app/pages/connect.vue | high | mitigate | Render label, description, client name and admin name as text. The unit test fails if the page source contains v-html. Do not bind those strings as HTML. |
|
||||
| T-32.1-12 | Elevation of privilege | read checkbox | low | accept | The checkbox is disabled and the page's own POST includes read when the form was shown. A client that omits read only narrows the grant. The server must not reinsert read, because the existing 422 smoke posts backend alone. Narrowing is not a privilege gain. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
pnpm test in vue-oauth-layer passes. No app /connect file is modified. No tag and no push.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /connect maps the consent JSON, locks read in the UI, and refetches on backend_link_changed.
|
||||
- The layer has no runtime dependency beyond the Nuxt peer the menu layer already pins.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer
|
||||
- Repo /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer
|
||||
- app/pages/connect.vue
|
||||
- app/utils/connectConsent.ts exports scopeBoxes, allowBody, denyBody, backendAdminLogin, shouldRefetch
|
||||
- tests/connect.test.ts
|
||||
- Prop loginPath default /login
|
||||
- README install shape git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
plan: 04
|
||||
subsystem: auth
|
||||
tags: [nuxt, oauth, mcp, consent, vue-oauth-layer]
|
||||
|
||||
requires:
|
||||
- phase: 32.1-03
|
||||
provides: MCP consent show JSON with scope_details, logo_url, consent_required, and 409 backend_link_changed
|
||||
provides:
|
||||
- "Public Nuxt layer vue-oauth-layer with /connect for MCP redirect consent"
|
||||
- "allowBody keeps locked read, omits scopes when consent_required is false, and sends backend_admin.login only with the admin line"
|
||||
- "A 409 backend_link_changed refetches the show; the layer is not tagged or pushed"
|
||||
affects: [32.1-05]
|
||||
|
||||
actuals:
|
||||
tokens: 65479
|
||||
tasks: 2
|
||||
commits: 0
|
||||
plan_head_before: 23238ccd47b615529948d4f094713928272a308a
|
||||
plan_head_after: 23238ccd47b615529948d4f094713928272a308a
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "vue-oauth-layer copies the vue-menu-layer skeleton: fileURLToPath paths, nuxt 4.4.8 peer, node --test, no extra runtime dependency"
|
||||
- "The page and the tests import app/utils/connectConsent.ts; labels are text interpolations"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-oauth-layer/package.json
|
||||
- ../vue-oauth-layer/nuxt.config.ts
|
||||
- ../vue-oauth-layer/app/pages/connect.vue
|
||||
- ../vue-oauth-layer/app/utils/connectConsent.ts
|
||||
- ../vue-oauth-layer/tests/connect.test.ts
|
||||
- ../vue-oauth-layer/README.md
|
||||
- ../vue-oauth-layer/.playground/nuxt.config.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "The new repo's branch is main, matching the other Nuxt layers. It has no remote and no tag."
|
||||
- "A 409 backend_link_changed renders the show again and does not post immediately, so a stored consent cannot loop."
|
||||
- "backend_admin.login is sent when the admin line applies and backend stays in the grant, including when scopes are omitted because consent_required is false."
|
||||
|
||||
patterns-established:
|
||||
- "Pattern: scopeBoxes starts every scope_details entry checked and copies locked. allowBody drops cleared names except locked ones."
|
||||
- "Pattern: loginPath defaults to /login and is only the 401 handoff. The layer does not sign the user in."
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "/connect maps the MCP consent show JSON to checkboxes, keeps locked read in the allow POST, and renders client name, labels, descriptions, and the admin name as text."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: vue-oauth-layer/tests/connect.test.ts
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "The admin line, logo alt text, 409 refetch, and an immediate allow when consent_required is false are implemented and covered. The README shows the git pin shape and says the tag does not exist yet."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: vue-oauth-layer/tests/connect.test.ts
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: README.md install line and empty git tag list
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 14min
|
||||
completed: 2026-10-28
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32.1 Plan 04: vue-oauth-layer /connect Summary
|
||||
|
||||
**A new Nuxt layer renders MCP redirect consent at /connect, locks read in the UI, and refetches when the administrator link changes**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 14 min
|
||||
- **Started:** 2026-10-28T14:45:32Z
|
||||
- **Completed:** 2026-10-28T14:59:10Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 13
|
||||
|
||||
`actuals.tokens` is chars/4 of `git log -p` in vue-oauth-layer (261918 / 4). The pnpm lockfile is most of that diff. summercms.go has no production commits in the measured range.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created `/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer` with `git init -b main`. No remote, no tag, no push. Peer dependency is nuxt 4.4.8. Dev dependencies are nuxt 4.4.8, typescript 5.9.3, and vue 3.5.35. There is no other runtime dependency.
|
||||
- `app/utils/connectConsent.ts` maps show JSON to checkboxes and the POST body. Locked `read` stays in `scopes` when it was requested. `consent_required: false` omits `scopes`. `shouldRefetch` is true only for HTTP 409 and `backend_link_changed`.
|
||||
- `app/pages/connect.vue` imports that module, reads `?request=`, and calls `GET` and `POST /oauth/mcp/requests/{request}` on the same origin with credentials included. It does not add a Nitro proxy. Client name, scope label, scope description, logo alt, and the administrator name are text. The page source does not contain `v-html`. A 401 navigates to the `loginPath` prop, default `/login`.
|
||||
- When `backend_admin` is set and `backend` was requested, the page shows "also act as admin" and the administrator name, and the allow body sends `backend_admin.login` unless `backend` was cleared. A non-empty `logo_url` is an image. When `consent_required` is false the page posts allow immediately and does not render checkboxes. A 409 `backend_link_changed` loads the show again and renders it instead of posting again.
|
||||
- `pnpm test` passes: 8 tests, 0 failed.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Layer repo `vue-oauth-layer` on `main`. summercms.go had no commits in the measured range `23238ccd47b615529948d4f094713928272a308a..HEAD` at summary time (`commits: 0`).
|
||||
|
||||
1. **Task 1: Tracer: show JSON maps to a locked-read allow body** - `02c466e` (feat)
|
||||
2. **Task 2: Finish /connect and the layer README** - `37c1e12` (feat)
|
||||
3. **Rule 1 follow-up: empty redirect leaves the page ready** - `7f805b3` (fix)
|
||||
|
||||
**Plan metadata:** this summary and the roadmap progress update, committed in summercms.go after this file was written.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../vue-oauth-layer/package.json` - layer package, test script, pinned Nuxt peer
|
||||
- `../vue-oauth-layer/nuxt.config.ts` - absolute css path via fileURLToPath, no modules
|
||||
- `../vue-oauth-layer/app/utils/connectConsent.ts` - scopeBoxes, allowBody, denyBody, backendAdminLogin, shouldRefetch
|
||||
- `../vue-oauth-layer/app/pages/connect.vue` - /connect page
|
||||
- `../vue-oauth-layer/app/assets/css/connect.css` - neutral tokens for the page
|
||||
- `../vue-oauth-layer/tests/connect.test.ts` - node:test mapping and a source check against raw HTML
|
||||
- `../vue-oauth-layer/README.md` - install shape, extends, loginPath, and the unpushed tag
|
||||
- `../vue-oauth-layer/.playground/nuxt.config.ts` - playground extends the layer by an absolute path
|
||||
- `../vue-oauth-layer/tsconfig.json`, `.gitignore`, `.nvmrc`, `pnpm-workspace.yaml`, `pnpm-lock.yaml` - menu-layer skeleton so install and Vite resolve inside the layer
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The repository branch is `main`, the same branch the sibling layers use. `git init` alone would have created `master` because `init.defaultBranch` is master. The default-branch guard reports `main` as protected: this repo has no `.planning/config.json`, so `git.allow_default_branch_commits` is not set. The commits are on `main` anyway. This is a new repository whose only branch is the one the plan publishes. It is not a commit on summercms.go's default branch. No planning config was added to the layer to silence the guard.
|
||||
- After `409 backend_link_changed` the page fetches the show and renders it. It does not post that show immediately. An immediate allow would send the same decision again and loop while `consent_required` stays false.
|
||||
- `backend_admin` is omitted when the user clears `backend`, because the server only checks that field when `backend` is in the grant. When `consent_required` is false, scopes are omitted and `backend_admin.login` is still sent if the admin line applies, so the full stored grant does not 409 on the linked administrator.
|
||||
- A decision response with an empty `redirect_uri` sets the page back to ready instead of leaving it on Loading.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing critical] Menu-layer skeleton files the plan list did not name**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `pnpm install` creates `node_modules`, Vite reads `tsconfig.json` from the layer, and `nuxt.config.ts` was required to build every path with `fileURLToPath`. A config with no path would not satisfy that rule.
|
||||
- **Fix:** Added `.gitignore`, `tsconfig.json`, `.nvmrc`, `pnpm-workspace.yaml` (the menu layer's `allowBuilds`), `pnpm-lock.yaml`, and `app/assets/css/connect.css` wired from the absolute layer directory. `dev` and `dev:prepare` scripts match the menu layer so the playground can start. No package beyond the pinned Nuxt, Vue, and TypeScript set was added.
|
||||
- **Files modified:** those skeleton files in vue-oauth-layer
|
||||
- **Commit:** `02c466e`
|
||||
|
||||
**2. [Rule 1 - Bug] Empty redirect_uri left the page on Loading**
|
||||
- **Found during:** Task 2, after the page commit
|
||||
- **Issue:** A 200 decision body with an empty `redirect_uri` never left `submitting`, so the template stayed on Loading.
|
||||
- **Fix:** Set state back to ready when the body has no redirect.
|
||||
- **Files modified:** `app/pages/connect.vue`
|
||||
- **Commit:** `7f805b3`
|
||||
|
||||
**3. [Rule 3 - Blocking] `git init -b main` instead of the global default `master`**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `init.defaultBranch` is `master`. vue-menu-layer, vue-journal-layer, vue-faq-layer, and vue-paymentgateway-layer are all on `main`.
|
||||
- **Fix:** Initialized the repo with `-b main`.
|
||||
- **Files modified:** none (branch name only)
|
||||
- **Commit:** `02c466e` (root commit on `main`)
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. A phase-scoped decision and metric may be recorded in STATE.md. STATE.md and state.json are not committed.
|
||||
|
||||
The default-branch guard returned protected for `main` inside the new repo. See Decisions Made. Nothing was pushed or tagged.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
The install line in the README does not resolve until someone accepts a later release and the tag exists. Plan 05 owns that checkpoint. This plan did not create the git remote `git.golem15.com/golem15/vue-oauth-layer.git`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 05 can run `pnpm test` in vue-oauth-layer. The first tag, if the release checkpoint says so, is v0.1.0 on this repo. An application that already has `app/pages/connect.vue` keeps that page. This plan did not edit one.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../vue-oauth-layer/package.json
|
||||
- FOUND: ../vue-oauth-layer/nuxt.config.ts
|
||||
- FOUND: ../vue-oauth-layer/app/pages/connect.vue
|
||||
- FOUND: ../vue-oauth-layer/app/utils/connectConsent.ts
|
||||
- FOUND: ../vue-oauth-layer/tests/connect.test.ts
|
||||
- FOUND: ../vue-oauth-layer/README.md
|
||||
- FOUND: ../vue-oauth-layer/.playground/nuxt.config.ts
|
||||
- FOUND: 02c466e
|
||||
- FOUND: 37c1e12
|
||||
- FOUND: 7f805b3
|
||||
- `pnpm test`: 8 passed, 0 failed, no `not ok`
|
||||
- `git remote` empty, `git tag` empty
|
||||
- `app/pages/connect.vue` does not contain `v-html`
|
||||
|
||||
---
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
*Completed: 2026-10-28*
|
||||
@@ -0,0 +1,383 @@
|
||||
---
|
||||
phase: 32.1
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on:
|
||||
- 32.1-01
|
||||
- 32.1-02
|
||||
- 32.1-03
|
||||
- 32.1-04
|
||||
files_modified:
|
||||
- modules/pact/mcp_call_negative_test.go
|
||||
- modules/pier/dispatch_mcpcall_test.go
|
||||
- modules/pier/prm_scopes_test.go
|
||||
- ../sm-user-plugin/classes/mcp_guard_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_consent_test.go
|
||||
- ../sm-oauthserver-plugin/authorizations_kind_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_backfill_test.go
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
autonomous: false
|
||||
requirements: []
|
||||
estimate:
|
||||
tokens: 58000
|
||||
raw_tokens: 58000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-2: MCPCallFrom on a bare context is ok false, and a frontend tool and a backend tool each carry MCPCall for the guard that owns the tool (D-09, D-11)."
|
||||
- "SC-1: Implies cases on the v0.1.14 worktree cover OAuth read, personal-token non-expansion, and write not satisfying Implies read (D-01a). PRM with and without MCPAdvertisedScopes is asserted (D-04)."
|
||||
- "SC-3: Consent replace does not merge, offline_access follows the column, revoke deletes the MCP code, authorizations kind is mcp or oidc, and the backfill is idempotent when the migration exists (D-06, D-08, D-12, D-13, D-14)."
|
||||
- "SC-4: TestDocsTree and docs:build --check pass. Tags, pushes, and the changelog post happen only after the release checkpoint accepts them (D-16)."
|
||||
artifacts:
|
||||
- path: modules/pact/mcp_call_negative_test.go
|
||||
provides: "Bare-context MCPCallFrom and a FrontendTokenScope literal without Implies"
|
||||
contains: "TestMCPCallFromBareContext"
|
||||
- path: modules/pier/dispatch_mcpcall_test.go
|
||||
provides: "Frontend and backend dispatch both set MCPCall"
|
||||
contains: "TestDispatchMCPCallBothGuards"
|
||||
- path: ../sm-oauthserver-plugin/mcp_consent_test.go
|
||||
provides: "Replace, offline_access, and revoke-deletes-code cases"
|
||||
contains: "TestMCPConsentReplace"
|
||||
key_links:
|
||||
- from: modules/pier/dispatch_mcpcall_test.go
|
||||
to: pact.MCPCallFrom
|
||||
via: a real tools/call on each guard
|
||||
pattern: MCPCallFrom
|
||||
- from: mcp_backfill_test.go
|
||||
to: migration 202610280001_backfill_golem15_oauthserver_mcp_consents
|
||||
via: two migrate runs
|
||||
pattern: backfill
|
||||
---
|
||||
|
||||
<objective>
|
||||
Last plan of the phase: the unit tests that prove SC-1, SC-2, SC-3 and SC-4 beyond the tracer smokes of plans 01–04, then a human gate before any tag, push, or changelog post (D-16).
|
||||
|
||||
Purpose: the project rule puts the broad test pass last. Tagging is not part of an autonomous task.
|
||||
Output: test files in the framework repo, the user-plugin worktree, and sm-oauthserver-plugin; pnpm test of vue-oauth-layer; docs checker. Tags only if the checkpoint option is tag-and-publish.
|
||||
|
||||
Do not edit app submodules, do not turn mcp.enabled on in an application, and do not hardcode the next framework version.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
|
||||
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VALIDATION.md
|
||||
@modules/pier/dispatch_test.go
|
||||
@modules/pier/tracer_smoke_test.go
|
||||
@modules/pact/mcp_call_test.go
|
||||
@../sm-oauthserver-plugin/mcp_consent_smoke_test.go
|
||||
@../sm-oauthserver-plugin/testmain_test.go
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: bare context has no MCP call, and both guards' tool calls do</name>
|
||||
<reversibility rating="reversible">Tests only. They lock the accessor contract already added in plan 01.</reversibility>
|
||||
<files>modules/pact/mcp_call_negative_test.go, modules/pier/dispatch_mcpcall_test.go, modules/pier/prm_scopes_test.go</files>
|
||||
<read_first>
|
||||
modules/pact/mcp_call_test.go (do not weaken TestMCPCallRoundTrip)
|
||||
modules/pier/mcp_seam_test.go
|
||||
modules/pier/dispatch_test.go and modules/pier/tracer_smoke_test.go for the mount helpers
|
||||
modules/pier/backend_guard.go BackendGuard constant
|
||||
modules/pier/prm.go
|
||||
</read_first>
|
||||
<action>
|
||||
Add tests. Do not change production behaviour unless a test shows the plan 01 setter missed a guard; the setter in dispatcher.run is shared, so a miss is a test-setup bug, not a second setter.
|
||||
|
||||
modules/pact/mcp_call_negative_test.go: TestMCPCallFromBareContext asserts MCPCallFrom(context.Background()) and MCPCallFrom on a request context that never called WithMCPCall return ok false (D-11). TestFrontendTokenScopeLiteralWithoutImplies builds FrontendTokenScope{Name, Label, Description} with no Implies field and asserts Implies is empty, so existing composite literals stay valid.
|
||||
|
||||
modules/pier/prm_scopes_test.go: TestPRMUsesAdvertisedScopes mounts pier with a verifier that implements MCPAdvertisedScopes and expects that slice on the protected-resource document, including a declared name. TestPRMFallsBackToPRMScopes mounts pier with a verifier that does not implement the interface and expects PRMScopes, which includes backend (D-04).
|
||||
|
||||
modules/pier/dispatch_mcpcall_test.go: TestDispatchMCPCallBothGuards, package pier_test, calls one frontend tool and one backend tool. Each handler reads pact.MCPCallFrom and records Guard and Tool. The frontend call's Guard is the frontend subject's guard. The backend call's Guard is the backend guard name. SessionID is not the word ephemeral. A personal-token versus OAuth difference is not asserted here; that is the user-plugin test in the next task. Do not print tokens. Commit in the framework repo, staging only these test files. Do not git add -A.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short -count=1 -run 'TestMCPCallFromBareContext|TestFrontendTokenScopeLiteralWithoutImplies|TestPRMUsesAdvertisedScopes|TestPRMFallsBackToPRMScopes|TestDispatchMCPCallBothGuards' ./modules/pact ./modules/pier</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or any of those five test names has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All five named tests PASS.
|
||||
- The backend tool's recorded guard is the backend guard, and the frontend tool's guard is the frontend subject.
|
||||
- No production file is in this commit.
|
||||
</acceptance_criteria>
|
||||
<done>SC-2's negative and both guards, and SC-1's PRM fallback, are proven in the framework repo.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Plugin, layer, and docs proofs for SC-1 through SC-4</name>
|
||||
<precondition>docker info exits 0; the user-plugin worktree exists at /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin on phase-32.1-mcp-implied-scopes; vue-oauth-layer/package.json exists.</precondition>
|
||||
<reversibility rating="reversible">Tests and a docs-checker run. No schema write except the oauthserver test database.</reversibility>
|
||||
<files>../sm-user-plugin/classes/mcp_guard_test.go, ../sm-oauthserver-plugin/mcp_consent_test.go, ../sm-oauthserver-plugin/authorizations_kind_test.go, ../sm-oauthserver-plugin/mcp_backfill_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer</files>
|
||||
<read_first>
|
||||
The worktree classes/mcp_guard_test.go (keep TestMCPImpliesOAuth)
|
||||
../sm-oauthserver-plugin/mcp_consent_test.go and mcp_consent_smoke_test.go
|
||||
../sm-oauthserver-plugin/testmain_test.go (freshDB skips when testing.Short)
|
||||
../sm-oauthserver-plugin/authorizations_smoke_test.go
|
||||
../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go if it exists
|
||||
../vue-oauth-layer/tests/connect.test.ts
|
||||
</read_first>
|
||||
<action>
|
||||
sm-user-plugin, on the v0.1.14 worktree, extend classes/mcp_guard_test.go. Do not edit an app submodule. Add TestMCPImpliesPersonalUnchanged: ClientID empty, scopes read, catalog Implies read on servers:read, MCPAllows rejects a requirement of servers:read, and MCPDispatch credential ID equals TokenID and does not HasScope servers:read. Add TestMCPImpliesWriteDoesNotGrantRead: OAuth ClientID set, scopes write, Implies read, MCPAllows rejects servers:read. Add TestMCPImpliesNotPersisted: after MCPDispatch the subject.Scopes slice is unchanged. Keep the v0.1.14 credential-id behaviour. Commit in the plugin repo only.
|
||||
|
||||
sm-oauthserver-plugin, Docker, not -short. Use the package Postgres helper. Add to mcp_consent_test.go: TestMCPConsentReplace allows read and write, then allows only read, and the consent row's scopes are only read (not a union). The test must fail if the code calls SaveConsent. TestMCPOfflineAccessUntick: pending offline_access true, POST scopes without that word, the issued MCP code's offline_access column is false; a POST that omits the scopes field leaves the flag as the pending row had it. TestMCPRevokeDeletesCode: issue an MCP code, DELETE /oauth/authorizations/{client_id}, exchanging that code fails, and a second MCP client of the same user still has its token. The revoked event's ClientID is the revoked client and TokenID is zero.
|
||||
|
||||
authorizations_kind_test.go TestAuthorizationsKind: an OIDC consent lists kind oidc and an MCP consent lists kind mcp, and the keys are client_id, name, logo_url, scopes, first_authorized_at, last_used_at, kind.
|
||||
|
||||
If the D-14 file exists, mcp_backfill_test.go TestMCPConsentBackfillIdempotent: a live MCP access token with no consent gets one row whose scopes union the token scopes and include offline_access when the column is true; a pair that already has a consent is left as it was; a second migrate inserts nothing. If the checkpoint in plan 03 held the migration, do not add this test and say that in the summary.
|
||||
|
||||
Do not log bearer tokens or authorization codes in test output. Assert status codes and ids.
|
||||
|
||||
vue-oauth-layer: pnpm install only if node_modules is missing, then pnpm test. Do not add a dependency.
|
||||
|
||||
Framework: go test ./cmd/summer -run TestDocsTree -count=1 and go run ./cmd/summer docs:build --check, then go vet ./... && go test -short ./... . Commit framework test fixes in the framework repo and plugin tests in each plugin repo. Do not git add -A. Do not tag or push in this task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go test -short -count=1 -run 'TestMCPImplies' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesPersonalUnchanged has no PASS line</fails_when>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 300s -run 'TestMCPConsentReplace|TestMCPOfflineAccessUntick|TestMCPRevokeDeletesCode|TestAuthorizationsKind|TestMCPConsentBackfillIdempotent' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]` for a test this command still names, or a line contains FAIL. If plan 03 held the backfill, `[no tests to run]` is a failure only when TestMCPConsentBackfillIdempotent is still named in the -run list; drop that name and record why.</fails_when>
|
||||
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
|
||||
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- TestMCPImpliesPersonalUnchanged, TestMCPImpliesWriteDoesNotGrantRead, TestMCPConsentReplace, TestMCPOfflineAccessUntick, TestMCPRevokeDeletesCode and TestAuthorizationsKind each have a PASS line.
|
||||
- TestMCPConsentBackfillIdempotent has a PASS line when the migration file exists, and is absent when the migration was held.
|
||||
- TestDocsTree, docs:build --check, go vet ./... and go test -short ./... pass.
|
||||
- vue-oauth-layer pnpm test passes.
|
||||
</acceptance_criteria>
|
||||
<done>SC-1 through SC-4 have automated proofs beyond the earlier tracer smokes, and no tag has been created.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Whether to tag, push, and post the changelog</name>
|
||||
<reversibility rating="costly">A pushed framework tag is the release other apps will require. It is not created unless this checkpoint accepts it (D-16).</reversibility>
|
||||
<decision>Tag and push the framework 0.5 patch, the sm-user-plugin patch, the sm-oauthserver-plugin patch, and the first vue-oauth-layer tag, then post the framework changelog?</decision>
|
||||
<context>D-16 is patch releases only. The version numbers are computed from existing tags at execution time, never typed in advance. The changelog is the summercms.io post for the framework tag. Plugin repos are pushed before any app pointer bump, and this phase does not bump app pointers. There is no auto_select: an unattended run must stop here. On resume, before the next task changes any ref, write `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt`. First line is `hold` or `tag-and-publish`. Then four sections in this order: a header line `framework yes` or `framework no`, one local tag name per line, then a line that is only a period, and the same for `user`, `oauth`, and `layer`. The header is yes only when that repo already has a remote named origin. Tag names come from a read-only for-each-ref of refs/tags. Do not create a tag, do not push, and do not publish the changelog in this task.</context>
|
||||
<options>
|
||||
<option id="tag-and-publish">
|
||||
<name>Tag, push, and post the changelog</name>
|
||||
<pros>The three gaps are on patch tags an application can require. The framework tag gets its changelog post the same day.</pros>
|
||||
<cons>The tags are public. A mistake is a further patch, not a rewritten tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Do not tag or push</name>
|
||||
<pros>The code stays local until the user asks again.</pros>
|
||||
<cons>SC-4's patch tags stay unmet until a later go-ahead. No changelog post.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Select tag-and-publish or hold</resume-signal>
|
||||
<read_first>
|
||||
.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md D-16
|
||||
CLAUDE.md section on changelog posts
|
||||
</read_first>
|
||||
<acceptance_criteria>
|
||||
- No git tag, git push, or changelog publish runs before this checkpoint returns.
|
||||
- hold leaves every repo untagged by this plan.
|
||||
- `32.1-05-release-decision.txt` exists before the next task changes any ref, and its first line is the accepted option id.
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Tag and publish only if the checkpoint accepted it</name>
|
||||
<reversibility rating="costly">Pushed tags and a changelog post are the release. hold does none of this.</reversibility>
|
||||
<files>../sm-oauthserver-plugin/go.mod, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md</files>
|
||||
<read_first>
|
||||
../sm-oauthserver-plugin/go.mod require lines for summercms and sm-user-plugin
|
||||
CLAUDE.md changelog section (scripts/changelog-post.py, no token printing)
|
||||
../sm-summercmsio-app/DEPLOY.md section Posting to the changelog, if present
|
||||
</read_first>
|
||||
<action>
|
||||
Read `32.1-05-release-decision.txt`. Do not rewrite it after any ref changes. If you resumed and the file is not on disk yet, write it in the form the checkpoint describes, from the accepted option and a read-only for-each-ref of each repo, before changing any ref, then leave the file unchanged. If the first line is hold, do not create a tag, do not push, and do not run the changelog script. Say that in the summary and stop.
|
||||
|
||||
If the accepted option is tag-and-publish (D-16): compute versions from git tag --list. Framework: highest v0.5.* patch, plus one. Never a minor bump and never a version literal written into Go source. sm-user-plugin: highest v0.1.* patch, plus one, on branch phase-32.1-mcp-implied-scopes. sm-oauthserver-plugin: highest v0.1.* patch, plus one. vue-oauth-layer: v0.1.0, the version already in its package.json, only if that tag does not already exist.
|
||||
|
||||
Order: commit any uncommitted test files by explicit path in their own repos. Tag the framework. Tag the user plugin. In sm-oauthserver-plugin, set the require versions of summercms and sm-user-plugin to those new tags, commit that go.mod and go.sum change by path, then tag the plugin. Tag vue-oauth-layer. Do not delete or move the existing summercms replace line.
|
||||
|
||||
Push each repo that already has origin: the branch that was tagged, then the tag. Do not force-push. If vue-oauth-layer has no origin, leave the local tag and stop before inventing a remote; report that the empty remote git@git.golem15.com:golem15/vue-oauth-layer.git still has to be created by the user. Do not bump any app submodule pointer.
|
||||
|
||||
Changelog, only for the framework tag, from ../sm-summercmsio-app: scripts/changelog-post.py source for that tag, then publish `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md` with Highlights, Added, Changed, and Fixed. The notes say which framework, sm-user-plugin, sm-oauthserver-plugin and vue-oauth-layer versions close the three gaps, and they may name sm-servers-app as the audience (D-16). Do not put that application name into framework source, READMEs, or docs. No planning IDs, no quick codes, no H1. Do not print, copy, or mint the journal token. If ~/.config/summercms/journal-token is missing or the script refuses it, stop and ask. Do not git add -A. The verify reads the checkpoint record and fails when that record is absent or the local refs, the origin listing, or the changelog post disagree with it. Do not point the verify command at tag creation or at a push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python3 -c 'import pathlib, subprocess, sys, urllib.request, urllib.error
|
||||
root = pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/summercms.go")
|
||||
phase = root / ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con"
|
||||
rec = phase / "32.1-05-release-decision.txt"
|
||||
notes = phase / "32.1-05-changelog-notes.md"
|
||||
repos = {"framework": root, "user": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin"), "oauth": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin"), "layer": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer")}
|
||||
if not rec.is_file():
|
||||
sys.exit("checkpoint record absent")
|
||||
lines = [ln.strip() for ln in rec.read_text().splitlines()]
|
||||
if not lines or lines[0] not in ("hold", "tag-and-publish"):
|
||||
sys.exit("checkpoint record option is not hold or tag-and-publish")
|
||||
opt = lines[0]
|
||||
sections = {}
|
||||
i = 1
|
||||
for name in ("framework", "user", "oauth", "layer"):
|
||||
if i >= len(lines):
|
||||
sys.exit("checkpoint record missing " + name)
|
||||
head = lines[i].split()
|
||||
i += 1
|
||||
if len(head) != 2 or head[0] != name or head[1] not in ("yes", "no"):
|
||||
sys.exit("checkpoint record bad header " + name)
|
||||
tags = []
|
||||
while i < len(lines) and lines[i] != ".":
|
||||
if lines[i]:
|
||||
tags.append(lines[i])
|
||||
i += 1
|
||||
if i >= len(lines) or lines[i] != ".":
|
||||
sys.exit("checkpoint record missing end " + name)
|
||||
i += 1
|
||||
sections[name] = tags
|
||||
def git_out(path, args):
|
||||
if not path.is_dir():
|
||||
sys.exit("repo missing " + str(path))
|
||||
r = subprocess.run(["git", "-C", str(path), *args], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.exit("git failed in " + path.name)
|
||||
return r.stdout
|
||||
def current_tags(path):
|
||||
out = git_out(path, ["for-each-ref", "--format=%(refname:short)", "refs/tags"])
|
||||
return [ln.strip() for ln in out.splitlines() if ln.strip()]
|
||||
def has_origin(path):
|
||||
r = subprocess.run(["git", "-C", str(path), "remote", "get-url", "origin"], capture_output=True, text=True)
|
||||
return r.returncode == 0
|
||||
def remote_tags(path):
|
||||
out = git_out(path, ["ls-remote", "--tags", "origin"])
|
||||
names = []
|
||||
for ln in out.splitlines():
|
||||
parts = ln.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
ref = parts[-1]
|
||||
if ref.endswith("^{}"):
|
||||
continue
|
||||
prefix = "refs/tags/"
|
||||
if ref.startswith(prefix):
|
||||
names.append(ref[len(prefix):])
|
||||
return names
|
||||
def bump(tags, prefix):
|
||||
best = None
|
||||
for t in tags:
|
||||
if t.startswith(prefix) and t[len(prefix):].isdigit():
|
||||
n = int(t[len(prefix):])
|
||||
best = n if best is None else max(best, n)
|
||||
if best is None:
|
||||
sys.exit("no baseline tag for " + prefix)
|
||||
return prefix + str(best + 1)
|
||||
if opt == "hold":
|
||||
for name in ("framework", "user", "oauth", "layer"):
|
||||
if set(current_tags(repos[name])) != set(sections[name]):
|
||||
sys.exit("hold but local tags differ " + name)
|
||||
sys.exit(0)
|
||||
expected = {"framework": {bump(sections["framework"], "v0.5.")}, "user": {bump(sections["user"], "v0.1.")}, "oauth": {bump(sections["oauth"], "v0.1.")}, "layer": set() if "v0.1.0" in sections["layer"] else {"v0.1.0"}}
|
||||
fw_tag = None
|
||||
for name in ("framework", "user", "oauth", "layer"):
|
||||
cur = set(current_tags(repos[name]))
|
||||
base = set(sections[name])
|
||||
added = cur - base
|
||||
removed = base - cur
|
||||
if removed:
|
||||
sys.exit("tags removed in " + name)
|
||||
if added != expected[name]:
|
||||
sys.exit("tags disagree in " + name)
|
||||
if has_origin(repos[name]) and not expected[name] <= set(remote_tags(repos[name])):
|
||||
sys.exit("push missing in " + name)
|
||||
if name == "framework":
|
||||
fw_tag = next(iter(expected[name]))
|
||||
token = pathlib.Path.home() / ".config" / "summercms" / "journal-token"
|
||||
if not token.is_file():
|
||||
sys.exit(0)
|
||||
if fw_tag is None or not str(fw_tag).startswith("v"):
|
||||
sys.exit("framework tag missing")
|
||||
ver = fw_tag[1:]
|
||||
if not notes.is_file():
|
||||
sys.exit("changelog notes missing")
|
||||
content = notes.read_text().strip()
|
||||
if (not content) or content.startswith("# "):
|
||||
sys.exit("changelog notes empty or start with an H1")
|
||||
for h in ("## Highlights", "## Added", "## Changed", "## Fixed"):
|
||||
if h not in content:
|
||||
sys.exit("changelog notes missing heading")
|
||||
if ver not in content:
|
||||
sys.exit("changelog notes missing version")
|
||||
url = "https://summercms.io/changelog/summercms-" + ver.replace(".", "-")
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "summercms-plan-verify"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "replace")
|
||||
except Exception:
|
||||
sys.exit("changelog fetch failed")
|
||||
if ("SummerCMS " + ver) not in body:
|
||||
sys.exit("changelog post missing " + ver)'</automated>
|
||||
<fails_when>non-zero exit. That includes checkpoint record absent, checkpoint record option is not hold or tag-and-publish, checkpoint record missing, checkpoint record bad header, repo missing, git failed, hold but local tags differ, no baseline tag, tags removed, tags disagree, push missing, framework tag missing, changelog notes missing, changelog notes empty or start with an H1, changelog notes missing heading, changelog notes missing version, changelog fetch failed, or changelog post missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- hold: git tag --list in each repo shows no tag created by this task, and the changelog script was not run.
|
||||
- tag-and-publish: the framework tag is the next v0.5 patch, the two plugins are the next v0.1 patches, vue-oauth-layer is v0.1.0, and each repo that had origin was pushed. The framework changelog post was attempted, or the task stopped because the token file was missing.
|
||||
- No app submodule pointer moved.
|
||||
</acceptance_criteria>
|
||||
<done>D-16 either shipped as patch tags plus the framework changelog, or stayed local because the checkpoint said hold.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test output and changelog notes | Tokens and authorization codes must not be copied into logs or the public post. |
|
||||
| git push of a release tag | The tag is what other repositories will require. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32.1-13 | Information disclosure | tests and changelog-post.py | medium | mitigate | Assert status codes and ids. Do not print bearer tokens, authorization codes, or the journal token. If the token file is missing, stop. |
|
||||
| T-32.1-14 | Spoofing | release tags | low | accept | Tagging does not change PKCE, redirect matching, or the read/write ceiling. The checkpoint is the control on publishing, not a new auth path. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
The named tests PASS, docs:build --check passes, and no tag exists unless tag-and-publish was accepted.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 through SC-4 are covered by the tests named in this plan.
|
||||
- Tags, pushes, and the changelog post are behind the checkpoint.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer
|
||||
|
||||
Tests added:
|
||||
- TestMCPCallFromBareContext
|
||||
- TestFrontendTokenScopeLiteralWithoutImplies
|
||||
- TestPRMUsesAdvertisedScopes
|
||||
- TestPRMFallsBackToPRMScopes
|
||||
- TestDispatchMCPCallBothGuards
|
||||
- TestMCPImpliesPersonalUnchanged
|
||||
- TestMCPImpliesWriteDoesNotGrantRead
|
||||
- TestMCPImpliesNotPersisted
|
||||
- TestMCPConsentReplace
|
||||
- TestMCPOfflineAccessUntick
|
||||
- TestMCPRevokeDeletesCode
|
||||
- TestAuthorizationsKind
|
||||
- TestMCPConsentBackfillIdempotent when the D-14 migration file exists
|
||||
|
||||
Release, only after tag-and-publish: next framework v0.5 patch tag, next sm-user-plugin v0.1 patch tag, next sm-oauthserver-plugin v0.1 patch tag, vue-oauth-layer v0.1.0, and the framework changelog post.
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
plan: 05
|
||||
subsystem: auth
|
||||
tags: [mcp, oauth, release, changelog, scopes]
|
||||
|
||||
requires:
|
||||
- phase: 32.1-01
|
||||
provides: MCP scope list and MCPCall on the tool-call context
|
||||
- phase: 32.1-02
|
||||
provides: OAuth implied scopes in sm-user-plugin
|
||||
- phase: 32.1-03
|
||||
provides: MCP consent replace, kind, and backfill
|
||||
- phase: 32.1-04
|
||||
provides: vue-oauth-layer /connect
|
||||
provides:
|
||||
- "Unit tests for a bare MCP call, both guards, PRM scopes, implied scopes, consent replace, kind, and backfill"
|
||||
- "SummerCMS v0.5.2, sm-user-plugin v0.1.16, sm-oauthserver-plugin v0.1.4, and local vue-oauth-layer v0.1.0"
|
||||
- "Changelog post https://summercms.io/changelog/summercms-0-5-2"
|
||||
affects: [33]
|
||||
|
||||
actuals:
|
||||
tokens: 6474
|
||||
tasks: 4
|
||||
commits: 1
|
||||
plan_head_before: 06a144e19f1bca9b918af7adfc44e7b7a1e3f011
|
||||
plan_head_after: 9094bf9ad99c92b4343d8d3f1062ef4863f58e00
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Patch tags are computed from git tag --list at release time, recorded in 32.1-05-release-decision.txt before any ref changes"
|
||||
- "sm-oauthserver-plugin bumps require versions after those tags are pushed and keeps the summercms replace"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt
|
||||
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md
|
||||
- modules/pact/mcp_call_negative_test.go
|
||||
- modules/pier/dispatch_mcpcall_test.go
|
||||
- modules/pier/prm_scopes_test.go
|
||||
- ../sm-user-plugin/mcp_implies_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_consent_test.go
|
||||
- ../sm-oauthserver-plugin/authorizations_kind_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_backfill_test.go
|
||||
modified:
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
- ../sm-oauthserver-plugin/go.sum
|
||||
- .planning/ROADMAP.md
|
||||
|
||||
key-decisions:
|
||||
- "tag-and-publish. Framework v0.5.2 on master, sm-user-plugin v0.1.16 on phase-32.1-mcp-implied-scopes, sm-oauthserver-plugin v0.1.4 on master after the require bump, vue-oauth-layer v0.1.0 local only."
|
||||
- "sm-user-plugin v0.1.16 stays on phase-32.1-mcp-implied-scopes and is not retargeted onto v0.1.15. That tag is not an ancestor."
|
||||
- "vue-oauth-layer has no origin. The tag was not pushed and no remote was created."
|
||||
|
||||
patterns-established:
|
||||
- "Release decision file is written from for-each-ref before the first tag or push and is not rewritten."
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A bare context has no MCP call, both guards record MCPCall, and PRM uses advertised scopes or falls back."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/pact/mcp_call_negative_test.go#TestMCPCallFromBareContext
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: modules/pier/dispatch_mcpcall_test.go#TestDispatchMCPCallBothGuards
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: modules/pier/prm_scopes_test.go#TestPRMUsesAdvertisedScopes
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "OAuth implied scopes do not expand a personal token, write does not grant read, and the subject slice is unchanged."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: sm-user-plugin/mcp_implies_test.go#TestMCPImpliesPersonalUnchanged
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "MCP consent replace, offline_access, revoke, authorization kind, and idempotent backfill."
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: sm-oauthserver-plugin/mcp_consent_test.go#TestMCPConsentReplace
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: sm-oauthserver-plugin/mcp_backfill_test.go#TestMCPConsentBackfillIdempotent
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Patch tags pushed where origin existed, and the framework changelog post is live."
|
||||
verification:
|
||||
- kind: other
|
||||
ref: python3 plan task-4 verify (exit 0) against 32.1-05-release-decision.txt and https://summercms.io/changelog/summercms-0-5-2
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 34min
|
||||
completed: 2026-10-28
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32.1 Plan 05: Unit tests and patch release Summary
|
||||
|
||||
**SummerCMS v0.5.2, sm-user-plugin v0.1.16, sm-oauthserver-plugin v0.1.4, and local vue-oauth-layer v0.1.0 ship the MCP OAuth scope, caller, and consent gaps, with the framework changelog posted**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 34 min
|
||||
- **Started:** 2026-10-28T15:09:30Z
|
||||
- **Completed:** 2026-10-28T15:42:50Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 12
|
||||
|
||||
`actuals.tokens` is chars/4 of `git show` for `9094bf9`, `756797c`, `4f8618e`, and `a7743e7`, plus the decision file and changelog notes (25896 / 4). This summary is not in that count. `commits: 1` is `git rev-list --count 06a144e19f1bca9b918af7adfc44e7b7a1e3f011..9094bf9` at summary time. The metadata commit is after that range.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Framework tests prove a bare context has no MCP call, both guards set one, and PRM uses an advertised list or falls back.
|
||||
- sm-user-plugin tests keep implied scopes off the stored subject. sm-oauthserver-plugin tests prove consent replace, offline access, revoke, authorization kind, and an idempotent backfill.
|
||||
- Tags: SummerCMS `v0.5.2` on `9094bf9` (pushed `master`, then the tag), sm-user-plugin `v0.1.16` on `756797c` (pushed `phase-32.1-mcp-implied-scopes`, then the tag), sm-oauthserver-plugin `v0.1.4` on `a7743e7` (pushed `master`, then the tag), vue-oauth-layer `v0.1.0` on `7f805b3` (local only).
|
||||
- Changelog post id 46: https://summercms.io/changelog/summercms-0-5-2. The plan python verify exited 0.
|
||||
- No app submodule pointer moved. `go.work` in sm-oauthserver-plugin stayed unstaged.
|
||||
|
||||
## Task Commits
|
||||
|
||||
The test commits were already on the branches before this continuation. They were not redone.
|
||||
|
||||
1. **Task 1: Tracer: bare context has no MCP call, and both guards' tool calls do** - `9094bf9` (test, summercms.go)
|
||||
2. **Task 2: Plugin, layer, and docs proofs** - `756797c` (test, sm-user-plugin), `4f8618e` (test, sm-oauthserver-plugin). vue-oauth-layer had no new commit in this plan; its tests were already on `7f805b3`.
|
||||
3. **Task 3: Release checkpoint** - no commit. The accepted option is `tag-and-publish`, recorded in `32.1-05-release-decision.txt` before any tag or push. That file was not rewritten.
|
||||
4. **Task 4: Tag and publish** - `a7743e7` (chore, sm-oauthserver-plugin) sets `summercms v0.5.2` and `sm-user-plugin v0.1.16` and keeps the summercms replace. Tags and pushes are listed above. No framework commit in this task before the metadata commit.
|
||||
|
||||
**Plan metadata:** this summary, the decision file, the changelog notes, and the roadmap check, committed in summercms.go after this file was written.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/pact/mcp_call_negative_test.go` - bare context and a FrontendTokenScope literal without Implies
|
||||
- `modules/pier/dispatch_mcpcall_test.go` - frontend and backend tool calls record MCPCall
|
||||
- `modules/pier/prm_scopes_test.go` - advertised scopes and the PRMScopes fallback
|
||||
- `../sm-user-plugin/mcp_implies_test.go` - personal token, write-does-not-grant-read, subject slice unchanged
|
||||
- `../sm-oauthserver-plugin/mcp_consent_test.go` - replace, offline access, revoke
|
||||
- `../sm-oauthserver-plugin/authorizations_kind_test.go` - kind mcp or oidc
|
||||
- `../sm-oauthserver-plugin/mcp_backfill_test.go` - idempotent backfill
|
||||
- `../sm-oauthserver-plugin/go.mod` - require SummerCMS v0.5.2 and sm-user-plugin v0.1.16
|
||||
- `../sm-oauthserver-plugin/go.sum` - sum for sm-user-plugin v0.1.16
|
||||
- `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt` - checkpoint record and the tag lists from before the release
|
||||
- `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md` - notes published for v0.5.2
|
||||
- `.planning/ROADMAP.md` - 32.1-05 checked, 5/5 plans executed
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The checkpoint option `tag-and-publish` was applied. Versions came from the tag lists in the decision file: next v0.5 patch is v0.5.2, next user v0.1 patch is v0.1.16, next oauth v0.1 patch is v0.1.4, and vue-oauth-layer is v0.1.0 because that tag did not exist.
|
||||
- sm-user-plugin was tagged on `phase-32.1-mcp-implied-scopes` at `756797c`. `v0.1.15` is not an ancestor. The tag was not moved onto `v0.1.15`. The tag message and the changelog say the missing-scope fields from v0.1.15 are absent. The plugin `go.mod` still requires SummerCMS v0.5.0; the new seams are in v0.5.2, which sm-oauthserver-plugin v0.1.4 requires.
|
||||
- vue-oauth-layer has no `origin`. `v0.1.0` is local. `git@git.golem15.com:golem15/vue-oauth-layer.git` was not created.
|
||||
- STATE.md and state.json were not edited and are not in the metadata commit. Current Position stays phase 33.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
None during the release continuation. The test tasks were already committed.
|
||||
|
||||
The user-plugin test file is `mcp_implies_test.go` at the plugin root, not `classes/mcp_guard_test.go`. That path was chosen by the earlier test commit and was left as committed.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 0 auto-fixed during the release continuation
|
||||
**Impact on plan:** The release followed the accepted checkpoint. The user-plugin tag is intentionally not based on v0.1.15.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Pushing summercms.go `master` published 44 commits since v0.5.1, including the phase 32 review fixes and planning commits that were already on local master. The push was a fast-forward. No force-push.
|
||||
- Phase 33 plan 01 reserved the name v0.5.2 for a later tag that would also carry phase 33 seams, and plan 33-14 was going to create that tag. This release took v0.5.2 because it was the next v0.5 patch. Phase 33 must not reuse v0.5.2. The next framework patch is v0.5.3 unless a newer v0.5 tag appears first.
|
||||
- vue-oauth-layer `README.md` inside `v0.1.0` still says the tag does not exist yet. The release order tagged the existing commit and did not add a README commit. The tag was not moved. "No tag has been pushed" is still true, because there is no remote.
|
||||
- `state.advance-plan` was not run. It would move Current Position, which is phase 33. Roadmap 32.1-05 was checked by editing ROADMAP.md directly.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Create `git@git.golem15.com:golem15/vue-oauth-layer.git`, then push `main` and the annotated tag `v0.1.0` from `/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer`. Do not force-push. Until that remote exists, apps cannot pin the layer. The tagged README still describes the pre-tag install shape.
|
||||
|
||||
No journal token was printed, copied, or minted.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- The three MCP OAuth gaps are on the tags above. No application go.mod or submodule pointer was bumped.
|
||||
- Phase 33's release ledger must treat v0.5.2 as already published. Its framework seams belong on a later patch.
|
||||
- Current Position was left on phase 33.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
- `vue-oauth-layer/README.md` line 9, inside tag `v0.1.0`: "That tag does not exist yet." The local tag exists; the remote does not. Not edited, because editing would require moving the tag.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `32.1-05-release-decision.txt` (sha256 `3e33d20e4c626c7c43d5da66930ac4b51a0cc02d2995c1fc7e6d5e59f638fa8f`, first line `tag-and-publish`, not rewritten after the tags)
|
||||
- FOUND: `32.1-05-changelog-notes.md`
|
||||
- FOUND: summercms.go `9094bf9`, sm-user-plugin `756797c`, sm-oauthserver-plugin `4f8618e` and `a7743e7`
|
||||
- FOUND remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Local only: vue-oauth-layer `v0.1.0`
|
||||
- Plan task-4 python verify exit 0. Changelog URL https://summercms.io/changelog/summercms-0-5-2 contains the posted title.
|
||||
|
||||
---
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
*Completed: 2026-10-28*
|
||||
@@ -0,0 +1,29 @@
|
||||
## Highlights
|
||||
|
||||
SummerCMS 0.5.2 lets an MCP OAuth agent use the scopes a plugin declares, and lets a tool call see which subject invoked it. sm-user-plugin v0.1.16 applies read and write as implied scopes for OAuth subjects only. sm-oauthserver-plugin v0.1.4 stores one consent per MCP client, replaces it on a later allow, and backfills live grants. vue-oauth-layer v0.1.0 is the `/connect` page for that consent. Together these versions close the three gaps sm-servers-app needs before MCP OAuth can be switched on: the same tool list as a personal token, attribution of the caller, and per-client revoke.
|
||||
|
||||
## Added
|
||||
|
||||
- One MCP scope list. Plugins publish member-token scopes, the OAuth server advertises that list (optionally filtered), and the protected-resource metadata uses the same names. An empty list still means the five fixed scopes, including backend. OIDC discovery is unchanged.
|
||||
- An MCP call on the request context: guard, user, client, token, scopes, session and tool. A request that is not an MCP tool call has none.
|
||||
- A pending consent view reports whether offline access was requested.
|
||||
- sm-user-plugin v0.1.16 expands a declared scope when an OAuth grant holds the read or write that the scope marks as implied. A personal token is not expanded, write does not imply read, and the stored subject scopes stay as issued. The dispatch credential still carries the personal token id.
|
||||
- sm-oauthserver-plugin v0.1.4 records MCP consent per client, shows scope details on the consent JSON, marks authorizations as mcp or oidc, and backfills a consent for each live grant. A second migrate inserts nothing.
|
||||
- vue-oauth-layer v0.1.0 renders `/connect` from that JSON: checkboxes, read locked, the administrator named as text, and a refetch when the backend link changed. The layer's git remote is not published yet, so an app cannot pin the tag until that repository exists.
|
||||
|
||||
## Changed
|
||||
|
||||
- A later MCP allow replaces the stored consent scopes. It does not merge them with the previous grant.
|
||||
- Unticking offline access clears that flag on the issued code. Omitting the scopes field leaves the pending flag as it was.
|
||||
- Revoking an authorized MCP client deletes its unused code and leaves the user's other clients in place.
|
||||
- sm-oauthserver-plugin v0.1.4 requires SummerCMS v0.5.2 and sm-user-plugin v0.1.16.
|
||||
- sm-user-plugin v0.1.16 is not a descendant of v0.1.15. It does not include the missing-scope and missing-permission fields from that tag. Build it with SummerCMS v0.5.2; its go.mod still states v0.5.0 and selects the newer framework only when the application does.
|
||||
|
||||
## Fixed
|
||||
|
||||
- A hidden relation field or partial field no longer keeps its routes after it is removed from the form.
|
||||
- Record events fire when a hasMany child is linked, unlinked, or attached from a deferred commit, and the event locale is the request locale's primary tag.
|
||||
- A widget action dispatched while the widget connects is delivered.
|
||||
- Uploading through a form extension follows that extension's visibility.
|
||||
- A required field that belongs to a form extension is checked on the extension's own value, so saving the host form no longer fails on a rule the owner model cannot satisfy.
|
||||
- The additive API check compares the new release with the previous one after the tree is tagged.
|
||||
@@ -0,0 +1,70 @@
|
||||
tag-and-publish
|
||||
framework yes
|
||||
v0.1.1
|
||||
v0.1.10
|
||||
v0.1.11
|
||||
v0.1.12
|
||||
v0.1.13
|
||||
v0.1.14
|
||||
v0.1.15
|
||||
v0.1.16
|
||||
v0.1.17
|
||||
v0.1.18
|
||||
v0.1.19
|
||||
v0.1.2
|
||||
v0.1.20
|
||||
v0.1.21
|
||||
v0.1.22
|
||||
v0.1.23
|
||||
v0.1.24
|
||||
v0.1.25
|
||||
v0.1.26
|
||||
v0.1.27
|
||||
v0.1.3
|
||||
v0.1.4
|
||||
v0.1.5
|
||||
v0.1.6
|
||||
v0.1.7
|
||||
v0.1.8
|
||||
v0.1.9
|
||||
v0.2.0
|
||||
v0.2.1
|
||||
v0.2.2
|
||||
v0.2.3
|
||||
v0.2.4
|
||||
v0.3.0
|
||||
v0.3.1
|
||||
v0.4.0
|
||||
v0.4.1
|
||||
v0.4.2
|
||||
v0.4.3
|
||||
v0.4.4
|
||||
v0.5.0
|
||||
v0.5.1
|
||||
.
|
||||
user yes
|
||||
v0.1.0
|
||||
v0.1.1
|
||||
v0.1.10
|
||||
v0.1.11
|
||||
v0.1.12
|
||||
v0.1.13
|
||||
v0.1.14
|
||||
v0.1.15
|
||||
v0.1.2
|
||||
v0.1.3
|
||||
v0.1.4
|
||||
v0.1.5
|
||||
v0.1.6
|
||||
v0.1.7
|
||||
v0.1.8
|
||||
v0.1.9
|
||||
.
|
||||
oauth yes
|
||||
v0.1.0
|
||||
v0.1.1
|
||||
v0.1.2
|
||||
v0.1.3
|
||||
.
|
||||
layer no
|
||||
.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent - Context
|
||||
|
||||
**Gathered:** 2026-10-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
An agent connected to `/mcp` over OAuth gets the same tools, attribution and revocation as one using a member personal API token, so that a consuming application can switch OAuth sign-in on in production. Three additive gaps are closed:
|
||||
|
||||
1. **Declared scopes over MCP OAuth.** Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted on the MCP OAuth server, and appear in pier's protected-resource metadata. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (+ `backend` on the MCP server).
|
||||
2. **MCP call context.** A route run by an MCP tool call can read who called it (guard, user, client ID, token id, scopes) and the MCP session through a `pact` accessor.
|
||||
3. **MCP consent row.** `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions.
|
||||
|
||||
Everything ships as patch releases: framework 0.5.x, `sm-oauthserver-plugin`, and `sm-user-plugin` (it publishes the scope catalog and applies the implied scopes). Module READMEs and `docs/` pages describe the changes. The phase also starts a public Nuxt layer, `vue-oauth-layer`, with the `/connect` consent page (D-17).
|
||||
|
||||
The three gaps come from sm-servers-app phase 05 (`/media/nvme/dev/pxpx/sm-servers-app/.planning/phases/05-agent-access-over-mcp/05-CONTEXT.md`, D-36 to D-38), escalated as upstream Requests 3, 4 and 5 on 2026-10-28. That app keeps `golem15.oauthserver.mcp.enabled` off in production until they ship. Its expected-gap tests (`TestMCPOAuthToolsMatchPersonal`, `TestMCPOAuthWriteAttribution`) must turn into passing assertions against the released versions without app-side scope middleware.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Declared scopes and the read/write ceiling
|
||||
- **D-01:** **The 19.7 D-20 ceiling stays.** OAuth `read`/`write` remain the ceiling: a non-read-only tool still needs `write`, and a route's `user.scope:<declared>` still needs the declared scope in the grant. A write tool behind `user.scope:bm:write` needs both `write` and `bm:write`, as a personal token with read/write plus declared scopes does today. No pier ceiling change.
|
||||
- **D-01a:** **Declared scopes can be implied by stock `read`/`write` (Request 3).** `pact.FrontendTokenScope` gets an additive field (proposed `Implies`, value `"read"` or `"write"`, empty = not implied). An OAuth MCP grant holding the stock scope also satisfies every declared scope that names it as implied. For example, `servers:read` with `Implies: "read"` is satisfied by a `read` grant, so an agent granted `read write` lists the same servers tools as a personal token with `servers:read servers:write` (sm-servers-app's "OAuth list equals personal list"). The expansion applies only to OAuth MCP subjects, in sm-user-plugin's MCP guard (`MCPAllows`/`MCPDispatch`) or where the subject's scopes are built. A personal token's scopes keep their meaning, and the user still never gets a scope the plugin did not mark as implied. Declared scopes are still advertised (D-03) for clients that want a narrower grant. — **Reversibility:** costly — once apps rely on read/write implying their scopes, removing it shrinks live agents' tool lists.
|
||||
- **D-02:** **Declared scopes are offered on the MCP OAuth server only**, like `backend` (19.7 D-21). The OIDC server keeps its PHP-parity scope list.
|
||||
- **D-03:** **Advertised scopes = fixed scopes + declared scopes, filtered by an optional opt-in list.** When the config list is set, only the declared scopes it names are advertised and accepted. When it is unset, every declared scope is. The fixed scopes (`read`, `write`, `ai`, `offline_access`, `backend`) are always present. The same set drives the wristband MCP server's metadata `scopes_supported`, its authorize allow-list (`allowedAuthorizeScopes`) and pier's PRM `scopes_supported`. This rule must be documented clearly in the oauthserver README, the pier README and `docs/services/oauth-server.md` / `docs/services/mcp.md`. A name in the list that no plugin declares should be a clear boot error or warning; the planner decides which. — **Reversibility:** costly — scope names published in metadata and stored in grants.
|
||||
- **D-04:** **The config key lives in sm-oauthserver-plugin** (proposed `golem15.oauthserver.mcp_scopes`), next to where `NewServers` builds `mcpOpts.ScopesSupported`. **pier reads the list from the published verifier** (`pact.MCPTokenVerifier`, extended additively) instead of its own `PRMScopes` constant, so the PRM and the authorization server cannot drift. `pier.PRMScopes` stays as the fallback when no verifier publishes a list.
|
||||
- **D-05:** **Plumbing: a published pact catalog.** The plugin that owns member tokens (sm-user-plugin, which already compiles and validates `FrontendTokenScopes()` at boot) publishes the compiled list on the app (`app.Lookup[...]`) through a new additive pact interface. sm-oauthserver-plugin and pier read it at boot. Labels and descriptions travel with the names so the consent page can show them.
|
||||
|
||||
### Consent page
|
||||
- **D-06:** **Per-scope checkboxes.** This changes the consent behaviour from 19.4's allow/deny-the-whole-request. Every requested scope starts ticked. `read`, when requested, is locked (cannot be unticked). `offline_access`, `backend` and declared scopes can be unticked. A submit that leaves no data-bearing scope keeps the existing 422 `no_grantable_scopes`. `MCPConsentDecide` already intersects submitted ∩ requested (`grantedScopes`), so the server side mostly exists.
|
||||
- **D-07:** **Both surfaces.** The MCP consent JSON (show answer of `controllers/mcp_consent_api.go`) carries a label and description per requested scope: the FrontendTokenScope label/description resolved through phrasebook for declared scopes, existing text for fixed scopes. It also says whether each scope is locked, so redirect-mode app pages can render checkboxes or keep all-or-nothing (as the consuming app chose in its D-07). The built-in consent page renders the checkboxes. *Research must confirm which built-in page the MCP flow uses today* (`views/consent.html.tmpl` vs redirect-only to an app `/connect` page). If the MCP flow has no built-in page, the checkboxes go wherever the plugin ships its MCP consent rendering, and the planner raises it.
|
||||
- **D-08:** **Re-consent on scope growth.** When a client that already holds a consent requests any scope the recorded consent does not cover, the user goes through the consent page again. After the decision, the stored consent's scopes become the newly granted set.
|
||||
|
||||
### MCP call context accessor
|
||||
- **D-09:** **The accessor lives in `pact`:** `pact.MCPCallFrom(ctx) (MCPCall, bool)` with a matching setter that pier calls next to `bouncer.WithDispatch` in `dispatch.go`. Plugins already import pact, so they need no pier or bouncer import.
|
||||
- **D-10:** **New `pact.MCPCall` wrapper:** `MCPCall{Subject MCPSubject, SessionID string, Tool string}`. `MCPSubject` (the guard-facing type) stays unchanged. `SessionID` is the MCP session id and is empty in pier's stateless mode. `Tool` is the tool name. Exact field names are the planner's call within this shape. — **Reversibility:** costly — exported pact API that consuming apps will read.
|
||||
- **D-11:** **Every MCP tool call carries it:** OAuth and personal-token, frontend and backend guards. The subject is the one pier resolved for that tool's guard (`id.subjectFor(t.guardName)`). Plain HTTP requests have none (`ok == false`). A personal-token call's credential keeps carrying the token id as today (sm-user-plugin v0.1.14); the accessor is additive to it, not a replacement.
|
||||
|
||||
### MCP consent row and revoke
|
||||
- **D-12:** **The same `golem15_oauthserver_consents` table.** When an MCP code is issued (`MCPConsentDecide` → `IssueCode`, and any direct grant path through the MCP store), upsert the (user_id, client_id) row with the granted scopes. MCP clients are DCR clients distinct from OIDC clients, so the unique (user_id, client_id) constraint does not collide. The existing `revoke` (deletes the consent, plus codes, access and refresh tokens of the pair, then emits `MCPSubjectRevoked{ClientID}`, which pier uses to evict sessions) then works unchanged for MCP clients.
|
||||
- **D-13:** **MCP clients appear in `GET /oauth/authorizations`** with an additive kind marker (e.g. `"kind":"mcp"`; OIDC entries get `"oidc"` or omit it, per the planner). Existing fields and order are unchanged.
|
||||
- **D-14:** **A data migration backfills consents** for grants that predate this release: one row per (user, client) with live MCP access or refresh tokens, scopes = union of the tokens' scopes, `created_at` = earliest token. It must be idempotent and skip pairs that already have a consent. — **Reversibility:** one-way — writes rows into a shared table on every consuming install.
|
||||
- **D-15:** **One revoke ends everything for that client:** frontend and backend (`backend_user_id`) access and every MCP session of the client. No separate backend revoke.
|
||||
|
||||
### Consent page as a Nuxt layer
|
||||
- **D-17:** **An initial `vue-oauth-layer` ships in this phase.** It is a new public Nuxt layer repo next to `vue-journal-layer`, `vue-faq-layer`, `vue-menu-layer` and `vue-paymentgateway-layer`, with the same structure and the same pnpm-git-dependency-pinned-by-tag consumption. It provides the `/connect` page of redirect consent mode, rendered from the MCP consent JSON (D-07): client name and logo, per-scope checkboxes with labels and descriptions, `read` locked, the "also act as admin <name>" line for `backend`, allow/deny, and the `backend_link_changed` 409 re-show. Apps adopt it later as their own work, so 32.1 does not migrate any app's existing `/connect` page. The layer gets a tag. It is one plan in the phase.
|
||||
|
||||
### Release
|
||||
- **D-16:** Patch releases only: framework 0.5.x (never hardcode the version), `sm-user-plugin` (catalog publication, implied scopes), `sm-oauthserver-plugin` (scopes, consent JSON/page, consent row, backfill, listing marker), plus the first tag of `vue-oauth-layer`. Commit and push each plugin before bumping pointers in apps. Each pushed framework tag gets a changelog post. The release notes tell sm-servers-app which versions close Requests 3–5.
|
||||
|
||||
### Claude's Discretion
|
||||
- The pact interface name and shape for the published scope catalog (D-05), within "names + label + description, additive".
|
||||
- Whether an opt-in name nobody declares fails boot or warns (D-03).
|
||||
- Field names of `MCPCall` and the setter name (D-10).
|
||||
- How the kind marker is spelled in the authorizations listing (D-13).
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope
|
||||
- `.planning/ROADMAP.md` § "Phase 32.1" — goal and the four success criteria.
|
||||
|
||||
### Prior decisions this phase builds on
|
||||
- `.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md` — pier design; D-10 (tool binds to route, in-process dispatch), D-16 (permissions from route middleware), D-20 (read/write ceiling, kept by D-01), D-21 (`backend` scope on the MCP server only).
|
||||
- `.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md` — sm-oauthserver-plugin's two-Server design, client kinds, consent model, additive wristband interfaces.
|
||||
- `/media/nvme/dev/pxpx/sm-servers-app/.planning/phases/05-agent-access-over-mcp/05-CONTEXT.md` § "Decisions after gap verification (2026-10-28)" — D-36/D-37/D-38, the source of this phase (Requests 3, 4, 5).
|
||||
- `/media/nvme/dev/pxpx/sm-servers-app/.planning/phases/05-agent-access-over-mcp/05-09-PLAN.md` — the expected-gap tests the releases must satisfy (`TestMCPOAuthToolsMatchPersonal`, `TestMCPOAuthWriteAttribution`, `TestMCPOAuthEmptyGrant`) and the rejected app-side scope middleware.
|
||||
- `/media/nvme/dev/pxpx/sm-servers-app/OFFBOARDING.md` § 8 — the request texts (what, why, interim).
|
||||
- `../vue-journal-layer/` (and the faq/menu/paymentgateway layers) — the layer structure and release pattern to copy for `vue-oauth-layer`.
|
||||
- `../sm-bm-app/.planning/phases/03.3-mcp-access-for-bm/03.3-CONTEXT.md` — a consuming app's MCP access decisions (D-07 consent page all-or-nothing in that app; personal tokens alongside OAuth).
|
||||
|
||||
### Docs to update
|
||||
- `modules/pier/README.md`, `modules/wristband/README.md`, `modules/pact/README.md`
|
||||
- `docs/services/mcp.md`, `docs/services/oauth-server.md`, `docs/services/authentication.md`
|
||||
- `../sm-oauthserver-plugin/README.md`, the sm-user-plugin README
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `modules/pact/capabilities.go:821-835`: `FrontendTokenScope{Name, Label, Description}` and `HasFrontendTokenScopes`; `ValidTokenScopeName`.
|
||||
- `modules/pact/mcp.go:134`: `MCPSubject{Guard, UserID, Scopes, ClientID, TokenID}`, wrapped by the new `MCPCall`.
|
||||
- `modules/pact/mcp.go:289-303`: `MCPSubjectChanged` with `MCPSubjectRevoked` + `ClientID`, which pier already uses to evict one client's sessions (`modules/pier/events.go:75-90`).
|
||||
- `../sm-oauthserver-plugin/controllers/mcp_consent_api.go`: `MCPConsentDecide` and `grantedScopes` (submitted ∩ requested) already support partial grants.
|
||||
- `../sm-oauthserver-plugin/controllers/authorizations.go`: `AuthorizationsIndex` and `revoke(userID, clientID)` (consent + codes + access/refresh tokens in one tx, then the revoked event).
|
||||
|
||||
### Established Patterns
|
||||
- Scope lists are hardcoded today: `modules/wristband/server.go:99` (`ScopesSupported`), `modules/wristband/authorize.go:24` (`authorizeAllowedScopes`, used only when `Options.ScopesSupported` is empty), `modules/pier/prm.go:14` (`PRMScopes`).
|
||||
- `../sm-oauthserver-plugin/servers.go:158` builds the MCP server's scopes as OIDC scopes + `backend`; the declared/opt-in set goes in here.
|
||||
- `modules/pier/filter.go:60-70`: the read/write ceiling (`id.writes()`) before the guard's `MCPAllows`; unchanged by D-01.
|
||||
- sm-user-plugin `classes/mcp_guard.go`: `MCPAllows` requires the route's `user.scope` names in `subject.Scopes`; the personal-token `MCPDispatch` builds an in-memory credential carrying the token.
|
||||
- Find-or-publish of shared registries on `backpack.App` (`pact.MCPGuards`) is the model for the published scope catalog.
|
||||
|
||||
### Integration Points
|
||||
- `modules/pier/dispatch.go:177-200`: where `bouncer.WithDispatch` is set; the `MCPCall` setter goes next to it (session id from the call info, tool from `t.name`).
|
||||
- `modules/pier/prm.go:mountPRM`: already looks up `pact.MCPTokenVerifier`; extend it to read the scope list from there.
|
||||
- `../sm-oauthserver-plugin/classes/mcp_store.go`: the MCP grant/token store; the consent upsert hooks in at code issue (and at direct grant minting).
|
||||
- `../sm-oauthserver-plugin/updates/`: new migration for the backfill (D-14).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The opt-in-or-all rule (D-03) must be documented with examples: unset = all declared scopes, set = only those listed, fixed scopes always.
|
||||
- A redirect-mode app may keep an all-or-nothing consent page; the JSON must give it enough (label, description, locked) to render either way.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Offering declared scopes on the OIDC server (non-MCP OAuth clients). Rejected for this phase (D-02).
|
||||
- Release/rollout order across framework, sm-user-plugin, sm-oauthserver-plugin and the consuming app was not discussed in detail; the planner follows the standard "plugin first, pointer bump second" order.
|
||||
- Moving apps' existing `/connect` pages (sm-servers-app, sm-bm-app, sm-demo-app) onto `vue-oauth-layer`: each app's own work.
|
||||
- Switching OAuth on in sm-servers-app production: that app's decision once the releases land.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- 27 pending todos matched on keywords only (e.g. "Record PHP OIDC parity against WavePath", user-plugin logout cookie, activate-by-code second factor). None touch MCP scopes, call context or MCP consent; none were folded.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
@@ -0,0 +1,143 @@
|
||||
# Phase 32.1: MCP OAuth for plugin apps - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-28
|
||||
**Phase:** 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
**Areas discussed:** Declared scopes vs ceiling, Consent page for scopes, MCP subject accessor, MCP consent row & revoke
|
||||
|
||||
---
|
||||
|
||||
## Declared scopes vs ceiling
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Keep both | D-20 read/write ceiling stays; declared scopes are extra gates | ✓ |
|
||||
| Declared scope stands alone | Tools behind a declared scope skip the read/write ceiling | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Fixed + all declared | Every FrontendTokenScope is advertised | |
|
||||
| Fixed + config opt-in list | Only listed declared scopes advertised | |
|
||||
| Other | | ✓ |
|
||||
|
||||
**User's choice:** Opt-in config list if set; if unset, all declared scopes. "Document this properly."
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| MCP server only | Same as `backend` (D-21) | ✓ |
|
||||
| Both servers | OIDC clients could request plugin scopes too | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| You decide | Researcher/planner pick the mechanism | |
|
||||
| Published pact catalog | User plugin publishes the compiled list; oauthserver and pier read it | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Consent page for scopes
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| oauthserver, pier reads it | `golem15.oauthserver.mcp_scopes`; pier reads from the verifier | ✓ |
|
||||
| pier (mcp.scopes) | MCP module owns the list | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Label+description, all-or-nothing | As in 19.4 | |
|
||||
| Per-scope checkboxes | User can untick individual scopes | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Both: JSON + built-in page | Consent JSON carries label/description; built-in page renders them | ✓ |
|
||||
| JSON only | Built-in page shows raw names | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Show consent again | Any uncovered requested scope sends the user through consent | ✓ |
|
||||
| You decide | Follow the OIDC path | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| All ticked, read locked | `read` can't be unticked; others can | ✓ |
|
||||
| All ticked, none locked | Anything can be unticked | |
|
||||
|
||||
---
|
||||
|
||||
## MCP subject accessor
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| pact | `pact.MCPSubjectFrom(ctx)` | ✓ |
|
||||
| pier | `pier.SubjectFrom(ctx)` | |
|
||||
| bouncer | Subject field on `bouncer.Dispatch` | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Every MCP tool call | OAuth + PAT, frontend + backend | ✓ |
|
||||
| OAuth calls only | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| MCPSubject as is | Guard, UserID, ClientID, TokenID, Scopes | |
|
||||
| Plus the MCP session id | Also Mcp-Session-Id | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| New pact.MCPCall wrapper | `MCPCall{Subject, SessionID, Tool}`; MCPSubject unchanged | ✓ |
|
||||
| SessionID field on MCPSubject | Guards see the field too | |
|
||||
|
||||
---
|
||||
|
||||
## MCP consent row & revoke
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Same consents table | Upsert (user, client) at MCP code issue | ✓ |
|
||||
| Separate MCP consents table | New table, revoke/listing look in both | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, with a kind marker | Additive `"kind":"mcp"` | ✓ |
|
||||
| Yes, no new field | | |
|
||||
| Separate MCP list endpoint | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Migration backfills rows | From live MCP access/refresh tokens | ✓ |
|
||||
| Lazy: write on next refresh | | |
|
||||
| Nothing | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, one revoke ends all | Frontend + backend + sessions | ✓ |
|
||||
| You decide | | |
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Pact catalog interface name/shape; boot error vs warning for unknown opt-in names; `MCPCall` field and setter names; kind marker spelling.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Declared scopes on the OIDC server.
|
||||
- Detailed rollout order across repos (planner uses the standard plugin-first order).
|
||||
|
||||
---
|
||||
|
||||
## Follow-up (same day): source notes and Vue layer
|
||||
|
||||
The source notes were found in `/media/nvme/dev/pxpx/sm-servers-app` phase 05 (D-36 to D-38, Requests 3–5). Request 3 asked that OAuth `read`/`write` map onto declared scopes, which differs from the locked "advertise declared scopes" approach.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Both: declared implies + advertise | Additive `Implies` on FrontendTokenScope; declared scopes still advertised | ✓ |
|
||||
| Mapping only | Exactly Request 3; drop advertising | |
|
||||
| Advertise only (as locked) | Clients must request servers:* explicitly | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, initial layer in 32.1 | New public vue-oauth-layer with the /connect page | ✓ |
|
||||
| Defer to its own phase | JSON contract only | |
|
||||
@@ -0,0 +1,358 @@
|
||||
# Phase 32.1: MCP OAuth for plugin apps — Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-28
|
||||
**Files analyzed:** 28
|
||||
**Analogs found:** 27 / 28 (the new `vue-oauth-layer` repo has no in-repo file; copy the sibling `vue-menu-layer` git repo)
|
||||
|
||||
Tracked-source check: framework paths are `git ls-files` in this repo. Plugin paths are `git ls-files` inside `sm-oauthserver-plugin` (`df2eddd`, v0.1.3) and the `sm-user-plugin` submodule at `sm-demo-app/plugins/golem15/user` (`07196a4`, **v0.1.13**). That submodule is the only local checkout; **implement the user plugin from tag v0.1.14 (`a1b78b80`), not from this worktree.** `vue-menu-layer` is its own repo (`ef3a090`). Do not edit app submodule pointers.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `modules/pact/capabilities.go` (`FrontendTokenScope.Implies`) | model | transform | same file, `FrontendTokenScope` / `TokenScope` | exact (in-place) |
|
||||
| `modules/pact/mcp.go` (`MCPCall`, `WithMCPCall`, `MCPCallFrom`, `MCPAdvertisedScopes`) | model | request-response | `modules/bouncer/dispatch.go` + `modules/pact/mcp.go` `MCPTokenVerifier` | exact |
|
||||
| `modules/pier/dispatch.go` | service | request-response | same file, `bouncer.WithDispatch` site | exact (in-place) |
|
||||
| `modules/pier/prm.go` | service | request-response | same file, `mountPRM` | exact (in-place) |
|
||||
| `modules/wristband/consent.go` (`PendingRequestView.OfflineAccess`) | model | CRUD | same file, `PendingRequestView` | exact (in-place) |
|
||||
| `modules/{pact,pier,wristband}/README.md`, `docs/services/{mcp,oauth-server,authentication}.md` | config | transform | the same pages | exact (in-place) |
|
||||
| pact/pier unit tests (new cases) | test | request-response | `modules/wristband/mcp_scopes_test.go`, existing pier tests | role-match |
|
||||
| sm-user-plugin `classes/api_token_scopes.go` | service | transform | same file, `CompileAPITokenScopes` | exact (in-place; branch from v0.1.14) |
|
||||
| sm-user-plugin `classes/mcp_guard.go` | middleware | request-response | same file, `MCPAllows` / `MCPDispatch` | exact (in-place; v0.1.14 body) |
|
||||
| sm-user-plugin `plugin.go` `bootAPITokenScopes` | provider | event-driven (boot) | same function + `app.Publish[pact.MCPPersonalTokenResolver]` | exact |
|
||||
| sm-user-plugin `README.md` | config | transform | same README | exact |
|
||||
| sm-user-plugin `classes/mcp_guard_test.go` (extend) | test | request-response | existing `TestMCP*` in that package | role-match |
|
||||
| sm-oauthserver-plugin `config/config.yaml` (`mcp_scopes`) | config | file-I/O | same file header + `modules/pier/config.go` `originsKey` | role-match |
|
||||
| sm-oauthserver-plugin `servers.go` `NewServers` | service | transform | same function | exact (in-place) |
|
||||
| sm-oauthserver-plugin `classes/mcp_verifier.go` | service | request-response | same type; optional method like `methodAllower` | role-match |
|
||||
| sm-oauthserver-plugin `controllers/mcp_consent_api.go` | controller | request-response | same file | exact (in-place) |
|
||||
| sm-oauthserver-plugin `controllers/authorizations.go` | controller | CRUD | same file `revoke` | exact (in-place) |
|
||||
| sm-oauthserver-plugin `updates/<new backfill>.go` | migration | batch | `updates/202610230001_add_backend_user_id.go` | exact |
|
||||
| sm-oauthserver-plugin `lang/en/lang.yaml`, `lang/pl/lang.yaml` | config | file-I/O | existing oauthserver lang files | exact |
|
||||
| sm-oauthserver-plugin `README.md` | config | transform | same README | exact |
|
||||
| sm-oauthserver-plugin consent/authorizations/backfill tests | test | CRUD | `mcp_consent_smoke_test.go`, `authorizations_smoke_test.go` | exact (extend) |
|
||||
| `vue-oauth-layer` (`package.json`, `nuxt.config.ts`, `app/pages/connect.vue`, `tests/*.test.ts`, README) | component | request-response | `vue-menu-layer` (sibling repo) | role-match |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/pact/capabilities.go` (model, transform)
|
||||
|
||||
**Analog:** `modules/pact/capabilities.go` (tracked)
|
||||
|
||||
**Core pattern** (lines 814–834) — add `Implies string` only. Zero value keeps existing composite literals valid. Update the comment that says the struct carries no permission patterns.
|
||||
|
||||
```go
|
||||
type FrontendTokenScope struct {
|
||||
Name string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
type HasFrontendTokenScopes interface {
|
||||
FrontendTokenScopes() []FrontendTokenScope
|
||||
}
|
||||
```
|
||||
|
||||
Do not add a method named `FrontendTokenScopes` on the new catalog interface (that name is already `HasFrontendTokenScopes`).
|
||||
|
||||
---
|
||||
|
||||
### `modules/pact/mcp.go` (model, request-response)
|
||||
|
||||
**Analogs:** `modules/bouncer/dispatch.go` (context key) and `modules/pact/mcp.go` (interfaces already here).
|
||||
|
||||
**Context-key pattern** (`modules/bouncer/dispatch.go` lines 5–34). Copy the unexported key. Prefer not turning a nil ctx into `context.Background()`: pier's dispatch context is never nil. `MCPCallFrom` returns `ok == false` when the value is absent (plain HTTP).
|
||||
|
||||
```go
|
||||
type dispatchKey struct{}
|
||||
|
||||
func WithDispatch(ctx context.Context, d *Dispatch) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, dispatchKey{}, d)
|
||||
}
|
||||
```
|
||||
|
||||
**Optional interface, not a new method on `MCPTokenVerifier`** (`modules/pact/mcp.go` lines 217–225). A new method would break every fake. Match the second-interface pattern in `modules/pier/backend_guard.go` lines 29–31:
|
||||
|
||||
```go
|
||||
type methodAllower interface {
|
||||
MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string, method string) (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
Recommended additive types (discretion, already in RESEARCH):
|
||||
|
||||
```go
|
||||
type MemberTokenScopeCatalog interface {
|
||||
MemberTokenScopes() []FrontendTokenScope
|
||||
}
|
||||
type MCPAdvertisedScopes interface {
|
||||
MCPScopesSupported() []string
|
||||
}
|
||||
type MCPCall struct {
|
||||
Subject MCPSubject
|
||||
SessionID string
|
||||
Tool string
|
||||
}
|
||||
```
|
||||
|
||||
`MCPSubject` stays lines 134–140 (`Guard`, `UserID`, `Scopes`, `ClientID`, `TokenID`). Publish lookup shape is `modules/backpack/app.go` lines 59–73: `Publish[T]` / `Lookup[T]`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/pier/dispatch.go` (service, request-response)
|
||||
|
||||
**Analog:** same file, lines 169–208.
|
||||
|
||||
Set the call on the line immediately before `WithDispatch`. `subject` is `id.subjectFor(t.guardName)` (line 169). `Tool` is `t.name`. `SessionID` comes from the call info's session, forced to `""` on the stateless path and never the audit word `"ephemeral"`.
|
||||
|
||||
```go
|
||||
subject := id.subjectFor(t.guardName)
|
||||
// ...
|
||||
credential := disp.Credential
|
||||
if credential == nil && t.guardName == BackendGuard {
|
||||
credential = id.credential
|
||||
}
|
||||
callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential})
|
||||
```
|
||||
|
||||
Do not expand scopes here. Ceiling stays in `modules/pier/filter.go` (`id.writes()` before `MCPAllows`).
|
||||
|
||||
---
|
||||
|
||||
### `modules/pier/prm.go` (service, request-response)
|
||||
|
||||
**Analog:** same file, lines 12–40.
|
||||
|
||||
```go
|
||||
var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
|
||||
// ...
|
||||
if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil {
|
||||
if res := verifier.MCPResource(); res != "" { /* issuer */ }
|
||||
}
|
||||
meta := &oauthex.ProtectedResourceMetadata{
|
||||
Resource: p.cfg.Resource,
|
||||
ScopesSupported: append([]string(nil), PRMScopes...),
|
||||
BearerMethodsSupported: []string{"header"},
|
||||
}
|
||||
```
|
||||
|
||||
Replace only the `ScopesSupported` assignment: if the same verifier also implements `pact.MCPAdvertisedScopes` and the slice is non-empty, clone that; otherwise keep `PRMScopes`. Do not import the oauthserver plugin. Do not read the raw member-token catalog (that skips the opt-in filter).
|
||||
|
||||
---
|
||||
|
||||
### `modules/wristband/consent.go` (model, CRUD)
|
||||
|
||||
**Analog:** same file, lines 35–74. Add `OfflineAccess bool` to `PendingRequestView` and fill it in `PendingRequest`. Do not change `IssueCode`'s signature. The store reads a context value (same idea as `classes.WithConsentedBackend`) so a direct `IssueCode` call keeps today's flag.
|
||||
|
||||
```go
|
||||
type PendingRequestView struct {
|
||||
ClientName string
|
||||
RedirectHost string
|
||||
ScopesRequested []string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error)
|
||||
```
|
||||
|
||||
Authorize allow-list is already `Options.ScopesSupported` when non-empty (`modules/wristband/authorize.go` `allowedAuthorizeScopes`). No wristband change is required to accept a declared name once the host fills the slice. An empty slice falls back to the PHP four-name map and drops `backend` — the host must never pass an empty slice.
|
||||
|
||||
---
|
||||
|
||||
### sm-user-plugin `classes/api_token_scopes.go` + `plugin.go` (service + boot provider)
|
||||
|
||||
**Analog (structure only):** `sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` lines 52–127 and `plugin.go` lines 154–195. **Branch from v0.1.14.** Do not edit the submodule.
|
||||
|
||||
`CompileAPITokenScopes` already rejects built-in names `read`/`write`/`ai`, duplicates, empty label/description, and fails boot. Extend that same loop: reject `Implies` other than `""`, `"read"`, `"write"`, and reject declared names `backend` and `offline_access`. Built-ins keep `Implies` empty. Phrase-key check stays in `bootAPITokenScopes` (`tr.Has`).
|
||||
|
||||
Publish the compiled list as the new pact interface, beside the existing concrete publish and the guard publish:
|
||||
|
||||
```go
|
||||
return app.Publish(catalog) // today: *APITokenScopeCatalog
|
||||
// and:
|
||||
return app.Publish[pact.MCPPersonalTokenResolver](guard)
|
||||
```
|
||||
|
||||
The published catalog includes built-ins (labels for consent) plus declared scopes. Advertised declared names are catalog entries whose name is not `read`, `write`, or `ai`. `offline_access` and `backend` are not member-token scopes; oauthserver prepends the fixed five.
|
||||
|
||||
---
|
||||
|
||||
### sm-user-plugin `classes/mcp_guard.go` (middleware, request-response)
|
||||
|
||||
**Analog:** v0.1.13 worktree `MCPAllows` lines 87–136 is the wrong credential body. On **v0.1.14** `MCPDispatch` sets `credential.ID = subject.TokenID` only when `subject.ClientID == ""` and leaves OAuth credentials at ID 0. Keep that. One helper used by both `MCPAllows` and `MCPDispatch`:
|
||||
|
||||
- `ClientID == ""`: return `subject.Scopes` unchanged (personal token).
|
||||
- else: clone scopes; for each catalog entry with `Implies` `"read"` or `"write"`, if the grant contains that stock scope and the declared name is absent, append it.
|
||||
- Never write the expanded set onto the token row, the session fingerprint, or `subject.Scopes` as stored.
|
||||
|
||||
`MCPAllows` today (v0.1.13 lines 90–94), which the helper replaces for the scope check only:
|
||||
|
||||
```go
|
||||
for _, scope := range req.Scopes {
|
||||
if !slices.Contains(subject.Scopes, scope) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`MCPDispatch` puts the helper's slice on `models.APIToken.Scopes` (`lagoon.Jsonable`). `middleware.Scope` reads `token.HasScope` (`slices.Contains` on that slice). Do not set `APIToken.OAuthClientID`. The OAuth client id belongs on `pact.MCPCall`. Do not expand scopes in pier's backend guard (`modules/pier/backend_guard.go`).
|
||||
|
||||
---
|
||||
|
||||
### sm-oauthserver-plugin `servers.go` + `config/config.yaml` (service + config)
|
||||
|
||||
**Analog:** `servers.go` lines 154–158 and `modules/pier/config.go` `originsKey` lines 230–262.
|
||||
|
||||
```go
|
||||
mcpOpts := opts
|
||||
mcpOpts.Resource = cfg.MCPResource
|
||||
mcpOpts.ScopesSupported = append(slices.Clone(opts.ScopesSupported), "backend")
|
||||
```
|
||||
|
||||
`mcpOpts := opts` is already a struct copy. Build the MCP slice on that copy only: fixed five (`read`, `write`, `ai`, `offline_access`, `backend`), then declared names in catalog order, filtered by `golem15.oauthserver.mcp_scopes` when `Config.Has` is true. Empty list (key set) means fixed five only. Unset means every declared scope. Do not assign `cfg.OIDC.ScopesSupported`. Do not append `"backend"` twice. Unknown opt-in name fails boot (name the key and the name), matching `CompileAPITokenScopes`.
|
||||
|
||||
Read the list with `Lookup` and the `[]any` / `[]string` / comma-string switch from `originsKey` (env `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES`). Config file header documents the env spelling (`config/config.yaml` lines 1–3).
|
||||
|
||||
`classes/mcp_verifier.go` returns `pact.MCPGrant` (lines 109–118) and must also implement `MCPScopesSupported()` with the same slice `NewServers` assigned. Publish both `pact.MCPTokenVerifier` and the optional scopes interface on that value.
|
||||
|
||||
---
|
||||
|
||||
### sm-oauthserver-plugin `controllers/mcp_consent_api.go` (controller, request-response)
|
||||
|
||||
**Analog:** same file. Keep `scopes []string`. Add `scope_details` (`name`, `label`, `description`, `locked`) and `logo_url` on the client. `locked` is true only for requested `read`.
|
||||
|
||||
Show body today (lines 24–43):
|
||||
|
||||
```go
|
||||
type mcpConsentShowBody struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Client mcpConsentClient `json:"client"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
BackendAdmin *mcpConsentAdmin `json:"backend_admin"`
|
||||
}
|
||||
```
|
||||
|
||||
Keep `grantedScopes` (lines 168–180). Do not force `read` back in (smoke test posts `scopes: ["backend"]` and expects 422). Augment the requested list with `offline_access` when the pending flag is set, intersect against that, strip the word before `IssueCode`, and set the column from the decision via context. A submit whose only remaining scope is `offline_access` still 422. `read` is data-bearing. `backend` counts only when a link is recorded (existing strip at lines 144–161).
|
||||
|
||||
Labels: catalog phrase through `phrasebook.Translator.Get` when the text contains `::`, else the literal. `backend` and `offline_access` are not in the user catalog; add en+pl phrase keys in this plugin. Logo: `classes.ClientLogoURL` (already used in `authorizations.go` line 81).
|
||||
|
||||
**Do not call `oidcTx.SaveConsent`.** It unions ( `classes/oidc_store.go` lines 328–361). MCP decision replaces scopes (`ON CONFLICT DO UPDATE SET scopes = excluded.scopes`). Silent re-grant (`consent_required: false`, nothing missing) only `TouchConsent` (lines 364–366) and issues the requested subset. First grant inserts. Deny does not change the row. Upsert after `IssueCode` returns nil. Store `offline_access` in the consent scope list when the flag was granted. `models.ClientKindMCP` / `ClientKindOIDC` are `"mcp"` / `"oidc"` (`models/client.go` lines 14–17).
|
||||
|
||||
---
|
||||
|
||||
### sm-oauthserver-plugin `controllers/authorizations.go` (controller, CRUD)
|
||||
|
||||
**Analog:** same file, lines 21–157.
|
||||
|
||||
Add `Kind string \`json:"kind"\`` last on `authorization`. Select `c.kind`. Always set it (`oidc` or `mcp`), do not omit OIDC. Key order stays `client_id`, `name`, `logo_url`, `scopes`, `first_authorized_at`, `last_used_at`, then `kind`. Update `authorizations_smoke_test.go` which requires exactly six keys.
|
||||
|
||||
`revoke` (lines 134–157) locks the consent row then deletes `Consent`, `AuthCode`, `AccessToken`, `RefreshToken`. Also delete `models.MCPCode` for the same pair. Take `classes.lockUserGrants` (exclusive, `classes/mcp_backend_binding.go` lines 61–75) inside the transaction **before** the deletes. Keep 404 `errNotFound` when no consent. Keep the existing `MCPSubjectRevoked` with `ClientID` and **no** `TokenID` (lines 124–126). Do not add a second backend revoke: access/refresh rows already carry `backend_user_id`.
|
||||
|
||||
---
|
||||
|
||||
### sm-oauthserver-plugin backfill migration (migration, batch)
|
||||
|
||||
**Analog:** `updates/202610230001_add_backend_user_id.go` lines 15–35 (`init() { Register(...) }`, ID sorts after `202610230001_add_golem15_oauthserver_backend_user_id`).
|
||||
|
||||
```go
|
||||
var addBackendUserID = []*gormigrate.Migration{{
|
||||
ID: "202610230001_add_golem15_oauthserver_backend_user_id",
|
||||
Migrate: func(tx *gorm.DB) error { return run(tx, []string{ /* SQL */ }) },
|
||||
Rollback: func(tx *gorm.DB) error { return run(tx, []string{ /* SQL */ }) },
|
||||
}}
|
||||
func init() { Register(addBackendUserID...) }
|
||||
```
|
||||
|
||||
A Go loop inside `Migrate` is the practical way to union JSON scope arrays (`models.Strings` is `lagoon.Jsonable[[]string]`). One row per live MCP `(user_id, client_id)`; skip pairs that already have a consent; include `offline_access` from the boolean column; `created_at` = earliest live token; `last_used_at` stays null; second run inserts nothing. `Rollback` no-ops with a comment (D-14 is one-way). Do not delete every MCP consent on rollback.
|
||||
|
||||
---
|
||||
|
||||
### Docs (config, transform)
|
||||
|
||||
**Analogs:** the pages themselves. Identifiers and the code that exports them land in the same commit (`go test ./cmd/summer -run TestDocsTree`). Framework docs never name a consuming app. Document:
|
||||
|
||||
- `pact.MCPCall` / `MCPCallFrom` / setter: `Subject.TokenID` is a `user_api_tokens` id only when `ClientID == ""`; otherwise it is the oauthserver access-token row. `Subject.Scopes` is the grant, not the implied expansion.
|
||||
- `golem15.oauthserver.mcp_scopes`: unset = all declared, set = only those names, empty list = fixed five only, fixed five always. Config keys are not checked by the docs checker; review them by hand.
|
||||
- pier PRM reads the verifier list, else `PRMScopes`.
|
||||
- Existing DCR clients with a non-nil scope ceiling silently drop names outside that ceiling. Do not rewrite old ceilings.
|
||||
|
||||
Do not add checkboxes to `views/consent.html.tmpl` (OIDC page). MCP authorize always redirects to `{issuer}/connect?request=`.
|
||||
|
||||
---
|
||||
|
||||
### `vue-oauth-layer` (component, request-response) — no file in this repo
|
||||
|
||||
**Analog:** sibling repo `/media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer` (git `ef3a090`), not `vue-journal-layer` (journal pulls SEO/i18n/fonts).
|
||||
|
||||
**package.json** (lines 1–24): `"type": "module"`, `"main": "./nuxt.config.ts"`, `"engines": {"node": ">=22.18"}`, peer `nuxt` 4.4.8, dev `vue` 3.5.35 and `typescript` 5.9.3, `"test": "node --test --test-reporter=tap tests/*.test.ts"`.
|
||||
|
||||
**nuxt.config.ts** (lines 1–13): paths from `fileURLToPath(new URL('.', import.meta.url))`. A relative path resolves against the consuming app.
|
||||
|
||||
**Tests:** `tests/menuLinks.test.ts` lines 1–25 — `node:test` + `node:assert/strict` over pure functions, no browser. Map show JSON → boxes, locked `read`, POST body, 409 re-fetch. Page at `app/pages/connect.vue` (Nuxt merges layer pages; an app's own page wins). Render labels as text, not `v-html`. Do not proxy the OAuth API. 401 redirects to a documented login prop, default `/login`. Do not tag or push the repo in the plan. Do not edit any app's `/connect`.
|
||||
|
||||
README install line shape (`vue-menu-layer/README.md` lines 5–7 and 27–35):
|
||||
|
||||
```bash
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.2.0
|
||||
```
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-menu-layer'],
|
||||
})
|
||||
```
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Publish / lookup
|
||||
**Source:** `modules/backpack/app.go` lines 59–73
|
||||
**Apply to:** user-plugin catalog, oauthserver verifier, pier PRM lookup
|
||||
|
||||
```go
|
||||
func (a *App) Publish[T any](value T) error
|
||||
func (a *App) Lookup[T any]() (T, bool)
|
||||
```
|
||||
|
||||
### Optional second interface
|
||||
**Source:** `modules/pier/backend_guard.go` lines 29–31
|
||||
**Apply to:** `pact.MCPAdvertisedScopes` on the existing verifier value. Do not add methods to `MCPTokenVerifier`.
|
||||
|
||||
### Config list (YAML list, string slice, or comma env string)
|
||||
**Source:** `modules/pier/config.go` lines 230–246
|
||||
**Apply to:** `golem15.oauthserver.mcp_scopes`. Use `Config.Has` to tell unset from empty. `Config.String` returns `""` for both.
|
||||
|
||||
### Consent intersection (keep)
|
||||
**Source:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go` lines 168–180
|
||||
**Apply to:** MCP consent decide. Augment requested with `offline_access` first; do not special-case `read`.
|
||||
|
||||
### Consent replace vs union
|
||||
**Source:** `sm-oauthserver-plugin/classes/oidc_store.go` lines 328–366 (`SaveConsent` unions; `TouchConsent` only stamps `last_used_at`)
|
||||
**Apply to:** MCP must not call `SaveConsent`. Decision path replaces `scopes`. Silent path calls `TouchConsent` only.
|
||||
|
||||
### Grants lock then delete
|
||||
**Source:** `classes/mcp_backend_binding.go` lines 43–75 and `controllers/authorizations.go` lines 134–157
|
||||
**Apply to:** per-client revoke, plus `models.MCPCode`. Event stays `MCPSubjectRevoked` + `ClientID` only (`authorizations.go` lines 124–126).
|
||||
|
||||
### Error JSON
|
||||
**Source:** `mcp_consent_api.go` `writeDecision` lines 196–208 and `authorizations.go` lines 116–120
|
||||
**Apply to:** new consent and revoke branches. 401 `unauthenticated`, 404 `request_not_found` / `not_found`, 409 `backend_link_changed`, 422 `no_grantable_scopes`, 500 `server_error`.
|
||||
|
||||
### Boot failure on a bad declaration
|
||||
**Source:** `CompileAPITokenScopes` error strings (`golem15.user: plugin %s declared ...`) and `bootAPITokenScopes` missing phrase key
|
||||
**Apply to:** bad `Implies`, reserved names `backend`/`offline_access`, unknown `mcp_scopes` entry.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `vue-oauth-layer` repo (does not exist) | component | request-response | Not in this module. Copy `vue-menu-layer`, not journal. RESEARCH Pattern 10 is the page behaviour; there is no `/connect` Vue page in the framework. |
|
||||
|
||||
User-plugin files exist only as a v0.1.13 submodule. They are valid structural analogs. The credential-id assignment must be copied from tag v0.1.14, which is not checked out. RESEARCH quotes that function (`classes/mcp_guard.go` lines 115–143 on the tag).
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/pact`, `modules/pier`, `modules/wristband`, `modules/bouncer`, `modules/backpack`, `modules/compass`; `sm-oauthserver-plugin`; `sm-demo-app/plugins/golem15/user` (v0.1.13, read-only); `vue-menu-layer`.
|
||||
**Files scanned:** the classification table plus `oidc_store.go`, `mcp_backend_binding.go`, `mcp_verifier.go`, `models/client.go`.
|
||||
**Pattern extraction date:** 2026-10-28
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
---
|
||||
phase: 32.1
|
||||
review: 32.1-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: open
|
||||
title: "An in-flight MCP allow recreates a consent the user just revoked"
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "Clearing a declared scope does not stop `read` or `write` from implying it"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "`mcp_scopes` does not limit what an OAuth `read` or `write` grant can call"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "The consent backfill treats unusable tokens as live grants"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "Replacing a consent does not revoke refresh tokens that still carry the removed scopes"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "sm-user-plugin v0.1.16 does not require the framework version it uses"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "`/connect` can allow a stale request after `?request=` changes"
|
||||
open: 7
|
||||
total: 7
|
||||
recorded: 2026-10-28T16:03:08.042Z
|
||||
---
|
||||
|
||||
# Phase 32.1: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| CR-01 | critical | open | - |
|
||||
| WR-01 | warning | open | - |
|
||||
| WR-02 | warning | open | - |
|
||||
| WR-03 | warning | open | - |
|
||||
| WR-04 | warning | open | - |
|
||||
| WR-05 | warning | open | - |
|
||||
| WR-06 | warning | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
reviewed: 2026-10-28T15:58:24Z
|
||||
depth: standard
|
||||
files_reviewed: 53
|
||||
files_reviewed_list:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/mcp.go
|
||||
- modules/pact/mcp_call_test.go
|
||||
- modules/pact/mcp_call_negative_test.go
|
||||
- modules/pact/README.md
|
||||
- modules/pier/dispatch.go
|
||||
- modules/pier/prm.go
|
||||
- modules/pier/session.go
|
||||
- modules/pier/stateless.go
|
||||
- modules/pier/mcp_seam_test.go
|
||||
- modules/pier/dispatch_mcpcall_test.go
|
||||
- modules/pier/prm_scopes_test.go
|
||||
- modules/pier/README.md
|
||||
- modules/wristband/authorize.go
|
||||
- modules/wristband/server.go
|
||||
- modules/wristband/consent.go
|
||||
- modules/wristband/mcp_scopes_smoke_test.go
|
||||
- modules/wristband/README.md
|
||||
- docs/services/authentication.md
|
||||
- docs/services/mcp.md
|
||||
- docs/services/oauth-server.md
|
||||
- sm-user-plugin/classes/api_token_scopes.go
|
||||
- sm-user-plugin/classes/mcp_guard.go
|
||||
- sm-user-plugin/plugin.go
|
||||
- sm-user-plugin/go.mod
|
||||
- sm-user-plugin/mcp_implies_test.go
|
||||
- sm-user-plugin/README.md
|
||||
- sm-oauthserver-plugin/classes/mcp_scopes.go
|
||||
- sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
- sm-oauthserver-plugin/classes/mcp_backend_binding.go
|
||||
- sm-oauthserver-plugin/classes/mcp_store.go
|
||||
- sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
- sm-oauthserver-plugin/controllers/authorizations.go
|
||||
- sm-oauthserver-plugin/config/config.yaml
|
||||
- sm-oauthserver-plugin/plugin.go
|
||||
- sm-oauthserver-plugin/servers.go
|
||||
- sm-oauthserver-plugin/lang/en/lang.yaml
|
||||
- sm-oauthserver-plugin/lang/pl/lang.yaml
|
||||
- sm-oauthserver-plugin/README.md
|
||||
- sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
|
||||
- sm-oauthserver-plugin/mcp_scopes_test.go
|
||||
- sm-oauthserver-plugin/mcp_consent_test.go
|
||||
- sm-oauthserver-plugin/mcp_consent_smoke_test.go
|
||||
- sm-oauthserver-plugin/mcp_backfill_test.go
|
||||
- sm-oauthserver-plugin/authorizations_smoke_test.go
|
||||
- sm-oauthserver-plugin/authorizations_kind_test.go
|
||||
- sm-oauthserver-plugin/go.mod
|
||||
- vue-oauth-layer/app/pages/connect.vue
|
||||
- vue-oauth-layer/app/utils/connectConsent.ts
|
||||
- vue-oauth-layer/tests/connect.test.ts
|
||||
- vue-oauth-layer/nuxt.config.ts
|
||||
- vue-oauth-layer/package.json
|
||||
- vue-oauth-layer/README.md
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 6
|
||||
info: 0
|
||||
total: 7
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 32.1: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-28T15:58:24Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 53
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed the phase 32.1 commits only: summercms.go `63d05e8`, `9e29d6f`, `876f0f0`, `9094bf9`; sm-user-plugin `25fd981`, `193a381`, `756797c`; sm-oauthserver-plugin `644641a`, `52529c6`, `a409074`, `4f8618e`, `a7743e7`; vue-oauth-layer `02c466e`, `37c1e12`, `7f805b3`. Lockfiles, the vue skeleton, and phase 33 planning commits were not treated as product changes. Advisory only; no product file was modified.
|
||||
|
||||
The scope list, `MCPCall` placement, PRM copy, and the OAuth-only `Implies` expansion match the phase decisions. The consent decision does not. `IssueCode` commits the authorization code, then a later transaction writes the consent row, and neither takes `LockUserGrants`. A revoke that lands in that gap deletes the code and the old consent, and the allow puts the consent back. Separately, clearing a declared scope on `/connect` does not stop the user guard from implying it back from locked `read`/`write`, and a replaced consent does not revoke refresh tokens that still carry the removed scopes.
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: An in-flight MCP allow recreates a consent the user just revoked
|
||||
|
||||
**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:221-241`, `sm-oauthserver-plugin/controllers/authorizations.go:138-164`, `sm-oauthserver-plugin/classes/mcp_store.go:256-279`
|
||||
|
||||
**Issue:** `MCPConsentDecide` calls `srv.IssueCode`, which commits `MarkIssued` in its own transaction, and only then calls `recordMCPConsent`. `MarkIssued` does not take `LockUserGrants`. Revoke does: it locks, deletes the consent, and deletes MCP codes, access tokens, and refresh tokens for that user and client.
|
||||
|
||||
This interleaving is possible whenever a consent row already exists (a re-consent, including the silent `consent_required: false` path):
|
||||
|
||||
1. Allow's `IssueCode` commits. The new MCP code exists. The consent row is still the old one, or not yet replaced.
|
||||
2. Revoke commits. It deletes that consent and the new code, then returns `{"revoked": true}`.
|
||||
3. Allow's `recordMCPConsent` runs. `Take` now returns not found, so `replaceMCPConsent` inserts a new row.
|
||||
4. Allow returns 200 and the redirect for a code revoke already deleted.
|
||||
|
||||
The token exchange fails, but the consent row is back. The next authorize for scopes that row covers sets `consent_required` false, and `vue-oauth-layer` posts allow immediately. Revoke did not stick. The same split leaves a live code and a 500 if the consent write fails after `IssueCode` succeeds, because the redirect is withheld while the code hash is already stored.
|
||||
|
||||
**Fix:** Issue the code and upsert the consent in one transaction that holds `LockUserGrants` for the whole unit, the same exclusive lock revoke takes. Revoke then either waits and deletes both rows, or commits first and the allow sees that result inside the lock. Do not write the consent after `IssueCode` returns.
|
||||
|
||||
```go
|
||||
err = s.backend.WithinTx(ctx, func(tx Tx) error {
|
||||
if err := classes.LockUserGrants(ctx, tx.DB(), []uint{userID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.MarkIssued(ctx, pending.ID, sha256Hex(code), userID, grantedScopes, collectionIDs, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.MarkConsented(ctx, client.ClientID); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceMCPConsent(ctx, tx.DB(), userID, client.ClientID, stored, now)
|
||||
})
|
||||
```
|
||||
|
||||
`replaceMCPConsent` has to use that same `tx`. A consent failure must roll the code back.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Clearing a declared scope does not stop `read` or `write` from implying it
|
||||
|
||||
**File:** `sm-user-plugin/classes/mcp_guard.go:160-174`, `sm-oauthserver-plugin/controllers/mcp_consent_api.go:408-418`, `vue-oauth-layer/app/pages/connect.vue:147-160`
|
||||
|
||||
**Issue:** `read` is locked whenever it was requested. Declared scopes are checkboxes the user can clear, and `grantedScopes` then omits them from the token and, on a real replace, from the consent row. `mcpEffectiveScopes` adds every catalog name whose `Implies` is `read` or `write` whenever the OAuth grant still holds that stock scope. A grant of only `read` is also what a client receives when the user cleared `servers:read` and left locked `read`. `MCPAllows` and the in-memory `user.scope` credential both see the name again, so the tool still runs. The checkbox is not a denial for any scope implied by a stock scope that remains on the grant.
|
||||
|
||||
**Fix:** Store an explicit denial, or stop presenting an implied name as optional while its stock scope is on the request. If the name stays optional, `mcpEffectiveScopes` must not append a declared name the user cleared on this grant. Absence cannot mean both "never requested" and "user denied" without a separate denial list.
|
||||
|
||||
### WR-02: `mcp_scopes` does not limit what an OAuth `read` or `write` grant can call
|
||||
|
||||
**File:** `sm-user-plugin/classes/mcp_guard.go:165-173`, `sm-oauthserver-plugin/classes/mcp_scopes.go:25-57`, `sm-oauthserver-plugin/config/config.yaml:77-84`
|
||||
|
||||
**Issue:** `AdvertisedMCPScopes` drops declared names that are not in `golem15.oauthserver.mcp_scopes`. An empty list is documented as the five fixed names only, and authorize rejects anything outside the advertised slice. `mcpEffectiveScopes` never reads that slice. It walks the whole member-token catalog. A client that was only allowed to request `read` still satisfies every `Implies: "read"` name, including names the operator excluded and that `/connect` never shows.
|
||||
|
||||
**Fix:** Intersect the expansion with the advertised MCP scope list (the same slice `NewServers` stored on the verifier). An unset filter still implies every declared name. A set or empty filter then matches what metadata, authorize, and the consent page show.
|
||||
|
||||
### WR-03: The consent backfill treats unusable tokens as live grants
|
||||
|
||||
**File:** `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go:151-182`
|
||||
|
||||
**Issue:** `loadLiveMCPTokens` keeps a row when `revoked_at` is null and `expires_at` is null or in the future. It does not require an active, unrevoked MCP client, a non-null access-token expiry, or `rotated_to_id IS NULL`. `MCPTokenVerifier` rejects an access token with a nil expiry, a revoked client, or an inactive client (`classes/mcp_verifier.go:103-115`). A refresh token with `rotated_to_id` set is a replay, not a usable grant (`modules/wristband/token.go:332-340`). Those rows still seed `golem15_oauthserver_consents`. The inserted consent outlives the token, so a later authorize for those scopes skips the prompt even though this server would not have honored the source token.
|
||||
|
||||
**Fix:** Match the verifier. Require `c.revoked_at IS NULL AND c.is_active`, `a.expires_at IS NOT NULL AND a.expires_at > NOW()` for access tokens, and `r.rotated_to_id IS NULL` for refresh tokens.
|
||||
|
||||
### WR-04: Replacing a consent does not revoke refresh tokens that still carry the removed scopes
|
||||
|
||||
**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:348-364`, `modules/wristband/token.go:361-370`
|
||||
|
||||
**Issue:** A real allow replaces `golem15_oauthserver_consents.scopes` with the new grant. It does not revoke that client's access or refresh tokens. Refresh rotation copies `rec.Scopes` and `rec.OfflineAccess` from the existing refresh row, not from the consent row. After the user clears `write` or `offline_access` on a later allow, the old refresh token keeps minting access tokens with the old scope set until it expires or the user deletes the whole authorization.
|
||||
|
||||
**Fix:** In the same locked transaction as the consent replace, revoke this client’s access tokens and refresh-token lineages whose scopes are not a subset of the new grant (and whose `offline_access` column is set when the new grant omits it). Leave other clients alone.
|
||||
|
||||
### WR-05: sm-user-plugin v0.1.16 does not require the framework version it uses
|
||||
|
||||
**File:** `sm-user-plugin/go.mod:6`, `sm-user-plugin/go.mod:183`
|
||||
|
||||
**Issue:** The tagged module calls `pact.MemberTokenScopeCatalog` and `FrontendTokenScope.Implies`, which shipped in summercms v0.5.2. `go.mod` still requires `git.golem15.com/golem15/summercms v0.5.0` and replaces it with `../summercms.go`. Downstream modules ignore that replace. An app that selects v0.1.16 without already selecting summercms v0.5.2 does not compile. sm-oauthserver-plugin v0.1.4 happens to require v0.5.2, so the full stack builds only because of that other module.
|
||||
|
||||
**Fix:** Require `git.golem15.com/golem15/summercms v0.5.2` in sm-user-plugin and drop the sibling replace from the release commit. A local replace can stay uncommitted in a development checkout.
|
||||
|
||||
### WR-06: `/connect` can allow a stale request after `?request=` changes
|
||||
|
||||
**File:** `vue-oauth-layer/app/pages/connect.vue:52-68`
|
||||
|
||||
**Issue:** `load` has no generation token. `watch(requestId)` starts another `load` while the previous `$fetch` is in flight, and `consent_required === false` posts allow before render. Whichever response arrives last assigns `show` and, if that body does not require consent, calls `submit('allow')` for the request it loaded, which may no longer be `requestId`. Both requests can be approved, and the browser can be sent to the redirect of the one the user is no longer on.
|
||||
|
||||
**Fix:** Capture a sequence number (or the request id) before the fetch and ignore the response, including the auto-allow, when it is no longer current.
|
||||
|
||||
```ts
|
||||
let loadSeq = 0
|
||||
async function load(fromRefetch = false) {
|
||||
const seq = ++loadSeq
|
||||
const id = requestId.value
|
||||
// ...
|
||||
const body = await $fetch<ConsentShow>(consentUrl(), { credentials: 'include' })
|
||||
if (seq !== loadSeq || id !== requestId.value) return
|
||||
// assign show and maybe submit
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-28T15:58:24Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
phase: "32.1"
|
||||
slug: "mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 32.1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
> Seeded from `32.1-RESEARCH.md` § Validation Architecture. No REQ-IDs are mapped; rows use success criteria SC-1..SC-4.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (stdlib). Plugins use testcontainers-go v0.44.0 off `-short`. vue-oauth-layer uses `node --test`. |
|
||||
| **Config file** | none — Go has no repo-level test config. Layer: `package.json` script `test`, copied from vue-menu-layer. |
|
||||
| **Quick run command** | `go test -short ./modules/pact ./modules/pier ./modules/wristband ./modules/bouncer` |
|
||||
| **Full suite command** | `go test -short ./...` in this repo; `go test ./...` in each plugin repo (Docker); `pnpm test` in vue-oauth-layer |
|
||||
| **Estimated runtime** | ~60 seconds for the quick command; plugin non-short tests depend on Docker |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** `go test -short` on the packages that commit touched (`./modules/pact`, `./modules/pier`, `./modules/wristband`, or the plugin's `-short` suite), plus `go vet` on those packages.
|
||||
- **After every plan wave:** `go test -short ./...` in the repo that merged. Plugin waves that touch consent or revoke also run the non-short tests named below when Docker is up.
|
||||
- **Before `$msd-verify-work`:** Full `-short` suite in the framework repo, non-short consent/revoke/backfill tests in sm-oauthserver-plugin, `go test -short ./...` in sm-user-plugin on the v0.1.14 branch, `pnpm test` in the layer, `go test ./cmd/summer -run TestDocsTree`.
|
||||
- **Max feedback latency:** 120 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 32.1-01-01 | 01 | 1 | SC-1 | — | Declared scope names are advertised and unknown names are rejected; OIDC discovery scopes stay unchanged | unit | `go test -short ./modules/wristband -run 'TestMCPScopes\|TestMetadata' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| 32.1-02-01 | 02 | 2 | SC-1 | — | `Implies` applies only to OAuth MCP subjects; personal tokens are not expanded | unit | `go test -short . -run 'TestMCP' -count=1` in sm-user-plugin on v0.1.14 | ❌ W0 | ⬜ pending |
|
||||
| 32.1-01-02 | 01 | 1 | SC-1 | — | pier PRM `scopes_supported` matches the published catalog | unit | `go test -short ./modules/pier -run 'TestPRM\|TestProtected' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| 32.1-01-03 | 01 | 1 | SC-2 | — | `MCPCallFrom` returns the call subject on a dispatched tool and is empty on a plain request | unit | `go test -short ./modules/pier -run TestDispatch -count=1` | ❌ W0 | ⬜ pending |
|
||||
| 32.1-03-01 | 03 | 3 | SC-3 | — | Consent replace, revoke of one client, and MCP code deletion do not touch another client | integration | `cd ../sm-oauthserver-plugin && go test -count=1 -run 'TestMCPConsent\|TestAuthorizations\|TestMCPRevocation'` | ❌ W0 | ⬜ pending |
|
||||
| 32.1-04-01 | 04 | 4 | SC-3 | — | Consent page posts scopes as JSON and does not render phrasebook strings as HTML | unit | `pnpm test` in vue-oauth-layer | ❌ W0 | ⬜ pending |
|
||||
| 32.1-05-01 | 05 | 5 | SC-4 | — | Docs identifiers resolve | unit | `go test ./cmd/summer -run TestDocsTree -count=1` | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
Task IDs above are placeholders until plans exist. The executor updates this map to the real task IDs.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/pact` test that `FrontendTokenScope` still composites without `Implies` and that `MCPCallFrom` is false on a bare context — SC-2 negative
|
||||
- [ ] `modules/pier` PRM test with and without `MCPAdvertisedScopes` — SC-1
|
||||
- [ ] `modules/pier` dispatch test that the context carries `MCPCall` for a frontend tool and a backend tool — SC-2
|
||||
- [ ] sm-user-plugin `mcp_guard_test.go` cases for `Implies`, written against v0.1.14 — SC-1
|
||||
- [ ] sm-oauthserver-plugin consent upsert, replace-not-merge, offline_access flag, revoke-deletes-MCP-code, backfill idempotence — SC-3
|
||||
- [ ] `authorizations_smoke_test.go` key list includes `kind` — SC-3
|
||||
- [ ] vue-oauth-layer `tests/connect.test.ts` — page mapping
|
||||
- [ ] Framework install: none. Layer: `pnpm install` in the new repo once `package.json` exists
|
||||
|
||||
Existing wristband and pier test infrastructure hosts the new cases. No new test dependency.
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Patch tags of the framework and `sm-oauthserver-plugin`, and the changelog post | SC-4 | Tagging and publishing need an explicit user go-ahead and are not plan steps | After the code lands, ask before `git tag` or `scripts/changelog-post.py publish` |
|
||||
|
||||
All other phase behaviors have automated verification.
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
verified: 2026-10-28T15:54:38Z
|
||||
status: passed
|
||||
score: 8/8 must-haves verified
|
||||
covered_files:
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md"
|
||||
- "docs/services/authentication.md"
|
||||
- "docs/services/mcp.md"
|
||||
- "docs/services/oauth-server.md"
|
||||
- "modules/pact/README.md"
|
||||
- "modules/pact/capabilities.go"
|
||||
- "modules/pact/mcp.go"
|
||||
- "modules/pact/mcp_call_negative_test.go"
|
||||
- "modules/pact/mcp_call_test.go"
|
||||
- "modules/pier/README.md"
|
||||
- "modules/pier/dispatch.go"
|
||||
- "modules/pier/dispatch_mcpcall_test.go"
|
||||
- "modules/pier/events.go"
|
||||
- "modules/pier/filter.go"
|
||||
- "modules/pier/identity.go"
|
||||
- "modules/pier/mcp_seam_test.go"
|
||||
- "modules/pier/prm.go"
|
||||
- "modules/pier/prm_scopes_test.go"
|
||||
- "modules/pier/session.go"
|
||||
- "modules/pier/stateless.go"
|
||||
- "modules/wristband/README.md"
|
||||
- "modules/wristband/authorize.go"
|
||||
- "modules/wristband/consent.go"
|
||||
- "modules/wristband/mcp_scopes_smoke_test.go"
|
||||
- "modules/wristband/server.go"
|
||||
covered_digest: "v2:sha256:647c82bb902ca0f7439d83e8c93465a4d4a63e02d02ec0ed4838ce50b4489262"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
decision_coverage:
|
||||
honored: 18
|
||||
total: 18
|
||||
not_honored: []
|
||||
---
|
||||
|
||||
# Phase 32.1: MCP OAuth for plugin apps Verification Report
|
||||
|
||||
**Phase Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. Three gaps ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
|
||||
**Verified:** 2026-10-28T15:54:38Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
MVP mode does not apply. Phase 32.1 has no `Mode: mvp` in ROADMAP.md. The goal is user-story shaped but does not match the validator (`I want an agent`, not `I want to`).
|
||||
|
||||
Sibling implementations were read and tested in their own checkouts. They cannot be listed in `covered_files` because the fingerprint refuses paths outside this repository. The released commits are `summercms` `v0.5.2` (`9094bf9`, tag object `35c1f0f`), `sm-user-plugin` `v0.1.16` (`756797c`), `sm-oauthserver-plugin` `v0.1.4` (`a7743e7`), and local `vue-oauth-layer` `v0.1.0` (`7f805b3`). Framework `HEAD` (`bba6539`) is only the phase-05 planning commit; `v0.5.2..HEAD` does not change product code.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
| --- | ------- | ---------- | ---------- |
|
||||
| 1 | Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth. Wristband metadata and the authorize allow-list, and pier protected-resource scopes, include them, so a tool behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope (including a stock scope the declaration `Implies`). | ✓ VERIFIED | `AdvertisedMCPScopes` appends declared catalog names after the five fixed names. `NewServers` assigns that one slice to `mcpOpts.ScopesSupported` and `NewMCPTokenVerifier`. Metadata and `allowedAuthorizeScopes` both call `resolvedScopes`. pier copies `MCPScopesSupported` when non-empty. `MCPAllows` / `MCPDispatch` share `mcpEffectiveScopes`, which keeps names already on the grant and, only when `ClientID` is set, appends catalog names whose `Implies` is held. `tools/list` reaches that check through `filter.go` `allows`. `user.scope` reads the dispatch credential via `HasScope`. `TestMCPAdvertisedScopes` (metadata equals the verifier, including `acme:read`) and `TestMCPImplies*` passed. |
|
||||
| 2 | A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context. A personal-token call's credential keeps the token id. | ✓ VERIFIED | `dispatch.go` sets `WithMCPCall` with `id.subjectFor` immediately before `bouncer.WithDispatch`. OAuth identity copies `ClientID` and `TokenID` from the grant onto both frontend and backend subjects. `MCPCallFrom` on a bare context is false. `MCPDispatch` sets `credential.ID = subject.TokenID` only when `ClientID` is empty. `TestMCPCallFromBareContext`, `TestMCPCallRoundTrip`, `TestDispatchMCPCallBothGuards`, and `TestMCPImpliesPersonalUnchanged` passed. |
|
||||
| 3 | `sm-oauthserver-plugin` records a consent for MCP grants, so per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents. | ✓ VERIFIED | `MCPConsentDecide` calls `recordMCPConsent` after `IssueCode`. `replaceMCPConsent` is `ON CONFLICT` assignment, not `SaveConsent`. `revoke` takes `LockUserGrants` and deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens for that user and client, then fires `MCPSubjectRevoked` with `ClientID` and a zero `TokenID`. pier `revokedMatch` closes only that client's sessions when `TokenID` is 0. `TestMCPConsentReplace`, `TestMCPRevokeDeletesCode` (other client's token still verifies; event shape matches), and `TestMCPConsentBackfillIdempotent` passed. Rollback returns nil and does not delete. |
|
||||
| 4 | Module READMEs and `docs/` pages describe the three changes. The framework and `sm-oauthserver-plugin` have patch tags. | ✓ VERIFIED | pact, pier, wristband, user-plugin and oauthserver READMEs plus `docs/services/mcp.md`, `oauth-server.md` and `authentication.md` name `MCPCallFrom`, `MCPAdvertisedScopes`, `MemberTokenScopeCatalog`, `Implies`, `mcp_scopes`, consent replace and the per-client revoke. No consuming application is named. `go test ./cmd/summer -run TestDocsTree` passed. Remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Changelog `https://summercms.io/changelog/summercms-0-5-2` returned HTTP 200 with title `SummerCMS 0.5.2`. |
|
||||
| 5 | An empty MCP `Options.ScopesSupported` advertises and accepts `read`, `write`, `ai`, `offline_access` and `backend`. A non-empty list is used as given, so `DefaultOptions` still refuses `backend`. OIDC discovery stays `openid`, `profile`, `email`. `PendingRequestView.OfflineAccess` is the pending flag. `IssueCode`'s signature is unchanged. | ✓ VERIFIED | `resolvedScopes` in `modules/wristband/server.go`. `PendingRequest` copies `pending.OfflineAccess`. `IssueCode(ctx, requestID, userID, grantedScopes, collectionIDs)`. `TestMCPScopesSmoke`, `TestDiscoveryFields` and `TestMCPOfflineAccessUntick` passed. |
|
||||
| 6 | Implies expansion is OAuth-only. `write` does not satisfy `Implies` `read`. The compiled catalog is published as `pact.MemberTokenScopeCatalog`. Implied names are not written onto the subject, the token row, or a fingerprint. The demo app user submodule was not edited. | ✓ VERIFIED | `bootAPITokenScopes` publishes the concrete catalog and `pact.MemberTokenScopeCatalog`. `mcpEffectiveScopes` returns `subject.Scopes` unchanged when `ClientID` is empty and never writes the clone back. `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesNotPersisted`, `TestMCPImpliesAlreadyPresent` and `TestMCPImpliesCompileRejects` passed. `sm-demo-app/plugins/golem15/user` is still tag `v0.1.13`. |
|
||||
| 7 | `golem15.oauthserver.mcp_scopes` unset advertises every declared member scope; a set list keeps only those names; an empty list is the five fixed names; an unknown name fails boot and names the key. Fixed names are always present. pier copies the verifier list or `PRMScopes` and does not read the member-token catalog. | ✓ VERIFIED | `classes/mcp_scopes.go` and `servers.go`. `mountPRM` type-asserts `MCPAdvertisedScopes` only. `TestMCPAdvertisedScopes`, `TestPRMUsesAdvertisedScopes` and `TestPRMFallsBackToPRMScopes` passed. |
|
||||
| 8 | Authorization entries carry `kind` `oidc` or `mcp`. An allow with no scopes field grants the whole request. `offline_access` stays a column flag. The `/connect` layer locks `read` in the UI, does not ask the server to force `read`, renders client and scope text rather than raw HTML, and refetches on `409 backend_link_changed`. | ✓ VERIFIED | `TestAuthorizationsKind` and `TestMCPOfflineAccessUntick` passed. `grantedScopes` returns the request when `submitted` is nil. `allowBody` keeps locked names and omits `scopes` when consent is already stored. `connect.vue` interpolates `{{ }}` and calls `shouldRefetch` then `load(true)`. `node --test tests/connect.test.ts` passed (8 tests), including the source scan that rejects `v-html`. |
|
||||
|
||||
**Score:** 8/8 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
All trackable CONTEXT.md decisions are honored by shipped artifacts (18/18). Non-blocking.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
| -------- | ----------- | ------ | ------- |
|
||||
| `modules/pact/mcp.go` | Catalog, advertised scopes, `MCPCall` / `WithMCPCall` / `MCPCallFrom` | ✓ VERIFIED | Interfaces and accessor are exported and used by pier and both plugins. On `v0.5.2`. Absent from `v0.5.0`. |
|
||||
| `modules/pact/capabilities.go` | `FrontendTokenScope.Implies` | ✓ VERIFIED | Field present. User plugin rejects any value other than empty, `read`, or `write`. |
|
||||
| `modules/pier/dispatch.go` | `MCPCall` beside `WithDispatch` | ✓ VERIFIED | Lines 215–216. Both guards' tool calls observed it. |
|
||||
| `modules/pier/prm.go` | PRM scopes from the verifier, else `PRMScopes` | ✓ VERIFIED | Does not look up `MemberTokenScopeCatalog`. |
|
||||
| `modules/wristband/server.go` | One `resolvedScopes` slice | ✓ VERIFIED | Metadata uses it. Authorize builds its allow-list from the same method. |
|
||||
| `modules/wristband/consent.go` | `PendingRequestView.OfflineAccess`; `IssueCode` signature unchanged | ✓ VERIFIED | Direct copy from the pending row. |
|
||||
| `sm-user-plugin/classes/mcp_guard.go` | OAuth-only Implies shared by `MCPAllows` and `MCPDispatch` | ✓ VERIFIED | On tag `v0.1.16`. |
|
||||
| `sm-user-plugin/plugin.go` | Publish `MemberTokenScopeCatalog` | ✓ VERIFIED | `bootAPITokenScopes`. |
|
||||
| `sm-oauthserver-plugin/classes/mcp_scopes.go` | `golem15.oauthserver.mcp_scopes` | ✓ VERIFIED | Key documented in `config/config.yaml` (commented default). |
|
||||
| `sm-oauthserver-plugin/classes/mcp_verifier.go` | `MCPScopesSupported` | ✓ VERIFIED | Same slice stored at `NewServers`. |
|
||||
| `sm-oauthserver-plugin/controllers/mcp_consent_api.go` | `scope_details` and consent replace | ✓ VERIFIED | `replaceMCPConsent` uses `ON CONFLICT`. |
|
||||
| `sm-oauthserver-plugin/controllers/authorizations.go` | Revoke under `lockUserGrants`, then the client-scoped event | ✓ VERIFIED | Deletes `MCPCode` with the other pair rows. |
|
||||
| `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go` | Idempotent backfill, no-delete rollback | ✓ VERIFIED | `WHERE NOT EXISTS`. `Rollback` returns nil. |
|
||||
| `vue-oauth-layer/app/pages/connect.vue` | `/connect` checkboxes | ✓ VERIFIED | Imports `allowBody`. Text interpolations only. Local tag `v0.1.0`. |
|
||||
| `vue-oauth-layer/app/utils/connectConsent.ts` | Show JSON to POST body | ✓ VERIFIED | Locked `read` stays. No server-side force-read field. |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
| ---- | --- | --- | ------ | ------- |
|
||||
| `wristband.Server.Metadata` | `allowedAuthorizeScopes` | `resolvedScopes` | ✓ WIRED | Both call the same method. A non-empty host list is cloned as given. |
|
||||
| `NewServers` | `MCPTokenVerifier.MCPScopesSupported` | the slice assigned to `mcpOpts.ScopesSupported` | ✓ WIRED | `TestMCPAdvertisedScopes` compared metadata JSON to the verifier. |
|
||||
| `pier.mountPRM` | `pact.MCPAdvertisedScopes` | lookup of `MCPTokenVerifier`, then type assert | ✓ WIRED | Empty or unimplemented falls back to `PRMScopes`. |
|
||||
| `pier.dispatch` | `pact.MCPCallFrom` | `WithMCPCall` immediately before `WithDispatch` | ✓ WIRED | Frontend and backend tool handlers observed the call. |
|
||||
| `MCPAllows` | `MCPDispatch` | `mcpEffectiveScopes` | ✓ WIRED | One helper. Personal tokens return the same slice. |
|
||||
| `plugin.bootAPITokenScopes` | `pact.MemberTokenScopeCatalog` | `app.Publish` | ✓ WIRED | `TestMCPImpliesOAuth` looks the interface up. |
|
||||
| `MCPConsentDecide` | `golem15_oauthserver_consents` | replace upsert after `IssueCode`, not `SaveConsent` | ✓ WIRED | Second allow of a smaller set stored only `read`. |
|
||||
| `authorizations.revoke` | MCP sessions | `LockUserGrants`, delete `MCPCode`, `MCPSubjectRevoked` with `ClientID` | ✓ WIRED | pier `revokedMatch` selects that client when `TokenID` is 0. |
|
||||
| `connect.vue` | `POST /oauth/mcp/requests/{request}` | `allowBody` (`decision`, `scopes`, `backend_admin`) | ✓ WIRED | Page and tests import the same functions. |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
| -------- | ------------- | ------ | ------------------ | ------ |
|
||||
| Wristband MCP metadata `scopes_supported` | `resolvedScopes()` | `Options.ScopesSupported` set from `AdvertisedMCPScopes` (catalog plus fixed names) | Yes | ✓ FLOWING |
|
||||
| pier protected-resource `scopes_supported` | `advertised.MCPScopesSupported()` | the verifier slice, else `PRMScopes` | Yes | ✓ FLOWING |
|
||||
| MCP consent row `scopes` | `stored` in `MCPConsentDecide` | granted intersection of the pending request | Yes | ✓ FLOWING |
|
||||
| Route `pact.MCPCall.Subject` | `id.subjectFor` | OAuth grant or personal-token resolver (`ClientID`, `TokenID`, scopes) | Yes | ✓ FLOWING |
|
||||
| `user.scope` check during a tool call | dispatch `*models.APIToken.Scopes` | `mcpEffectiveScopes` (grant, plus OAuth implied names) | Yes | ✓ FLOWING |
|
||||
| `/connect` checkboxes | `scope_details` from consent show | catalog labels, not a hardcoded tool list | Yes | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
| -------- | ------- | ------ | ------ |
|
||||
| Bare context has no MCP call; round trip keeps the subject | `go test -short -count=1 ./modules/pact -run 'TestMCPCallFromBareContext\|TestMCPCallRoundTrip\|TestFrontendTokenScopeLiteralWithoutImplies'` | ok | ✓ PASS |
|
||||
| Both guards record `MCPCall`; PRM uses the advertised list or falls back | `go test -short -count=1 ./modules/pier -run 'TestDispatchMCPCallBothGuards\|TestPRMUsesAdvertisedScopes\|TestPRMFallsBackToPRMScopes\|TestMCPSeam'` | ok | ✓ PASS |
|
||||
| Empty MCP scope list includes `backend`; OIDC discovery stays the three names | `go test -short -count=1 ./modules/wristband -run 'TestMCPScopesSmoke\|TestDiscoveryFields'` | ok | ✓ PASS |
|
||||
| OAuth Implies, personal token unchanged, write does not grant read | `go test -short -count=1 . -run TestMCPImplies` in `sm-user-plugin` | ok | ✓ PASS |
|
||||
| Declared name `acme:read` is on MCP metadata and the verifier; unknown `mcp_scopes` fails boot | `GOWORK=off` not required; `go test -short -count=1 . -run TestMCPAdvertisedScopes` in `sm-oauthserver-plugin` | ok | ✓ PASS |
|
||||
| Consent replace, offline flag, revoke leaves the other client, kind, idempotent backfill | `GOWORK=off go test -count=1 -timeout 180s -run 'TestMCPConsentReplace\|TestMCPRevokeDeletesCode\|TestMCPConsentBackfillIdempotent\|TestAuthorizationsKind\|TestMCPOfflineAccessUntick'` | ok (6.951s) | ✓ PASS |
|
||||
| `/connect` mapping, locked read, no `v-html` | `node --test tests/connect.test.ts` | 8 pass | ✓ PASS |
|
||||
| Docs identifiers | `go test ./cmd/summer -count=1 -run TestDocsTree` | ok | ✓ PASS |
|
||||
| Patch tags on the remotes | `git ls-remote --tags` for `v0.5.2`, `v0.1.16`, `v0.1.4` | each tag object matches the local tag | ✓ PASS |
|
||||
| Changelog post | `curl -fsS https://summercms.io/changelog/summercms-0-5-2` | HTTP 200, title `SummerCMS 0.5.2` | ✓ PASS |
|
||||
|
||||
`TestMCPAdvertisedScopes` was run in the oauth plugin directory, whose uncommitted `go.work` points `sm-user-plugin` at `sm-demo-app` `v0.1.13`. That test publishes its own catalog and does not import the phase user plugin. The consent tests were run with `GOWORK=off`, so they used `go.mod`'s `sm-user-plugin v0.1.16`.
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No phase plan or success criterion declares a `scripts/*/tests/probe-*.sh` probe. None were required.
|
||||
|
||||
### Test Quality Audit
|
||||
|
||||
| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict |
|
||||
|-----------|-----------|--------|---------|----------|-----------------|---------|
|
||||
| `modules/pact/mcp_call_negative_test.go` | SC-2 | 2 | 0 | no | value (`ok` false; empty `Implies`) | sufficient |
|
||||
| `modules/pier/dispatch_mcpcall_test.go` | SC-2 | 1 | 0 | no | behavioral (real `tools/call` on both guards) | sufficient |
|
||||
| `modules/pier/prm_scopes_test.go` | SC-1 | 2 | 0 | no | value (scope list includes `acme:read`, fallback includes `backend`) | sufficient |
|
||||
| `sm-user-plugin/mcp_implies_test.go` | SC-1, SC-2 | 6 matched by `TestMCPImplies` | 0 | no | value (credential id, scopes, slice identity) | sufficient |
|
||||
| `sm-oauthserver-plugin/mcp_consent_test.go` | SC-3 | replace, offline, revoke | 0 in the run | no | behavioral (stored row, other token still verifies, event equality) | sufficient |
|
||||
| `sm-oauthserver-plugin/mcp_backfill_test.go` | SC-3 | idempotent backfill | 0 in the run | no | behavioral | sufficient |
|
||||
| `vue-oauth-layer/tests/connect.test.ts` | layer must-haves | 8 | 0 | no | value, plus a source scan for `v-html` | sufficient |
|
||||
|
||||
**Disabled tests on requirements:** 0
|
||||
**Circular patterns detected:** 0
|
||||
**Insufficient assertions:** 0
|
||||
|
||||
`TestDispatchMCPCallBothGuards` checks guard, tool and session id. It does not echo `ClientID` or `TokenID`. Those fields are copied with the whole `MCPSubject` in `identity.go` (`ClientID: grant.ClientID`, `TokenID: grant.TokenID`) and stored by `WithMCPCall`. The personal-token id rule is asserted directly in `TestMCPImpliesPersonalUnchanged`.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
| ----------- | ---------- | ----------- | ------ | -------- |
|
||||
| none | all five plans `requirements: []` | ROADMAP says Requirements: TBD. `REQUIREMENTS.md` has no Phase 32.1 IDs. | ✓ SATISFIED | No ID to satisfy and none orphaned. |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
| ---- | ---- | ------- | -------- | ------ |
|
||||
| `sm-user-plugin/go.mod` | require | `summercms v0.5.0` while the tagged source calls `pact.MemberTokenScopeCatalog`, which does not exist on `v0.5.0` | ⚠️ Warning | A module graph that selects only `v0.5.0` does not compile `v0.1.16`. `sm-oauthserver-plugin` `v0.1.4` requires `summercms v0.5.2` and `sm-user-plugin v0.1.16`, so the graph that turns MCP OAuth on selects `v0.5.2`. The local `replace` is ignored by dependents. Not a failed success criterion. |
|
||||
| `vue-oauth-layer` | tag `v0.1.0` | No `origin`. README inside the tag still says the tag does not exist. | ⚠️ Warning | SC-4 does not require this tag. Apps cannot pin the layer until a remote exists. The local tag was not moved. |
|
||||
| `sm-oauthserver-plugin/go.work` | uncommitted | `replace` of `sm-user-plugin` points at `sm-demo-app/plugins/golem15/user` (`v0.1.13`), not the phase branch | ℹ️ Info | Not on tag `v0.1.4`. Dependents do not use `go.work`. A local `go test` without `GOWORK=off` would compile the wrong user plugin. |
|
||||
| `sm-user-plugin/classes/device_auth.go` | comments | The letters `XXXX` match a `XXX` debt scan | ℹ️ Info | Short-code format, not a `XXX` marker. No `TBD` / `FIXME` / `XXX` debt markers in the phase files. |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. The state transitions (Implies expansion, consent replace, revoke isolation, backfill idempotence, both guards' `MCPCall`) were exercised by the named tests above. The `/connect` contract is covered by `node --test`, including the no-raw-HTML source check. No live browser OAuth session was required: the phase ships the framework and plugins, and it does not turn `mcp.enabled` on in an application.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gap blocks the phase goal. Declared member scopes are on the MCP authorization server and the protected-resource metadata. An OAuth subject can list and run a `user.scope:<declared>` tool either because the grant holds that name or because the catalog marks it implied by `read` or `write`. The route can read the caller through `pact.MCPCallFrom`, and a personal token's credential id is still the token id. An MCP allow writes a consent row that a later smaller allow replaces, and deleting that authorization revokes only that client. Docs describe the three changes. `v0.5.2` and `sm-oauthserver-plugin` `v0.1.4` are on their remotes, and the framework changelog post is live.
|
||||
|
||||
The two warnings (user-plugin minimum version, unpublished vue layer) do not stop an application that takes `sm-oauthserver-plugin` `v0.1.4` from compiling the OAuth path. They should be fixed in a later patch if the user plugin is built on its own, or when `vue-oauth-layer` gets a remote.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-28T15:54:38Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
334
.planning/phases/33-seo-output-audit-and-content/33-01-PLAN.md
Normal file
334
.planning/phases/33-seo-output-audit-and-content/33-01-PLAN.md
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/content.go
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/signpost/README.md
|
||||
- modules/signpost/example_content_test.go
|
||||
- modules/signpost/content_fields_smoke_test.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/field_request_options_smoke_test.go
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- docs/plugins/menu-item-types.md
|
||||
- docs/backend/field-types.md
|
||||
- scripts/check-phase33.sh
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, D-93, D-90, D-106, D-43, D-86, D-31, D-85, D-50, D-51, D-96, D-10]
|
||||
|
||||
estimate:
|
||||
tokens: 90000
|
||||
raw_tokens: 90000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "pact.ContentDescriptor gains Kind and Tools, pact.ContentRecord gains PublishedAt, Parents, Authors and TopHeading, and pact.ContentLocale gains Images, Videos and Headings, with the value types ContentRef, ContentPerson, ContentImage, ContentVideo and ContentHeading; every field is optional and names nothing SEO-specific (D-93, D-90)"
|
||||
- "A content type that sets none of the new fields describes, lists and matches exactly as in v0.5.1; signpost.Describe and signpost.Records hand the new fields through unchanged, proven by TestContentDescriptorFields and the runnable signpost example (D-93: sitemaps and the page registry consume Records)"
|
||||
- "The descriptor fields carry what JSON-LD, breadcrumbs, the image and News sitemaps, the missing-alt report and the heading checks need: first publication date, ordered candidate parents (first is the default primary), authors with an optional reference to their own page, body images with alt and a decorative flag, videos, the body's heading outline and the top heading level the body may use (D-43, D-86, D-31, D-85, D-50, D-51)"
|
||||
- "ContentDescriptor.Tools names the MCP tools that change a type's records by operation (edit, headings, image_alt), so an audit tool can point every content issue at a fixing tool without importing the content plugin (SC-3, D-10, D-96)"
|
||||
- "A plugin field type can supply per-request options through pact.FormFieldType.RequestOptions; cabana merges them into the field's typeOptions in the served form schema on create, update and preview, an option set in fields.yaml wins over a request option of the same key, and an element therefore reads a site setting without a token, cookie or admin route (UI-SPEC Q-2, 2026-10-28 Journal H1 setting)"
|
||||
- "A RequestOptions error never fails the form: the schema is served with the fields.yaml options only and the error is logged once per request"
|
||||
- "The exported API surface of the framework after this plan is strictly additive against v0.5.1: no element removed or changed and no method added to an existing interface (D-106), checked by scripts/check-phase33.sh --additive"
|
||||
- "scripts/check-phase33.sh exists with the stages self-test, go, plugins, layers, consumers, docker, admin, docs, marker, additive and compat; --self-test passes and a stage whose repository is missing fails with the repository named (Wave 0)"
|
||||
- "33-release-log.md lists what v0.5.2 will carry (the six Phase 32 fix commits CR-01, CR-02, CR-04, WR-01, WR-02, WR-07 plus this plan's seams) and the unreleased commits of sm-seo-plugin and sm-journal-plugin; nothing is pushed or tagged in this plan (2026-10-28 release cadence: all tags in 33-14)"
|
||||
- "Jakub's answer on the meaning of pact.RecordSaved.Locale (32-UAT test 4, WR-02) is recorded before v0.5.2 freezes it"
|
||||
- "The plan list of Phase 33 is 14 plans, as Jakub confirmed on 2026-10-28; this plan is the first of them and the framework part of v0.5.2 (2026-10-28 plan list)"
|
||||
- {statement: "A descriptor that leaves the new slices nil and one that sets them to empty slices are treated alike by signpost and by the docs examples (no nil-versus-empty drift for consumers)", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-90"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "No framework file added or changed by this plan names SEO, a sitemap, JSON-LD or a consuming application; the marker stage of check-phase33.sh proves it"
|
||||
- requirement_id: "D-106"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "No existing exported element of pact, signpost or cabana changes its signature or meaning, and no interface gains a method; v0.5.1 consumers build and test unchanged"
|
||||
artifacts:
|
||||
- path: "modules/pact/content.go"
|
||||
provides: "additive descriptor fields and value types"
|
||||
contains: "PublishedAt *time.Time"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "FormFieldType.RequestOptions"
|
||||
contains: "RequestOptions"
|
||||
- path: "modules/signpost/content_fields_smoke_test.go"
|
||||
provides: "TestContentDescriptorFields (tracer)"
|
||||
contains: "func TestContentDescriptorFields("
|
||||
- path: "modules/cabana/field_request_options_smoke_test.go"
|
||||
provides: "TestFieldTypeRequestOptions"
|
||||
contains: "func TestFieldTypeRequestOptions("
|
||||
- path: "scripts/check-phase33.sh"
|
||||
provides: "phase gate engine (Wave 0)"
|
||||
contains: "--additive"
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-release-log.md"
|
||||
provides: "v0.5.2 contents and the release order 33-14 follows"
|
||||
contains: "v0.5.2"
|
||||
key_links:
|
||||
- from: "modules/pact/content.go"
|
||||
to: "modules/signpost/content.go"
|
||||
via: "Registry.Describe and Registry.Records return the type's *pact.ContentRecord values as they come, new fields included"
|
||||
pattern: "func \\(r \\*Registry\\) (Describe|Records)\\("
|
||||
- from: "modules/pact/capabilities.go"
|
||||
to: "modules/cabana/http.go"
|
||||
via: "the form schema handler calls each plugin field type's RequestOptions once per request and merges the map into TypeOptions"
|
||||
pattern: "RequestOptions"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan gives every later plan what it needs from the framework: the content facts that JSON-LD, breadcrumbs, image and News sitemaps and the audit read from a content type (one generic, additive seam), a way for a plugin field type to receive per-request options (the Blog Editor's heading levels follow the Journal H1 setting), the phase gate script, and the release log that 33-14 follows.
|
||||
|
||||
<objective>
|
||||
Extend the framework additively (D-90, D-106) and prepare v0.5.2 without tagging it:
|
||||
- `pact.ContentDescriptor`, `pact.ContentRecord`, `pact.ContentLocale` gain optional fields (Pattern 1 of 33-RESEARCH.md, names confirmed at the Task 1 checkpoint);
|
||||
- `pact.FormFieldType.RequestOptions` lets a plugin field type add per-request options to its schema entry (UI-SPEC Q-2, planner decision: a generic seam rather than an element-side fetch);
|
||||
- `scripts/check-phase33.sh` with every stage the phase gate needs (Wave 0 of 33-VALIDATION.md);
|
||||
- `33-release-log.md`: v0.5.2 = the unreleased Phase 32 fixes plus these seams; plugin versions are read from `git tag` in 33-14.
|
||||
|
||||
Purpose: Phase 33 builds on tagged versions (Q-01); every plugin in this phase develops against `../summercms.go` through `replace`, and 33-14 tags v0.5.2 with everything this plan adds.
|
||||
|
||||
Output: commits in this repository (code and docs separately from the planning-docs commit of the release log). Nothing is pushed or tagged.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-01 (descriptor shape, `[ASSUMED]` until Task 1):** `ContentDescriptor.Kind string` with constants `ContentKindArticle = "article"`, `ContentKindCollection = "collection"`, `ContentKindProfile = "profile"`, `ContentKindPage = "page"`; `ContentDescriptor.Tools map[string]string` with constants `ContentToolEdit = "edit"`, `ContentToolHeadings = "headings"`, `ContentToolImageAlt = "image_alt"` (the value is a full MCP tool name such as `journal_update_post`); `ContentRecord.PublishedAt *time.Time`, `ContentRecord.Parents []ContentRef` (ordered candidates, first is the default primary), `ContentRecord.Authors []ContentPerson`, `ContentRecord.TopHeading int` (the highest heading level the body should contain: 2 when the page renders the title as the only H1, 0 when the type sets no rule); `ContentLocale.Images []ContentImage`, `ContentLocale.Videos []ContentVideo`, `ContentLocale.Headings []ContentHeading`; `ContentRef{Type, ID string}` (Type is a menu item type key), `ContentPerson{Name string; Ref ContentRef}` (Ref.Type "" when the person has no page), `ContentImage{URL, Alt string; Decorative bool; Width, Height int}`, `ContentVideo{Provider, ID string}`, `ContentHeading{Level int; Text string}`. Each field gets a one-sentence doc comment in the existing style.
|
||||
- **P33-02 (per-request field options):** `FormFieldType.RequestOptions func(ctx context.Context, db *gorm.DB) (map[string]any, error)` with `json:"-"`. cabana calls it at most once per field type per schema request, merges the map under the field's `typeOptions` (keys present in fields.yaml win), and logs and ignores an error. The Blog Editor (33-02) uses it for `headingLevels` and `genericAnchors`.
|
||||
- **P33-03 (release log):** `.planning/phases/33-seo-output-audit-and-content/33-release-log.md` is the phase's release ledger, written like `32-06-release-log.md`: planned contents per repository now, and each executed release step appended in 33-14 so a resumed run never re-tags.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-06-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-UAT.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
modules/pact/content.go today (v0.5.1): ContentDescriptor{Controller, Model string; Records, Describe, Match funcs}; ContentList{Pattern, After, Limit, UnprefixedLocale}; ContentPage{Records []ContentRecord; Next}; ContentDescribe{ID, Pattern, UnprefixedLocale}; ContentRecord{ID string; Published bool; LastMod *time.Time; Locales []ContentLocale}; ContentLocale{Locale, URL, Title, Description, Image string; Words *int}; ContentMatch / ContentMatchResult{ID, Locale string; Exact bool}.
|
||||
modules/pact/capabilities.go FormFieldType{Name, Element string; Translatable bool; Scripts, Styles, Options []string; CheckOptions, Decode, Load, ListValue, Upload funcs}; FormFieldValue{DB, Record, Field, Options, Locale, Default, Value, Translated}.
|
||||
modules/cabana/schema_types.go: the served field carries TypeOptions map[string]any `json:"typeOptions,omitempty"`; modules/cabana/field_type.go:263 sets it at compile time.
|
||||
admin/src/components/form/fields/PluginField.vue:44 serializes props.field.typeOptions into the element's options attribute (no SPA change needed).
|
||||
scripts/check-phase32.sh: stage engine (detect_go, go_named, run_* stages, additive via scripts/apisurface against the newest tag not containing HEAD, compat via snapshots/worktrees in a scratch sibling farm, --self-test planting each case).
|
||||
Framework commits after v0.5.1 that v0.5.2 carries: 676ca53 (CR-01), 5471b66 (CR-02), d660cd0 (CR-04), 468af06 (WR-01), 1583651 (WR-02), 0e187cf (WR-07).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Each task ends with the commit groups below, staged by explicit path (never `-A` or `.`); another session may have uncommitted `.planning/STATE.md`, `.planning/state.json`, `.planning/ROADMAP.md` or `.planning/G15OFFICE.md` changes, which stay untouched. Code and docs commits are separate from planning-docs commits.
|
||||
- Resume check: `git log --format=%s | grep -Fx '<subject>'` for each subject. All present: re-run the task's `<verify>` and continue; some present: continue at the first missing group.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | none (checkpoint answer recorded in the SUMMARY) | answer recorded |
|
||||
| 2 | `feat(signpost): content descriptor fields for dates, parents, authors, media and outline` | TestContentDescriptorFields passes |
|
||||
| 3 | `feat(cabana): per-request options for plugin field types` | TestFieldTypeRequestOptions passes |
|
||||
| 4 | A `test: phase 33 gate script`; B `docs(33-01): release log for v0.5.2` | gate self-test and stages pass |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Confirm the Go shape of the descriptor fields and RequestOptions, and the meaning of RecordSaved.Locale</name>
|
||||
<reversibility rating="one-way">The fields become published framework API in v0.5.2; every content plugin fills them and sm-seo-plugin, sm-sitemap-plugin and the layers read them, so renaming later breaks consumers.</reversibility>
|
||||
<decision>Adopt P33-01 and P33-02 as written, adjust names, or drop RequestOptions; and confirm WR-02 (32-UAT test 4) before v0.5.2 freezes it.</decision>
|
||||
<context>
|
||||
P33-01 lists the proposed fields (Pattern 1 of 33-RESEARCH.md, plus Tools for SC-3 and TopHeading/Headings for the Journal H1 setting's CMS-side check). They name no SEO concept: feeds, search indexers and audit tools use the same facts. P33-02 is the planner's resolution of UI-SPEC Q-2: the Blog Editor needs the per-site heading levels and generic-anchor list on every request, and field options are compiled at boot today. The alternative is that the editor fetches a public Journal endpoint, which breaks the element rule "no direct route" (UI-SPEC Shared rules) less cleanly. WR-02 (commit 1583651) set RecordSaved.Locale to the primary tag of the request locale; 32-UAT test 4 asks Jakub to accept that or ask for the content-locale meaning.
|
||||
</context>
|
||||
<options>
|
||||
<option id="as-planned">
|
||||
<name>Adopt P33-01 and P33-02 as written; accept WR-02's request-locale meaning</name>
|
||||
<pros>Unblocks 33-02 and 33-03 with the shape research and UI-SPEC assume; one framework release carries every Phase 33 seam</pros>
|
||||
<cons>Two seams freeze at once in v0.5.2</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Adopt with the names or fields Jakub changes (stated in the answer)</name>
|
||||
<pros>Names fixed before they become API</pros>
|
||||
<cons>Later plans use the adjusted names; the executor records them in the SUMMARY for every downstream plan</cons>
|
||||
</option>
|
||||
<option id="no-request-options">
|
||||
<name>Adopt P33-01 only; the editor reads its options from a public Journal endpoint</name>
|
||||
<pros>One framework seam fewer</pros>
|
||||
<cons>The Blog Editor element makes its own request, against the UI-SPEC element rule; 33-02 Task 4 changes accordingly</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "as-planned", "adjust: <changes>" or "no-request-options", plus "WR-02: accept" or "WR-02: content locale"</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: End-to-end "a content type describes a record with dates, parents, authors, images, videos and its outline, and signpost hands them through" — one path only</name>
|
||||
<files>modules/pact/content.go, modules/signpost/content_fields_smoke_test.go, modules/signpost/example_content_test.go, modules/pact/README.md, modules/signpost/README.md, docs/plugins/menu-item-types.md</files>
|
||||
<read_first>
|
||||
- modules/pact/content.go (whole file; the doc-comment style to copy)
|
||||
- modules/signpost/content.go (Describe, Records, ContentTypes, ContentTypeForModel)
|
||||
- modules/signpost/content_test.go and modules/signpost/example_content_test.go (the fake content type the tests use)
|
||||
- docs/plugins/menu-item-types.md (the content descriptor section)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 1)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-93 and P33-01 (as answered in Task 1): add the fields and the five value types to `modules/pact/content.go`, each with a one-sentence comment in the file's style and no SEO wording (D-90): Kind and Tools on ContentDescriptor with the Kind and Tool constants; PublishedAt, Parents, Authors, TopHeading on ContentRecord; Images, Videos, Headings on ContentLocale. Tools values are full MCP tool names. No existing field, signature or comment meaning changes (D-106). signpost needs no logic change if Describe and Records already return the type's records as given; read `modules/signpost/content.go` and change it only if it copies records field by field. Add `modules/signpost/content_fields_smoke_test.go` with `TestContentDescriptorFields`: register a fake type with Kind article and Tools {edit: acme_update_page}, whose Describe answers PublishedAt, two Parents, one Author with a Ref, two Images (one decorative with empty alt), one Video and three Headings with TopHeading 2; assert signpost.Describe and signpost.Records return every value unchanged and that `ContentType(key)` exposes Kind and Tools; a second fake type that sets none of them describes exactly as before. Extend the runnable example in `example_content_test.go` with the new fields (Output comment updated). Document the fields in the descriptor tables of `modules/pact/README.md`, `modules/signpost/README.md` and `docs/plugins/menu-item-types.md` (every identifier must exist; the docs checker enforces it). Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/signpost -run 'TestContentDescriptorFields|Example' -count=1 -v 2>&1 | tail -30</automated>
|
||||
<fails_when>non-zero exit, a "--- FAIL" line, "no tests to run", or no "--- PASS: TestContentDescriptorFields" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "PublishedAt \*time.Time" modules/pact/content.go` prints one line; `grep -n "^type Content\(Ref\|Person\|Image\|Video\|Heading\) struct" modules/pact/content.go` prints five lines
|
||||
- `grep -n "ContentKindArticle\|ContentToolImageAlt" modules/pact/content.go` prints at least two lines
|
||||
- `go doc ./modules/pact ContentRecord` lists PublishedAt, Parents, Authors and TopHeading
|
||||
- TestContentDescriptorFields covers the set-everything type and the set-nothing type and passes
|
||||
- docs/plugins/menu-item-types.md names every new field
|
||||
</acceptance_criteria>
|
||||
<done>A content type can describe the new facts and every consumer of signpost receives them; types that do not set them are unaffected.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Per-request options for plugin field types (RequestOptions) merged into the served schema</name>
|
||||
<files>modules/pact/capabilities.go, modules/cabana/field_type.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/field_request_options_smoke_test.go, modules/pact/README.md, docs/backend/field-types.md, admin/openapi/admin.json, admin/src/api/schema.d.ts</files>
|
||||
<read_first>
|
||||
- modules/pact/capabilities.go (FormFieldType ~689-725)
|
||||
- modules/cabana/field_type.go (compile of TypeOptions ~240-270, Decode/Load call sites)
|
||||
- modules/cabana/http.go (the per-request form schema filter, where visible fields are chosen per administrator)
|
||||
- modules/cabana/schema_types.go (TypeOptions JSON)
|
||||
- modules/cabana/admin_openapi.go (~566, the form description that names typeOptions)
|
||||
- an existing plugin field type test in modules/cabana (grep -ln "FormFieldType{" modules/cabana/*_test.go) for the fixture pattern
|
||||
- docs/backend/field-types.md
|
||||
</read_first>
|
||||
<behavior>
|
||||
- A field type with RequestOptions returning {headingLevels: [2,3,4]} serves typeOptions containing headingLevels on the create form schema, the update form schema and the preview schema
|
||||
- The same key set in fields.yaml for that field wins over the request value
|
||||
- RequestOptions runs once per schema request for each field type, however many fields use the type
|
||||
- RequestOptions returning an error serves the schema with the fields.yaml options only, answers 200, and logs one warning naming the field type
|
||||
- A field type without RequestOptions serves byte-identical schema JSON to v0.5.1 (CanonicalJSON comparison like TestFormExtensionUnextendedSchemaUnchanged)
|
||||
- A form extension's plugin field (32-01) gets its request options the same way
|
||||
</behavior>
|
||||
<action>
|
||||
Per P33-02: add `RequestOptions func(ctx context.Context, db *gorm.DB) (map[string]any, error)` with `json:"-"` and a doc comment to `pact.FormFieldType`. In cabana's form schema path (http.go, where the per-request schema copy is built), for each plugin field whose type has RequestOptions, call it once per type per request with the request's read DB, and merge the result into a copy of that field's TypeOptions (never mutate the compiled schema; keys already present win). On error, log once with the type name and serve the unmerged options. Write `TestFieldTypeRequestOptions` (subtests per behavior line) in `modules/cabana/field_request_options_smoke_test.go` first, then implement. Update the swag description in `admin_openapi.go` (typeOptions: "the type's own fields.yaml keys, plus per-request options the type supplies") and regenerate with `scripts/check-admin-openapi.sh`; commit `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` only if they change. Document RequestOptions in `docs/backend/field-types.md`, `modules/cabana/README.md` and `modules/pact/README.md`. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/cabana -run 'TestFieldTypeRequestOptions|TestFormExtensionUnextendedSchemaUnchanged' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestFieldTypeRequestOptions" line</fails_when>
|
||||
<automated>scripts/check-admin-openapi.sh --check</automated>
|
||||
<fails_when>non-zero exit (the committed OpenAPI or schema.d.ts differs from the generated one)</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "RequestOptions func(ctx context.Context, db \*gorm.DB) (map\[string\]any, error)" modules/pact/capabilities.go` prints one line
|
||||
- `grep -n "RequestOptions" modules/cabana/http.go modules/cabana/field_type.go` prints at least one line
|
||||
- `grep -n "RequestOptions" docs/backend/field-types.md` prints at least one line
|
||||
- TestFieldTypeRequestOptions has a subtest for each behavior line and passes
|
||||
</acceptance_criteria>
|
||||
<done>A plugin field type can deliver per-request options to its element through the existing typeOptions attribute; nothing changes for other field types.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Phase gate script and the v0.5.2 release log</name>
|
||||
<files>scripts/check-phase33.sh, .planning/phases/33-seo-output-audit-and-content/33-release-log.md</files>
|
||||
<read_first>
|
||||
- scripts/check-phase32.sh (whole file: usage, detect_go, go_named, run_* stages, run_self_test, additive_compare, run_compat, run_all, run_stage)
|
||||
- scripts/apisurface/main.go
|
||||
- .planning/phases/32-seo-foundation/32-06-release-log.md (ledger format)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md (Wave 0 and the decision-to-test map)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Validation Architecture, release order)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/check-phase33.sh` from check-phase32.sh's engine (keep detect_go, go_named, the scratch sibling farm, additive and compat logic and the self-test planting style; label everything Phase 33). Stages and what each runs:
|
||||
- `--go`: `go vet ./...` and `go test -short ./...` here;
|
||||
- `--plugins`: vet and `test -short` in ../sm-sitemap-plugin, ../sm-seo-plugin, ../sm-journal-plugin, plus `npm --prefix <repo> run test` (vitest) in sm-seo-plugin and sm-journal-plugin;
|
||||
- `--layers`: for ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, ../vue-faq-layer: `npm --prefix <layer> run test`, `npm --prefix <layer> run build:playground`, and the layer's SSR smoke (journal: `node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt`; seo: `npm --prefix ../vue-seo-layer run smoke:ssr`; menu and faq: `npm --prefix <layer> run smoke:ssr`), and `node testing/guard-check.mjs` in vue-seo-layer (the build-guard fixtures 33-08 adds);
|
||||
- `--consumers`: build sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and sm-bm-app (Go `go build ./...`; the Nuxt apps' `build` script);
|
||||
- `--docker`: the Docker suites (`go test ./...` without -short) of the three Go plugins;
|
||||
- `--admin`: `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh`;
|
||||
- `--docs`: TestDocsTree and `go run ./cmd/summer docs:build --check`;
|
||||
- `--marker`: the framework diff since v0.5.1 (`git diff v0.5.1 -- modules docs`) names no SEO word, sitemap, JSON-LD or consuming application (the D-90 word list of check-phase32.sh's marker stage, plus the app names grzyby, fonoteka, plytarium, summercmsio, demo.summercms);
|
||||
- `--additive`: apisurface of every changed framework package against the newest tag not containing HEAD;
|
||||
- `--compat`: three consumers tested (sm-sitemap-plugin, sm-journal-plugin, sm-demo-app) and every other sibling that requires the framework built.
|
||||
A stage whose repository or directory is missing fails with "missing <path>" (33-14's `--all` needs every repository; vue-seo-layer appears in 33-07). `--self-test` plants: an additive removal, an interface method added, a marker hit, a failing go test, a missing repo; each must be detected. Then write `33-release-log.md` with sections "Planned contents" (framework v0.5.2 = the six fix SHAs above plus this plan's two seams; `git -C ../sm-seo-plugin log --oneline v0.1.0..HEAD` and `git -C ../sm-journal-plugin log --oneline v0.2.5..HEAD` pasted as the unreleased plugin fixes), "Release order" (framework → sm-journal-plugin → sm-sitemap-plugin → sm-seo-plugin → vue-seo-layer → vue-journal-layer, vue-menu-layer, vue-faq-layer → apps; versions read from `git tag` in 33-14), and an empty "Executed steps" table. Commit group A (script) and group B (release log, planning docs).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase33.sh --self-test</automated>
|
||||
<fails_when>non-zero exit, or a planted case reported as undetected</fails_when>
|
||||
<automated>bash scripts/check-phase33.sh --go --docs --marker --additive</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL", "refuse:" or marker hit line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash scripts/check-phase33.sh` with no argument prints a usage listing all eleven stage flags and exits 2
|
||||
- `grep -c "missing " scripts/check-phase33.sh` is at least 1 (missing-repository failure path)
|
||||
- `grep -n "676ca53\|5471b66\|d660cd0\|468af06\|1583651\|0e187cf" .planning/phases/33-seo-output-audit-and-content/33-release-log.md` prints six lines
|
||||
- the additive stage compares against v0.5.1 and passes
|
||||
</acceptance_criteria>
|
||||
<done>The phase has its gate script and a release ledger; v0.5.2's contents are written down and nothing is released.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| plugin code → framework API | plugins fill descriptor fields and RequestOptions; consumers trust the values only as data |
|
||||
| admin SPA ← form schema | request options reach the browser inside typeOptions |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-01 | Information disclosure | RequestOptions in the served schema | medium | mitigate | the schema is served only to authenticated administrators with access to the form (existing cabana guard); the doc comment states that request options must hold no secret, and TestFieldTypeRequestOptions asserts the merge happens only in the authenticated schema handler |
|
||||
| T-33-02 | Denial of service | RequestOptions called per field | low | mitigate | called once per field type per request (behavior test), errors fail open |
|
||||
| T-33-03 | Tampering | breaking published API | high | mitigate | additive stage of check-phase33.sh (apisurface vs v0.5.1) and the Task 1 checkpoint |
|
||||
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | this plan installs no package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -short ./...` green after every commit.
|
||||
- `bash scripts/check-phase33.sh --self-test` and `--go --docs --marker --additive` pass.
|
||||
- `scripts/check-admin-openapi.sh --check` passes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The descriptor fields and RequestOptions exist with docs and smoke tests, strictly additive against v0.5.1.
|
||||
- check-phase33.sh exists and its self-test passes.
|
||||
- The release ledger records v0.5.2's contents; no push, no tag.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Struct fields: `pact.ContentDescriptor.Kind`, `pact.ContentDescriptor.Tools`, `pact.ContentRecord.PublishedAt`, `pact.ContentRecord.Parents`, `pact.ContentRecord.Authors`, `pact.ContentRecord.TopHeading`, `pact.ContentLocale.Images`, `pact.ContentLocale.Videos`, `pact.ContentLocale.Headings`, `pact.FormFieldType.RequestOptions`.
|
||||
- Types: `pact.ContentRef`, `pact.ContentPerson`, `pact.ContentImage`, `pact.ContentVideo`, `pact.ContentHeading`.
|
||||
- Constants: `pact.ContentKindArticle`, `pact.ContentKindCollection`, `pact.ContentKindProfile`, `pact.ContentKindPage`, `pact.ContentToolEdit`, `pact.ContentToolHeadings`, `pact.ContentToolImageAlt`.
|
||||
- Tests: `TestContentDescriptorFields` (modules/signpost/content_fields_smoke_test.go), `TestFieldTypeRequestOptions` (modules/cabana/field_request_options_smoke_test.go).
|
||||
- CLI: `scripts/check-phase33.sh` with `--self-test`, `--go`, `--plugins`, `--layers`, `--consumers`, `--docker`, `--admin`, `--docs`, `--marker`, `--additive`, `--compat`, `--all`.
|
||||
- Files: `.planning/phases/33-seo-output-audit-and-content/33-release-log.md`.
|
||||
- Config keys: none.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md` when done; record the Task 1 answer (including any renamed field) at the top so plans 33-02 to 33-14 use the final names.
|
||||
</output>
|
||||
372
.planning/phases/33-seo-output-audit-and-content/33-02-PLAN.md
Normal file
372
.planning/phases/33-seo-output-audit-and-content/33-02-PLAN.md
Normal file
@@ -0,0 +1,372 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["33-01"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/models/settings.go
|
||||
- ../sm-journal-plugin/models/settings/fields.yaml
|
||||
- ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go
|
||||
- ../sm-journal-plugin/config/config.yaml
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/classes/headings.go
|
||||
- ../sm-journal-plugin/classes/journal_settings.go
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/classes/media_names.go
|
||||
- ../sm-journal-plugin/classes/image_alt.go
|
||||
- ../sm-journal-plugin/classes/content_descriptors.go
|
||||
- ../sm-journal-plugin/classes/content_facts.go
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go
|
||||
- ../sm-journal-plugin/menu_item_types.go
|
||||
- ../sm-journal-plugin/controllers/api/posts.go
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
- ../sm-journal-plugin/controllers/api/categories_test.go
|
||||
- ../sm-journal-plugin/controllers/api/settings.go
|
||||
- ../sm-journal-plugin/controllers/api/maintenance.go
|
||||
- ../sm-journal-plugin/routes.go
|
||||
- ../sm-journal-plugin/mcp.go
|
||||
- ../sm-journal-plugin/console/fix_headings.go
|
||||
- ../sm-journal-plugin/console/import_posts.go
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/outline.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/linkform.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/imageform.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/headings_test.go
|
||||
- ../sm-journal-plugin/journal_descriptor_fields_test.go
|
||||
- ../sm-journal-plugin/classes/media_names_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-3, D-50, D-51, D-81, D-83, D-85, D-96, D-43, D-93, D-10]
|
||||
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Journal has a per-site setting content_h1_to_h2 (Settings › Journal, new tab Headings and links, switch default on) with the config key golem15.journal.content_h1_to_h2 as the value when no settings row exists (2026-10-28 Journal H1 setting, amends D-50)"
|
||||
- "While the setting is on, every H1 saved in Journal rich text becomes an H2 on every Journal rich-text write path, post content and category pillar text alike: admin saves of posts and categories (the mlblogeditor and blogeditor field types), the /_journal/api/v1 write API (and so MCP), the markdown path and the import command (categories have no API or import write path today, so their pillar text is written through the admin form only); a post save answers the plural notice journal:editor.h1_converted and a category save journal:editor.h1_converted_category on the editor field, and the API answer carries the post phrase in its notices (D-50, D-96, UI-SPEC C27, CONTEXT 2026-10-28 H1 conversion scope)"
|
||||
- "While the setting is off, H1 in post content and category pillar text is stored as written, the editor offers H1 to H4, no conversion notice appears, and journal:fix-headings refuses with exit code 1 and the copy that names the setting (2026-10-28 Journal H1 setting, UI-SPEC partial: H1 setting off)"
|
||||
- "Switching the setting on or off never rewrites stored content; only journal:fix-headings --apply (dry run by default) and the MCP tool journal_fix_headings with dry_run false convert stored H1 headings, in post content (default and translated) and in category pillar text, and report both counts (D-50, UI-SPEC Destructive actions, CONTEXT 2026-10-28 H1 conversion scope)"
|
||||
- "GET /_journal/api/v1/posts/{slug} and every category row of GET /_journal/api/v1/categories carry content_h1_to_h2 (boolean, additive key) so the journal layer reads the setting from the Journal API for post bodies and category pillar text, not from a layer option (2026-10-28 Journal H1 setting, H1 conversion scope)"
|
||||
- "The Blog Editor receives headingLevels and genericAnchors per request through RequestOptions: H2 to H4 with a pasted H1 parsed as H2 while the setting is on, H1 to H4 while it is off (D-51, UI-SPEC C28 J1)"
|
||||
- "The editor's outline panel (J2) lists headings in document order and flags skipped levels, empty headings, link-only headings, an H1 in Source mode while the setting is on, and links whose whole text is on the generic list; it recomputes 300 ms after a change, starts collapsed, never takes focus on load and never edits content (D-51, D-81)"
|
||||
- "The link form (J3) offers nofollow, sponsored and ugc checkboxes only for absolute http(s) URLs, writes rel with the checked values, never puts rel on internal links, and warns without blocking when the link text is on the generic list; the stored-HTML policy keeps rel with only those three tokens (D-83, D-81, D-80)"
|
||||
- "The image form (J4) sets a slugged file name before upload (lowercase, Polish diacritics transliterated, non-alphanumeric runs to one hyphen, at most 80 characters, empty becomes image), requires alt unless Decorative image is checked, and inserts alt=\"\" for a decorative image; the server applies the same slug rule to uploaded file names (D-85)"
|
||||
- "Journal's descriptors fill the 33-01 fields: posts give PublishedAt, Parents (their categories in order), Authors (with a journal-author reference when author pages are on), Images (featured image plus body images with alt and decorative), Videos (YouTube embeds), Headings and TopHeading (2 while the setting is on, 0 while off), Kind article and Tools {edit: journal_update_post, headings: journal_fix_headings, image_alt: journal_set_image_alt}; categories give Parents (their parent), Kind collection, Headings from their pillar text, TopHeading (2 while the setting is on, 0 while off) and Tools {headings: journal_fix_headings}; tags give Kind collection (D-93, D-43, D-96, CONTEXT 2026-10-28 H1 conversion scope)"
|
||||
- "A new journal-author menu item type describes author pages (Kind profile, Records of authors with at least one published post, exact Match on the author pattern, host key golem15.journal.author_pages default true), so author pages get the SEO tab, ProfilePage data and a sitemap group (D-43, D-34)"
|
||||
- "MCP has journal_get_settings, journal_update_settings, journal_fix_headings (dry_run) and journal_set_image_alt (dry_run, bulk), each bound to a /_journal/api/v1 route with golem15.journal.manage_settings or golem15.journal.access_posts (D-10, D-85, UI-SPEC MCP parity)"
|
||||
- "The admin save notice, the outline counts and every plural phrase use CLDR maps (en one/other, pl one/few/many/other) (UI-SPEC zero-one-many)"
|
||||
- "J2 shows outlineEmpty when the text has no heading (UI-SPEC empty: J2)"
|
||||
- "J4 keeps the existing uploading status line until the upload result arrives, then inserts the image (UI-SPEC loading: J4)"
|
||||
- {statement: "J4 slug rule: \"Lwia Grzywa – w lesie!.JPG\" becomes lwia-grzywa-w-lesie with the extension .jpg, an empty name becomes image, and names are cut at 80 characters (UI-SPEC J4 backstop)", verification: backstop}
|
||||
- "Planner assumption (UI-SPEC unresolved, J2 long text): outline items with long heading text wrap inside the panel with a hanging indent under the level tag and are never truncated; verify in the editor at 360 px"
|
||||
- "The setting's label, comment and console output name category content as well as post content, with the P33-54 wording that adapts UI-SPEC C27 to the wider scope (CONTEXT 2026-10-28 H1 conversion scope)"
|
||||
prohibitions:
|
||||
- requirement_id: "D-50"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "Turning the H1 setting on or off never rewrites already stored content"
|
||||
- requirement_id: "D-96"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "Journal never imports sm-seo-plugin or names SEO settings; everything SEO reads comes through the signpost descriptor"
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/classes/headings.go"
|
||||
provides: "ConvertH1 on the x/net/html tokenizer"
|
||||
contains: "func ConvertH1("
|
||||
- path: "../sm-journal-plugin/console/fix_headings.go"
|
||||
provides: "journal:fix-headings"
|
||||
contains: "journal:fix-headings"
|
||||
- path: "../sm-journal-plugin/classes/content_facts.go"
|
||||
provides: "body images, videos and headings from content_html"
|
||||
contains: "pact.ContentHeading"
|
||||
- path: "../sm-journal-plugin/headings_test.go"
|
||||
provides: "TestHeadingConversionTracer, TestHeadingConversion, TestFixHeadingsCommand, TestSetImageAlt, TestJournalSettingsTools"
|
||||
contains: "func TestHeadingConversionTracer("
|
||||
- path: "../sm-journal-plugin/journal_descriptor_fields_test.go"
|
||||
provides: "TestJournalDescriptorFields, TestJournalAuthorType"
|
||||
contains: "func TestJournalDescriptorFields("
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/classes/blog_editor.go"
|
||||
to: "../sm-journal-plugin/classes/headings.go"
|
||||
via: "decodeBlogHTML calls ConvertH1 when the setting is on and returns the h1_converted notice"
|
||||
pattern: "ConvertH1\\("
|
||||
- from: "../sm-journal-plugin/controllers/api/posts.go"
|
||||
to: "../sm-journal-plugin/classes/journal_settings.go"
|
||||
via: "Show adds content_h1_to_h2 from the effective setting; Store and Update convert and return notices"
|
||||
pattern: "content_h1_to_h2"
|
||||
- from: "../sm-journal-plugin/classes/blog_editor.go"
|
||||
to: "summercms.go/modules/pact/capabilities.go"
|
||||
via: "the blogeditor and mlblogeditor types set RequestOptions to the settings' heading levels and generic list"
|
||||
pattern: "RequestOptions"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan makes Journal, the first content plugin, supply what the rest of the phase consumes and fix its own content (D-96): the per-site H1 setting with conversion on every write path, the editor's heading levels, outline, link rel and image naming, the new descriptor facts, author pages as a content type, and MCP tools for its settings, stored headings and image alt.
|
||||
|
||||
<objective>
|
||||
Implement in `../sm-journal-plugin` (branch main, not pushed):
|
||||
- the Journal H1 setting (2026-10-28, amends D-50/D-51): column, config key, settings tab, API flag, conversion with notices on every Journal rich-text write path for post content and category pillar text, `journal:fix-headings` over both (CONTEXT 2026-10-28 H1 conversion scope);
|
||||
- Blog Editor J1–J4 (UI-SPEC C28) on RequestOptions (33-01 P33-02), with the stored-HTML policy keeping `rel` (D-83) and server-side slugged media names (D-85);
|
||||
- descriptor facts for posts, categories and tags plus a `journal-author` type (D-93, D-43);
|
||||
- MCP tools `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` (D-10, D-85).
|
||||
|
||||
Purpose: the layer's title element and htmlGate demotion (33-09), JSON-LD and breadcrumbs (33-06), image and News sitemaps (33-04) and the audit's CMS checks (33-10) all read what this plan provides.
|
||||
|
||||
Output: commits in ../sm-journal-plugin (main, not pushed; 33-14 tags). The framework is used through the existing `replace => ../summercms.go`.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-04 (setting precedence):** the settings row's `content_h1_to_h2` decides; the config key `golem15.journal.content_h1_to_h2` (default true, new `config/config.yaml` shipped through `ConfigFS`) is used only when no settings row exists. `classes.EffectiveH1ToH2(ctx, db, cfg) bool` is the one reader; README documents it.
|
||||
- **P33-05 (bulk alt tool lives in Journal):** the UI-SPEC's `[ASSUMED]` `seo_set_image_alt` becomes `journal_set_image_alt`: alt text is post content, which only Journal may write (D-96); the SEO issue report points at it through `ContentDescriptor.Tools["image_alt"]` (33-10). Research had the tool in 33-11; it moves here with Journal's other content fixes.
|
||||
- **P33-06 (featured image alt):** the featured image alt in descriptors and in the API stays as today (UI-SPEC Q-6): the attachment's title when set, otherwise the post title.
|
||||
- **P33-07 (author type URL):** `journal-author` default pattern `/blog/author/:author` (the journal layer's default route), host override through `menu_items.patterns`, `:author` is the backend user's author_slug.
|
||||
- **P33-54 (H1 scope wording, CONTEXT 2026-10-28 H1 conversion scope):** Jakub's decision widens the setting from post content to every Journal rich-text field, post content and category pillar text alike, so the C27 copy that names only posts is adapted; every other C27 string stays verbatim. Label `journal:journal.h1_to_h2_label`: EN "Convert H1 to H2 in post and category content", PL "Zamieniaj H1 na H2 w treści wpisów i kategorii". Comment `journal:journal.h1_to_h2_comment`: the first sentence becomes EN "Turn it off only when the page template does not show the post title or the category name as the H1." and PL "Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1."; the rest of C27's comment is unchanged. New notice key `journal:editor.h1_converted_category` for category saves, worded like `editor.h1_converted` with "the category name" in place of "the post title" (EN one/other ":count H1 heading was converted to H2, because the page shows the category name as the H1." / ":count H1 headings were converted to H2, because the page shows the category name as the H1."; PL one/few/many/other "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla nazwę kategorii jako H1." / "…nagłówki…" / "…nagłówków…" / "…nagłówka…"). Console (English only): dry run "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save.", applied "Converted :count H1 headings in :posts posts and :categories categories.", nothing to do "No H1 headings found in post or category content."; the refusal line stays as C27 words it.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 33-01 (final names in 33-01-SUMMARY.md): pact.ContentDescriptor{Kind, Tools}; pact.ContentRecord{PublishedAt, Parents []ContentRef, Authors []ContentPerson, TopHeading}; pact.ContentLocale{Images []ContentImage, Videos []ContentVideo, Headings []ContentHeading}; pact.FormFieldType.RequestOptions func(ctx, *gorm.DB) (map[string]any, error).
|
||||
sm-journal-plugin today: models/settings.go (row ID 1, Fillable/Rules), models/settings/fields.yaml (tabs general, search, rss), classes/blog_editor.go (BlogEditorTypes, decodeBlogHTML returns pact.FormFieldDecoded{Value, Notices}), classes/format_html.go (bluemonday UGC policy, RequireNoFollowOnLinks(false), SanitizeHTMLReport), classes/media_store.go (StoredMedia{URL, Path}), classes/content_descriptors.go (describePosts, firstImages, describeCategories, describeTag, TagPagesKey), classes/menu_item_types.go (MenuItemTypes, menuAlternates), controllers/api/posts.go (Show ~122, Store, Update, serializePost ~587, featuredImages ~629), controllers/api/rss.go:17-40 (settings row read with defaults), console/export_posts.go:24-60 (bonfire.Command with Bare dry-run flag), mcp.go (tools bound to /_journal/api/v1 routes, permission mcpAccessPosts), admin_permissions.go (golem15.journal.manage_settings, golem15.journal.access_posts), routes.go (public group throttle journal-public-api, write group throttle journal-api).
|
||||
Editor: assets/src/blogeditor/{element.ts (attributes field-name, record-id, locale, ui-locale, options JSON, disabled), toolbar.ts:45-47 heading2..4, extensions.ts:80 heading, labels.ts (format with {name}), blogeditor.css}; tests/blogeditor/*; scripts/check-dist.sh.
|
||||
Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor additions (C28)" tables, en and pl, verbatim.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups per task, staged by path in ../sm-journal-plugin with `git -c safe.directory='*' -C ../sm-journal-plugin add <paths>` then `commit -m "<subject>"`. Leave `go.work.sum` (untracked, another session's) alone. Every commit keeps `go vet ./...` and `go test -short ./...` green there; each group adds the lang keys it uses to both lang files.
|
||||
- Resume check: `git -c safe.directory='*' -C ../sm-journal-plugin log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `feat: H1 to H2 setting with the admin save conversion (tracer)` | TestHeadingConversionTracer passes |
|
||||
| 2 | A `feat: H1 conversion on the API, markdown and import paths`; B `feat: journal:fix-headings and the settings API`; C `feat: MCP tools for settings, headings and image alt` | all write paths and tools pass |
|
||||
| 3 | A `feat: descriptor dates, parents, authors, media and outline`; B `feat: journal-author content type` | descriptor tests pass |
|
||||
| 4 | A `feat: stored HTML keeps link rel and media names are slugged`; B `feat: editor heading levels and outline panel`; C `feat: editor link rel, generic-anchor warning and image naming`; D `docs: headings, editor and MCP tools in README` | dist check passes; plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an editor saves a post with an H1 and gets it back as an H2 with a notice, and the public API reports the setting" — one path only</name>
|
||||
<files>../sm-journal-plugin/models/settings.go, ../sm-journal-plugin/models/settings/fields.yaml, ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/classes/headings.go, ../sm-journal-plugin/classes/journal_settings.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/headings_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/models/settings.go, models/settings/fields.yaml, updates/202610060006_create_golem15_journal_settings.go, updates/registry.go
|
||||
- ../sm-seo-plugin/updates/202610280001_add_golem15_seo_redirects_source_key.go (additive ALTER with Rollback)
|
||||
- ../sm-seo-plugin/config/config.yaml and ../sm-seo-plugin/plugin.go:20-30,79-80 (ConfigFS, HasConfig)
|
||||
- ../sm-journal-plugin/classes/blog_editor.go (decodeBlogHTML, removalNotices) and classes/format_html.go (SanitizeHTMLReport)
|
||||
- ../sm-journal-plugin/controllers/api/posts.go (Show, serializePost) and controllers/api/rss.go:17-40
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go (admin harness for a post save)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 and its copy)
|
||||
</read_first>
|
||||
<action>
|
||||
Per the 2026-10-28 Journal H1 setting and P33-04: migration `202610280001_add_golem15_journal_settings_headings` adds `content_h1_to_h2 boolean NOT NULL DEFAULT true` and `generic_anchors text NULL` to golem15_journal_settings (Rollback drops both), registered in `updates/registry.go` style; model fields ContentH1ToH2 bool and GenericAnchors *string added to Fillable and Rules (boolean; nullable|string|max:5000). fields.yaml gets the tab `golem15.journal::lang.journal.tab_headings` after General with `content_h1_to_h2` (switch, span left, default true, label h1_to_h2_label, comment h1_to_h2_comment) and `generic_anchors` (textarea size small, span full, default = the en and pl seed lists of C27 joined by newlines). New `config/config.yaml` with the commented key `content_h1_to_h2: true`; plugin.go embeds it, implements `ConfigFS()` and asserts `pact.HasConfig`. `classes/journal_settings.go`: `EffectiveH1ToH2(ctx, db, cfg) bool` and `GenericAnchors(ctx, db) []string` (row value, else the seed lists). `classes/headings.go`: `ConvertH1(html string) (string, int)` rewriting `<h1 ...>`/`</h1>` start and end tags to h2 with the x/net/html tokenizer, keeping attributes and every other byte. decodeBlogHTML calls it when EffectiveH1ToH2 is true and appends the notice `golem15.journal::lang.editor.h1_converted` with Count n (FormFieldNotice). Show adds `content_h1_to_h2` to the post payload (additive key; no existing key changes). Lang keys of C27 in en and pl with the P33-54 wording for the label and comment (CLDR plurals). `TestHeadingConversionTracer` in headings_test.go: admin save of a post whose content holds `<h1>A</h1><p>x</p>` stores `<h2>A</h2><p>x</p>` and the save response meta.notices has the converted phrase with count 1; GET /_journal/api/v1/posts/{slug} answers content_h1_to_h2 true; with the setting switched off the same save stores the H1 unchanged, adds no notice, and Show answers false. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestHeadingConversionTracer' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestHeadingConversionTracer" line</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func ConvertH1(" ../sm-journal-plugin/classes/headings.go` prints one line
|
||||
- `grep -n "content_h1_to_h2" ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go ../sm-journal-plugin/models/settings/fields.yaml ../sm-journal-plugin/controllers/api/posts.go` prints a line in each file
|
||||
- `grep -n "h1_converted" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each
|
||||
- TestHeadingConversionTracer asserts both setting states and passes
|
||||
</acceptance_criteria>
|
||||
<done>The setting exists, the admin save converts H1 with a notice while it is on, and the public post answer reports it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Conversion on every other write path (post content and category pillar text), journal:fix-headings over both, the settings API and the MCP tools (settings, fix headings, set image alt)</name>
|
||||
<files>../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/controllers/api/categories_test.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/controllers/api/settings.go, ../sm-journal-plugin/controllers/api/maintenance.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/image_alt.go, ../sm-journal-plugin/console/fix_headings.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/mcp.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/headings_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/controllers/api/posts.go (Store, Update, the transaction and RecordSaved emission from 62c75b1/7a03640)
|
||||
- ../sm-journal-plugin/controllers/api/record_events.go and auth.go (backend-audience JWT, permissions)
|
||||
- ../sm-journal-plugin/console/export_posts.go:24-60 and console/import_posts.go
|
||||
- ../sm-journal-plugin/mcp.go and mcp_test.go (tool binding pattern)
|
||||
- ../sm-journal-plugin/journal_api_writes_test.go (API write harness)
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go (category serializer, post_count) and controllers/api/categories_test.go (exact key lists), models/category.go (Content is the pillar text), categories_admin_editor_test.go (category admin save harness)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 console copy, MCP parity row) and P33-54 above (the wording that replaces C27's post-only strings)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- API Store and Update of content `<h1 class="x">T</h1>` store `<h2 class="x">T</h2>` and the JSON answer gains `notices: [{message, count}]` with the h1_converted phrase; translated content in a locale converts the same way; with the setting off nothing converts and `notices` is absent
|
||||
- Markdown content (FormatHTML path) with `# T` stores an h2 while on
|
||||
- `journal:import-posts` converts while on and prints the converted count
|
||||
- An admin save of a category whose pillar text holds an H1 stores an H2 while on, with the notice journal:editor.h1_converted_category on the content field; while off it stores the H1 unchanged without a notice
|
||||
- GET /_journal/api/v1/categories rows carry content_h1_to_h2 matching the setting (categories_test.go's key lists gain it; every other key unchanged)
|
||||
- `journal:fix-headings` (dry run default) prints "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save." and changes nothing; `--apply` converts default and translated post content and category pillar text, fires RecordSaved per changed post and category, prints "Converted :count H1 headings in :posts posts and :categories categories."; nothing to do prints "No H1 headings found in post or category content."; with the setting off it exits 1 with the refusal copy
|
||||
- GET/PUT /_journal/api/v1/settings read and write content_h1_to_h2 and generic_anchors (other settings columns too) with golem15.journal.manage_settings; 403 without it
|
||||
- POST /_journal/api/v1/fix-headings {dry_run} answers {posts, categories, headings, dry_run} and refuses 409 with the refusal phrase while the setting is off
|
||||
- POST /_journal/api/v1/images/alt {dry_run, changes:[{post_id, locale, src, alt}]} sets alt on the matching img in content_html (default or translated), answers the per-change diff, refuses the whole batch 422 when one src is not in the post, and with dry_run writes nothing
|
||||
- MCP lists journal_get_settings, journal_update_settings, journal_fix_headings and journal_set_image_alt, each bound to its route
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-50, D-96, D-85, P33-05 and P33-54 (CONTEXT 2026-10-28 H1 conversion scope: every Journal rich-text write path, post content and category pillar text alike): call ConvertH1 in the API write paths after sanitizing (default and translated content_html, inside the existing transaction) and return the additive `notices` key; apply it in FormatHTML's caller for markdown content and in console/import_posts.go. decodeBlogHTML (already converting since Task 1 for both blogeditor types) picks the notice key by the field's record: `editor.h1_converted_category` for a Category, `editor.h1_converted` otherwise. The categories serializer in posts_helpers.go adds `content_h1_to_h2` (EffectiveH1ToH2) to every category row, additive; categories_test.go's exact key lists gain it. `console/fix_headings.go`: bonfire command `journal:fix-headings` modeled on export_posts.go (flags `apply` Bare, `dry-run` implied default; Examples; English output exactly as P33-54), scanning posts (content_html default and translated) and categories (content), registered in plugin.go Commands(). `controllers/api/settings.go`: ShowSettings/UpdateSettings (validation via the model's Rules). `controllers/api/maintenance.go`: FixHeadings (posts and categories, answer {posts, categories, headings, dry_run}) and SetImageAlt handlers running in one transaction (dry_run rolls back), with RecordSaved per changed post and category through the existing emission helper; `classes/image_alt.go` rewrites the alt attribute of `<img>` whose src equals the change's src with the x/net/html tokenizer (escaping through the tokenizer, never string concatenation). Routes in the write group (throttle journal-api). MCP tools in mcp.go: `get_settings` (GET /settings), `update_settings` (PUT /settings), `fix_headings` (POST /fix-headings), `set_image_alt` (POST /images/alt, destructive true), permissions golem15.journal.manage_settings for the first three and golem15.journal.access_posts for set_image_alt. Tests first in headings_test.go: `TestHeadingConversion` (API, markdown, import, translated, category admin save with its notice), `TestFixHeadingsCommand` (posts and categories), `TestJournalSettingsTools`, `TestSetImageAlt`. Commit groups A, B, C as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestHeadingConversion|TestFixHeadingsCommand|TestJournalSettingsTools|TestSetImageAlt' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or one of the four test names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "journal:fix-headings" ../sm-journal-plugin/console/fix_headings.go` prints at least one line and `commands_documented_test.go` passes (command documented)
|
||||
- `grep -n '"fix_headings"\|"set_image_alt"\|"get_settings"\|"update_settings"' ../sm-journal-plugin/mcp.go` prints four lines
|
||||
- the refusal copy "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings." appears in console/fix_headings.go
|
||||
- `grep -n ":categories categories" ../sm-journal-plugin/console/fix_headings.go` prints at least one line, and `grep -n "h1_converted_category" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each
|
||||
- `grep -n '"content_h1_to_h2"' ../sm-journal-plugin/controllers/api/posts_helpers.go` prints at least one line
|
||||
- the existing Journal API answer keys are unchanged apart from the additive content_h1_to_h2 (journal_api_matrix_test.go and categories_test.go pass)
|
||||
</acceptance_criteria>
|
||||
<done>Every Journal rich-text write path converts post content and category pillar text while the setting is on, stored content is only rewritten on request (both kinds), and Journal's settings and content fixes are MCP tools.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Descriptor facts for posts, categories and tags, and the journal-author content type</name>
|
||||
<files>../sm-journal-plugin/classes/content_descriptors.go, ../sm-journal-plugin/classes/content_facts.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/journal_descriptor_fields_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/classes/content_descriptors.go (describePosts, firstImages, describeCategories, describeTag, tagPages, TagPagesKey)
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go (MenuItemTypes, tag type registration, menuAlternates) and menu_item_types.go
|
||||
- ../sm-journal-plugin/controllers/api/authors.go (author lookup by author_slug)
|
||||
- ../sm-journal-plugin/content_descriptors_test.go (fixture style)
|
||||
- summercms.go/modules/pact/content.go (33-01 fields)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- A published post with categories C1, C2 describes Parents [journal-category C1, journal-category C2] in that order, PublishedAt = published_at, Authors [{name, Ref journal-author <user id>}] while author pages are on and Ref.Type "" while off
|
||||
- Its locales list Images: the featured image first (alt per P33-06), then body images in document order with alt, Decorative true for alt="" and width/height from attributes; Videos for each figure data-embed youtube; Headings from h1 to h6 with trimmed text
|
||||
- TopHeading is 2 while content_h1_to_h2 is on and 0 while off; Kind article; Tools has edit, headings and image_alt with the journal_ tool names
|
||||
- A child category describes Parents [its parent]; categories and tags have Kind collection
|
||||
- A category with pillar text describes Headings from it, TopHeading 2 while content_h1_to_h2 is on and 0 while off, and Tools {headings: journal_fix_headings} (CONTEXT 2026-10-28 H1 conversion scope)
|
||||
- journal-author: Records lists only authors with a published post, Describe answers the name as Title and the author URL per locale, Match is exact on the pattern, Kind profile; with golem15.journal.author_pages false Describe answers empty URLs and Match never matches
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-93, D-43, P33-06/P33-07 and the 2026-10-28 H1 conversion scope: `classes/content_facts.go` parses content_html once per locale with x/net/html into images (src, alt, decorative, width, height), YouTube videos and headings. Fill the 33-01 fields in describePosts, describeCategories and describeTag (same queries batched per page; no N+1 per record); describeCategories also parses the pillar text (content) for Headings and sets TopHeading from EffectiveH1ToH2 and Tools {headings: journal_fix_headings}, so the SEO plugin's CMS check h1_in_content (33-10) covers category pillar text too. Add the `journal-author` menu item type next to journal-tag (references and Resolve as the tag type does, Content descriptor with Model = the backend user morph type, Controller "", Kind profile), host key constant `AuthorPagesKey = "golem15.journal.author_pages"` (default true when unset), lang keys for its label. Tests first in journal_descriptor_fields_test.go (`TestJournalDescriptorFields`, `TestJournalAuthorType`). Commit groups A and B.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestJournalDescriptorFields|TestJournalAuthorType|TestRecordSavedDescribesNewURL' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestJournalDescriptorFields or TestJournalAuthorType missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "journal-author" ../sm-journal-plugin/classes/menu_item_types.go` prints at least one line
|
||||
- `grep -n "PublishedAt:\|Parents:\|TopHeading:\|Tools:" ../sm-journal-plugin/classes/content_descriptors.go` prints at least four lines
|
||||
- `grep -n "golem15.journal.author_pages" ../sm-journal-plugin/classes/content_descriptors.go` prints one line
|
||||
</acceptance_criteria>
|
||||
<done>Every Journal content type describes the facts the sitemaps, JSON-LD, breadcrumbs and the audit need, and author pages are a content type.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Blog Editor J1–J4 on RequestOptions, link rel in the stored-HTML policy, slugged media names</name>
|
||||
<files>../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/classes/media_names.go, ../sm-journal-plugin/classes/media_names_test.go, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/outline.ts, ../sm-journal-plugin/assets/src/blogeditor/linkform.ts, ../sm-journal-plugin/assets/src/blogeditor/imageform.ts, ../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/{element.ts, extensions.ts, toolbar.ts, labels.ts, blogeditor.css, serializer.ts} and tests/blogeditor/{smoke.test.ts, corpus.test.ts}
|
||||
- ../sm-journal-plugin/classes/blog_editor.go (BlogEditorTypes), format_html.go (newHTMLPolicy), media_store.go
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go (TestEditorOutputPassesPolicy and the corpus)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C28 J1–J4, Blog Editor copy table, Shared rules, spacing exceptions)
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
</read_first>
|
||||
<action>
|
||||
Per UI-SPEC C28 and D-51, D-81, D-83, D-85: BlogEditorTypes sets RequestOptions to {headingLevels: [2,3,4] or [1,2,3,4] by EffectiveH1ToH2, genericAnchors: GenericAnchors()}. element.ts reads `options.headingLevels` (default [2,3,4]) into the TipTap heading levels with a parse rule mapping `<h1>` to level 2 when 1 is absent; toolbar.ts adds `heading1` first when level 1 is allowed (label heading1). `outline.ts`: the J2 bar `blogeditor__outline-toggle` (aria-expanded, collapsed by default, never focused on load) and panel (ol of headings with level tag, flags flagSkipped, flagEmpty, flagLinkOnly, flagH1 only while level 1 is not allowed and Source mode holds an H1; outlineLinks with flagGeneric per link whose trimmed, lowercased, punctuation-stripped text is on genericAnchors; outlineEmpty; 12px indent per level below the first content level; 300 ms debounce; activating an item moves the caret to the heading; never edits content; long items wrap with a hanging indent). `linkform.ts`: J3 fieldset linkRel with relNofollow, relSponsored, relUgc enabled only for absolute http(s) URLs (relHint otherwise), Apply writes rel, internal links get none, genericWarning line (aria-live polite) without blocking Apply. `imageform.ts`: J4 queue form per file with fileName (slug rule on blur and Insert, extension shown fixed), altText (altRequired unless decorative), decorative checkbox (clears and disables alt, inserts alt=""), caption; Insert uploads the renamed file and inserts on the upload result. labels.ts gets every C28 key in en and pl (CLDR plurals for outlineCount and outlineProblems); blogeditor.css copies the editor metrics listed in UI-SPEC Spacing exceptions, light DOM, `--c-*` colours only. Server: format_html.go allows `rel` on `a` matching `^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$` (bluemonday AllowAttrs.Matching.OnElements); `classes/media_names.go` `MediaFileName(name string) string` implements the slug rule (smoke test `TestMediaFileName` in classes/media_names_test.go with the UI-SPEC example, an empty name and an 81-character name) and media_store.go applies it to uploads; editor-corpus.json gains a rel link and a decorative image so TestEditorOutputPassesPolicy pins them. Rebuild the bundle with `npm --prefix ../sm-journal-plugin run build` and commit assets/dist with the sources. README: the setting, its precedence, the command, the MCP tools, journal-author and the editor additions. Commit groups A to D as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-journal-plugin run test 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "failed" count above 0 in the vitest summary</fails_when>
|
||||
<automated>bash ../sm-journal-plugin/scripts/check-dist.sh</automated>
|
||||
<fails_when>non-zero exit (the committed bundle differs from a fresh build or typecheck fails)</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestEditorOutputPassesPolicy|TestMediaFileName|TestBlogEditor' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line or "no tests to run"</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "headingLevels" ../sm-journal-plugin/assets/src/blogeditor/element.ts ../sm-journal-plugin/classes/blog_editor.go` prints a line in each
|
||||
- `grep -n "nofollow|sponsored|ugc" ../sm-journal-plugin/classes/format_html.go` prints one line
|
||||
- `grep -n "flagSkipped\|genericWarning\|decorative" ../sm-journal-plugin/assets/src/blogeditor/labels.ts` prints at least three lines
|
||||
- `grep -n "func MediaFileName(" ../sm-journal-plugin/classes/media_names.go` prints one line, and a Go test asserts "Lwia Grzywa – w lesie!.JPG" → "lwia-grzywa-w-lesie.jpg"
|
||||
- check-dist.sh passes after the dist commit
|
||||
</acceptance_criteria>
|
||||
<done>The editor follows the per-site heading setting, shows the outline and link warnings, writes rel on external links, names and describes images properly, and the server enforces the same rules.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin/API/MCP client → Journal write paths | untrusted HTML content and alt text |
|
||||
| editor element ← typeOptions | per-request options shown to administrators |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-04 | Tampering | ConvertH1 / image alt rewrite | high | mitigate | both work on x/net/html tokens after the bluemonday policy, never on raw string replacement; attribute values are re-escaped by the tokenizer; TestSetImageAlt includes an alt with quotes and angle brackets |
|
||||
| T-33-05 | Elevation of privilege | settings and maintenance routes | high | mitigate | backend-audience JWT plus golem15.journal.manage_settings / access_posts, 403 tests in TestJournalSettingsTools |
|
||||
| T-33-06 | Tampering | link rel attribute | medium | mitigate | the policy accepts only the three rel tokens (regex), the corpus test pins it |
|
||||
| T-33-07 | Tampering | uploaded file names | medium | mitigate | MediaFileName slugs server-side ([a-z0-9-], 80 characters) whatever the client sends |
|
||||
| T-33-08 | Denial of service | fix-headings over a large site | low | accept | CLI/MCP operation by an administrator, one transaction per post, dry run by default |
|
||||
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package; the bundle rebuilds from the committed lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...` green after every commit.
|
||||
- Docker suites: TestHeadingConversionTracer, TestHeadingConversion (posts and category pillar text), TestFixHeadingsCommand (posts and categories), TestJournalSettingsTools, TestSetImageAlt, TestJournalDescriptorFields, TestJournalAuthorType.
|
||||
- `npm --prefix ../sm-journal-plugin run test` and `bash ../sm-journal-plugin/scripts/check-dist.sh` pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The H1 setting works in both modes on every Journal rich-text write path (post content and category pillar text), and the post and category answers expose it.
|
||||
- The editor additions J1–J4 work as UI-SPEC C28 describes.
|
||||
- Descriptors carry the new facts and author pages are a content type.
|
||||
- Four Journal MCP tools exist.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Config key: `golem15.journal.content_h1_to_h2` (default true), `golem15.journal.author_pages` (default true).
|
||||
- Settings columns: `golem15_journal_settings.content_h1_to_h2`, `golem15_journal_settings.generic_anchors`; migration `202610280001_add_golem15_journal_settings_headings`.
|
||||
- Go symbols: `classes.ConvertH1`, `classes.EffectiveH1ToH2`, `classes.GenericAnchors`, `classes.MediaFileName`, `classes.AuthorPagesKey`, API handlers `ShowSettings`, `UpdateSettings`, `FixHeadings`, `SetImageAlt`; `Plugin.ConfigFS`.
|
||||
- Menu item type: `journal-author`.
|
||||
- Routes: `GET /_journal/api/v1/settings`, `PUT /_journal/api/v1/settings`, `POST /_journal/api/v1/fix-headings` (answer `posts`, `categories`, `headings`, `dry_run`), `POST /_journal/api/v1/images/alt`; additive keys `content_h1_to_h2` (post Show and every category row of `GET /_journal/api/v1/categories`) and `notices` (write answers).
|
||||
- CLI: `journal:fix-headings [--apply]` (posts and categories).
|
||||
- MCP tools: `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt`.
|
||||
- Files: `config/config.yaml`, `classes/headings.go`, `classes/journal_settings.go`, `classes/content_facts.go`, `classes/image_alt.go`, `classes/media_names.go`, `console/fix_headings.go`, `controllers/api/settings.go`, `controllers/api/maintenance.go`, `assets/src/blogeditor/{outline,linkform,imageform}.ts`, `headings_test.go`, `journal_descriptor_fields_test.go`.
|
||||
- Lang keys: the C27 and C28 keys (`journal.tab_headings`, `journal.h1_to_h2_label`, `journal.h1_to_h2_comment`, `journal.generic_anchors_label`, `journal.generic_anchors_comment`, `editor.h1_converted`, and the element labels) with the P33-54 wording, plus `editor.h1_converted_category`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
396
.planning/phases/33-seo-output-audit-and-content/33-03-PLAN.md
Normal file
396
.planning/phases/33-seo-output-audit-and-content/33-03-PLAN.md
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["33-01"]
|
||||
files_modified:
|
||||
- ../sm-sitemap-plugin/contract/contract.go
|
||||
- ../sm-sitemap-plugin/classes/slots.go
|
||||
- ../sm-sitemap-plugin/classes/rebuild.go
|
||||
- ../sm-sitemap-plugin/classes/writer.go
|
||||
- ../sm-sitemap-plugin/classes/lastmod.go
|
||||
- ../sm-sitemap-plugin/classes/types.go
|
||||
- ../sm-sitemap-plugin/classes/listing.go
|
||||
- ../sm-sitemap-plugin/classes/generator.go
|
||||
- ../sm-sitemap-plugin/classes/generator_test.go
|
||||
- ../sm-sitemap-plugin/classes/serialize.go
|
||||
- ../sm-sitemap-plugin/classes/serialize_test.go
|
||||
- ../sm-sitemap-plugin/classes/golden_test.go
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/tracer.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/tracer.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/urlto.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/urlto.json
|
||||
- ../sm-sitemap-plugin/classes/tiers.go
|
||||
- ../sm-sitemap-plugin/classes/tiers_test.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite_test.go
|
||||
- ../sm-sitemap-plugin/classes/manifest.go
|
||||
- ../sm-sitemap-plugin/classes/manifest_test.go
|
||||
- ../sm-sitemap-plugin/classes/urlto.go
|
||||
- ../sm-sitemap-plugin/classes/urlto_test.go
|
||||
- ../sm-sitemap-plugin/classes/events.go
|
||||
- ../sm-sitemap-plugin/assets/sitemap.xsl
|
||||
- ../sm-sitemap-plugin/tests/php/gen-goldens.php
|
||||
- ../sm-sitemap-plugin/models/settings.go
|
||||
- ../sm-sitemap-plugin/models/settings/fields.yaml
|
||||
- ../sm-sitemap-plugin/models/settings_test.go
|
||||
- ../sm-sitemap-plugin/models/url.go
|
||||
- ../sm-sitemap-plugin/models/type_setting.go
|
||||
- ../sm-sitemap-plugin/models/definition.go
|
||||
- ../sm-sitemap-plugin/models/definition_item.go
|
||||
- ../sm-sitemap-plugin/models/definition/fields.yaml
|
||||
- ../sm-sitemap-plugin/models/definition_test.go
|
||||
- ../sm-sitemap-plugin/models/registry.go
|
||||
- ../sm-sitemap-plugin/updates/202610280001_create_golem15_sitemap_urls.go
|
||||
- ../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go
|
||||
- ../sm-sitemap-plugin/updates/202610280003_create_golem15_sitemap_types.go
|
||||
- ../sm-sitemap-plugin/updates/registry.go
|
||||
- ../sm-sitemap-plugin/controllers/admin_registry.go
|
||||
- ../sm-sitemap-plugin/controllers/settings.go
|
||||
- ../sm-sitemap-plugin/controllers/settings/config_form.yaml
|
||||
- ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm
|
||||
- ../sm-sitemap-plugin/controllers/definitions.go
|
||||
- ../sm-sitemap-plugin/controllers/definitions/config_form.yaml
|
||||
- ../sm-sitemap-plugin/controllers/definitions_test.go
|
||||
- ../sm-sitemap-plugin/admin.go
|
||||
- ../sm-sitemap-plugin/extensions_settings.go
|
||||
- ../sm-sitemap-plugin/listeners.go
|
||||
- ../sm-sitemap-plugin/jobs.go
|
||||
- ../sm-sitemap-plugin/schedule.go
|
||||
- ../sm-sitemap-plugin/console/rebuild.go
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-sitemap-plugin/console/import.go
|
||||
- ../sm-sitemap-plugin/console/import_winter_test.go
|
||||
- ../sm-sitemap-plugin/import_test.go
|
||||
- ../sm-sitemap-plugin/plugin.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/routes_test.go
|
||||
- ../sm-sitemap-plugin/sitemap_smoke_test.go
|
||||
- ../sm-sitemap-plugin/sitemap_index_test.go
|
||||
- ../sm-sitemap-plugin/config/config.yaml
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.sum
|
||||
- ../sm-sitemap-plugin/lang/en/lang.yaml
|
||||
- ../sm-sitemap-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, D-30, D-32, D-55, D-56, D-57, D-93, D-95, D-24, D-22]
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /sitemap.xml answers a sitemap index (application/xml) whose entries are per-type files sitemap-<slug>-<n>.xml with a lastmod each; each file lists at most file_size URLs (setting, default 1000, range 1 to 50 000) with <loc> and a real <lastmod>, and no <priority> or <changefreq> (D-30)"
|
||||
- "File numbering is stable: URLs keep their file number across rebuilds, new URLs append to the last file of their group with room, a deleted, unpublished or excluded URL leaves a hole instead of shifting later files, and only a change of file_size renumbers (D-30, RESEARCH Pattern 2, Pitfall 5)"
|
||||
- "A URL's lastmod moves only when its record is saved with a real change (RecordSaved with a non-empty Changes list outside the ignore list golem15.sitemap.lastmod_ignore); a no-op save, a counter update or a rebuild leaves it unchanged (D-30, RESEARCH Pitfall 4)"
|
||||
- "Every URL of a record with more than one translated locale carries xhtml:link alternates for each translated locale including itself, plus x-default pointing to the default locale's URL; a locale without a URL gets no alternate and a single-locale record gets none (D-55, D-56, D-57)"
|
||||
- "The sitemap consumes signpost.Records per content type (D-93) and asks the contract.Filter published by another plugin for each batch; without a Filter everything published is included, and with one, excluded URLs (noindex, canonical, redirect) leave the files and are counted by reason (D-95, D-24)"
|
||||
- "The settings singleton (one tile, AdminSingleton) has the tabs Files (site_url, file_size, the S1 files partial), Content types (per signpost type include switch, file slug, HTML heading) and Additional addresses (url and lastmod rows); it replaces the Phase 17 definitions screen and the old settings tile (D-32, UI-SPEC C26, Q-4)"
|
||||
- "The PHP-parity output is gone: priority tiers, the definitions screen, the update manifest, the festival BeforeAddItem/AddItem events, the PHP goldens and the XSL stylesheet (/sitemap.xsl answers 404); this removal is what resolves the todo .planning/todos/completed/sitemap-xslt-browser-deprecation.md (g15office w8kupqi3), which Jakub moved to completed on 2026-10-28 (D-32)"
|
||||
- "A migration moves every url item of golem15_sitemap_definitions into the Additional addresses rows and frontend_url into site_url; the definitions table stays in the database unread, so a rollback keeps the data (D-32)"
|
||||
- "The site URL of every <loc> is the setting site_url, else the config key golem15.sitemap.site_url, else app.url; the request's Host header is never used (CONTEXT 2026-10-28 accepted defaults: sitemap site URL precedence, RESEARCH Open Question 5)"
|
||||
- "Rebuilds run as a conga job: debounced to one queued rebuild per golem15.sitemap.rebuild_delay_seconds (default 60) after record events, on every settings save, nightly through the scheduled command sitemap:rebuild (pact.HasSchedule, 03:00), and by hand with sitemap:rebuild"
|
||||
- "The sitemap plugin publishes contract.Listing (Settings with SiteURL, IndexURL, Groups and the HTML-sitemap and IndexNow fields filled by 33-04; Entries paging the included URLs of a group) for other plugins, without depending on any of them (D-95)"
|
||||
- "The index URL stays /sitemap.xml, so robots.txt Sitemap: lines and Search Console submissions need no change (D-22)"
|
||||
- "S1 shows files.empty before the first build and the files.building callout while a rebuild job runs (UI-SPEC empty and loading: S1, sitemap rebuild)"
|
||||
- "The settings form refuses an invalid file size, file slug, duplicate slug or site address with the errors.* copy of UI-SPEC C26 (UI-SPEC error: save refused)"
|
||||
- {statement: "A file size change renumbers every file in one rebuild and the index lists the new files at once (documented on the field comment)", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-95"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The sitemap never lists a URL that the published Filter excluded, and never imports or names the SEO plugin"
|
||||
- requirement_id: "D-30"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "A rebuild never moves an existing URL to another file unless file_size changed"
|
||||
artifacts:
|
||||
- path: "../sm-sitemap-plugin/contract/contract.go"
|
||||
provides: "Filter, Candidate, Decision, Group, Listing, ListingSettings, Entry, EntriesQuery, EntriesPage, reasons"
|
||||
contains: "type Filter interface"
|
||||
- path: "../sm-sitemap-plugin/classes/slots.go"
|
||||
provides: "stable slot assignment"
|
||||
contains: "func "
|
||||
- path: "../sm-sitemap-plugin/classes/writer.go"
|
||||
provides: "index and urlset writers on encoding/xml escaping"
|
||||
contains: "xml.EscapeText"
|
||||
- path: "../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go"
|
||||
provides: "settings columns and the definitions url-item migration"
|
||||
contains: "extra_urls"
|
||||
- path: "../sm-sitemap-plugin/sitemap_index_test.go"
|
||||
provides: "TestSitemapIndex, TestStableNumbering, TestLastmod, TestFilterExcludes, TestNoFilter, TestMigrateDefinitions, TestHreflangAlternates"
|
||||
contains: "func TestSitemapIndex("
|
||||
key_links:
|
||||
- from: "../sm-sitemap-plugin/classes/rebuild.go"
|
||||
to: "summercms.go/modules/signpost/content.go"
|
||||
via: "Records per included content type, paged by Next"
|
||||
pattern: "Records\\("
|
||||
- from: "../sm-sitemap-plugin/classes/rebuild.go"
|
||||
to: "../sm-sitemap-plugin/contract/contract.go"
|
||||
via: "app.Lookup[contract.Filter]() decides each batch; absent means include everything"
|
||||
pattern: "Lookup\\[contract\\.Filter\\]"
|
||||
- from: "../sm-sitemap-plugin/listeners.go"
|
||||
to: "../sm-sitemap-plugin/classes/lastmod.go"
|
||||
via: "*pact.RecordSaved with non-ignored Changes sets lastmod; RecordSaved/RecordDeleted queue a debounced rebuild"
|
||||
pattern: "\\*pact\\.RecordSaved"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: priority -->
|
||||
<!-- planner-discipline-allow: changefreq -->
|
||||
<!-- planner-discipline-allow: sitemap.xsl -->
|
||||
<!-- planner-discipline-allow: priority_tiers -->
|
||||
<!-- planner-discipline-allow: cache_seconds -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan rewrites `sm-sitemap-plugin` to the D-30 format (the confirmed breaking change D-32): an index at `/sitemap.xml`, per-type files of a configurable size with stable numbering, real lastmod, hreflang alternates, and the filter contract the SEO plugin implements.
|
||||
|
||||
<objective>
|
||||
Rewrite ../sm-sitemap-plugin (main, not pushed; 33-14 tags the next minor version, breaking):
|
||||
- a published contract sub-package (`contract`) with the Filter other plugins implement and the Listing other plugins read (D-95);
|
||||
- a stored slot table and a rebuild job over signpost content types (D-30, D-93);
|
||||
- event-driven lastmod, hreflang alternates, index and file routes;
|
||||
- the settings singleton replacing the Phase 17 screens, the migration of definitions, and the removal of the PHP-parity code (D-32).
|
||||
|
||||
Purpose: SC-1 ("every site serves a sitemap index with per-type files of a configurable size, stable numbering and real lastmod, consistent with noindex and robots.txt").
|
||||
|
||||
Output: commits in ../sm-sitemap-plugin. Image and News sitemaps, IndexNow, the HTML-sitemap API, the management API and MCP tools are plan 33-04.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-08 (contract package):** `git.golem15.com/golem15/sm-sitemap-plugin/contract` (package `contract`), not the plugin's root package, because importing the root package runs its `init` and registers the plugin. Types: `Candidate{TypeKey, RecordID, Locale, URL string}`; `Decision{Exclude bool; Reason string; Group string; LastMod *time.Time}`; `Group{Key string; Titles map[string]string}`; `Filter interface { Decide(ctx, []Candidate) ([]Decision, error); Groups(ctx) ([]Group, error) }`; reasons `ReasonNoindex = "noindex"`, `ReasonCanonical = "canonical"`, `ReasonRedirect = "redirect"`, `ReasonType = "type"`; `Listing interface { Settings(ctx) (ListingSettings, error); Entries(ctx, EntriesQuery) (EntriesPage, error) }`; `ListingSettings{SiteURL, IndexURL string; DefaultLocale string; Groups []GroupInfo; HTMLEnabled bool; HTMLPaths map[string]string; HTMLPageSize int; KeyFile string}`; `GroupInfo{Key, Slug string; Titles map[string]string; Count int}`; `EntriesQuery{Group, Locale, After string; Limit int}`; `Entry{Group, TypeKey, RecordID, Locale, URL, Title string; LastMod *time.Time}`; `EntriesPage{Entries []Entry; Next string; Total int}`. HTMLEnabled, HTMLPaths, HTMLPageSize and KeyFile stay zero until 33-04 adds their settings.
|
||||
- **P33-09 (routes):** Go serves `GET /sitemap.xml` (index, literal root route) and `GET /_sitemap/files/{file}` (`Where("file", "sitemap-[a-z0-9-]+-[0-9]+\\.xml")`); the index's `<loc>` entries are the public root URLs `<site>/sitemap-<slug>-<n>.xml`, which the SEO layer (33-07) or an nginx rewrite maps to `/_sitemap/files/`. No root wildcard route is registered (it would capture every one-segment path of a site Go serves).
|
||||
- **P33-10 (slot table):** `golem15_sitemap_urls` (id, grp, type_key, record_id, locale, url (path), title, file_no, first_seen, lastmod, alternates jsonb, images jsonb, excluded_reason, updated_at; unique (type_key, record_id, locale); index (grp, file_no)). Extra addresses use type_key `extra` and record_id = hex sha256 of the path. Group = per-type slug unless the Filter's Decision.Group overrides it; extra addresses go to `pages`. Default slug: last segment of the type key pluralised (y→ies, else +s unless it ends in s): journal-post → posts, journal-category → categories, journal-tag → tags, journal-author → authors, seo-page → pages.
|
||||
- **P33-11 (reasons shown):** S1's left-out table shows noindex, canonical, redirect and type; unpublished and deleted rows are holes, not "left out".
|
||||
- **P33-12 (debounce):** `golem15_sitemap_settings.rebuild_pending_until timestamptz`; an event sets it with a conditional UPDATE and enqueues one rebuild with that delay only when no pending time lies in the future; the job clears it.
|
||||
</objective>
|
||||
|
||||
<assumption_delta_decision>
|
||||
Signal: "chosen" (the ROADMAP's "per-type files of a configurable size").
|
||||
Primary noun: a sitemap file is (group slug, file number) under a per-site file size; the single PHP file is gone.
|
||||
Decision: promote. The per-type index becomes the only format (D-32); file size is a real setting (default 1000, 1 to 50 000, renumbering on change), not a constant.
|
||||
Invariant test: TestStableNumbering (deletes leave holes, new URLs append, only a file-size change renumbers).
|
||||
</assumption_delta_decision>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
signpost (framework): Registry.ContentTypes() / ContentType(key) / Records(ctx, key, ContentList) / Describe(ctx, key, id, unprefixed); pact.ContentRecord{ID, Published, LastMod, PublishedAt, Parents, Authors, TopHeading, Locales[]{Locale, URL, Title, Description, Image, Words, Images, Videos, Headings}}; pact.RecordSaved{Controller, Model, ID, Locale, Created, Changes []RecordChange{Attribute, Locale, Old, New}}; pact.RecordDeleted.
|
||||
conga: conga.Job(fn, conga.MaxAttempts(n)); conga.From(app).Dispatch(ctx, db, args, conga.DispatchOpts{Label, Delay}); Enqueue(ctx, db, args, conga.EnqueueOpts{Delay}); pact.HasJobs; pact.HasSchedule → []pact.ScheduledCommand{Command, Args, Cadence: pact.DailyAt(3, 0)}.
|
||||
Analogs: ../sm-seo-plugin/jobs.go + classes/generate.go (job kind and dispatch after commit with lagoon.AfterCommit), ../sm-seo-plugin/extensions_settings.go (per-type settings tabs as a FieldsYAML document built at boot from signpost ContentTypes on the plugin's own singleton), ../sm-seo-plugin/controllers/settings.go (AdminSingleton linked from SettingsItem.Controller, D-104), ../sm-seo-plugin/classes/settings_write.go (settings singleton + history pattern).
|
||||
Today's plugin: routes.go (GET /sitemap.xml, /sitemap.xsl, cache), models/settings.go (frontend_url, frontend_default_locale, updates_manifest_path, tiers), models/definition*.go, controllers/definitions.go, classes/{generator,serialize,tiers,rewrite,manifest,urlto,events}.go, console/import_winter.go (PHP rows), config/config.yaml (cache_seconds, priority_tiers), plugin.go (Requires nil, init registers).
|
||||
XML namespaces: sitemap 0.9 http://www.sitemaps.org/schemas/sitemap/0.9, xhtml http://www.w3.org/1999/xhtml.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups staged by path in ../sm-sitemap-plugin (`git -c safe.directory='*' -C ../sm-sitemap-plugin add <paths>`; deletions with `git rm`). Every commit keeps `go vet ./...` and `go test -short ./...` green there. The go.mod require stays on the existing framework tag with `replace => ../summercms.go` (already present); 33-14 bumps it.
|
||||
- Resume check: `git -c safe.directory='*' -C ../sm-sitemap-plugin log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | none (checkpoint answer recorded in the SUMMARY) | answer recorded |
|
||||
| 2 | `feat: sitemap index and per-type files from a slot table (tracer)` | TestSitemapIndex passes |
|
||||
| 3 | A `feat: sitemap settings singleton with content types and additional addresses`; B `feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL`; C `feat: migrate definitions into additional addresses` | settings and migration tests pass |
|
||||
| 4 | A `feat: real lastmod from record events and debounced rebuilds`; B `feat: hreflang alternates and the sitemap filter contract`; C `docs: README for the per-type sitemap format` | all sitemap_index_test.go tests pass; plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Confirm the concrete public format of the new sitemaps</name>
|
||||
<reversibility rating="one-way">The file names and the index URL are public: Search Console submissions, robots.txt and crawlers' caches follow them, and grzybyfunkcjonalne.pl moves to them in 33-13.</reversibility>
|
||||
<decision>D-32 (the breaking switch) is decided; this confirms the concrete URLs, the XSL removal and the folding of the Phase 17 screen before code is written.</decision>
|
||||
<context>
|
||||
Proposed (RESEARCH A4, A5, Pitfall 8; UI-SPEC Q-4): (1) the index stays at /sitemap.xml, so GSC and robots.txt keep their URL; (2) files are /sitemap-<slug>-<n>.xml at the site root, slug per content type (posts, categories, tags, authors, pages; registry kinds offers, services, landing-pages), plus sitemap-images-<n>.xml and sitemap-news.xml in 33-04; Go serves them under /_sitemap/files/ and the SEO layer or nginx maps the root; (3) the XSL view is removed (Chrome 158 stops running XSLT on 2026-11-17; the HTML sitemap is the human view), which resolves the todo sitemap-xslt-browser-deprecation.md (g15office w8kupqi3; already moved to .planning/todos/completed on 2026-10-28); (4) the Phase 17 "Sitemap" definitions screen and the old settings tile become one settings singleton; url items migrate to Additional addresses; priority, change frequency, tiers and the update manifest are removed; the definitions table stays unread in the database.
|
||||
</context>
|
||||
<options>
|
||||
<option id="as-proposed">
|
||||
<name>Adopt (1) to (4)</name>
|
||||
<pros>No GSC resubmission of the index; one admin screen; no XSL left to break</pros>
|
||||
<cons>Editors lose the styled XML view (replaced by the HTML sitemap, 33-04/33-08)</cons>
|
||||
</option>
|
||||
<option id="keep-xsl">
|
||||
<name>Adopt (1), (2), (4) and keep an XSL for the index only</name>
|
||||
<pros>A styled view for browsers that still run XSLT</pros>
|
||||
<cons>Breaks in Chrome from 2026-11-17; the completed todo would have to be reopened</cons>
|
||||
</option>
|
||||
<option id="other-names">
|
||||
<name>Adopt with other file names or another index URL (stated in the answer)</name>
|
||||
<pros>Names chosen by Jakub before they become public</pros>
|
||||
<cons>A new index URL needs a GSC submission and robots.txt change on every site</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "as-proposed", "keep-xsl" or "other-names: <names>"</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: End-to-end "a content type's published records appear in an index at /sitemap.xml that links per-type files of N URLs" — one path only</name>
|
||||
<files>../sm-sitemap-plugin/contract/contract.go, ../sm-sitemap-plugin/models/url.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/updates/202610280001_create_golem15_sitemap_urls.go, ../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/classes/slots.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/jobs.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/sitemap_index_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-sitemap-plugin/plugin.go, routes.go, models/settings.go, updates/202610110002_create_golem15_sitemap_settings.go, testmain_test.go, sitemap_smoke_test.go
|
||||
- ../sm-seo-plugin/jobs.go and classes/generate.go:14-28,168-177 (job kind, dispatch)
|
||||
- ../sm-seo-plugin/fixture_test.go (a fake content type plugin in tests)
|
||||
- summercms.go/modules/signpost/content.go (Records paging)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 2, Code Examples: index writer)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-30, D-93 and P33-08 to P33-10: create the contract package with every type of P33-08. Migration 202610280001 creates golem15_sitemap_urls (P33-10); migration 202610280002 adds to golem15_sitemap_settings: site_url text, file_size integer NOT NULL DEFAULT 1000, default_locale text, extra_urls jsonb NOT NULL DEFAULT '[]', rebuild_pending_until timestamptz (the url-item copy is Task 3's). models/url.go and the new Settings fields. `classes/slots.go`: assign file numbers per group (keep existing numbers, append to the last file with room by first_seen then id, holes stay). `classes/rebuild.go`: `Rebuild(ctx, app) (RebuildResult, error)` walks signpost.ContentTypes, pages Records (Limit 500), keeps Published records with a URL per locale, upserts rows (title from ContentLocale.Title, lastmod initialised from the record's LastMod), marks vanished rows excluded "deleted", assigns slots in one transaction, and returns added/changed/removed URL lists (IndexNow in 33-04 uses them). `classes/writer.go`: index and urlset writers with bufio and `xml.EscapeText`, RFC 3339 UTC lastmod, no priority and no change frequency. jobs.go: `RebuildArgs` (kind `golem15.sitemap.rebuild`) with conga.MaxAttempts(3); plugin.go asserts pact.HasJobs. routes.go: replace the old handlers with the index (`GET /sitemap.xml`, absolute <loc> on the site URL per the precedence in must_haves, which is Jakub's 2026-10-28 accepted default in CONTEXT: setting, then golem15.sitemap.site_url, then app.url) and `GET /_sitemap/files/{file}` (P33-09), both reading the table, content type application/xml, X-Robots-Tag noindex on files, 404 for an unknown or empty file; keep the throttle bucket. `TestSitemapIndex` in sitemap_index_test.go (Docker): a fake content type with 5 published records and 1 draft, file_size 2 → after Rebuild the index lists sitemap-pages-1.xml to -3.xml with lastmod; file 1 lists 2 URLs with absolute <loc> and <lastmod>; the draft is absent; no priority or change frequency element anywhere; an unknown file answers 404. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestSitemapIndex' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestSitemapIndex" line</fails_when>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type Filter interface\|type Listing interface" ../sm-sitemap-plugin/contract/contract.go` prints two lines
|
||||
- `grep -n "xml.EscapeText" ../sm-sitemap-plugin/classes/writer.go` prints at least one line
|
||||
- `grep -n "/_sitemap/files/{file}" ../sm-sitemap-plugin/routes.go` prints one line
|
||||
- `grep -rn "party.Register\|func init" ../sm-sitemap-plugin/contract/` prints nothing
|
||||
</acceptance_criteria>
|
||||
<done>A real index and per-type files are served from the slot table after a rebuild job.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Settings singleton (Files, Content types, Additional addresses), removal of the PHP-parity code, and the definitions migration</name>
|
||||
<files>../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/models/settings_test.go, ../sm-sitemap-plugin/models/type_setting.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go, ../sm-sitemap-plugin/updates/202610280003_create_golem15_sitemap_types.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/classes/types.go, ../sm-sitemap-plugin/controllers/admin_registry.go, ../sm-sitemap-plugin/controllers/settings.go, ../sm-sitemap-plugin/controllers/settings/config_form.yaml, ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm, ../sm-sitemap-plugin/admin.go, ../sm-sitemap-plugin/extensions_settings.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter_test.go, ../sm-sitemap-plugin/import_test.go, ../sm-sitemap-plugin/routes_test.go, ../sm-sitemap-plugin/sitemap_smoke_test.go, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/sitemap_index_test.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/generator_test.go, ../sm-sitemap-plugin/classes/serialize.go, ../sm-sitemap-plugin/classes/serialize_test.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/full.golden, ../sm-sitemap-plugin/classes/testdata/full.json, ../sm-sitemap-plugin/classes/testdata/tracer.golden, ../sm-sitemap-plugin/classes/testdata/tracer.json, ../sm-sitemap-plugin/classes/testdata/urlto.golden, ../sm-sitemap-plugin/classes/testdata/urlto.json, ../sm-sitemap-plugin/classes/tiers.go, ../sm-sitemap-plugin/classes/tiers_test.go, ../sm-sitemap-plugin/classes/rewrite.go, ../sm-sitemap-plugin/classes/rewrite_test.go, ../sm-sitemap-plugin/classes/manifest.go, ../sm-sitemap-plugin/classes/manifest_test.go, ../sm-sitemap-plugin/classes/urlto.go, ../sm-sitemap-plugin/classes/urlto_test.go, ../sm-sitemap-plugin/classes/events.go, ../sm-sitemap-plugin/assets/sitemap.xsl, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/models/definition.go, ../sm-sitemap-plugin/models/definition_item.go, ../sm-sitemap-plugin/models/definition/fields.yaml, ../sm-sitemap-plugin/models/definition_test.go, ../sm-sitemap-plugin/controllers/definitions.go, ../sm-sitemap-plugin/controllers/definitions/config_form.yaml, ../sm-sitemap-plugin/controllers/definitions_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-sitemap-plugin/admin.go, controllers/definitions.go, models/definition*.go, console/import_winter.go and import_test.go
|
||||
- ../sm-seo-plugin/controllers/settings.go, controllers/settings/config_form.yaml, extensions_settings.go (singleton + per-type tabs pattern)
|
||||
- ../sm-seo-plugin/controllers/rules/_seo_status.htm (partial view-model pattern with summer-callout and summer-table classes)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C26, S1, the sitemap copy table, Destructive actions: file size)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- The settings tile (icon network, existing category, label sitemap:settings.label) opens the singleton; the old Phase 17 tile and the definitions screen are gone
|
||||
- Content types tab lists one block per registered signpost type in registration order: type_<key>_include (switch default on), type_<key>_slug (default per P33-10), type_<key>_title (mltext, default the type label); a duplicate slug refuses with errors.slug_taken, a slug outside [a-z0-9-] with errors.slug
|
||||
- file_size outside 1..50000 refuses with errors.file_size; site_url refuses with the existing errors.frontend_url phrase when not an absolute http(s) URL
|
||||
- Saving dispatches a rebuild; a type switched off moves its rows to excluded reason type
|
||||
- Additional addresses rows (url path, optional lastmod) appear in the pages group with their lastmod
|
||||
- Migration: definitions url items become extra_urls rows (url as given, no lastmod), frontend_url becomes site_url, frontend_default_locale becomes default_locale; reference items are dropped (content types cover them); the definitions table is untouched; Rollback clears the copied values only
|
||||
- sitemap:import-winter writes PHP url items into extra_urls (the companion import path of the apps keeps working)
|
||||
- GET /sitemap.xsl answers 404; the old cache config key is gone
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-32, UI-SPEC C26 and Task 1's answer: build the singleton controller `golem15.sitemap.settings` (AdminSingleton, settings tile via SettingsItem.Controller) with fields.yaml tabs Files (site_url text left, file_size number right, files partial S1 full), Additional addresses (extra_urls repeater, titleFrom url, rows url text left and lastmod datepicker right); the Content types tab is a FieldsYAML document built at boot from signpost ContentTypes by a self-extension on the singleton (the SEO plugin's extensions_settings.go pattern), stored in golem15_sitemap_types (type_key unique, include bool, slug, title via the translation writer) created by migration 202610280003. S1 partial `_sitemap_files.htm`: summer-table of files (index first; File in DM Mono linked to the public URL, Addresses, Last modified), then the Left out table by reason (P33-11), then the files.index_note callout; files.empty and files.building per C26. Remove the PHP-parity code and its tests (git rm the files listed in files_modified), the definitions controller, model and form, and the `cache_seconds` and `priority_tiers` config keys; config/config.yaml gets commented `site_url`, `lastmod_ignore` (default [updated_at, created_at, views, sort_order]) and `rebuild_delay_seconds` (60). Extend migration 202610280002 with the url-item copy (write the TestMigrateDefinitions case first). Retarget console/import_winter.go to extra_urls. Every copy key of the C26 sitemap table in both lang files. Commit groups A, B, C.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestSettingsSingleton|TestMigrateDefinitions|TestImportWinter|TestSitemapIndex' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestSettingsSingleton or TestMigrateDefinitions missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-sitemap-plugin ls-files assets classes/testdata tests/php` prints nothing
|
||||
- `grep -rn "priority_tiers\|cache_seconds" ../sm-sitemap-plugin --include=*.go --include=*.yaml` prints nothing
|
||||
- `grep -n "golem15.sitemap.settings" ../sm-sitemap-plugin/controllers/settings.go` prints at least one line
|
||||
- TestMigrateDefinitions asserts the copied extra_urls, the site_url and that golem15_sitemap_definitions still holds its rows
|
||||
</acceptance_criteria>
|
||||
<done>One settings screen manages the new format, the PHP-parity code is gone and existing sites' data migrates.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Real lastmod from record events, debounced and nightly rebuilds, hreflang alternates, and the Filter and Listing contract</name>
|
||||
<files>../sm-sitemap-plugin/classes/lastmod.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/classes/listing.go, ../sm-sitemap-plugin/listeners.go, ../sm-sitemap-plugin/schedule.go, ../sm-sitemap-plugin/console/rebuild.go, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_index_test.go, ../sm-sitemap-plugin/commands_documented_test.go</files>
|
||||
<read_first>
|
||||
- summercms.go/modules/pact/record_events.go (RecordSaved, RecordChange, no-op saves fire with empty Changes)
|
||||
- ../sm-seo-plugin/listeners.go (subscribing to *pact.RecordSaved / *pact.RecordDeleted on app.Events)
|
||||
- ../sm-seo-plugin/classes/settings_write.go:301-313 (lagoon.AfterCommit enqueue)
|
||||
- summercms.go/modules/pact/capabilities.go (HasSchedule, ScheduledCommand, DailyAt) and docs/plugins/scheduling.md
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go:53-55 (the single x-default alternate when translate has no locale)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pitfalls 3, 4, 5; Pattern 3)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestStableNumbering: 5 URLs at size 2 → files 1,1,2,2,3; delete the second → file 1 keeps one URL (hole), files 2 and 3 unchanged; add two → they go to file 3 then a new file 4; change size to 3 → renumbered 1,1,1,2,2,2
|
||||
- TestLastmod: a save whose Changes touch title moves lastmod of that record's rows (only the changed locale's row for a translated change); a save with empty Changes or only updated_at/views does not; a rebuild never moves lastmod
|
||||
- TestHreflangAlternates: a record with pl and en URLs → each url element carries xhtml:link for pl, en and x-default (default locale's URL); a record with only pl → none; a locale with an empty URL → absent
|
||||
- TestFilterExcludes: with a published Filter returning Exclude noindex for one candidate and Group offers for another, the first leaves the files (counted under noindex), the second lands in sitemap-offers-1.xml; Filter.Groups titles reach Listing.Settings Groups
|
||||
- TestNoFilter: without a Filter, everything published is listed
|
||||
- TestDebouncedRebuild: three RecordSaved events within the delay queue exactly one rebuild job
|
||||
- Listing.Entries pages a group's included URLs by after/limit with Total; Listing.Settings answers SiteURL, IndexURL and Groups with counts
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-30, D-55 to D-57, D-95 and P33-12: listeners.go subscribes `*pact.RecordSaved` and `*pact.RecordDeleted` (type looked up with signpost ContentTypeForModel); `classes/lastmod.go` sets lastmod = now on the record's rows when a change outside `golem15.sitemap.lastmod_ignore` exists (Locale "" → every locale row, else that locale's row), then requests a debounced rebuild (P33-12) after commit; RecordDeleted marks rows excluded deleted and requests a rebuild. rebuild.go: batch candidates per page through `app.Lookup[contract.Filter]()` when present (Decide; Exclude → excluded_reason; Group → grp; LastMod → initial lastmod for new rows only), excluded types (include off) → reason type; alternates per record = translated locales with a URL (≥2) plus x-default on default_locale (settings, else the record's first locale). writer.go emits xhtml:link alternates (namespace declared on the urlset only when used). `classes/listing.go` implements contract.Listing over the table and settings and plugin.go publishes it in Register with `app.Publish[contract.Listing]`. schedule.go: pact.HasSchedule with `{Command: "sitemap:rebuild", Cadence: pact.DailyAt(3, 0)}`; console/rebuild.go runs a rebuild synchronously (`--queue` dispatches the job instead), documented for commands_documented_test.go. README rewritten for the new format: routes, settings, contract package (with a Filter example), lastmod rules, config keys, the breaking change and the migration. Tests first per behavior list. Commit groups A, B, C.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestSitemapIndex|TestStableNumbering|TestLastmod|TestHreflangAlternates|TestFilterExcludes|TestNoFilter|TestDebouncedRebuild|TestMigrateDefinitions' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the eight names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Lookup\[contract.Filter\]" ../sm-sitemap-plugin/classes/rebuild.go` prints one line
|
||||
- `grep -n "Publish\[contract.Listing\]\|Publish(" ../sm-sitemap-plugin/plugin.go` prints at least one line
|
||||
- `grep -n "sitemap:rebuild" ../sm-sitemap-plugin/schedule.go ../sm-sitemap-plugin/console/rebuild.go` prints a line in each
|
||||
- `grep -n "x-default" ../sm-sitemap-plugin/classes/rebuild.go ../sm-sitemap-plugin/classes/writer.go` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>The sitemap is event-driven, multilingual, filterable by another plugin and readable through the published Listing.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| crawlers → /sitemap.xml, /_sitemap/files | anonymous, throttled reads |
|
||||
| content data → XML output | titles and URLs written into XML |
|
||||
| other plugins → contract.Filter | in-process decisions |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-09 | Tampering | XML writer | high | mitigate | every value through xml.EscapeText; only http(s) absolute locs built from the site URL setting/config, never from Host; TestSitemapIndex includes a title and path with &, < and quotes |
|
||||
| T-33-10 | Denial of service | file and index routes | medium | mitigate | served from the table (no per-request generation), existing per-IP bucket kept, files route constrained by Where |
|
||||
| T-33-11 | Information disclosure | unpublished records | high | mitigate | only Published records with a URL are stored as included; drafts tested absent |
|
||||
| T-33-12 | Tampering | settings site_url | medium | mitigate | absolute http(s) validation, settings guarded by the existing sitemap permission |
|
||||
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...` after every commit.
|
||||
- Docker suite: the eight sitemap_index_test.go tests plus TestSettingsSingleton and TestImportWinter.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /sitemap.xml is an index of per-type files with stable numbering, real lastmod and hreflang.
|
||||
- The sitemap works with and without a Filter; the PHP-parity mode is gone and old data migrates.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Package `git.golem15.com/golem15/sm-sitemap-plugin/contract`: `Filter`, `Candidate`, `Decision`, `Group`, `Listing`, `ListingSettings`, `GroupInfo`, `EntriesQuery`, `Entry`, `EntriesPage`, `ReasonNoindex`, `ReasonCanonical`, `ReasonRedirect`, `ReasonType`.
|
||||
- Go symbols: `classes.Rebuild`, `classes.RebuildResult`, `RebuildArgs` (kind `golem15.sitemap.rebuild`), the Listing implementation.
|
||||
- Tables/columns: `golem15_sitemap_urls`; `golem15_sitemap_types`; settings columns `site_url`, `file_size`, `default_locale`, `extra_urls`, `rebuild_pending_until`; migrations 202610280001 to 202610280003.
|
||||
- Routes: `GET /sitemap.xml` (index), `GET /_sitemap/files/{file}`; removed `GET /sitemap.xsl`.
|
||||
- Admin: controller `golem15.sitemap.settings` (singleton), partial `sitemap_files` (S1).
|
||||
- CLI: `sitemap:rebuild [--queue]`; schedule entry `sitemap:rebuild` daily 03:00.
|
||||
- Config keys: `golem15.sitemap.site_url`, `golem15.sitemap.lastmod_ignore`, `golem15.sitemap.rebuild_delay_seconds`; removed `golem15.sitemap.cache_seconds`, `golem15.sitemap.priority_tiers`.
|
||||
- Tests: `TestSitemapIndex`, `TestStableNumbering`, `TestLastmod`, `TestHreflangAlternates`, `TestFilterExcludes`, `TestNoFilter`, `TestDebouncedRebuild`, `TestMigrateDefinitions`, `TestSettingsSingleton`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-03-SUMMARY.md` when done; note the Task 1 answer and, if g15office task w8kupqi3 is still open, that it can be closed as resolved by removal.
|
||||
</output>
|
||||
289
.planning/phases/33-seo-output-audit-and-content/33-04-PLAN.md
Normal file
289
.planning/phases/33-seo-output-audit-and-content/33-04-PLAN.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["33-03"]
|
||||
files_modified:
|
||||
- ../sm-sitemap-plugin/classes/images.go
|
||||
- ../sm-sitemap-plugin/classes/news.go
|
||||
- ../sm-sitemap-plugin/classes/indexnow.go
|
||||
- ../sm-sitemap-plugin/classes/htmlgroups.go
|
||||
- ../sm-sitemap-plugin/classes/rebuild.go
|
||||
- ../sm-sitemap-plugin/classes/slots.go
|
||||
- ../sm-sitemap-plugin/classes/writer.go
|
||||
- ../sm-sitemap-plugin/classes/listing.go
|
||||
- ../sm-sitemap-plugin/models/settings.go
|
||||
- ../sm-sitemap-plugin/models/settings/fields.yaml
|
||||
- ../sm-sitemap-plugin/models/url.go
|
||||
- ../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go
|
||||
- ../sm-sitemap-plugin/updates/registry.go
|
||||
- ../sm-sitemap-plugin/controllers/settings.go
|
||||
- ../sm-sitemap-plugin/controllers/settings/config_form.yaml
|
||||
- ../sm-sitemap-plugin/controllers/settings/_indexnow_status.htm
|
||||
- ../sm-sitemap-plugin/controllers/api/auth.go
|
||||
- ../sm-sitemap-plugin/controllers/api/html.go
|
||||
- ../sm-sitemap-plugin/controllers/api/settings.go
|
||||
- ../sm-sitemap-plugin/controllers/api/indexnow.go
|
||||
- ../sm-sitemap-plugin/jobs.go
|
||||
- ../sm-sitemap-plugin/mcp.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/plugin.go
|
||||
- ../sm-sitemap-plugin/config/config.yaml
|
||||
- ../sm-sitemap-plugin/lang/en/lang.yaml
|
||||
- ../sm-sitemap-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
- ../sm-sitemap-plugin/sitemap_extras_test.go
|
||||
- .planning/phases/33-seo-output-audit-and-content/COVERAGE.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, D-31, D-34, D-24, D-10, D-30]
|
||||
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "With images_enabled (default on) the index lists sitemap-images-<n>.xml files whose <url> entries carry the page <loc> and one <image:image><image:loc> per featured or body image of that page, at most 1000 images per page, numbered stably like the per-type files (D-31)"
|
||||
- "With news_enabled (default off) the index lists sitemap-news.xml with at most 1000 articles (content Kind article) first published in the last two days, filtered by news_sources (article types and their categories; empty = every article type), each with publication name (news_publication, else app.name), language, publication_date from PublishedAt and title (D-31, RESEARCH Pitfall 9)"
|
||||
- "With indexnow_enabled (default off) the plugin generates a 32-character hex key with crypto/rand on save, serves it at GET /_sitemap/indexnow/<key>.txt (the SEO layer maps the root /<key>.txt), and after each rebuild posts the added, changed and removed URLs (at most 10 000 per request) to https://api.indexnow.org/indexnow through a fetchguard client limited to that host; 403, 422 and 429 answers are recorded and shown in S2, 429 retries through the job's attempts (D-31)"
|
||||
- "Generating a new key (indexnow_new_key on save) makes the old key file answer 404 at once (UI-SPEC Destructive actions)"
|
||||
- "No IndexNow request is ever sent outside the production environment"
|
||||
- "GET /_sitemap/api/v1/html?locale= answers the HTML-sitemap overview (groups in the order pages, offers, services, categories as a tree, posts newest first, tags, authors, other types; each group with its title in the locale, total, the first html_page_size entries and more=true when larger) and GET /_sitemap/api/v1/html/{group}?locale=&page= one page of a group; both list exactly the indexable set of the XML sitemaps (no excluded URL), answer 404 when the HTML sitemap is off, for an unknown group or a page past the last, and carry X-Robots-Tag noindex (D-34, D-24)"
|
||||
- "The settings singleton gains the tabs Images and News, IndexNow (with the S2 indexnow_status partial) and HTML sitemap (html_enabled default on, html_path per locale default /mapa-strony pl and /sitemap en, html_page_size 50 to 500 default 150), with the C26 copy and errors.html_path / errors.page_size refusals (UI-SPEC C26)"
|
||||
- "contract.Listing.Settings answers HTMLEnabled, HTMLPaths, HTMLPageSize and KeyFile, so the SEO plugin and the layer read them without importing sitemap internals (D-95)"
|
||||
- "Every sitemap admin operation is an MCP tool: sitemap_get_settings (GET /_sitemap/api/v1/settings), sitemap_update_settings (PUT) and sitemap_rebuild (POST /_sitemap/api/v1/rebuild), listed only with the sitemap permission (D-10, UI-SPEC MCP parity)"
|
||||
- "S2 shows indexnow.no_key before a key exists and indexnow.never before the first notification, then the key, its URL and the last notification (UI-SPEC empty: S2)"
|
||||
- "S2 shows the indexnow.err_key, err_host or err_rate callout matching the last 403, 422 or 429 answer (UI-SPEC error: IndexNow)"
|
||||
- "The external API this plan integrates (IndexNow) has its capability matrix in COVERAGE.md, every opt-out with a reason"
|
||||
- {statement: "A HTML-sitemap group larger than html_page_size shows its first page plus the sitemap.all link and its group page is paginated (UI-SPEC zero-one-many: large groups; layer rendering in 33-08)", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-31"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "IndexNow never submits a URL whose host differs from the site URL's host, and never sends a request outside production"
|
||||
artifacts:
|
||||
- path: "../sm-sitemap-plugin/classes/indexnow.go"
|
||||
provides: "key generation, ping job body, answer recording"
|
||||
contains: "api.indexnow.org"
|
||||
- path: "../sm-sitemap-plugin/classes/htmlgroups.go"
|
||||
provides: "HTML sitemap groups, order, tree and paging"
|
||||
contains: "func "
|
||||
- path: "../sm-sitemap-plugin/mcp.go"
|
||||
provides: "sitemap MCP tools"
|
||||
contains: "pact.HasMCP"
|
||||
- path: "../sm-sitemap-plugin/sitemap_extras_test.go"
|
||||
provides: "TestImageSitemap, TestNewsSitemap, TestIndexNow, TestHTMLGroups, TestSitemapTools"
|
||||
contains: "func TestIndexNow("
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/COVERAGE.md"
|
||||
provides: "IndexNow capability matrix"
|
||||
contains: "INTEGRATE"
|
||||
key_links:
|
||||
- from: "../sm-sitemap-plugin/classes/rebuild.go"
|
||||
to: "../sm-sitemap-plugin/classes/indexnow.go"
|
||||
via: "a rebuild's added/changed/removed URLs dispatch the IndexNow job when enabled in production"
|
||||
pattern: "IndexNowArgs"
|
||||
- from: "../sm-sitemap-plugin/classes/indexnow.go"
|
||||
to: "summercms.go/modules/fetchguard"
|
||||
via: "fetchguard.NewClient(Policy{Mode: AllowHostsMode, AllowHosts: [api.indexnow.org]}).PostJSON"
|
||||
pattern: "fetchguard\\.NewClient"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan adds the image and News sitemaps, IndexNow pings, the data API behind the HTML sitemap page, and MCP tools for the sitemap settings.
|
||||
|
||||
<objective>
|
||||
Extend ../sm-sitemap-plugin (after 33-03; main, not pushed):
|
||||
- image sitemap files and the News sitemap from the 33-01 descriptor facts (D-31);
|
||||
- IndexNow: key, key file, ping job from rebuild diffs, S2 status (D-31);
|
||||
- the HTML-sitemap groups API that the layer's page renders (D-34, layer page in 33-08);
|
||||
- the remaining settings tabs, the management API and the MCP tools (D-10);
|
||||
- keeping `COVERAGE.md` (the IndexNow capability matrix written at planning time) in sync with what is built.
|
||||
|
||||
Purpose: SC-1 (image and News sitemaps, IndexNow) and the data side of the HTML sitemap.
|
||||
|
||||
Output: commits in ../sm-sitemap-plugin; a planning-docs commit here only if COVERAGE.md has to change.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-13 (image slots):** `golem15_sitemap_urls.image_file_no` (stable numbering of rows that have images, same rules as file_no) plus `published_at`, `kind` and `parent_refs jsonb` columns (for News and the category tree), filled by the rebuild; migration 202610280004 adds them with the media, News, IndexNow and HTML settings columns: images_enabled bool default true, news_enabled bool default false, news_publication text, news_sources jsonb default '[]', indexnow_enabled bool default false, indexnow_key text, indexnow_last_at timestamptz, indexnow_last_count int, indexnow_last_status int, html_enabled bool default true, html_path (translatable through the translation writer, default value pl /mapa-strony and en /sitemap), html_page_size int default 150.
|
||||
- **P33-14 (IndexNow request):** body `{host, key, urlList}`; no keyLocation (the key file is served at the root, so it covers every URL of the host); endpoint `https://api.indexnow.org/indexnow`; 200 and 202 are success; 400, 403, 422 recorded without retry; 429 makes the job return an error so conga retries (MaxAttempts 5).
|
||||
- **P33-15 (news sources values):** taglist values `type:<type key>` and `category:<type key>:<id>`; a News candidate matches when its type or one of its Parents is listed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-03-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 33-03: contract.Listing / ListingSettings{SiteURL, IndexURL, DefaultLocale, Groups, HTMLEnabled, HTMLPaths, HTMLPageSize, KeyFile}; classes.Rebuild → RebuildResult{Added, Changed, Removed []string}; golem15_sitemap_urls (grp, type_key, record_id, locale, url, title, file_no, first_seen, lastmod, alternates, images, excluded_reason); routes GET /sitemap.xml, GET /_sitemap/files/{file}; settings singleton golem15.sitemap.settings with Files, Content types, Additional addresses tabs.
|
||||
fetchguard: NewClient(Policy{Mode: AllowHostsMode, AllowHosts, Timeout}, cfg) → Client.PostJSON(ctx, url, header, payload) (*Result, error); WithTransport(ctx, rt) routes a test's requests to an httptest handler.
|
||||
Management API analog: ../sm-seo-plugin/controllers/api/auth.go `manage(...)` (cabana.ResolveBackend + cabana.Allows) and mcp.go (pact.MCP tools bound to routes, pier prefixes the plugin id).
|
||||
Specs (cited in 33-RESEARCH.md): image sitemap only image:image + image:loc, ≤1000 per url; News: articles from the last two days, ≤1000 news:news per sitemap; IndexNow key 8–128 [A-Za-z0-9-], ≤10 000 URLs per POST.
|
||||
Copy: UI-SPEC C26 tables (tabs, images, News, IndexNow, S2, HTML sitemap, errors).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups staged by path in ../sm-sitemap-plugin; COVERAGE.md committed here as planning docs. Every commit keeps vet and `test -short` green.
|
||||
- Resume check: `git -c safe.directory='*' -C ../sm-sitemap-plugin log --format=%s | grep -Fx '<subject>'`; here `git log --format=%s | grep -Fx 'docs(33-04): update the IndexNow coverage matrix'` (only when group C was needed).
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `feat: image sitemap files (tracer)` | TestImageSitemap passes |
|
||||
| 2 | A `feat: Google News sitemap`; B `feat: IndexNow key, key file and pings`; C (here, only if the matrix changed) `docs(33-04): update the IndexNow coverage matrix` | News and IndexNow tests pass |
|
||||
| 3 | A `feat: HTML sitemap groups API`; B `feat: sitemap management API and MCP tools`; C `docs: images, News, IndexNow, HTML sitemap and MCP in README` | plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "a post's featured and body images appear in sitemap-images-1.xml linked from the index" — one path only</name>
|
||||
<files>../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/models/url.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/classes/images.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/slots.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/sitemap_extras_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-sitemap-plugin/classes/{rebuild,slots,writer}.go and sitemap_index_test.go (33-03)
|
||||
- summercms.go/modules/pact/content.go (ContentLocale.Images, Image)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Code Examples: urlset with images; State of the Art: image tags)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-31 and P33-13: migration 202610280004 adds the P33-13 columns; the rebuild stores each locale row's images (featured Image first, then Images, absolute on the site URL when relative, deduplicated, at most 1000) and kind, published_at, parent_refs; slots.go assigns image_file_no to rows with images (stable, holes kept). writer.go emits the image urlset (namespace http://www.google.com/schemas/sitemap-image/1.1, only image:image and image:loc). The index adds sitemap-images-<n>.xml when images_enabled and the file has rows; the files route serves them. `TestImageSitemap`: a post with a featured image and two body images → sitemap-images-1.xml lists its page with three image:loc; images_enabled false removes the files from the index; a page with 1200 images lists 1000. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestImageSitemap|TestSitemapIndex' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestImageSitemap" line</fails_when>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "sitemap-image/1.1" ../sm-sitemap-plugin/classes/writer.go` prints one line
|
||||
- `grep -n "image_file_no" ../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go` prints one line
|
||||
</acceptance_criteria>
|
||||
<done>Images of every listed page reach Google through the image sitemap files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Google News sitemap and IndexNow (key, key file, ping job, S2)</name>
|
||||
<files>../sm-sitemap-plugin/classes/news.go, ../sm-sitemap-plugin/classes/indexnow.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/controllers/api/indexnow.go, ../sm-sitemap-plugin/controllers/settings.go, ../sm-sitemap-plugin/controllers/settings/config_form.yaml, ../sm-sitemap-plugin/controllers/settings/_indexnow_status.htm, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/jobs.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/sitemap_extras_test.go, .planning/phases/33-seo-output-audit-and-content/COVERAGE.md</files>
|
||||
<read_first>
|
||||
- summercms.go/modules/fetchguard/README.md (Client, PostJSON, WithTransport, AllowHostsMode)
|
||||
- ../sm-seo-plugin/classes/robots.go (how Staging()/non-production is detected) and classes/service.go Staging()
|
||||
- ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm (partial style from 33-03)
|
||||
- .planning/phases/33-seo-output-audit-and-content/COVERAGE.md (the matrix this task implements)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C26 Images and News, IndexNow tabs, S2 copy)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestNewsSitemap: articles published 1 hour and 47 hours ago are listed, one published 49 hours ago is not; a non-article type is never listed; news_sources [category:journal-category:3] keeps only articles with that parent; more than 1000 candidates list the newest 1000; news_enabled false: no sitemap-news.xml in the index and the file answers 404
|
||||
- TestIndexNow (fake transport): enabling and saving generates a 32-hex key; GET /_sitemap/indexnow/<key>.txt answers the key as text/plain; another name answers 404; after a rebuild with one new and one removed URL the job posts {host, key, urlList[2]} to api.indexnow.org; 202 recorded as last status; 403 and 422 recorded without retry; 429 makes the job fail so it retries; indexnow_new_key replaces the key and the old file answers 404; with the app environment not production no request is sent; a URL of another host is never in urlList
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-31, P33-14 and P33-15: `classes/news.go` builds the News urlset (namespace http://www.google.com/schemas/sitemap-news/0.9) from rows with kind article and published_at in the last 48 hours, newest first, limit 1000, publication name news_publication else config app.name, language = the row's locale. `classes/indexnow.go`: `GenerateKey()` (crypto/rand, hex, 32 characters), `IndexNowArgs` (kind golem15.sitemap.indexnow, URLs) dispatched by the rebuild after commit when enabled, in production and the diff is non-empty (chunks of 10 000), the job posting through a fetchguard client (AllowHostsMode, AllowHosts [api.indexnow.org], Timeout 10 s) and recording indexnow_last_at, _count, _status. `controllers/api/indexnow.go` serves the key file (`GET /_sitemap/indexnow/{file}` with Where `[A-Za-z0-9-]{8,128}\.txt`). Settings tabs Images and News (images_enabled, news_enabled, news_publication and news_sources hidden by trigger while news_enabled is unchecked; news_sources taglist mode array, customTags false, options from article types and their categories) and IndexNow (indexnow_enabled, partial S2 `_indexnow_status.htm`, indexnow_new_key checkbox hidden while disabled; the form's save generates the key). Config keys documented: `indexnow_endpoint` is not configurable (fixed host). COVERAGE.md was written at planning time (IndexNow capability matrix); keep it in sync: when the implementation drops, adds or changes a capability, update its row (every OPT-OUT keeps a reason) and commit it here as group C, otherwise there is no group C. Tests first. Commit groups A and B (plugin), C only if COVERAGE.md changed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestNewsSitemap|TestIndexNow' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestNewsSitemap or TestIndexNow missing from the "--- PASS" lines</fails_when>
|
||||
<automated>grep -c "| OPT-OUT |" .planning/phases/33-seo-output-audit-and-content/COVERAGE.md</automated>
|
||||
<fails_when>output is 0, or the command exits non-zero because the file is missing</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "crypto/rand" ../sm-sitemap-plugin/classes/indexnow.go` prints one line
|
||||
- `grep -n "AllowHostsMode" ../sm-sitemap-plugin/classes/indexnow.go` prints one line
|
||||
- `grep -n "sitemap-news/0.9" ../sm-sitemap-plugin/classes/news.go` prints one line
|
||||
- every OPT-OUT row in COVERAGE.md has a non-empty reason column
|
||||
</acceptance_criteria>
|
||||
<done>News and IndexNow work behind their switches, never outside production, and the integration's coverage is on record.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: HTML-sitemap groups API, the HTML sitemap tab, Listing fields, management API and MCP tools</name>
|
||||
<files>../sm-sitemap-plugin/classes/htmlgroups.go, ../sm-sitemap-plugin/classes/listing.go, ../sm-sitemap-plugin/controllers/api/auth.go, ../sm-sitemap-plugin/controllers/api/html.go, ../sm-sitemap-plugin/controllers/api/settings.go, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/mcp.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_extras_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/controllers/api/auth.go (manage wrapper), mcp.go, mcp_test.go
|
||||
- ../sm-seo-plugin/routes.go:79-85 (noindex wrapper) and Buckets()
|
||||
- ../sm-sitemap-plugin/classes/listing.go (33-03)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L3 group order and markup data, C26 HTML sitemap tab, MCP parity)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestHTMLGroups: overview for pl lists groups in the L3 order with titles from type_<key>_title in pl, categories nested by parent, posts newest first, others alphabetical; a group with 160 entries at page size 150 shows 150 and more=true; GET /html/posts?page=2 answers the rest with pages=2; page 3 → 404; unknown group → 404; html_enabled false → 404 for both; excluded URLs never appear; answers carry X-Robots-Tag noindex
|
||||
- Listing.Settings answers HTMLEnabled, HTMLPaths {pl:/mapa-strony, en:/sitemap}, HTMLPageSize and KeyFile (<key>.txt when IndexNow is on)
|
||||
- TestSitemapTools: GET/PUT /_sitemap/api/v1/settings and POST /_sitemap/api/v1/rebuild need a backend principal with the sitemap permission (401/403 otherwise); PUT validates like the form; the MCP catalog lists get_settings, update_settings and rebuild bound to those routes
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-34, D-10 and UI-SPEC L3/C26: `classes/htmlgroups.go` builds the overview and group pages from the slot table (included rows only, locale filter, L3 order, category tree from parent_refs, paging by html_page_size); `controllers/api/html.go` serves `GET /_sitemap/api/v1/html` and `GET /_sitemap/api/v1/html/{group}` (public, noindex header, throttle bucket `sitemap-html` 300/min per client IP behind trusted proxies). HTML sitemap tab fields (html_enabled switch, html_path mltext with the defaults, html_page_size number) and errors.html_path / errors.page_size validation. listing.go fills the HTML and KeyFile fields. Management API (`controllers/api/auth.go` manage wrapper modeled on the SEO plugin's; `controllers/api/settings.go` show/update; rebuild dispatch) under `/_sitemap/api/v1` with bucket `sitemap-api`; `mcp.go` declares get_settings, update_settings, rebuild (pact.HasMCP, guard backend, the plugin's existing permission). README documents every route, setting, config key and tool. Tests first. Commit groups A, B, C.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestHTMLGroups|TestSitemapTools|TestImageSitemap|TestNewsSitemap|TestIndexNow' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestHTMLGroups or TestSitemapTools missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "/_sitemap/api/v1/html" ../sm-sitemap-plugin/routes.go ../sm-sitemap-plugin/README.md` prints a line in each
|
||||
- `grep -n '"get_settings"\|"update_settings"\|"rebuild"' ../sm-sitemap-plugin/mcp.go` prints three lines
|
||||
- `grep -n "X-Robots-Tag" ../sm-sitemap-plugin/controllers/api/html.go ../sm-sitemap-plugin/routes.go` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>The HTML sitemap has its data API, the sitemap settings are complete and every sitemap operation is reachable through MCP.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| sitemap job → api.indexnow.org | outbound HTTP from the server |
|
||||
| visitors → HTML-sitemap API | anonymous reads |
|
||||
| admin/MCP → management API | authenticated writes |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-13 | Elevation of privilege (SSRF) | IndexNow ping | high | mitigate | fetchguard AllowHostsMode with the single host api.indexnow.org, HTTPS only, no redirects followed; URL list filtered to the site host (TestIndexNow) |
|
||||
| T-33-14 | Information disclosure | IndexNow key | low | accept | the key is public by design (served at the root); rotation through indexnow_new_key |
|
||||
| T-33-15 | Spoofing | staging pings | medium | mitigate | no request outside production (TestIndexNow non-production case) |
|
||||
| T-33-16 | Denial of service | HTML-sitemap API | medium | mitigate | table reads with paging capped at html_page_size ≤500, per-IP bucket sitemap-html |
|
||||
| T-33-17 | Elevation of privilege | management API and MCP | high | mitigate | cabana.ResolveBackend + permission check on every route, 401/403 tests in TestSitemapTools |
|
||||
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package (fetchguard and crypto/rand are framework/stdlib) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...` after every commit.
|
||||
- Docker suite: TestImageSitemap, TestNewsSitemap, TestIndexNow, TestHTMLGroups, TestSitemapTools, plus the 33-03 suite.
|
||||
- COVERAGE.md has no OPT-OUT row without a reason.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Image and News sitemaps and IndexNow work behind their switches.
|
||||
- The HTML-sitemap API answers exactly the indexable set.
|
||||
- Sitemap settings are manageable through MCP.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Columns: `golem15_sitemap_urls.image_file_no`, `published_at`, `kind`, `parent_refs`; settings `images_enabled`, `news_enabled`, `news_publication`, `news_sources`, `indexnow_enabled`, `indexnow_key`, `indexnow_last_at`, `indexnow_last_count`, `indexnow_last_status`, `html_enabled`, `html_path`, `html_page_size`; migration 202610280004.
|
||||
- Go symbols: `classes.GenerateKey`, `IndexNowArgs` (kind `golem15.sitemap.indexnow`), the News and image writers, HTML group builder, API handlers for html, settings, rebuild and the key file.
|
||||
- Routes: `GET /_sitemap/indexnow/{file}`, `GET /_sitemap/api/v1/html`, `GET /_sitemap/api/v1/html/{group}`, `GET /_sitemap/api/v1/settings`, `PUT /_sitemap/api/v1/settings`, `POST /_sitemap/api/v1/rebuild`; files `sitemap-images-<n>.xml`, `sitemap-news.xml`.
|
||||
- Throttle buckets: `sitemap-html`, `sitemap-api`.
|
||||
- MCP tools: `sitemap_get_settings`, `sitemap_update_settings`, `sitemap_rebuild`.
|
||||
- Partial: `indexnow_status` (S2).
|
||||
- Files: `classes/images.go`, `classes/news.go`, `classes/indexnow.go`, `classes/htmlgroups.go`, `controllers/api/{auth,html,settings,indexnow}.go`, `mcp.go`, `sitemap_extras_test.go`, `.planning/phases/33-seo-output-audit-and-content/COVERAGE.md` (written at planning time, kept in sync here).
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
341
.planning/phases/33-seo-output-audit-and-content/33-05-PLAN.md
Normal file
341
.planning/phases/33-seo-output-audit-and-content/33-05-PLAN.md
Normal file
@@ -0,0 +1,341 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["33-04"]
|
||||
files_modified:
|
||||
- ../sm-seo-plugin/go.mod
|
||||
- ../sm-seo-plugin/go.sum
|
||||
- ../sm-seo-plugin/go.work
|
||||
- ../sm-seo-plugin/plugin.go
|
||||
- ../sm-seo-plugin/menu_item_types.go
|
||||
- ../sm-seo-plugin/classes/sitemap_filter.go
|
||||
- ../sm-seo-plugin/classes/registry.go
|
||||
- ../sm-seo-plugin/classes/site.go
|
||||
- ../sm-seo-plugin/classes/llms.go
|
||||
- ../sm-seo-plugin/classes/history.go
|
||||
- ../sm-seo-plugin/classes/batch.go
|
||||
- ../sm-seo-plugin/classes/issues.go
|
||||
- ../sm-seo-plugin/classes/robots.go
|
||||
- ../sm-seo-plugin/classes/resolver.go
|
||||
- ../sm-seo-plugin/classes/settings_write.go
|
||||
- ../sm-seo-plugin/classes/partials.go
|
||||
- ../sm-seo-plugin/models/page.go
|
||||
- ../sm-seo-plugin/models/page/columns.yaml
|
||||
- ../sm-seo-plugin/models/page/fields.yaml
|
||||
- ../sm-seo-plugin/models/settings.go
|
||||
- ../sm-seo-plugin/models/settings/fields.yaml
|
||||
- ../sm-seo-plugin/models/robots.go
|
||||
- ../sm-seo-plugin/models/registry.go
|
||||
- ../sm-seo-plugin/updates/202610280002_create_golem15_seo_pages.go
|
||||
- ../sm-seo-plugin/updates/202610280003_add_golem15_seo_links_and_llms.go
|
||||
- ../sm-seo-plugin/updates/registry.go
|
||||
- ../sm-seo-plugin/extensions_robots.go
|
||||
- ../sm-seo-plugin/extension/_seo_status.htm
|
||||
- ../sm-seo-plugin/controllers/admin_registry.go
|
||||
- ../sm-seo-plugin/controllers/pages.go
|
||||
- ../sm-seo-plugin/controllers/pages/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/pages/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/pages/config_filter.yaml
|
||||
- ../sm-seo-plugin/controllers/pages/_page_status.htm
|
||||
- ../sm-seo-plugin/controllers/api/registry.go
|
||||
- ../sm-seo-plugin/controllers/api/site.go
|
||||
- ../sm-seo-plugin/controllers/api/llms.go
|
||||
- ../sm-seo-plugin/admin_navigation.go
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/mcp.go
|
||||
- ../sm-seo-plugin/operations.go
|
||||
- ../sm-seo-plugin/config/config.yaml
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/fixture_test.go
|
||||
- ../sm-seo-plugin/sitemap_filter_test.go
|
||||
- ../sm-seo-plugin/registry_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3, D-95, D-24, D-33, D-12, D-22, D-25, D-83, D-94, D-10]
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "sm-seo-plugin publishes a contract.Filter (sm-sitemap-plugin/contract) and requires golem15.sitemap: a URL whose effective SEO is noindex, whose canonical points elsewhere or that redirects leaves the XML and HTML sitemaps with the reason noindex, canonical or redirect; the non-production lock never excludes a URL, so staging and production list the same set (D-95, D-24)"
|
||||
- "The page registry (SEO › Pages, controller golem15.seo.pages) holds frontend pages with an address per locale, a kind (page, offer, service, landing), a name, a parent, a content-changed date and a note; it is the signpost content type seo-page, so each page gets the SEO tab, resolves ok, and is listed in the sitemaps grouped by kind into sitemap-pages, sitemap-offers, sitemap-services and sitemap-landing-pages files (D-33, D-30)"
|
||||
- "The frontend server reports its static routes with POST /_seo/api/v1/registry/routes (bearer golem15.seo.route_report_token, else notfound_token): unknown paths are inserted with origin reported and kind page, known ones get reported_at, nothing is ever deleted or overwritten, and a reported page missing from a newer report shows the Not on the site badge and the P9 warning (D-33, UI-SPEC C21)"
|
||||
- "A registry address that another content type owns refuses with pages.url_owned; a duplicate, an invalid address, a missing default-locale address and a parent loop refuse with their C21 copy (UI-SPEC C21, error: save refused)"
|
||||
- "GET /_seo/api/v1/site answers the site facts the layer needs: site_url, default_locale, locales, unprefixed_locale, links.new_tab, html_sitemap {enabled, paths, page_size}, routes [{type, pattern}] for the drift check, root_files (verification file names and the IndexNow key file) and llms (D-97 support, D-20c, D-103)"
|
||||
- "With llms_enabled (robots.txt screen, AI bots tab, default off) GET /llms.txt serves the site's indexable pages from the sitemaps grouped like the HTML sitemap, headed by llms_intro or the default meta description; off, it answers 404 (D-25, UI-SPEC C25)"
|
||||
- "robots.txt Sitemap: lines name the sitemap index from the sitemap plugin's Listing (/sitemap.xml) unless golem15.seo.robots_sitemaps overrides them; per-type files are never listed (D-22)"
|
||||
- "The SEO settings get a Links tab with links_new_tab (switch, default on), nofollow_domains and sponsored_domains (one domain per line, subdomains included, settings.domain_invalid on a bad line); all three are MCP-writable through seo_update_settings and reach the resolve answer as outbound {new_tab, nofollow_domains, sponsored_domains} (2026-10-28 layer options: links_new_tab; D-83)"
|
||||
- "The batch conflict noindex_published fires only when the URL is currently listed in a sitemap file (read through contract.Listing), and a CMS issue sitemap_blocked (Error, tool seo_update_robots) reports sitemap URLs that the production robots.txt blocks for Googlebot (D-12, D-24)"
|
||||
- "Every SEO write (admin, MCP, batch, import, undo) purges the rendered-site cache after commit through the framework's site-cache purge, so the layer's cached resolve answers and pages refresh (33-CONTEXT Claude's discretion: purge with D-97)"
|
||||
- "Pages tools seo_list_pages, seo_create_page, seo_update_page and seo_delete_page exist and TestEveryAdminOperationHasTool maps every Pages operation (D-10)"
|
||||
- "SEO › Pages sits between URL rules and robots.txt in the side menu with the icon files, behind golem15.seo.manage (UI-SPEC C15)"
|
||||
- "The Pages list shows pages.list_empty when empty (UI-SPEC empty: Pages list)"
|
||||
- "The SEO tab's P1 shows status.excluded_title with the matching status.excluded_noindex, _canonical, _redirect or _type text when the page is left out of the sitemaps (UI-SPEC partial: page excluded)"
|
||||
- "A reported page missing from the newest report keeps its data, shows the negative badge pages.state_missing and the P9 warning pages.status_missing, and is never deleted automatically (UI-SPEC partial: registry page missing)"
|
||||
- {statement: "Two route reports in a row with the same list change only reported_at; a report from a server with a stale build never removes an editor's title, kind or SEO values", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-33"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The route report never deletes a registry page and never overwrites a value an editor set"
|
||||
- requirement_id: "D-95"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "The SEO plugin reads sitemap data only through the published contract package; the sitemap plugin keeps working without SEO"
|
||||
artifacts:
|
||||
- path: "../sm-seo-plugin/classes/sitemap_filter.go"
|
||||
provides: "contract.Filter implementation on the resolver"
|
||||
contains: "Decide("
|
||||
- path: "../sm-seo-plugin/classes/registry.go"
|
||||
provides: "page registry service, signpost descriptor, route report"
|
||||
contains: "func "
|
||||
- path: "../sm-seo-plugin/controllers/api/site.go"
|
||||
provides: "GET /_seo/api/v1/site"
|
||||
contains: "func "
|
||||
- path: "../sm-seo-plugin/sitemap_filter_test.go"
|
||||
provides: "TestSitemapFilterTracer, TestFilterReasons, TestSitemapBlockedIssue, TestNoindexPublishedConflict"
|
||||
contains: "func TestSitemapFilterTracer("
|
||||
- path: "../sm-seo-plugin/registry_test.go"
|
||||
provides: "TestRegistry, TestRouteReport, TestSiteEndpoint, TestLLMSTxt, TestPurgeAfterWrite"
|
||||
contains: "func TestRegistry("
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/plugin.go"
|
||||
to: "../sm-sitemap-plugin/contract/contract.go"
|
||||
via: "Register publishes the Filter with app.Publish[contract.Filter]; Requires includes golem15.sitemap"
|
||||
pattern: "contract\\.Filter"
|
||||
- from: "../sm-seo-plugin/classes/history.go"
|
||||
to: "summercms.go/modules/cooler/sitecache.go"
|
||||
via: "each recorded write schedules the site-cache purge after commit"
|
||||
pattern: "Purge"
|
||||
- from: "../sm-seo-plugin/menu_item_types.go"
|
||||
to: "../sm-seo-plugin/classes/registry.go"
|
||||
via: "the seo-page MenuItemType's Content descriptor (Records, Describe, Match) reads the registry"
|
||||
pattern: "seo-page"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan connects the SEO plugin to the new sitemaps and gives the frontend layer its site-level facts: the sitemap filter, the page registry with the route report, the site endpoint, llms.txt, the Sitemap: line, the Links settings (new tab, nofollow and sponsored domains) and cache purging after SEO writes.
|
||||
|
||||
<objective>
|
||||
Extend ../sm-seo-plugin (main, not pushed):
|
||||
- implement and publish `contract.Filter` (D-95, D-24) and require golem15.sitemap;
|
||||
- the page registry: model, admin screen (C21), signpost type, route report endpoint, MCP tools (D-33, D-10);
|
||||
- `GET /_seo/api/v1/site`, `GET /llms.txt` (D-25), robots `Sitemap:` from the index (D-22);
|
||||
- the Links tab (`links_new_tab`, nofollow and sponsored domains) and `outbound` in the resolve answer (2026-10-28 layer options, D-83);
|
||||
- the refined batch conflict and the `sitemap_blocked` CMS issue (D-12, D-24);
|
||||
- site-cache purge after every SEO write.
|
||||
|
||||
Purpose: SC-1 (sitemaps consistent with noindex and robots.txt), D-33, and the contract the layer (33-07/33-08) builds on.
|
||||
|
||||
Output: commits in ../sm-seo-plugin. This plan runs after 33-04 (not beside it) because its tests mount the sibling sm-sitemap-plugin checkout through go.work, which 33-04 edits; a concurrent edit there would break this plan's builds. go.mod requires `git.golem15.com/golem15/sm-sitemap-plugin` at its existing tag with the sibling replaced in go.work; 33-14 bumps the require to the new sitemap tag.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-16 (resolve answer, additive, `costly`):** `Result.Outbound *Outbound` with JSON `outbound: {new_tab: bool, nofollow_domains: [string], sponsored_domains: [string]}`, present on ok answers. No existing key changes. 33-06 and 33-11 add further keys; the layer treats absent keys as empty.
|
||||
- **P33-17 (site answer, `costly`):** `GET /_seo/api/v1/site` (public group, noindex, bucket seo-resolve) answers `{site_url, default_locale, locales: [..], unprefixed_locale, links: {new_tab}, html_sitemap: {enabled, paths: {locale: path}, page_size}, routes: [{type, pattern}], root_files: [..], llms: bool}`; 33-06 adds `pagination` and `home_crumb`.
|
||||
- **P33-18 (registry table):** `golem15_seo_pages` (id, url text unique (default-locale path; other locales through the translation writer), title, kind page|offer|service|landing, origin manual|reported|mcp, parent_id, content_updated_at date, reported_at timestamptz, note, timestamps); settings column `routes_reported_at`. Groups: Filter.Groups answers pages, offers, services, landing-pages with titles from `pages.kind_*` per locale; Decide sets Group from the page kind.
|
||||
- **P33-19 (purge):** the purge is scheduled where history records a write (classes/history.go), so every write path that records history purges once per transaction (kind `seo`, slug = the URL or `*` for settings).
|
||||
- **P33-20 (route report token):** config `golem15.seo.route_report_token` (empty uses `notfound_token`; both empty → 404), constant-time comparison, body `{routes: [path...]}` of at most 2000 paths of at most 2048 bytes each.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-03-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-04-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-05-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 33-03: package git.golem15.com/golem15/sm-sitemap-plugin/contract — Filter{Decide(ctx, []Candidate) ([]Decision, error); Groups(ctx) ([]Group, error)}; Candidate{TypeKey, RecordID, Locale, URL}; Decision{Exclude, Reason, Group, LastMod}; Group{Key, Titles}; reasons noindex/canonical/redirect/type; Listing{Settings(ctx) (ListingSettings, error); Entries(ctx, EntriesQuery) (EntriesPage, error)}; ListingSettings{SiteURL, IndexURL, DefaultLocale, Groups, HTMLEnabled, HTMLPaths, HTMLPageSize, KeyFile}.
|
||||
sm-seo-plugin (Phase 32): classes.Service (NewService(app), SiteURL, Staging, DefaultLocale), Resolver.Resolve(ctx, rawURL) (Result{Action, Status, Location, URL, Locale, Staging, SEO, Sources, TypeKey, RecordID}) and ResolveRecord; classes/settings_write.go (allowed columns, SettingsValues, SetSettingsValue: four places per new column); classes/history.go (Record, WithVia); classes/batch.go (conflict kinds incl. noindex_published); classes/issues.go (Issue{Kind, URL, Locale, Details, Tool}, toolSetURLSEO = "seo_set_url_seo"); classes/robots.go (served body, robots_sitemaps); extensions_robots.go (fields on the robots singleton); controllers/redirects.go + config_*.yaml (list/form pattern); controllers/api/notfound.go (bearer token pattern); mcp.go (tools), operations.go (AdminOperations), operations_test.go (TestEveryAdminOperationHasTool, TestMCPReadToolsAreGET); routes.go (public group with noindex(), management group with BucketAPI); fixture_test.go (fake content plugin acme.pages, fake TranslationWriter); testmain_test.go (Docker Postgres).
|
||||
cooler: SiteCachePurger (Purge(ctx, kind, slug)) and the after-commit purge helper in modules/cooler/sitecache.go (read the file for the exact name).
|
||||
Copy: UI-SPEC C15, C20 Links tab, C21, C25 and their copy tables.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; lang keys in both files per group; `assets/dist` untouched (no element in this plan).
|
||||
- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `feat: sitemap filter excludes noindex, canonicalised and redirected URLs (tracer)` | TestSitemapFilterTracer passes |
|
||||
| 2 | A `feat: page registry with its admin screen and signpost type`; B `feat: route report endpoint`; C `feat: MCP tools for registry pages` | registry tests and the operations parity pass |
|
||||
| 3 | A `feat: site endpoint, llms.txt and the sitemap index in robots.txt`; B `feat: links settings and outbound rules in resolve`; C `feat: sitemap-aware batch conflict and the sitemap_blocked issue`; D `feat: purge the site cache after SEO writes`; E `docs: registry, site endpoint, llms.txt and links in README` | plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an editor sets a page to noindex and it leaves the XML sitemap with reason noindex" — one path only</name>
|
||||
<files>../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/go.work, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/classes/sitemap_filter.go, ../sm-seo-plugin/fixture_test.go, ../sm-seo-plugin/sitemap_filter_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-sitemap-plugin/contract/contract.go and classes/rebuild.go (33-03)
|
||||
- ../sm-seo-plugin/plugin.go, classes/resolver.go (ResolveRecord), fixture_test.go, testmain_test.go
|
||||
- summercms.go/modules/backpack/services.go (Publish keyed by the type argument; duplicate is an error)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 3)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-95, D-24 and P33-18: add the require of git.golem15.com/golem15/sm-sitemap-plugin (its current tag) and `replace git.golem15.com/golem15/sm-sitemap-plugin => ../sm-sitemap-plugin` in go.work; plugin.go adds "golem15.sitemap" to Requires and publishes `classes.SitemapFilter` with `app.Publish[contract.Filter]` in Register. `classes/sitemap_filter.go`: Decide resolves each candidate's record (ResolveRecord by type, id, locale; batched through one DB handle) ignoring the non-production lock: robots noindex → Exclude noindex; canonical whose path differs from the candidate's → Exclude canonical; a redirect source → Exclude redirect; else include. Groups answers nothing yet (Task 2 adds the registry kinds). The test fixture app mounts the sitemap plugin (import of its package in the test) next to the fake content plugin. `TestSitemapFilterTracer` (Docker): two published fake pages; set the first to noindex through the SEO tab admin save; run the sitemap rebuild; the sitemap file lists only the second; the excluded row's reason is noindex; the same holds with the app environment set to staging. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSitemapFilterTracer' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestSitemapFilterTracer" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "golem15.sitemap" ../sm-seo-plugin/plugin.go` prints at least one line
|
||||
- `grep -n "sm-sitemap-plugin/contract" ../sm-seo-plugin/classes/sitemap_filter.go ../sm-seo-plugin/plugin.go` prints a line in each
|
||||
- `grep -n "sm-sitemap-plugin =>" ../sm-seo-plugin/go.work` prints one line
|
||||
</acceptance_criteria>
|
||||
<done>The SEO plugin decides what the sitemap lists, and a noindex page leaves the sitemap.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Page registry (model, admin screen, signpost type seo-page, sitemap groups), route report endpoint and Pages MCP tools</name>
|
||||
<files>../sm-seo-plugin/models/page.go, ../sm-seo-plugin/models/page/columns.yaml, ../sm-seo-plugin/models/page/fields.yaml, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610280002_create_golem15_seo_pages.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/classes/registry.go, ../sm-seo-plugin/classes/sitemap_filter.go, ../sm-seo-plugin/menu_item_types.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/pages.go, ../sm-seo-plugin/controllers/pages/config_list.yaml, ../sm-seo-plugin/controllers/pages/config_form.yaml, ../sm-seo-plugin/controllers/pages/config_filter.yaml, ../sm-seo-plugin/controllers/pages/_page_status.htm, ../sm-seo-plugin/controllers/api/registry.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/registry_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/controllers/redirects.go, controllers/redirects/config_{list,form,filter}.yaml, models/redirect.go and models/redirect/{columns,fields}.yaml
|
||||
- ../sm-seo-plugin/controllers/api/notfound.go (token check) and controllers/api/redirects.go (management CRUD)
|
||||
- ../sm-seo-plugin/classes/owner.go (FindOwner via MatchPath) and classes/rules.go
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go (a MenuItemType with a Content descriptor)
|
||||
- ../sm-seo-plugin/operations.go and operations_test.go
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C21, Pages copy table, Destructive actions)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Create /kontakt (kind page, title Kontakt) → it resolves ok with the SEO tab values; it is a seo-page record in signpost; the sitemap lists it in sitemap-pages-1.xml; a kind offer page lands in sitemap-offers-1.xml
|
||||
- A page with en address /contact and pl /kontakt has two locale URLs and hreflang alternates; an empty en address means no en URL (UI-SPEC Q-15)
|
||||
- Address /blog/lwia-grzywa owned by a fake record → 422 pages.url_owned naming the type and title; duplicate → pages.url_taken; "kontakt" → pages.url_invalid; empty default-locale address → pages.url_required; parent loop → pages.parent_loop
|
||||
- Route report with the right token and ["/", "/kontakt", "/o-nas"] inserts /o-nas (origin reported, kind page, title derived from the last segment) and updates reported_at of the others; a later report without /o-nas marks it missing (badge, P9 warning) and deletes nothing; wrong token 401; no token configured 404; 2001 paths 422; a path owned by a content type is skipped
|
||||
- Deleting a page deletes its SEO entries (the existing RecordDeleted cleanup) and it leaves the sitemaps on the next rebuild
|
||||
- Filter.Groups answers pages, offers, services, landing-pages with the kind titles per locale; Decide groups registry candidates by kind
|
||||
- MCP lists list_pages, create_page, update_page, delete_page; TestEveryAdminOperationHasTool passes with the Pages controller
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-33, D-10, UI-SPEC C21 and P33-18/P33-20: migration 202610280002 creates golem15_seo_pages and the settings column routes_reported_at; models/page.go with url and title translatable through the translation writer (as the SEO settings' ml fields are), Rules and the C21 validation (FormBeforeCreate/Update hook in the controller translating :type/:title params, the Phase 32 pattern); `classes/registry.go`: CRUD service with history, `ReportRoutes(ctx, paths) (ReportResult, error)`, and the descriptor functions; `menu_item_types.go` registers MenuItemType `seo-page` (label pages.name, Content{Controller golem15.seo.pages, Model page morph, Kind page, Records, Describe (per-locale URLs, Title, LastMod content_updated_at, Parents [seo-page parent], Published true), Match exact}). Pages screen: config_list.yaml (title pages.list_title, recordUrl update, 20 per page, checkboxes, setup, toolbar [create, delete], noRecordsMessage pages.list_empty, defaultSort url asc, row state negative pages.state_missing for reported pages with reported_at before routes_reported_at), columns and filters per C21, form per C21 with P9 `_page_status.htm` (pages.status_reported plain or pages.status_missing warning, nothing for manual pages); navigation item Pages (icon files) after URL rules, permission golem15.seo.manage. `controllers/api/registry.go`: `POST /_seo/api/v1/registry/routes` (public group, bucket seo-notfound, P33-20 token) and the management CRUD `GET/POST /_seo/api/v1/pages`, `PUT/DELETE /_seo/api/v1/pages/{id}`; MCP tools list_pages, create_page, update_page, delete_page (destructive) and the AdminOperations rows for controller golem15.seo.pages. Config key `route_report_token` documented in config/config.yaml. Every C21 copy key in both lang files. Tests first in registry_test.go (`TestRegistry`, `TestRouteReport`). Commit groups A, B, C.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestRegistry|TestRouteReport|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestSitemapFilterTracer' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestRegistry, TestRouteReport or TestEveryAdminOperationHasTool missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"seo-page"' ../sm-seo-plugin/menu_item_types.go ../sm-seo-plugin/classes/registry.go` prints at least one line
|
||||
- `grep -n "/registry/routes" ../sm-seo-plugin/routes.go` prints one line and `grep -n "subtle.ConstantTimeCompare" ../sm-seo-plugin/controllers/api/registry.go` prints one line
|
||||
- `grep -n '"list_pages"\|"create_page"\|"update_page"\|"delete_page"' ../sm-seo-plugin/mcp.go` prints four lines
|
||||
- TestRouteReport asserts that a later report deletes nothing and keeps editor-set titles
|
||||
</acceptance_criteria>
|
||||
<done>Frontend pages live in a CMS registry with the SEO tab, sitemap groups by kind, a safe route report and MCP tools.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Site endpoint, llms.txt, robots Sitemap: from the index, Links settings with outbound rules in resolve, sitemap-aware conflict and issue, purge after writes</name>
|
||||
<files>../sm-seo-plugin/classes/site.go, ../sm-seo-plugin/classes/llms.go, ../sm-seo-plugin/classes/robots.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/classes/batch.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/classes/history.go, ../sm-seo-plugin/classes/partials.go, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/models/robots.go, ../sm-seo-plugin/updates/202610280003_add_golem15_seo_links_and_llms.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/extensions_robots.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/controllers/api/site.go, ../sm-seo-plugin/controllers/api/llms.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/registry_test.go, ../sm-seo-plugin/sitemap_filter_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/classes/settings_write.go (allowed columns ~36-41, SettingsValues 46-69, SetSettingsValue 72+, history and after-commit job 301-313)
|
||||
- ../sm-seo-plugin/classes/robots.go (served body, Sitemap lines), classes/batch.go (conflicts), classes/issues.go, classes/history.go
|
||||
- ../sm-seo-plugin/extensions_robots.go and extension/_seo_status.htm
|
||||
- summercms.go/modules/cooler/sitecache.go (the after-commit purge helper)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C20 Links tab, C25, SEO tab additions copy for status.excluded_*)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestSiteEndpoint: /_seo/api/v1/site answers every P33-17 key; root_files lists each verification file name and, when the sitemap's IndexNow is on, its key file; routes lists each content type's layered pattern; X-Robots-Tag noindex
|
||||
- TestLLMSTxt: off → 404; on → text/plain with "# <site name>", the intro (or default description) as a quote line, then one "## <group title>" section per non-empty group with "- [title](absolute url)" lines; excluded URLs never appear
|
||||
- Robots: without robots_sitemaps override the served file has exactly "Sitemap: <site>/sitemap.xml"; with the override, the configured lines
|
||||
- Links tab: links_new_tab default true; nofollow_domains "example.com\nhttps://x.pl/a" → 422 settings.domain_invalid naming line 2; the resolve answer of an ok page has outbound {new_tab: true, nofollow_domains: [example.com], sponsored_domains: []}; seo_update_settings with links_new_tab false turns it off
|
||||
- TestNoindexPublishedConflict: a batch noindex on a URL that is in a sitemap file reports noindex_published; on a URL not listed (type switched off) it does not
|
||||
- TestSitemapBlockedIssue: robots.txt "Disallow: /blog/" with a listed /blog/x → issue sitemap_blocked (Error, tool seo_update_robots, details.line)
|
||||
- TestPurgeAfterWrite: an SEO tab save, a redirect create through MCP and a settings save each post one purge after commit; a rolled-back batch posts none
|
||||
- P1 shows status.excluded_title with the reason text for a noindex page and for a page whose type is off in the sitemap settings
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-22, D-24, D-25, D-83, the 2026-10-28 layer options and P33-16/P33-17/P33-19: migration 202610280003 adds settings links_new_tab boolean NOT NULL DEFAULT true, nofollow_domains text, sponsored_domains text, and robots llms_enabled boolean NOT NULL DEFAULT false, llms_intro text. Settings: the four-place pattern of settings_write.go for the three Links columns with domain validation (lowercase host names, one per line, no scheme or path), the Links tab in models/settings/fields.yaml (links_new_tab switch right with settings.links_new_tab_comment; nofollow_domains and sponsored_domains textarea small left/right with settings.domains_comment); robots singleton fields llms_enabled and llms_intro (hide trigger) through extensions_robots.go. `classes/site.go` builds the P33-17 answer (Listing.Settings via app.Lookup[contract.Listing], verification names, signpost patterns); `controllers/api/site.go` serves it; `classes/llms.go` + `controllers/api/llms.go` serve the literal root route `GET /llms.txt` (bucket seo-robots). robots.go reads the index URL from Listing.Settings when `robots_sitemaps` is not set in config (the config default line changes to a comment explaining the fallback). resolver.go adds Outbound (P33-16). batch.go: noindex_published only when a contract.Listing entry lists the URL. issues.go: kind `sitemap_blocked` (Error, tool seo_update_robots) using the grobotstxt matcher against the production body. history.go schedules the after-commit purge (P33-19). partials.go/_seo_status.htm: the excluded callout from Filter decisions for the record. README: routes, config keys, the registry, the site answer, llms.txt, Links and purge. Tests first. Commit groups A to E.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSiteEndpoint|TestLLMSTxt|TestNoindexPublishedConflict|TestSitemapBlockedIssue|TestPurgeAfterWrite|TestRobots|TestSettingsLinks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestSiteEndpoint, TestLLMSTxt, TestNoindexPublishedConflict, TestSitemapBlockedIssue or TestPurgeAfterWrite missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"/llms.txt"\|"/site"' ../sm-seo-plugin/routes.go` prints two lines
|
||||
- `grep -n "links_new_tab" ../sm-seo-plugin/classes/settings_write.go ../sm-seo-plugin/models/settings/fields.yaml ../sm-seo-plugin/classes/resolver.go` prints a line in each
|
||||
- `grep -n "sitemap_blocked" ../sm-seo-plugin/classes/issues.go` prints at least one line
|
||||
- existing resolve keys are unchanged: TestResolverPrecedence and the Phase 32 resolve tests pass
|
||||
</acceptance_criteria>
|
||||
<done>The layer has a site endpoint, robots and llms.txt follow the sitemap, outbound link rules are settings, conflicts and issues know the sitemap, and every SEO write refreshes the rendered site.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| frontend server → route report | token-authenticated server-to-server POST |
|
||||
| visitors → /site, /llms.txt | anonymous reads |
|
||||
| admin/MCP → registry and settings | authenticated writes |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-18 | Tampering | route report poisoning the registry | high | mitigate | bearer token with constant-time comparison, size caps, insert-only (never delete or overwrite editor values), owned paths skipped; TestRouteReport |
|
||||
| T-33-19 | Information disclosure | /site answer | medium | mitigate | answers only public facts (site URL, locales, patterns, public root file names); no token, no unpublished data; reviewed against P33-17 in TestSiteEndpoint |
|
||||
| T-33-20 | Information disclosure | llms.txt | medium | mitigate | built from the sitemap's included set only (noindex/unpublished never present), off by default |
|
||||
| T-33-21 | Elevation of privilege | Pages admin and MCP | high | mitigate | golem15.seo.manage on every operation; TestEveryAdminOperationHasTool and the manage wrapper |
|
||||
| T-33-22 | Tampering | domain lists | low | mitigate | validated host names only; rendered by the layer as rel tokens, never as markup |
|
||||
| T-33-23 | Denial of service | purge after writes | low | mitigate | one purge per transaction after commit, coalesced by the framework purger |
|
||||
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package; sm-sitemap-plugin is a first-party sibling module |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit.
|
||||
- Docker suite: TestSitemapFilterTracer, TestRegistry, TestRouteReport, TestSiteEndpoint, TestLLMSTxt, TestNoindexPublishedConflict, TestSitemapBlockedIssue, TestPurgeAfterWrite and the Phase 32 suites.
|
||||
- `go -C ../sm-sitemap-plugin test -short ./...` still passes (sitemap without SEO).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Sitemaps leave out noindex, canonicalised and redirected URLs through the published filter.
|
||||
- The page registry works from the admin, MCP and the route report.
|
||||
- The site endpoint, llms.txt, robots Sitemap:, the Links settings and purge after writes work.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Go symbols: `classes.SitemapFilter` (Decide, Groups), registry service with `ReportRoutes`, `classes.Outbound` and `Result.Outbound`, site and llms builders, API handlers for registry, pages, site and llms.
|
||||
- Menu item type: `seo-page` (Kind page).
|
||||
- Tables/columns: `golem15_seo_pages`; settings `routes_reported_at`, `links_new_tab`, `nofollow_domains`, `sponsored_domains`; robots `llms_enabled`, `llms_intro`; migrations 202610280002, 202610280003.
|
||||
- Routes: `POST /_seo/api/v1/registry/routes`, `GET /_seo/api/v1/site`, `GET /llms.txt`, `GET/POST /_seo/api/v1/pages`, `PUT/DELETE /_seo/api/v1/pages/{id}`; resolve answer key `outbound`.
|
||||
- Admin: controller `golem15.seo.pages` (Pages), partial `page_status` (P9), Links tab, llms fields on the robots screen; issue kind `sitemap_blocked`.
|
||||
- MCP tools: `seo_list_pages`, `seo_create_page`, `seo_update_page`, `seo_delete_page`.
|
||||
- Config key: `golem15.seo.route_report_token`.
|
||||
- Tests: `TestSitemapFilterTracer`, `TestRegistry`, `TestRouteReport`, `TestSiteEndpoint`, `TestLLMSTxt`, `TestNoindexPublishedConflict`, `TestSitemapBlockedIssue`, `TestPurgeAfterWrite`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
338
.planning/phases/33-seo-output-audit-and-content/33-06-PLAN.md
Normal file
338
.planning/phases/33-seo-output-audit-and-content/33-06-PLAN.md
Normal file
@@ -0,0 +1,338 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["33-05"]
|
||||
files_modified:
|
||||
- ../sm-seo-plugin/classes/schema/graph.go
|
||||
- ../sm-seo-plugin/classes/schema/nodes.go
|
||||
- ../sm-seo-plugin/classes/schema/validate.go
|
||||
- ../sm-seo-plugin/classes/schema/custom.go
|
||||
- ../sm-seo-plugin/classes/breadcrumbs.go
|
||||
- ../sm-seo-plugin/classes/alternates.go
|
||||
- ../sm-seo-plugin/classes/pagination.go
|
||||
- ../sm-seo-plugin/classes/resolver.go
|
||||
- ../sm-seo-plugin/classes/normalize.go
|
||||
- ../sm-seo-plugin/classes/site.go
|
||||
- ../sm-seo-plugin/classes/settings_write.go
|
||||
- ../sm-seo-plugin/classes/entries.go
|
||||
- ../sm-seo-plugin/classes/field_types.go
|
||||
- ../sm-seo-plugin/classes/partials.go
|
||||
- ../sm-seo-plugin/classes/manage.go
|
||||
- ../sm-seo-plugin/models/settings.go
|
||||
- ../sm-seo-plugin/models/settings/fields.yaml
|
||||
- ../sm-seo-plugin/updates/202610280004_add_golem15_seo_publisher_pagination.go
|
||||
- ../sm-seo-plugin/updates/registry.go
|
||||
- ../sm-seo-plugin/extensions.go
|
||||
- ../sm-seo-plugin/extension/_seo_status.htm
|
||||
- ../sm-seo-plugin/extension/_seo_effective.htm
|
||||
- ../sm-seo-plugin/controllers/settings/_publisher_preview.htm
|
||||
- ../sm-seo-plugin/controllers/settings/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/api/graph.go
|
||||
- ../sm-seo-plugin/controllers/api/seo.go
|
||||
- ../sm-seo-plugin/field_types.go
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/mcp.go
|
||||
- ../sm-seo-plugin/operations.go
|
||||
- ../sm-seo-plugin/config/config.yaml
|
||||
- ../sm-seo-plugin/assets/src/elements/schema.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/graph.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/parent.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/index.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/labels.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/styles.css
|
||||
- ../sm-seo-plugin/assets/dist/seo.js
|
||||
- ../sm-seo-plugin/assets/dist/seo.css
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/structured_data_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, D-43, D-44, D-45, D-46, D-03, D-86, D-55, D-56, D-57, D-40, D-41, D-42, D-21, D-94, D-10, D-70, D-85]
|
||||
|
||||
estimate:
|
||||
tokens: 172000
|
||||
raw_tokens: 172000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The resolve answer of every ok page carries jsonld: one {\"@context\": \"https://schema.org\", \"@graph\": [...]} whose nodes link by @id: WebPage (or its subtype) + BreadcrumbList + references to WebSite and the publisher on every page; WebSite (+ SearchAction when golem15.seo.search_url is set) and the publisher on the home page; BlogPosting/Article/NewsArticle with author Person, publisher, image, datePublished, dateModified, wordCount and articleSection for article content; CollectionPage with mainEntity pointing to #itemlist for collections (the layer adds the ItemList node); ProfilePage + Person for profiles; Service with provider, areaServed and an optional Offer for registry offers and services; ContactPage + the publisher with address and opening hours; a VideoObject per YouTube video (D-43, D-94, D-96)"
|
||||
- "The publisher is set in the global settings (Publisher tab: Organisation or Person, name, logo through seoimage, url, email, phone, sameAs; Local business tab behind a switch with type, address, geo, opening hours and price range); a person cannot be a local business (settings.local_person) and every C20 refusal answers 422 (D-45, D-03)"
|
||||
- "The SEO tab gains Structured data (E8 seoschema: type select per kind, Service fields, custom JSON-LD with live parse feedback), Structured data preview (E9: the effective @graph of the saved page plus unsaved schema, required and recommended findings, the Rich Results Test link) and Primary category for content whose descriptor supplies Parents (D-44, D-46, D-86)"
|
||||
- "Saving custom JSON-LD refuses invalid JSON, non-objects, more than 16 KB, nesting deeper than 8, a literal <script and a type outside the kind's list with the schema.* copy; a custom node whose @id matches a generated node merges into it with custom values first and the save answers the schema.merged notice (D-44, UI-SPEC C17)"
|
||||
- "No e-commerce type (Product, Offer as a product) is offered or generated (out of scope)"
|
||||
- "Breadcrumbs come from one source: the resolve answer's breadcrumbs [{name, url, path}] equals the BreadcrumbList items; the first item is the home crumb (settings breadcrumb_home per locale), a post's trail goes through its primary parent (seo_primary_parent, else the first Parent, recursively, at most 6 levels) and the current page is last (D-86)"
|
||||
- "hreflang comes from one source: the resolve answer's alternates list each translated locale with a URL including the page itself plus x-default for the default locale; a locale without a translation is absent and a page with one locale has no alternates; html_lang, seo.og.locale and seo.og.locale_alternates come from the same data (D-55, D-56, D-57)"
|
||||
- "Pagination follows the per-site form (Pagination tab: query ?page=N default, or path /<segment>/N with a segment per locale): ?page=1 and /<segment>/1 answer 301 to the unnumbered URL, the other form answers 301 to the configured one, a garbage page value answers not_found with pagination.invalid true; page 2 and later answer ok with index,follow, a self-referencing canonical with the page, and the title and description suffixes with %page% filled and %pages% left for the layer (D-40, D-41, D-42, D-21)"
|
||||
- "GET /_seo/api/v1/site additionally answers pagination {form, param, segments} and home_crumb per locale, so the layer builds numbered links and checks drift (D-40, D-20c)"
|
||||
- "Validation runs in three places: E9 in the admin against Google's required and recommended properties per type (FAQPage, CollectionPage, WebPage and Service get the noRules line), the crawler in 33-10 on the rendered page, and the Rich Results Test link per URL (D-46)"
|
||||
- "MCP: seo_get_graph (GET /_seo/api/v1/graph?url=&schema=) returns the graph and findings; seo_set_url_seo and apply_batch accept primary_parent and schema; seo_update_settings writes every new settings key; TestEveryAdminOperationHasTool maps the seo_graph widget action (D-10)"
|
||||
- "E9 with no findings shows the single allGood line; E8 with no value shows the kind's default type and note with the custom JSON-LD collapsed (UI-SPEC empty: E8, E9)"
|
||||
- "E9 shows the checking line with aria-busy while building and queues at most one request (UI-SPEC loading: E9)"
|
||||
- "An E9 action failure shows the 13px danger line with circle-alert and keeps the last graph; an element script failure shows the existing ExtensionFailure box (UI-SPEC error)"
|
||||
- "A 422 on the settings form or on E8 shows field lines, the count banner and the tab error badge (UI-SPEC error: save refused)"
|
||||
- "E8 and E9 follow the ML locale switcher; P2 rows are per locale (UI-SPEC partial: SEO in one locale only)"
|
||||
- "P1 shows status.primary_gone when the stored primary category is no longer assigned, and the trail falls back to the first category (UI-SPEC partial: primary category gone)"
|
||||
- "E9's JSON pre has max-height 360px and scrolls in both axes (UI-SPEC overflow: E9)"
|
||||
- {statement: "E8 value round-trip: Service fields come back as the same JSON string; an invalid price shows priceInvalid; a JSON parse error names line and column; more than 16 KB shows jsonTooLarge (UI-SPEC E8 backstop)", verification: backstop}
|
||||
- "Before github.com/chromedp/chromedp (33-10, the rendered comparison) or github.com/gen2brain/webp (33-11, WebP variants) enters any go.mod, Jakub verifies both modules at one blocking checkpoint (Task 4), and the approved version of each, or its rejection, is recorded at the top of 33-06-SUMMARY.md (RESEARCH Package Legitimacy Audit: both [ASSUMED]; CONTEXT 2026-10-28 rendered comparison and image formats; P33-56)"
|
||||
prohibitions:
|
||||
- requirement_id: "D-44"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "Custom JSON-LD can never close the script element or inject markup: a literal <script is refused on save and the JSON is re-serialised by the server, never echoed as typed"
|
||||
artifacts:
|
||||
- path: "../sm-seo-plugin/classes/schema/graph.go"
|
||||
provides: "@graph builder per kind"
|
||||
contains: "@graph"
|
||||
- path: "../sm-seo-plugin/classes/schema/validate.go"
|
||||
provides: "required/recommended rules per type"
|
||||
contains: "func "
|
||||
- path: "../sm-seo-plugin/classes/pagination.go"
|
||||
provides: "page form normalisation and suffixes"
|
||||
contains: "%pages%"
|
||||
- path: "../sm-seo-plugin/structured_data_test.go"
|
||||
provides: "TestGraphTracer, TestGraphPerKind, TestCustomJSONLD, TestAlternates, TestBreadcrumbs, TestPagination, TestPublisherSettings, TestGraphTool"
|
||||
contains: "func TestGraphTracer("
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/classes/resolver.go"
|
||||
to: "../sm-seo-plugin/classes/schema/graph.go"
|
||||
via: "resolveAt builds jsonld, breadcrumbs and alternates from the described record and settings"
|
||||
pattern: "schema\\.Build"
|
||||
- from: "../sm-seo-plugin/assets/src/elements/graph.ts"
|
||||
to: "../sm-seo-plugin/controllers/api/graph.go"
|
||||
via: "the seo_graph widget action (op graph) and the MCP tool share classes/schema Build + Validate"
|
||||
pattern: "seo_graph"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan puts structured data, breadcrumbs, hreflang and pagination into the one resolve answer per URL, and gives editors the publisher settings and the schema fields in the SEO tab.
|
||||
|
||||
<objective>
|
||||
Extend ../sm-seo-plugin (main, not pushed):
|
||||
- the JSON-LD @graph builder per content kind with validators (D-43, D-46) and the publisher and local-business settings (D-45, D-03);
|
||||
- breadcrumbs with the primary category (D-86), alternates/x-default/og:locale (D-55..57), pagination forms and suffixes (D-40..42) in resolve and /site;
|
||||
- SEO tab additions E8 (schema), E9 (graph preview), primary category, P1/P2 rows (D-44, D-46);
|
||||
- MCP `seo_get_graph` and the new keys on existing tools (D-10);
|
||||
- the blocking legitimacy checkpoint for the phase's two new Go modules, chromedp and gen2brain/webp, before 33-10 and 33-11 add them (P33-56).
|
||||
|
||||
Purpose: SC-2 (one JSON-LD @graph, hreflang and breadcrumbs from the CMS) on the backend side; the layer renders it (33-07 to 33-09).
|
||||
|
||||
Output: commits in ../sm-seo-plugin, with the rebuilt element bundle.
|
||||
|
||||
**Planner decisions recorded here (resolve answer additions are `costly`: every app's layer reads them):**
|
||||
- **P33-21 (resolve answer keys, additive):** top level `html_lang` (locale code, region kept when the locale has one), `alternates: [{hreflang, url}]` (absolute), `jsonld` (object or null), `breadcrumbs: [{name, url, path}]`, `pagination: {form, param, segment, page, invalid}` (present only when the request carries a page); inside `seo.og`: `locale`, `locale_alternates` (from config `golem15.seo.og_locales`, default {pl: pl_PL, en: en_US, de: de_DE}, else `<code>_<CODE>`). No existing key changes.
|
||||
- **P33-22 (@id scheme):** `<site>/#website`, `<site>/#organization` or `<site>/#person` (publisher), `<url>#webpage`, `<url>#breadcrumb`, `<url>#article`, `<url>#itemlist` (layer node), `<url>#profile`, `<url>#service`, `<url>#video-<n>`.
|
||||
- **P33-23 (primary parent field):** extension dropdowns accept only listed options (32-01), so the per-record choice is a plugin field type `seoparent` (element `golem15-seo-parent`, a 44px select styled as the built-in dropdown with emptyOption field.primary_category_default) whose Load answers {selected, options} from the descriptor's Parents; it shows only for types whose records have Parents. The value is stored in the entry's data (key primary_parent) on every locale row, as the social image is (Phase 32 Q-5); seo_schema likewise (key schema).
|
||||
- **P33-24 (%pages%):** the resolver cannot know a listing's page count; it fills %page% and leaves the literal %pages% in seo.title and seo.description on page ≥ 2; the layer's usePagination replaces it (33-08).
|
||||
- **P33-25 (search):** config `golem15.seo.search_url` (for example `/szukaj?q={search_term_string}`), empty means no SearchAction.
|
||||
- **P33-56 (one legitimacy checkpoint for both Go modules, 2026-10-28 revision):** Jakub chose github.com/chromedp/chromedp (CONTEXT, rendered comparison) and github.com/gen2brain/webp (CONTEXT, image formats); RESEARCH's audit marks both [ASSUMED] because its legitimacy seam cannot check Go modules. Both are verified together here, at the end of the plan 33-10 depends on, instead of inside 33-10 and 33-11, so that neither of those plans exceeds its task budget and the two install tasks run without stopping. The checkpoint installs nothing; each consumer task carries a precondition on this answer, and each decision note (written in 33-10 and 33-11 before their go.mod changes) cites it.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-05-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 33-01: pact.ContentDescriptor{Kind, Tools}; ContentRecord{PublishedAt, Parents []ContentRef{Type, ID}, Authors []ContentPerson{Name, Ref}, TopHeading}; ContentLocale{Images []ContentImage{URL, Alt, Decorative, Width, Height}, Videos []ContentVideo{Provider, ID}, Headings}.
|
||||
From 33-05: Result.Outbound; /site answer (P33-17); contract.Listing; registry kinds (page, offer, service, landing) on seo-page records.
|
||||
Phase 32: classes/resolver.go resolveAt (record, rules, templates, canonical keeps page via D-07 list), classes/normalize.go (one-hop normalisation), classes/settings_write.go four-place pattern, classes/field_types.go (seotext, seorobots, seoimage E7 Decode/Load), extensions.go seoTabYAML (anchors seo_robots, seo_og_image), extension/_seo_status.htm (P1), _seo_effective.htm (P2), assets/src/elements/* (E1–E7 framework-free custom elements, labels.ts by ui-locale, styles.css light DOM, summer-action through WidgetField), tests/elements/*.test.ts (vitest + happy-dom), scripts/check-dist.sh.
|
||||
Google (cited in 33-RESEARCH.md): Article has no required properties, recommended author, dateModified, datePublished, headline, image; BreadcrumbList needs at least two ListItem with position, name, item; VideoObject requires name, thumbnailUrl, uploadDate; LocalBusiness requires name and address; FAQ rich results are gone (no rules for FAQPage).
|
||||
Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local business), E8/E9 element copy, server copy for E8, P8.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; element changes rebuild `assets/dist` (`npm --prefix ../sm-seo-plugin run build`) in the same group; lang keys in both files.
|
||||
- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `feat: JSON-LD graph, breadcrumbs and hreflang in the resolve answer (tracer)` | TestGraphTracer passes |
|
||||
| 2 | A `feat: publisher, local business and home crumb settings`; B `feat: pagination forms and suffixes in resolve and site` | settings and pagination tests pass |
|
||||
| 3 | A `feat: graph validation and custom JSON-LD`; B `feat: structured data, graph preview and primary category in the SEO tab`; C `feat: graph MCP tool and new SEO keys`; D `docs: structured data, breadcrumbs, hreflang and pagination in README` | element and Go suites pass |
|
||||
| 4 | none (checkpoint answers recorded at the top of the SUMMARY) | both answers recorded; plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "resolving a post's URL returns one @graph, its breadcrumbs and its hreflang alternates" — one path only</name>
|
||||
<files>../sm-seo-plugin/classes/schema/graph.go, ../sm-seo-plugin/classes/schema/nodes.go, ../sm-seo-plugin/classes/breadcrumbs.go, ../sm-seo-plugin/classes/alternates.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/updates/202610280004_add_golem15_seo_publisher_pagination.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/structured_data_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/classes/resolver.go (Result, SEO, resolveAt, contentLocale) and classes/templates.go
|
||||
- ../sm-seo-plugin/fixture_test.go (fake content plugin; extend its descriptor with the 33-01 fields)
|
||||
- summercms.go/modules/pact/content.go (33-01 fields)
|
||||
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-43 table, D-55..57, D-86)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 4, Pitfall 3, Pitfall 11)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-43, D-55 to D-57, D-86, P33-21 and P33-22: migration 202610280004 adds every settings column of this plan (publisher_*, local_* incl. local_hours jsonb, breadcrumb_home, page_form text default 'query', page_segment, page_title_suffix, page_description_suffix; translatable ones through the translation writer like site_name); Task 1 uses publisher_type and publisher_name only. `classes/schema`: `Build(ctx, in Input) (map[string]any, error)` composing nodes per P33-22 from the described record (Kind), settings and the page URL; nodes.go holds one function per node type. `classes/breadcrumbs.go`: home crumb, primary parent chain (entry data primary_parent, else first Parent; Describe each parent recursively, 6 levels, skip unpublished), current page; both the BreadcrumbList node and the answer list. `classes/alternates.go`: translated locales with a URL (≥2) plus x-default for the default locale, html_lang, og locale values. resolveAt sets the new keys for ok answers only. Config keys `og_locales` and `search_url` commented in config/config.yaml. `TestGraphTracer` (Docker): a fake article record with PublishedAt, two Parents, an author, two locales and a video, publisher Organisation "Acme" → resolve answers jsonld with WebPage, BlogPosting (headline, author name, datePublished, dateModified, image, wordCount, articleSection = first parent's title), BreadcrumbList (home, parent, post), VideoObject, WebSite and Organization nodes linked by the P33-22 @ids; breadcrumbs equals the BreadcrumbList items; alternates lists pl, en and x-default; a single-locale record has none. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestGraphTracer' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestGraphTracer" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func Build(" ../sm-seo-plugin/classes/schema/graph.go` prints one line
|
||||
- `grep -n 'json:"jsonld\|json:"alternates\|json:"breadcrumbs\|json:"html_lang' ../sm-seo-plugin/classes/resolver.go` prints four lines
|
||||
- TestResolverPrecedence and the Phase 32 resolve tests still pass (no existing key changed)
|
||||
</acceptance_criteria>
|
||||
<done>One resolve call returns the page's @graph, breadcrumbs and hreflang data.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Publisher, local business, home crumb and pagination settings; pagination forms, suffixes and normalisation in resolve and /site</name>
|
||||
<files>../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/classes/pagination.go, ../sm-seo-plugin/classes/normalize.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/site.go, ../sm-seo-plugin/classes/schema/nodes.go, ../sm-seo-plugin/controllers/settings/_publisher_preview.htm, ../sm-seo-plugin/controllers/settings/config_form.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/structured_data_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/classes/settings_write.go and models/settings/fields.yaml (tabs and the four-place pattern)
|
||||
- ../sm-seo-plugin/classes/normalize.go (one-hop normalisation) and classes/urls.go (D-07 parameter list keeps page)
|
||||
- ../sm-seo-plugin/controllers/settings/_placeholders.htm (settings partial pattern)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C20 tabs and copy, P8)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestPublisherSettings: Publisher tab saves type, name, logo, url, email, phone, sameAs; sameAs line "x.pl" → 422 settings.same_as_invalid line 1; local_enabled with publisher Person → 422 settings.local_person; local without street → settings.local_address_required; "9:00" → settings.time_invalid; closes before opens → settings.hours_order; country "POL" → settings.country_invalid; latitude 91 → settings.coordinate_invalid; P8 shows the generated publisher node and its findings (missing logo → warning line), or "No problems"
|
||||
- The publisher node becomes the local_type (LocalBusiness subtype) with PostalAddress, GeoCoordinates, openingHoursSpecification and priceRange when local_enabled; ContactPage pages reference it
|
||||
- TestPagination (query form): /blog?page=1 → 301 /blog; /blog?page=3 → ok, canonical /blog?page=3, robots index,follow, title "<title> – strona 3 z %pages% | Site" in pl, description suffix appended; /blog?page=abc, ?page=0, ?page=-1 → not_found with pagination.invalid; /blog/strona/3 → 301 /blog?page=3
|
||||
- TestPagination (path form, segment pl strona): /blog?page=3 → 301 /blog/strona/3; /blog/strona/1 → 301 /blog; /blog/strona/x → not_found invalid; /en/blog/page/2 uses the en segment
|
||||
- Every 301 reaches its final address in one hop together with the Phase 32 normalisation and redirects
|
||||
- /site answers pagination {form, param: page, segments} and home_crumb {pl: Strona główna, en: Home} by default; breadcrumb_home changes them
|
||||
- Unknown placeholders in a suffix refuse with settings.template_unknown; a segment outside [a-z0-9-] refuses with settings.page_segment_invalid
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-03, D-45, D-40 to D-42, D-21 and P33-24: the four-place settings pattern for every new column with C20 validation; fields.yaml tabs General (breadcrumb_home after twitter_site), Pagination, Publisher (publisher_logo uses the E7 seoimage type; P8 partial `_publisher_preview.htm` on update), Local business (local_enabled switch and the trigger-hidden fields, local_hours repeater with titleFrom day), in the C20 tab order. nodes.go builds the publisher node from these settings. `classes/pagination.go`: parse the page from the query or the configured path segment (per locale), the 301 rules (folded into the one-hop decision of normalize.go/resolver so a normalisation and a page redirect are one hop), the invalid answer, and the title/description suffixes (%page% filled, %pages% kept per P33-24); robots for page ≥ 2 stay index,follow with the canonical keeping the page (D-21). site.go adds pagination and home_crumb. Tests first. Commit groups A and B.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestPublisherSettings|TestPagination|TestSiteEndpoint|TestNormalize' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestPublisherSettings or TestPagination missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "page_form\|page_segment\|publisher_type\|local_enabled\|breadcrumb_home" ../sm-seo-plugin/classes/settings_write.go` prints at least five lines
|
||||
- `grep -n "%pages%" ../sm-seo-plugin/classes/pagination.go` prints at least one line
|
||||
- `grep -n '"pagination"\|"home_crumb"' ../sm-seo-plugin/classes/site.go` prints two lines
|
||||
</acceptance_criteria>
|
||||
<done>The publisher and pagination are settings, and every paginated URL resolves in its configured form with the D-41 rules.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Validators and custom JSON-LD, SEO tab E8/E9/primary category, P1/P2 additions, seo_get_graph and the new keys</name>
|
||||
<files>../sm-seo-plugin/classes/schema/validate.go, ../sm-seo-plugin/classes/schema/custom.go, ../sm-seo-plugin/classes/schema/graph.go, ../sm-seo-plugin/classes/field_types.go, ../sm-seo-plugin/classes/entries.go, ../sm-seo-plugin/classes/partials.go, ../sm-seo-plugin/classes/manage.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/extension/_seo_effective.htm, ../sm-seo-plugin/field_types.go, ../sm-seo-plugin/controllers/api/graph.go, ../sm-seo-plugin/controllers/api/seo.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/assets/src/elements/schema.ts, ../sm-seo-plugin/assets/src/elements/graph.ts, ../sm-seo-plugin/assets/src/elements/parent.ts, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/structured_data_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/extensions.go (seoTabYAML, reserved anchors, Save/Load, widget actions such as seo_snippet)
|
||||
- ../sm-seo-plugin/classes/field_types.go and field_types.go (seoimage Decode/Load and registration)
|
||||
- ../sm-seo-plugin/assets/src/elements/{serp,robots,image,actions,labels,index}.ts and styles.css (E1–E7 patterns, summer-action, summer:ml-locale)
|
||||
- ../sm-seo-plugin/tests/elements/host.ts and serp.test.ts (element test harness)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C16, C17, C18, E8/E9 copy, Shared rules, Color, Typography)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestCustomJSONLD: each refusal of C17 answers 422 with its schema.* key on seo_schema; a custom node with @id <url>#article and headline "X" replaces the generated headline and the save answers schema.merged naming the property; a custom node with a new @id is appended to the graph; the stored value is re-serialised JSON
|
||||
- TestGraphPerKind: collection → CollectionPage with mainEntity #itemlist; profile → ProfilePage + Person; registry offer → Service with provider ref and areaServed/price/currency from the schema fields; registry page with schema ContactPage → ContactPage referencing the publisher; home → WebSite with SearchAction when search_url is set
|
||||
- Validate: BlogPosting missing image → recommended finding image; VideoObject missing uploadDate → required finding; LocalBusiness without address → required; FAQPage, CollectionPage, WebPage, Service → rules false
|
||||
- TestGraphTool: GET /_seo/api/v1/graph?url=<post>&schema=<json> answers {locale, url, graph, nodes[{id, type, findings, rules}]}; without golem15.seo.manage 403; the seo_graph widget action answers the same data; MCP lists get_graph; TestEveryAdminOperationHasTool passes
|
||||
- seo_set_url_seo with primary_parent and schema writes the entry data on every locale row; apply_batch accepts both keys with dry_run
|
||||
- P1 shows status.primary_gone for a stored primary parent the record no longer has; P2 shows Schema type and Breadcrumb trail rows
|
||||
- Element smoke (vitest): E8 renders the article type options and the Service grid; E9 renders nodes and findings from a stubbed action answer; the parent select lists the options from its value
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-44, D-46, D-86, UI-SPEC C16–C18 and P33-23: `classes/schema/validate.go` with per-type required/recommended rules; `custom.go` decoding and merging per C17 server rules; field type `seoschema` (element golem15-seo-schema, option kind from the descriptor's Kind; Decode validates and normalises the JSON string) and `seoparent` (element golem15-seo-parent per P33-23); extensions.go inserts seo_primary_parent (only when the type's records have Parents), seo_schema and seo_graph (type widget, action seo_graph, fill [seo_schema], context update) at the C16 positions, writes primary_parent and schema into the entry data, and answers the merge notice; `controllers/api/graph.go` + route `GET /_seo/api/v1/graph` (management group) + MCP tool get_graph + AdminOperations rows (widget_action:seo_graph#graph → get_graph; the extension save keys stay on set_url_seo); controllers/api/seo.go and classes/manage.go accept the new keys. Elements per C17/C18 (light DOM, `--c-*` colours, labels by ui-locale with every E8/E9 key, 300 ms parse debounce, 800 ms graph debounce, one queued request, Rich Results link `https://search.google.com/test/rich-results?url=<encoded>` with rel noopener), parent.ts select; rebuild dist. P1 primary_gone and P2 rows. README sections for structured data, breadcrumbs, hreflang and pagination. Tests first (Go and a vitest smoke per element in tests/elements). Commit groups A to D.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCustomJSONLD|TestGraphPerKind|TestGraphTool|TestGraphTracer|TestEveryAdminOperationHasTool|TestSEOTab' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestCustomJSONLD, TestGraphPerKind or TestGraphTool missing from the "--- PASS" lines</fails_when>
|
||||
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -15 && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
|
||||
<fails_when>non-zero exit, a failed count above 0 in the vitest summary, or the dist check reporting a difference</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"seoschema"\|"seoparent"' ../sm-seo-plugin/classes/field_types.go ../sm-seo-plugin/field_types.go` prints at least two lines
|
||||
- `grep -n "golem15-seo-schema\|golem15-seo-graph\|golem15-seo-parent" ../sm-seo-plugin/assets/src/elements/index.ts` prints three lines
|
||||
- `grep -n '"get_graph"' ../sm-seo-plugin/mcp.go` prints one line
|
||||
- `grep -rn "Product" ../sm-seo-plugin/classes/schema/` prints nothing
|
||||
</acceptance_criteria>
|
||||
<done>Editors choose schema types and custom JSON-LD with live checks, see the effective graph with Google's rules, and MCP can do the same.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 4: Package legitimacy of github.com/chromedp/chromedp and github.com/gen2brain/webp before either enters a go.mod</name>
|
||||
<what-built>Nothing is installed. Per P33-56, this checkpoint verifies the two Go modules Jakub chose on 2026-10-28 (CONTEXT: rendered comparison through optional chromedp, used by 33-10; WebP variants through gen2brain/webp, used by 33-11). 33-RESEARCH.md's Package Legitimacy Audit marks both [ASSUMED]: the npm/pypi/crates seam cannot check Go modules, so only proxy.golang.org existence was confirmed (chromedp v0.21.0 of 2026-10-11, 82 versions since v0.1.0 in 2018-07; gen2brain/webp v0.6.4 of 2026-07-01, 31 versions since v0.1.0 in 2024-03).</what-built>
|
||||
<how-to-verify>
|
||||
1. chromedp: open https://pkg.go.dev/github.com/chromedp/chromedp and https://github.com/chromedp/chromedp: the module path matches, the repository belongs to the chromedp organisation (which also publishes github.com/chromedp/cdproto), the licence is permissive (MIT), the latest tag is v0.21.0 or newer, and its README says it drives a Chrome or Chromium binary through the DevTools protocol without other runtime dependencies.
|
||||
2. gen2brain/webp: open https://pkg.go.dev/github.com/gen2brain/webp and https://github.com/gen2brain/webp: the module path matches, the repository is the author's (gen2brain also maintains gen2brain/avif and go-fitz), the licence is permissive, the latest tag is v0.6.4 or newer, and the README describes libwebp through purego when the system library is present and a WASM build through wazero otherwise, with no cgo.
|
||||
3. Check proxy.golang.org lists the versions to pin: https://proxy.golang.org/github.com/chromedp/chromedp/@v/list and https://proxy.golang.org/github.com/gen2brain/webp/@v/list
|
||||
4. Answer for each module "approved vX.Y.Z" (the version to pin) or "rejected: reason".
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "chromedp: approved vX.Y.Z | rejected: reason; webp: approved vX.Y.Z | rejected: reason". A rejected chromedp means 33-10 Task 4 stops, its crawls keep reporting the comparison as skipped, and the SUMMARY records the decision for revisiting; a rejected webp means 33-11 Tasks 3 and 4 stop and the SUMMARY records WebP as not delivered.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin/MCP → custom JSON-LD and settings | untrusted structured input stored and later embedded in pages |
|
||||
| resolve answer → layer | JSON-LD rendered into a script element |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-24 | Tampering (XSS) | custom JSON-LD | high | mitigate | parse, objects only, 16 KB and depth 8 caps, refuse a literal <script, re-serialise with encoding/json (which escapes <, > and &); the layer additionally relies on unhead's JSON escaping (33-07); TestCustomJSONLD |
|
||||
| T-33-25 | Tampering | publisher sameAs, URLs | medium | mitigate | https-only absolute URLs validated on save; emitted only inside JSON |
|
||||
| T-33-26 | Spoofing | breadcrumbs and alternates | low | mitigate | built from signpost descriptors and settings only, never from the request |
|
||||
| T-33-27 | Elevation of privilege | graph tool, settings, SEO tab keys | high | mitigate | golem15.seo.manage on every route and widget action (existing manage wrapper), 403 in TestGraphTool |
|
||||
| T-33-28 | Denial of service | graph building per resolve | low | mitigate | descriptor reads per resolve are already one call; parent chain capped at 6 |
|
||||
| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | this plan installs no package (the element bundle rebuilds from the committed lockfile); Task 4 is the blocking human legitimacy checkpoint for the phase's two [ASSUMED] Go modules, github.com/chromedp/chromedp and github.com/gen2brain/webp, whose approved versions 33-10 and 33-11 pin exactly, each after its decision note |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit.
|
||||
- Docker suite: structured_data_test.go tests and the Phase 32/33-05 suites.
|
||||
- `npm --prefix ../sm-seo-plugin run test` and `bash ../sm-seo-plugin/scripts/check-dist.sh` pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The resolve answer carries one @graph, breadcrumbs, alternates and pagination data.
|
||||
- Publisher, local business, pagination and home crumb are settings; the SEO tab has E8, E9 and the primary category.
|
||||
- seo_get_graph and the new keys work through MCP.
|
||||
- Both new Go modules are verified (or rejected) before any go.mod changes, and the answers are on record.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Package `classes/schema`: `Build`, `Input`, node builders, `Validate`, custom JSON-LD decode/merge.
|
||||
- Go symbols: breadcrumbs, alternates and pagination builders; field types `seoschema`, `seoparent`; API handler `Graph`.
|
||||
- Resolve keys: `html_lang`, `alternates`, `jsonld`, `breadcrumbs`, `pagination`, `seo.og.locale`, `seo.og.locale_alternates`; /site keys `pagination`, `home_crumb`.
|
||||
- Settings columns: `breadcrumb_home`, `page_form`, `page_segment`, `page_title_suffix`, `page_description_suffix`, `publisher_type`, `publisher_name`, `publisher_logo`, `publisher_url`, `publisher_email`, `publisher_phone`, `publisher_same_as`, `local_enabled`, `local_type`, `local_price_range`, `local_street`, `local_postal_code`, `local_city`, `local_region`, `local_country`, `local_latitude`, `local_longitude`, `local_hours`; migration 202610280004.
|
||||
- Entry data keys: `primary_parent`, `schema`.
|
||||
- Elements: `golem15-seo-schema` (E8), `golem15-seo-graph` (E9), `golem15-seo-parent`; partial `publisher_preview` (P8).
|
||||
- Route: `GET /_seo/api/v1/graph`; MCP tool `seo_get_graph`.
|
||||
- Config keys: `golem15.seo.og_locales`, `golem15.seo.search_url`.
|
||||
- Tests: `TestGraphTracer`, `TestGraphPerKind`, `TestCustomJSONLD`, `TestPublisherSettings`, `TestPagination`, `TestGraphTool` (plus `TestAlternates` and `TestBreadcrumbs` subtests in the tracer file).
|
||||
- Checkpoint record: approved versions (or rejections) of `github.com/chromedp/chromedp` and `github.com/gen2brain/webp` at the top of 33-06-SUMMARY.md.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md` when done; put the Task 4 answers (the approved version of chromedp and of gen2brain/webp, or the rejection) at its top for 33-10 and 33-11, and copy the final resolve-answer JSON of TestGraphTracer into it so the layer fixtures (33-07 to 33-09) can be compared.
|
||||
</output>
|
||||
332
.planning/phases/33-seo-output-audit-and-content/33-07-PLAN.md
Normal file
332
.planning/phases/33-seo-output-audit-and-content/33-07-PLAN.md
Normal file
@@ -0,0 +1,332 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../vue-seo-layer/.gitignore
|
||||
- ../vue-seo-layer/.nvmrc
|
||||
- ../vue-seo-layer/package.json
|
||||
- ../vue-seo-layer/pnpm-lock.yaml
|
||||
- ../vue-seo-layer/pnpm-workspace.yaml
|
||||
- ../vue-seo-layer/tsconfig.json
|
||||
- ../vue-seo-layer/README.md
|
||||
- ../vue-seo-layer/nuxt.config.ts
|
||||
- ../vue-seo-layer/lib/seo-module.ts
|
||||
- ../vue-seo-layer/app/app.config.ts
|
||||
- ../vue-seo-layer/app/plugins/seo-head.ts
|
||||
- ../vue-seo-layer/app/composables/useCmsSeo.ts
|
||||
- ../vue-seo-layer/app/composables/useSchemaNode.ts
|
||||
- ../vue-seo-layer/app/composables/useSeoLabels.ts
|
||||
- ../vue-seo-layer/app/utils/graph.ts
|
||||
- ../vue-seo-layer/app/utils/pagePath.ts
|
||||
- ../vue-seo-layer/app/utils/labels.ts
|
||||
- ../vue-seo-layer/app/utils/types.ts
|
||||
- ../vue-seo-layer/app/assets/css/seo-tokens.css
|
||||
- ../vue-seo-layer/app/assets/css/seo-base.css
|
||||
- ../vue-seo-layer/server/middleware/seo.ts
|
||||
- ../vue-seo-layer/server/middleware/seo-root.ts
|
||||
- ../vue-seo-layer/server/lib/resolve.ts
|
||||
- ../vue-seo-layer/server/lib/lru.ts
|
||||
- ../vue-seo-layer/server/lib/site.ts
|
||||
- ../vue-seo-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-seo-layer/server/api/__seo__/resolve.get.ts
|
||||
- ../vue-seo-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-seo-layer/server/plugins/site-cache-purge.ts
|
||||
- ../vue-seo-layer/server/plugins/seo-notfound.ts
|
||||
- ../vue-seo-layer/.playground/nuxt.config.ts
|
||||
- ../vue-seo-layer/.playground/app/app.vue
|
||||
- ../vue-seo-layer/.playground/app/pages/index.vue
|
||||
- ../vue-seo-layer/.playground/app/pages/[...slug].vue
|
||||
- ../vue-seo-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-seo-layer/testing/fixture-api.mjs
|
||||
- ../vue-seo-layer/testing/nuxt-server.mjs
|
||||
- ../vue-seo-layer/testing/purge-check.mjs
|
||||
- ../vue-seo-layer/testing/purge-token-check.mjs
|
||||
- ../vue-seo-layer/testing/expect-seo.txt
|
||||
- ../vue-seo-layer/testing/fixtures/seo/resolve.json
|
||||
- ../vue-seo-layer/testing/fixtures/seo/site.json
|
||||
- ../vue-seo-layer/tests/graph.test.ts
|
||||
- ../vue-seo-layer/tests/pagePath.test.ts
|
||||
- ../vue-seo-layer/tests/lru.test.ts
|
||||
- ../vue-seo-layer/tests/labels.test.ts
|
||||
- ../vue-seo-layer/tests/sitePurge.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-2, D-97, D-20, D-20a, D-20b, D-20c, D-08, D-05, D-103, D-43, D-55, D-71, D-22, D-25]
|
||||
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A new Nuxt layer repository (name confirmed at Task 1, proposed vue-seo-layer) exists locally with nuxt 4.4.8 and no runtime package beyond Nuxt; it does not use @nuxtjs/seo, nuxt-schema-org, @nuxtjs/robots or @nuxtjs/sitemap (D-97, RESEARCH Standard Stack)"
|
||||
- "A Nitro middleware resolves every page request (skipping /_nuxt, /api, /__nuxt_error and asset files) through GET /_seo/api/v1/resolve with the visitor's X-Forwarded-For, enforces action redirect with the answer's status and Location and action gone with 410, and sets X-Robots-Tag from seo.x_robots_tag; not_found is left to Nuxt's routing except a pagination.invalid answer, which is a 404 (D-20a, D-103, D-08, D-71, D-42)"
|
||||
- "Normalisation and strict-URL answers (Phase 32) arrive as redirect decisions, so the layer enforces D-20b and D-20c on frontend routes in one hop without re-implementing any rule (D-20b, D-20c)"
|
||||
- "Redirects and 410 apply before Nitro's route cache, so swr-cached pages honour them; the Location always comes from the resolve answer, never from the request (D-20a)"
|
||||
- "Every server-rendered page gets its head from the CMS answer only: title, description, robots, canonical, og:* (including og:locale and og:locale:alternate), twitter:*, verification meta, <html lang>, hreflang links and exactly one <script type=\"application/ld+json\"> holding one @graph (CMS nodes plus nodes the layers add through useSchemaNode, merged by @id); client-side navigation fetches the same answer through /api/__seo__/resolve (D-43, D-55, D-97)"
|
||||
- "Resolve answers are cached in an LRU (path and query as key, TTL seo.resolveCacheTtl default 60 s, 1000 entries); the shared site-cache purge route POST /api/__site__/cache/purge (moved here from the journal, menu and FAQ layers unchanged in contract) also clears the resolve and site caches (RESEARCH Pitfall 6, 33-CONTEXT discretion: purge with D-97)"
|
||||
- "The layer serves the site-root files from the CMS: /robots.txt, /sitemap.xml, /sitemap-<name>.xml, /llms.txt, each verification file and the IndexNow key file listed in the site answer's root_files, proxied to the Go backend with their status, content type and X-Robots-Tag; any other one-segment path passes through (D-05, D-103, D-22, D-25, D-31)"
|
||||
- "A page that renders as 404 reports itself once to POST /_seo/api/v1/notfound with the bearer token runtimeConfig.seo.notfoundToken, its URL and the referer, without delaying the response (D-20)"
|
||||
- "With the CMS unreachable or no SEO plugin mounted, pages render normally without CMS head tags (fail open); seo.cms false turns every CMS call off for sites without the SEO plugin"
|
||||
- "useSeoLabels() returns the L8 dictionary of the page's content locale (resolve locale, then html_lang's primary subtag, then en), overridable in app.config.ts under seo.labels; the layer has no @nuxtjs/i18n dependency (UI-SPEC L8)"
|
||||
- "seo-tokens.css and seo-base.css load before the app's CSS and style only .seo-* classes (UI-SPEC L8, Spacing, Color)"
|
||||
- "Public layer components render on the server only: the data is fetched before render, so there is no client loading state (UI-SPEC loading: public layer components)"
|
||||
- "The module option seo.links.newTabHint defaults to true and reaches runtimeConfig.public.seo.links.newTabHint, so the visually hidden new-tab hint is on by default while the CMS's links_new_tab is on, and an app can switch it off (CONTEXT 2026-10-28 accepted defaults, UI-SPEC Q-8; applied to body links in 33-09)"
|
||||
- {statement: "Two concurrent requests for the same uncached path make one resolve call (in-flight de-duplication), and a purge during a request never serves a stale answer afterwards", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-20a"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The middleware never builds a redirect Location from request input (Host, path or query); it only forwards the resolver's absolute location"
|
||||
- requirement_id: "D-97"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The layer never emits a second JSON-LD script or a second canonical link"
|
||||
artifacts:
|
||||
- path: "../vue-seo-layer/server/middleware/seo.ts"
|
||||
provides: "resolve, redirect/410, X-Robots-Tag, event.context.seo"
|
||||
contains: "sendRedirect"
|
||||
- path: "../vue-seo-layer/app/composables/useCmsSeo.ts"
|
||||
provides: "head rendering from the resolve answer"
|
||||
contains: "application/ld+json"
|
||||
- path: "../vue-seo-layer/server/middleware/seo-root.ts"
|
||||
provides: "root file proxies"
|
||||
contains: "robots.txt"
|
||||
- path: "../vue-seo-layer/testing/expect-seo.txt"
|
||||
provides: "SSR smoke expectations"
|
||||
contains: "count="
|
||||
key_links:
|
||||
- from: "../vue-seo-layer/server/middleware/seo.ts"
|
||||
to: "../vue-seo-layer/server/lib/resolve.ts"
|
||||
via: "resolveCached(event, path) with LRU and X-Forwarded-For"
|
||||
pattern: "resolveCached"
|
||||
- from: "../vue-seo-layer/app/plugins/seo-head.ts"
|
||||
to: "../vue-seo-layer/app/composables/useCmsSeo.ts"
|
||||
via: "the app plugin applies useCmsSeo on every route from event.context.seo (SSR) or /api/__seo__/resolve (client)"
|
||||
pattern: "useCmsSeo"
|
||||
- from: "../vue-seo-layer/server/api/__site__/cache/purge.post.ts"
|
||||
to: "../vue-seo-layer/server/lib/resolve.ts"
|
||||
via: "purge clears nitro:routes and the resolve and site LRUs"
|
||||
pattern: "clear"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: @nuxtjs/seo -->
|
||||
<!-- planner-discipline-allow: nuxt-schema-org -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan creates the shared Nuxt SEO layer (D-97) and its runtime: every page's head comes from the CMS resolve answer, redirects and 410 are enforced before the page cache, root files are served from the CMS, and 404s are reported.
|
||||
|
||||
<objective>
|
||||
Create the layer repository locally (the remote is created with `tea` in 33-14, behind the release checkpoint) and build its runtime against a fixture API:
|
||||
- Nitro middleware for the redirect decision, X-Robots-Tag and the resolve context (D-20a, D-103, D-08);
|
||||
- head rendering with one JSON-LD script and hreflang from the CMS (D-43, D-55);
|
||||
- the resolve LRU and the shared site-cache purge route moved here (Pitfall 6);
|
||||
- root-file proxies (robots.txt, sitemaps, llms.txt, verification files, IndexNow key; D-05, D-103);
|
||||
- the 404 report call (D-20); labels and tokens (UI-SPEC L8).
|
||||
|
||||
Purpose: SC-2 (indexable pages render server-side with head tags and one JSON-LD @graph from the CMS); the build guard and components are 33-08.
|
||||
|
||||
Output: commits in ../vue-seo-layer (a new local repository, branch main, not pushed).
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-26 (contract the fixtures follow):** the resolve answer is Phase 32's (action, status, location, url, locale, staging, seo{title, description, canonical, robots, x_robots_tag, og{title, description, image, url, locale, locale_alternates}, twitter{card, site}, verification[{name, content}]}) plus 33-05's `outbound` and 33-06's `html_lang`, `alternates[{hreflang, url}]`, `jsonld`, `breadcrumbs[{name, url, path}]`, `pagination{form, param, segment, page, invalid}` and 33-11's `autolinks[{phrase, url}]`, `autolink_max`; the site answer is 33-05's P33-17 plus 33-06's `pagination{form, param, segments}` and `home_crumb`. Absent keys are treated as empty. The fixture files hold exactly these shapes; 33-06-SUMMARY.md carries a real answer to compare in 33-12.
|
||||
- **P33-27 (runtime config):** server-only `runtimeConfig.seo.apiBase` (NUXT_SEO_API_BASE, default http://127.0.0.1:8096/_seo/api/v1), `backendBase` (NUXT_SEO_BACKEND_BASE, default http://127.0.0.1:8096), `notfoundToken` (NUXT_SEO_NOTFOUND_TOKEN), `routeReportToken` (NUXT_SEO_ROUTE_REPORT_TOKEN, falls back to notfoundToken), `resolveCacheTtl` (seconds, default 60); `cachePurge.token`, `cachePurge.allowProxied` and the deprecated `siteCache` keys move here from the journal layer's config unchanged; module options (configKey `seo`): `cms` (default true), `breadcrumbs` (true), `links.newTabHint` (true, per CONTEXT 2026-10-28 accepted defaults: the new-tab hint is on by default while new tab is on, UI-SPEC Q-8; copied to `runtimeConfig.public.seo.links.newTabHint` for 33-09's body pipeline), `rootFiles` (true), `resolveFiles` (['pdf']), plus the 33-08 options.
|
||||
- **P33-28 (one head owner):** a single app plugin applies the head on every route; pages and other layers never call useSeoMeta for CMS values. useSchemaNode(node) registers a request-scoped node merged into the one graph by @id.
|
||||
- **P33-29 (smoke harness):** testing/smoke-ssr.mjs is copied from vue-journal-layer and gains `count=TEXT:N` (exactly N occurrences in the body; the count is the number after the last colon) and `header=Name:Value` (response header equals) assertions, and lets `contains=`, `notcontains=`, `count=` and `header=` repeat on one line (every occurrence applies; today a repeated key overwrites the earlier one); the fixture API serves /_seo/api/v1/resolve answers keyed by path from testing/fixtures/seo/resolve.json, /_seo/api/v1/site, records /_seo/api/v1/notfound calls and resolve hit counts, and serves the root files.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
Templates to copy: ../vue-journal-layer/{.gitignore, .nvmrc, package.json (scripts dev, build:playground, test, dev:prepare), pnpm-workspace.yaml, tsconfig.json, nuxt.config.ts (currentDir pattern, runtimeConfig cachePurge/siteCache, routeRules cache false), lib/journal-module.ts (defineNuxtModule, configKey, defaults, runtimeConfig.public copy), server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, server/plugins/journal-client-address.ts (X-Forwarded-For forwarding), testing/{smoke-ssr.mjs, fixture-api.mjs, nuxt-server.mjs, purge-check.mjs, purge-token-check.mjs}, tests/sitePurge.test.ts, .playground/}.
|
||||
Nuxt 4.4.8 / Nitro 2.13.4 / h3 1.15.11 / unhead 2.1.17 (pinned through nuxt): useHead, useSeoMeta, defineEventHandler, sendRedirect, setResponseHeader, createError, useRequestEvent, defineNuxtPlugin, useState, $fetch; unhead escapes "<" in JSON script innerHTML.
|
||||
Seo API (Go): GET /_seo/api/v1/resolve?url= (200 for ok and not_found, X-Robots-Tag noindex), GET /_seo/api/v1/site, POST /_seo/api/v1/notfound {url, referer} with Authorization Bearer; GET /_seo/api/v1/verification/{name}; Go root routes /robots.txt, /llms.txt, /sitemap.xml; /_sitemap/files/{file}; /_sitemap/indexnow/{file}.
|
||||
Copy: UI-SPEC L8 dictionary (en, pl) and the seo-tokens.css token table.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- The repository is new: Task 2 runs `git init -b main ../vue-seo-layer`. Commit groups staged by path with `git -C ../vue-seo-layer add <paths>`. `pnpm install` (no new package beyond the pins the other layers use) creates pnpm-lock.yaml, committed with package.json. Every commit keeps `npm --prefix ../vue-seo-layer run test` green.
|
||||
- Resume check: `git -C ../vue-seo-layer log --format=%s | grep -Fx '<subject>'` (a missing repository means Task 2 has not run).
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | none (checkpoint answer recorded in the SUMMARY) | answer recorded |
|
||||
| 2 | `feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)` | smoke tracer lines pass |
|
||||
| 3 | A `feat: redirect and 410 enforcement, X-Robots-Tag and the resolve cache`; B `feat: shared site cache purge route clears the SEO caches`; C `feat: root files from the CMS and the 404 report` | expect-seo.txt passes |
|
||||
| 4 | A `feat: SEO labels, tokens and base styles`; B `docs: README` | plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Confirm the shared layer's name before it becomes a repository</name>
|
||||
<reversibility rating="costly">Apps and the journal, menu and FAQ layers pin the layer by its git URL; a later rename means re-pinning every consumer.</reversibility>
|
||||
<decision>D-97 leaves the name to confirm; the repository is created locally now and on git.golem15.com with tea in 33-14.</decision>
|
||||
<context>Every other layer is named vue-<area>-layer (vue-journal-layer, vue-menu-layer, vue-faq-layer, vue-paymentgateway-layer). UI-SPEC and research use vue-seo-layer throughout. Its classes are .seo-*, its config key seo, its CSS tokens --seo-*.</context>
|
||||
<options>
|
||||
<option id="vue-seo-layer">
|
||||
<name>vue-seo-layer</name>
|
||||
<pros>Matches the naming of every other layer and of all phase documents</pros>
|
||||
<cons>None known</cons>
|
||||
</option>
|
||||
<option id="other">
|
||||
<name>Another name (stated in the answer)</name>
|
||||
<pros>Jakub's choice</pros>
|
||||
<cons>Plans 33-08 to 33-14 substitute the name; class and config names stay seo</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "vue-seo-layer" or "other: <name>"</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: End-to-end "a page rendered by Nuxt carries the CMS title, canonical, hreflang and one JSON-LD graph from the resolve answer" — one path only</name>
|
||||
<files>../vue-seo-layer/.gitignore, ../vue-seo-layer/.nvmrc, ../vue-seo-layer/package.json, ../vue-seo-layer/pnpm-lock.yaml, ../vue-seo-layer/pnpm-workspace.yaml, ../vue-seo-layer/tsconfig.json, ../vue-seo-layer/nuxt.config.ts, ../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/app/plugins/seo-head.ts, ../vue-seo-layer/app/composables/useCmsSeo.ts, ../vue-seo-layer/app/composables/useSchemaNode.ts, ../vue-seo-layer/app/utils/graph.ts, ../vue-seo-layer/app/utils/pagePath.ts, ../vue-seo-layer/app/utils/types.ts, ../vue-seo-layer/server/middleware/seo.ts, ../vue-seo-layer/server/lib/resolve.ts, ../vue-seo-layer/server/api/__seo__/resolve.get.ts, ../vue-seo-layer/.playground/nuxt.config.ts, ../vue-seo-layer/.playground/app/app.vue, ../vue-seo-layer/.playground/app/pages/index.vue, ../vue-seo-layer/.playground/app/pages/[...slug].vue, ../vue-seo-layer/testing/smoke-ssr.mjs, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/nuxt-server.mjs, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/testing/fixtures/seo/resolve.json, ../vue-seo-layer/testing/fixtures/seo/site.json, ../vue-seo-layer/tests/graph.test.ts, ../vue-seo-layer/tests/pagePath.test.ts</files>
|
||||
<read_first>
|
||||
- ../vue-journal-layer/nuxt.config.ts, package.json, lib/journal-module.ts, testing/smoke-ssr.mjs, testing/fixture-api.mjs, testing/nuxt-server.mjs, server/plugins/journal-client-address.ts
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 5, Code Examples: layer head, Pitfall 2)
|
||||
- .planning/phases/32-seo-foundation/32-03-SUMMARY.md and 32-04-SUMMARY.md (resolve answer and its 200/not_found behaviour)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-97, D-43, D-55 and P33-26 to P33-29: `git init -b main ../vue-seo-layer` with the name from Task 1; package.json (name, private, type module, main ./nuxt.config.ts, scripts dev, build:playground, test `node --test --test-reporter=tap tests/*.test.ts`, dev:prepare, `smoke:ssr` = `node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt`; peer and dev nuxt 4.4.8, dev typescript 5.9.3 and vue 3.5.35 as the journal layer pins; no runtime dependency). nuxt.config.ts registers lib/seo-module.ts and the P33-27 runtime config. seo-module.ts (configKey seo) copies its options into runtimeConfig.public.seo, with the P33-27 defaults (links.newTabHint true per CONTEXT 2026-10-28 accepted defaults). `server/lib/resolve.ts`: `resolveCached(event, path)` fetching `${apiBase}/resolve?url=` with X-Forwarded-For set to the client address (journal-client-address pattern), returns null on any failure or when cms is false. `server/middleware/seo.ts` (Task 2 scope: page-path test with `isPagePath` from app/utils/pagePath.ts, resolve, `event.context.seo = res`). `app/utils/graph.ts`: pure `mergeGraph(cmsJsonld, nodes)` merging by @id. `useCmsSeo(answer)` applies useSeoMeta/useHead per RESEARCH Code Examples with keys canonical, alt-<hreflang> and jsonld, html lang and og:locale/alternate; `app/plugins/seo-head.ts` takes the answer from useRequestEvent().context.seo during SSR (stored in useState for hydration) and from `$fetch('/api/__seo__/resolve', {query: {url}})` on client navigation (`server/api/__seo__/resolve.get.ts` proxies through resolveCached). Playground: pages index and [...slug] rendering an H1; fixture resolve.json with an ok answer for /blog/lwia-grzywa (title, canonical, alternates pl/en/x-default, jsonld with two nodes), and site.json. smoke-ssr.mjs per P33-29; expect-seo.txt tracer lines: `200 /blog/lwia-grzywa contains=<title>Lwia grzywa count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=<html lang="pl"`. Unit tests graph.test.ts (merge by @id, layer node appended, no duplicate @id) and pagePath.test.ts (/_nuxt/x, /api/x, /favicon.ico skipped; /blog/x and / are pages). Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line in the TAP output</fails_when>
|
||||
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -5 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit from the build or the smoke, or a "FAIL" line in the smoke output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-seo-layer log --oneline | wc -l` is at least 1 and `git -C ../vue-seo-layer remote` prints nothing (no remote yet)
|
||||
- `grep -n '"dependencies"' ../vue-seo-layer/package.json` prints nothing (no runtime dependency)
|
||||
- `grep -n "application/ld+json" ../vue-seo-layer/app/composables/useCmsSeo.ts` prints one line
|
||||
- the smoke tracer line asserts exactly one ld+json script and one canonical and passes
|
||||
</acceptance_criteria>
|
||||
<done>A Nuxt page extending the layer renders its head and one JSON-LD graph from the CMS answer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Redirect/410/X-Robots-Tag enforcement, the resolve LRU with the moved purge route, root-file proxies and the 404 report</name>
|
||||
<files>../vue-seo-layer/server/middleware/seo.ts, ../vue-seo-layer/server/middleware/seo-root.ts, ../vue-seo-layer/server/lib/resolve.ts, ../vue-seo-layer/server/lib/lru.ts, ../vue-seo-layer/server/lib/site.ts, ../vue-seo-layer/server/lib/siteCachePurge.ts, ../vue-seo-layer/server/api/__site__/cache/purge.post.ts, ../vue-seo-layer/server/plugins/site-cache-purge.ts, ../vue-seo-layer/server/plugins/seo-notfound.ts, ../vue-seo-layer/nuxt.config.ts, ../vue-seo-layer/.playground/nuxt.config.ts, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/purge-check.mjs, ../vue-seo-layer/testing/purge-token-check.mjs, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/testing/fixtures/seo/resolve.json, ../vue-seo-layer/testing/fixtures/seo/site.json, ../vue-seo-layer/tests/lru.test.ts, ../vue-seo-layer/tests/sitePurge.test.ts</files>
|
||||
<read_first>
|
||||
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, tests/sitePurge.test.ts, testing/purge-check.mjs, testing/purge-token-check.mjs (move unchanged in contract)
|
||||
- ../sm-seo-plugin/controllers/api/notfound.go (report body and token) and classes/verification.go (file name pattern)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 5, Pitfall 6, Security Domain)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- /stary (fixture redirect 301 to https://example.test/nowy) → 301 with exactly that Location; /usuniety (gone) → 410; a page answer with x_robots_tag "noindex, nofollow" → that X-Robots-Tag header; /blog?page=abc (not_found, pagination.invalid) → 404; an unknown path (not_found without invalid) is left to Nuxt
|
||||
- A request with a crafted Host or X-Forwarded-Host never changes the Location
|
||||
- The fixture counts one resolve call for two requests to the same path within the TTL; after POST /api/__site__/cache/purge with the token, the next request resolves again; the purge route keeps its 404/401/proxied rules (purge-check.mjs and purge-token-check.mjs pass unchanged)
|
||||
- Backend down: a page answers 200 without CMS tags (fail open)
|
||||
- /robots.txt, /sitemap.xml, /sitemap-posts-1.xml, /llms.txt, /google1234.html (in root_files) and /<key>.txt (in root_files) are proxied with the backend's status, content type and X-Robots-Tag; /kontakt and /random.txt (not in root_files) pass through to Nuxt; a backend failure on a root file answers 503 with Retry-After 60
|
||||
- A 404 page posts {url, referer} once to the fixture notfound endpoint with the bearer token; an unset token sends nothing
|
||||
- seo.cms false: no resolve, no root proxies, no report
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-20, D-20a, D-103, D-05, D-08, D-22, D-25 and P33-27/P33-29: middleware enforcement (sendRedirect with res.location and res.status; createError 410; setResponseHeader x-robots-tag; 404 only for pagination.invalid), never reading Host for Location. `server/lib/lru.ts`: pure LRU with TTL, max entries and in-flight de-duplication (unit-tested). `server/lib/site.ts`: cached site answer (TTL 300 s). Move the purge route, lib and plugin from vue-journal-layer unchanged in behaviour (keep the header comment's rules) and make the purge also clear the resolve and site LRUs. Copy testing/purge-check.mjs and purge-token-check.mjs and adapt the content change to this layer: instead of a Journal entity (--kind/--slug), purge-check changes the title of the fixture resolve answer for --path in its temporary copy, and the playground's nuxt.config gets `routeRules: {'/**': {swr: 300}}` so pages are route-cached; every other check of both scripts stays. `server/middleware/seo-root.ts`: exact /robots.txt, /sitemap.xml, /llms.txt; regex ^/sitemap-[a-z0-9-]+\.xml$; one-segment names ending .html, .txt or .xml that the site answer lists in root_files; proxied per P33-26 paths with status, content type and X-Robots-Tag copied; off when rootFiles or cms is false. `server/plugins/seo-notfound.ts`: hook after the response is rendered (Nitro `afterResponse` or `render:response`) for status 404 page paths, fire-and-forget POST with the bearer token. Extend the fixture API and expect-seo.txt for each behavior line (header= and count= assertions). Tests first. Commit groups A, B, C.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
|
||||
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -3 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -25 && node ../vue-seo-layer/testing/purge-check.mjs --output ../vue-seo-layer/.playground/.output --fixtures ../vue-seo-layer/testing/fixtures --path /blog/lwia-grzywa 2>&1 | tail -5 && node ../vue-seo-layer/testing/purge-token-check.mjs --output ../vue-seo-layer/.playground/.output --fixtures ../vue-seo-layer/testing/fixtures --path /blog/lwia-grzywa 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit from any command, a "FAIL" line, or no "purge-check: ok" / "purge-token-check: ok" final line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "sendRedirect" ../vue-seo-layer/server/middleware/seo.ts` prints one line and `grep -n "getRequestHost\|x-forwarded-host" ../vue-seo-layer/server/middleware/seo.ts` prints nothing
|
||||
- `grep -n "header=\|count=" ../vue-seo-layer/testing/expect-seo.txt` prints at least five lines
|
||||
- expect-seo.txt has a line for each behavior bullet's status (301, 410, 404, 503, 200)
|
||||
</acceptance_criteria>
|
||||
<done>The layer enforces the CMS's redirect decision before caching, serves the site's root files from the CMS, reports 404s, and its caches purge with the site.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Label dictionary, tokens and base styles; README</name>
|
||||
<files>../vue-seo-layer/app/app.config.ts, ../vue-seo-layer/app/composables/useSeoLabels.ts, ../vue-seo-layer/app/utils/labels.ts, ../vue-seo-layer/app/assets/css/seo-tokens.css, ../vue-seo-layer/app/assets/css/seo-base.css, ../vue-seo-layer/nuxt.config.ts, ../vue-seo-layer/tests/labels.test.ts, ../vue-seo-layer/README.md</files>
|
||||
<read_first>
|
||||
- ../vue-menu-layer/app/assets/css/menu-tokens.css and ../vue-journal-layer/app/assets/css/journal-tokens.css (token conventions, :root:root overrides)
|
||||
- ../vue-journal-layer/README.md (layer README structure)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L8, Public layers Spacing, Typography, Color tables, the layer dictionary)
|
||||
</read_first>
|
||||
<action>
|
||||
Per UI-SPEC L8: `app/utils/labels.ts` holds the en and pl dictionaries (every key of the "Public layer dictionary" table, {name} placeholders) and a pure `pickLabels(locale, htmlLang, overrides)`; `useSeoLabels()` reads the resolve answer's locale and app.config `seo.labels`. seo-tokens.css defines every token of the public Spacing and Color tables (--seo-space-1..5, --seo-target-size, --seo-sitemap-column-width, --seo-color-muted, --seo-color-accent, --seo-color-on-accent, --seo-color-border, --seo-font-size-small, --seo-breadcrumbs-separator); seo-base.css styles only .seo-* classes (including .seo-visually-hidden, the standard clip pattern) and no bare element; nuxt.config css order tokens then base. labels.test.ts covers the locale fallback chain and overrides. README in the layer structure: what it does, install (pnpm git dependency pinned by tag), options (P33-27), runtime config and environment variables, the resolve contract (P33-26), root files, purge, 404 report, labels and tokens, testing. Commit groups A and B.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
|
||||
<automated>grep -c "^\s*\.seo-" ../vue-seo-layer/app/assets/css/seo-base.css</automated>
|
||||
<fails_when>output 0 or a missing file</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\-\-seo-target-size\|\-\-seo-space-5" ../vue-seo-layer/app/assets/css/seo-tokens.css` prints two lines
|
||||
- `grep -En "^\s*(a|h1|h2|body|nav|ol|li)\s*[{,]" ../vue-seo-layer/app/assets/css/seo-base.css` prints nothing (no bare-element rule)
|
||||
- `grep -n "i18n" ../vue-seo-layer/package.json ../vue-seo-layer/nuxt.config.ts` prints nothing
|
||||
</acceptance_criteria>
|
||||
<done>The layer has its dictionary, tokens, base styles and documentation.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| visitor → Nitro middleware | untrusted path, query and headers |
|
||||
| Nitro → Go SEO API | internal HTTP with forwarded client address |
|
||||
| Go backend → purge route | bearer-token POST |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-29 | Spoofing (open redirect) | redirect enforcement | high | mitigate | Location only from the resolver's absolute answer (Phase 32 guarantees site-host targets unless an editor set an external target); no request header used; smoke line with a crafted Host |
|
||||
| T-33-30 | Tampering (XSS) | JSON-LD script | high | mitigate | innerHTML through unhead, which escapes < in JSON scripts; the graph is JSON from the server, never concatenated markup |
|
||||
| T-33-31 | Denial of service | resolve per request | medium | mitigate | LRU with TTL and in-flight de-duplication; X-Forwarded-For forwarded so the Go per-IP bucket applies per visitor |
|
||||
| T-33-32 | Elevation of privilege (SSRF) | root-file proxy | high | mitigate | proxy targets are fixed backendBase/apiBase paths; only allow-listed names (regex and the site answer's root_files) are forwarded, never a user-supplied URL |
|
||||
| T-33-33 | Spoofing | purge route | medium | mitigate | moved unchanged: 32+ character bearer token, proxied requests refused unless allowProxied; purge-check.mjs and purge-token-check.mjs |
|
||||
| T-33-34 | Information disclosure | runtime config | medium | mitigate | tokens and apiBase live in server-only runtimeConfig, never under public (the payload test of purge-token-check.mjs) |
|
||||
| T-33-SC | Tampering | npm installs | high | mitigate | no new package: package.json pins only nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3, the versions the existing layers already use; pnpm-lock.yaml committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm --prefix ../vue-seo-layer run test` green after every commit.
|
||||
- `npm --prefix ../vue-seo-layer run build:playground` and `npm --prefix ../vue-seo-layer run smoke:ssr` pass; purge-check.mjs passes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The layer repository exists locally with the runtime: head from the CMS, one JSON-LD graph, redirect/410/X-Robots-Tag enforcement, root files, 404 reports, purge, labels and tokens.
|
||||
- No push; the remote is created in 33-14.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Repository `../vue-seo-layer` (local, branch main).
|
||||
- Module: `lib/seo-module.ts` (configKey `seo`; options `cms`, `breadcrumbs`, `links.newTabHint` (default true, CONTEXT 2026-10-28 accepted defaults), `rootFiles`, `resolveFiles`).
|
||||
- Runtime config: `seo.apiBase`, `seo.backendBase`, `seo.notfoundToken`, `seo.routeReportToken`, `seo.resolveCacheTtl`, `cachePurge.token`, `cachePurge.allowProxied`, `siteCache.purgeToken`, `siteCache.allowProxied`; env `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`, `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN`.
|
||||
- Composables and utils: `useCmsSeo`, `useSchemaNode`, `useSeoLabels`, `mergeGraph`, `isPagePath`, `pickLabels`.
|
||||
- Server: middleware `seo.ts`, `seo-root.ts`; libs `resolve.ts` (`resolveCached`), `lru.ts`, `site.ts`, `siteCachePurge.ts`; routes `GET /api/__seo__/resolve`, `POST /api/__site__/cache/purge`; plugins `site-cache-purge.ts`, `seo-notfound.ts`.
|
||||
- CSS: `seo-tokens.css`, `seo-base.css` (`.seo-visually-hidden`).
|
||||
- Testing: `testing/smoke-ssr.mjs` with `count=` and `header=`, fixture API, `testing/expect-seo.txt`, unit tests `graph`, `pagePath`, `lru`, `labels`, `sitePurge`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-07-SUMMARY.md` when done; record the confirmed layer name at the top.
|
||||
</output>
|
||||
307
.planning/phases/33-seo-output-audit-and-content/33-08-PLAN.md
Normal file
307
.planning/phases/33-seo-output-audit-and-content/33-08-PLAN.md
Normal file
@@ -0,0 +1,307 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["33-07"]
|
||||
files_modified:
|
||||
- ../vue-seo-layer/lib/seo-module.ts
|
||||
- ../vue-seo-layer/lib/guard.ts
|
||||
- ../vue-seo-layer/lib/routes.ts
|
||||
- ../vue-seo-layer/nuxt.config.ts
|
||||
- ../vue-seo-layer/app/plugins/seo-route-robots.ts
|
||||
- ../vue-seo-layer/app/components/Seo/Breadcrumbs.vue
|
||||
- ../vue-seo-layer/app/components/Seo/Pagination.vue
|
||||
- ../vue-seo-layer/app/components/Seo/SitemapLink.vue
|
||||
- ../vue-seo-layer/app/components/global/SeoHtmlSitemapPage.vue
|
||||
- ../vue-seo-layer/app/seo-pages/SitemapPage.vue
|
||||
- ../vue-seo-layer/app/seo-pages/SitemapGroupPage.vue
|
||||
- ../vue-seo-layer/app/composables/usePagination.ts
|
||||
- ../vue-seo-layer/app/composables/useSeoSite.ts
|
||||
- ../vue-seo-layer/app/utils/paginationWindow.ts
|
||||
- ../vue-seo-layer/app/utils/pageHref.ts
|
||||
- ../vue-seo-layer/app/utils/breadcrumbClaim.ts
|
||||
- ../vue-seo-layer/app/assets/css/seo-base.css
|
||||
- ../vue-seo-layer/server/api/__seo__/sitemap.get.ts
|
||||
- ../vue-seo-layer/server/api/__seo__/site.get.ts
|
||||
- ../vue-seo-layer/server/plugins/seo-route-report.ts
|
||||
- ../vue-seo-layer/server/plugins/seo-drift.ts
|
||||
- ../vue-seo-layer/.playground/nuxt.config.ts
|
||||
- ../vue-seo-layer/.playground/app/pages/blog/index.vue
|
||||
- ../vue-seo-layer/.playground/app/pages/konto.vue
|
||||
- ../vue-seo-layer/testing/fixture-api.mjs
|
||||
- ../vue-seo-layer/testing/expect-seo.txt
|
||||
- ../vue-seo-layer/testing/fixtures/seo/resolve.json
|
||||
- ../vue-seo-layer/testing/fixtures/seo/site.json
|
||||
- ../vue-seo-layer/testing/fixtures/seo/html.json
|
||||
- ../vue-seo-layer/testing/guard-check.mjs
|
||||
- ../vue-seo-layer/testing/guard/indexable-ssr-false/nuxt.config.ts
|
||||
- ../vue-seo-layer/testing/guard/noindex-ok/nuxt.config.ts
|
||||
- ../vue-seo-layer/testing/guard/client-page/nuxt.config.ts
|
||||
- ../vue-seo-layer/testing/guard/client-page/app/pages/panel.client.vue
|
||||
- ../vue-seo-layer/testing/guard/global-ssr-false/nuxt.config.ts
|
||||
- ../vue-seo-layer/package.json
|
||||
- ../vue-seo-layer/tests/guard.test.ts
|
||||
- ../vue-seo-layer/tests/paginationWindow.test.ts
|
||||
- ../vue-seo-layer/tests/pageHref.test.ts
|
||||
- ../vue-seo-layer/tests/breadcrumbClaim.test.ts
|
||||
- ../vue-seo-layer/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, D-70, D-23, D-21, D-33, D-20c, D-86, D-40, D-41, D-42, D-71, D-34, D-97]
|
||||
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "nuxt build fails with \"[vue-seo-layer] indexable routes must render on the server (D-70): <routes>\" when a route rule sets ssr: false, a page is a .client.vue page, or the app sets ssr: false, unless the route is declared in seo.noindexRoutes (or the app sets seo.indexable: false); rules from nuxt.options.routeRules, nitro routeRules and every layer are checked after the layers merge, and experimental.inlineRouteRules is refused (D-70, RESEARCH Pattern 6, Pitfall 10)"
|
||||
- "A route in seo.noindexRoutes gets X-Robots-Tag: noindex, nofollow through its route rule and a robots noindex,nofollow meta; a page whose meta sets requiresAuth: true or seo.robots gets the same automatically without page code, which is how apps mark signed-in and auth pages (CONTEXT 2026-10-28 accepted defaults: auth pages are marked through route meta; no user layer) (D-23, D-21, D-08)"
|
||||
- "At server start the layer reports its static routes (paths without parameters, outside noindexRoutes) to POST /_seo/api/v1/registry/routes with the route-report token, once per process with one retry after 60 s, so no frontend page is missing from the registry (D-33)"
|
||||
- "At server start the layer compares the CMS's content-type URL patterns, pagination form and segments and HTML-sitemap paths (from /_seo/api/v1/site) with its build-time routes and options, and logs one warning per mismatch (D-20c drift check; RESEARCH Open Question 9, resolved by UI-SPEC L3: build-time options plus a startup drift check)"
|
||||
- "<SeoBreadcrumbs> renders the resolve answer's breadcrumbs as nav > ol with server-rendered <a href> links and the last item as aria-current=\"page\" text, separators CSS-generated; it renders nothing with fewer than two items, when page meta sets seo.breadcrumbs false or when the option seo.breadcrumbs is false; only the first instance per request renders (D-86, D-71, UI-SPEC L1)"
|
||||
- "<SeoPagination> with usePagination() renders numbered server-rendered links in the CMS-chosen form (query or path, per-locale segment), keeps other query parameters, maps page 1 to the unnumbered URL, shows five pages around the current one plus page 1 and the last page with gap items, omits previous/next at the ends, adds <link rel=prev/next> to the head and replaces %pages% in the CMS title and description; it renders nothing when last ≤ 1 (D-40, D-41, D-42, D-71, UI-SPEC L2)"
|
||||
- "The HTML sitemap is served at each locale's html_path (defaults /mapa-strony pl, /sitemap en) with group pages at <html_path>/<group>; it lists the sitemap plugin's HTML groups (an H1 page title, an H2 per group, categories nested, large groups linking to their paginated group page), answers 404 for an unknown group or a page past the last, and a failed API call answers 503 so no empty page is cached (D-34, UI-SPEC L3)"
|
||||
- "<SeoSitemapLink> renders the footer link to the locale's HTML sitemap only when the HTML sitemap is enabled (D-34, UI-SPEC L4)"
|
||||
- "The HTML sitemap overview omits empty groups and shows sitemap.empty when every group is empty (UI-SPEC empty: HTML sitemap)"
|
||||
- "Breadcrumbs render nothing on the home page and pagination renders nothing with one page (UI-SPEC empty: breadcrumbs, pagination)"
|
||||
- "An HTML-sitemap API failure answers 503 through createError, never an empty cached page (UI-SPEC error: HTML sitemap API down)"
|
||||
- "Long breadcrumb names and sitemap titles wrap (overflow-wrap anywhere) and are never truncated; the full text stays in the HTML (UI-SPEC long-text: public)"
|
||||
- "A group larger than html_page_size shows its first page plus the sitemap.all link on the overview, and its group page is paginated with <SeoPagination> (UI-SPEC zero-one-many: large HTML sitemap groups)"
|
||||
- {statement: "Pagination window: current=5, last=14 yields 1 … 3 4 5 6 7 … 14; current=1, last=3 yields 1 2 3 with no gap; the page 1 href has no number in both forms (UI-SPEC L2 backstop)", verification: backstop}
|
||||
- {statement: "A layout instance plus a page instance of <SeoBreadcrumbs> render exactly one nav.seo-breadcrumbs; the last item has aria-current=\"page\" and no link (UI-SPEC L1 backstop)", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-70"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The build guard cannot be passed by moving ssr: false into a layer, nitro routeRules or a .client.vue page; only an explicit noindex declaration lets a client-only route build"
|
||||
- requirement_id: "D-23"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "No route is marked noindex unless the app declared it (noindexRoutes) or its page meta asks for it; the layer never noindexes indexable pages by default"
|
||||
artifacts:
|
||||
- path: "../vue-seo-layer/lib/guard.ts"
|
||||
provides: "pure build-guard checks (isNoindex, offenders)"
|
||||
contains: "D-70"
|
||||
- path: "../vue-seo-layer/testing/guard-check.mjs"
|
||||
provides: "fixture builds expecting exit 1/0"
|
||||
contains: "indexable-ssr-false"
|
||||
- path: "../vue-seo-layer/app/components/Seo/Pagination.vue"
|
||||
provides: "L2"
|
||||
contains: "rel"
|
||||
- path: "../vue-seo-layer/app/components/Seo/Breadcrumbs.vue"
|
||||
provides: "L1"
|
||||
contains: "aria-current"
|
||||
key_links:
|
||||
- from: "../vue-seo-layer/lib/seo-module.ts"
|
||||
to: "../vue-seo-layer/lib/guard.ts"
|
||||
via: "nuxt.hook('pages:resolved') collects pages; nuxt.hook('nitro:config') merges route rules and throws on offenders"
|
||||
pattern: "nitro:config"
|
||||
- from: "../vue-seo-layer/server/plugins/seo-route-report.ts"
|
||||
to: "sm-seo-plugin POST /_seo/api/v1/registry/routes"
|
||||
via: "the build-time static route list posted with the route-report token"
|
||||
pattern: "registry/routes"
|
||||
- from: "../vue-seo-layer/app/composables/usePagination.ts"
|
||||
to: "../vue-seo-layer/app/utils/pageHref.ts"
|
||||
via: "hrefs in the site answer's pagination form"
|
||||
pattern: "pageHref"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan gives the shared layer the SSR build guard, automatic noindex for declared and signed-in routes, the route report and drift check, and the public components: breadcrumbs, pagination, the HTML sitemap page and its footer link.
|
||||
|
||||
<objective>
|
||||
Extend ../vue-seo-layer (after 33-07; main, not pushed):
|
||||
- the SSR build guard and its fixture playgrounds (D-70);
|
||||
- noindex for declared routes and pages whose meta requires sign-in (D-23, D-21);
|
||||
- the route report to the registry and the startup drift check (D-33, D-20c);
|
||||
- L1 `<SeoBreadcrumbs>` (D-86), L2 `<SeoPagination>` + `usePagination()` (D-40..42), L3 HTML sitemap pages and L4 `<SeoSitemapLink>` (D-34).
|
||||
|
||||
Purpose: SC-2 ("a build fails when an indexable route has ssr: false"; breadcrumbs and pagination from the CMS).
|
||||
|
||||
Output: commits in ../vue-seo-layer.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-30 (module options added):** `noindexRoutes: string[]` (route-rule patterns such as `/p/**`), `indexable: boolean` (default true), `routeReport: boolean` (default true), `pagination: {form: 'query' | 'path', param: 'page', segments: Record<locale, string>}` (default query, pl strona, en page), `htmlSitemap: {paths: Record<locale, string>} | false` (default pl /mapa-strony, en /sitemap); all copied into runtimeConfig.public.seo; the drift check compares them with the CMS.
|
||||
- **P33-31 (route artefact):** the module writes the static route list and every page route pattern into a server template (`#seo-routes`, addServerTemplate) read by the report and drift plugins; no network call at build time.
|
||||
- **P33-32 (HTML sitemap pages):** registered with extendPages per locale (names `seo-sitemap-<locale>` and `seo-sitemap-group-<locale>`), each page file rendering the global component `SeoHtmlSitemapPage`, which an app overrides by registering its own component of that name; data through `GET /api/__seo__/sitemap?locale=&group=&page=` proxying `/_sitemap/api/v1/html`; group-page breadcrumbs are built in the layer from home_crumb, sitemap.title and the group title, since the resolver does not know group pages.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-07-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 33-07: lib/seo-module.ts (configKey seo, runtimeConfig.public.seo), server/lib/resolve.ts resolveCached, server/lib/site.ts (cached /site answer), useCmsSeo, useSeoLabels (L8 keys breadcrumbs.label, pagination.label/previous/next/page, sitemap.title/groupTitle/pageSuffix/all/empty, sitemapLink), testing/smoke-ssr.mjs with count= and header=, fixture API, tokens in seo-tokens.css.
|
||||
Contracts (P33-26): resolve breadcrumbs [{name, url, path}], pagination {form, param, segment, page, invalid}; site pagination {form, param, segments}, home_crumb, html_sitemap {enabled, paths, page_size}, routes [{type, pattern}]; sitemap HTML API overview {groups[{key, slug, title, total, entries[{title, url, children?}], more}]} and group page {group, title, page, pages, entries}.
|
||||
Nuxt hooks: pages:resolved (page.mode === 'client' for .client.vue), nitro:config, extendPages, addServerTemplate (nuxt/kit); routeRules ssr false; experimental.inlineRouteRules.
|
||||
Existing journal pagination for reference: ../vue-journal-layer/app/utils/pagination.ts (paginationWindow) and app/components/Journal/Pagination.vue.
|
||||
Demo app real case: ../sm-demo-app/vue-demo-app/nuxt.config.ts routeRules '/p/**' and '/presentations/**' ssr false (33-12 declares them noindex).
|
||||
Copy: UI-SPEC L1–L4 markup, defaults and the L8 dictionary.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups staged by path with `git -C ../vue-seo-layer add <paths>`; `npm --prefix ../vue-seo-layer run test` green at every commit.
|
||||
- Resume check: `git -C ../vue-seo-layer log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `feat: the build fails when an indexable route renders only in the browser (tracer)` | guard-check.mjs passes |
|
||||
| 2 | A `feat: noindex for declared and signed-in routes`; B `feat: route report and drift check at server start` | smoke lines for noindex and report pass |
|
||||
| 3 | A `feat: breadcrumbs component`; B `feat: pagination component and composable`; C `feat: HTML sitemap pages and footer link`; D `docs: build guard, noindex routes and components` | plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an app with an indexable ssr:false route fails its build, and the same route declared noindex builds and is served with X-Robots-Tag" — one path only</name>
|
||||
<files>../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/lib/guard.ts, ../vue-seo-layer/testing/guard-check.mjs, ../vue-seo-layer/testing/guard/indexable-ssr-false/nuxt.config.ts, ../vue-seo-layer/testing/guard/noindex-ok/nuxt.config.ts, ../vue-seo-layer/testing/guard/client-page/nuxt.config.ts, ../vue-seo-layer/testing/guard/client-page/app/pages/panel.client.vue, ../vue-seo-layer/testing/guard/global-ssr-false/nuxt.config.ts, ../vue-seo-layer/package.json, ../vue-seo-layer/tests/guard.test.ts</files>
|
||||
<read_first>
|
||||
- ../vue-seo-layer/lib/seo-module.ts and nuxt.config.ts (33-07)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 6, Pitfall 10)
|
||||
- ../sm-demo-app/vue-demo-app/nuxt.config.ts (routeRules with ssr false, the real case)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-70 and P33-30: `lib/guard.ts` (pure, unit-tested): `isNoindex(path, noindexRoutes)` with Nitro route-rule pattern semantics (`/x/**`, `/x/*`, exact), `findOffenders({ssr, indexable, routeRules, pages, noindexRoutes, inlineRouteRules})` returning the offender list (global ssr false; each rule with ssr false not covered; each client-mode page not covered; inlineRouteRules true is itself an offender with a message to remove it). seo-module.ts collects pages in `pages:resolved`, and in `nitro:config` merges `nuxt.options.routeRules` with `nitro.routeRules`, throws `[vue-seo-layer] indexable routes must render on the server (D-70): <list>` when offenders exist, and adds `headers: {'x-robots-tag': 'noindex, nofollow'}` to each noindexRoutes pattern's rule. Fixture apps under testing/guard extend the layer (`extends: ['../../..']`): indexable-ssr-false (`'/x/**': {ssr: false}`), noindex-ok (the same plus `seo: {noindexRoutes: ['/x/**']}`), client-page (`app/pages/panel.client.vue`), global-ssr-false (`ssr: false`). `testing/guard-check.mjs` runs `nuxt build` for each fixture (the layer's node_modules nuxt binary, cwd the fixture), expects exit ≠ 0 and the D-70 message for the three offenders and exit 0 for noindex-ok, then starts noindex-ok's server and expects `X-Robots-Tag: noindex, nofollow` on /x/a; prints `guard-check: ok (<n> checks)` or `guard-check: FAIL ...` and exits 1. package.json script `guard:check`. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
|
||||
<automated>npm --prefix ../vue-seo-layer run guard:check 2>&1 | tail -8</automated>
|
||||
<fails_when>non-zero exit, a "guard-check: FAIL" line, or no "guard-check: ok" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "indexable routes must render on the server (D-70)" ../vue-seo-layer/lib/guard.ts ../vue-seo-layer/lib/seo-module.ts` prints at least one line
|
||||
- `grep -n "nitro:config\|pages:resolved" ../vue-seo-layer/lib/seo-module.ts` prints two lines
|
||||
- guard-check.mjs covers four fixtures and the header check
|
||||
</acceptance_criteria>
|
||||
<done>No app on the layer can ship an indexable page that renders only in the browser.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Noindex for declared and signed-in routes, the route report and the startup drift check</name>
|
||||
<files>../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/lib/routes.ts, ../vue-seo-layer/app/plugins/seo-route-robots.ts, ../vue-seo-layer/server/plugins/seo-route-report.ts, ../vue-seo-layer/server/plugins/seo-drift.ts, ../vue-seo-layer/server/api/__seo__/site.get.ts, ../vue-seo-layer/app/composables/useSeoSite.ts, ../vue-seo-layer/.playground/nuxt.config.ts, ../vue-seo-layer/.playground/app/pages/konto.vue, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/testing/fixtures/seo/site.json, ../vue-seo-layer/tests/guard.test.ts</files>
|
||||
<read_first>
|
||||
- ../vue-seo-layer/server/lib/site.ts and server/plugins/site-cache-purge.ts (33-07 server plugin style)
|
||||
- ../sm-seo-plugin/controllers/api/registry.go (33-05 route report body and token) — or 33-05 P33-20 if not yet built
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Open Questions 9, 10)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- The playground page /konto with definePageMeta({requiresAuth: true}) renders meta robots "noindex, nofollow" and the header X-Robots-Tag noindex, nofollow; a page with seo: {robots: 'noindex, follow'} renders that value; ordinary pages keep the CMS robots
|
||||
- The fixture API receives one POST /_seo/api/v1/registry/routes at server start with the bearer token and the static paths (/ and /blog); /konto (meta requiresAuth) and paths under noindexRoutes are left out; without a token or with routeReport false nothing is sent
|
||||
- With a fixture site answer whose journal-post pattern has no Nuxt route, whose pagination form is path while the build option is query, and whose html path differs, the server log has three "[vue-seo-layer] drift:" warnings and the server still starts
|
||||
- useSeoSite() returns the cached site answer on server and client (proxy /api/__seo__/site)
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-23, D-21, D-33, D-20c, P33-30/P33-31 and the CONTEXT 2026-10-28 accepted default (auth pages are marked through route meta; no user layer): the module adds `requiresAuth` and `seo` to `nuxt.options.experimental.extraPageMetaExtractionKeys`, so pages:resolved carries those meta keys at build time; `lib/routes.ts` (pure) turns resolved pages into static paths (leaving out pages whose meta has requiresAuth or a noindex seo.robots, and noindexRoutes) and route patterns (`:slug()` and `[...slug]` normalised to `:name`, catch-alls marked); the module writes `#seo-routes` with addServerTemplate. `app/plugins/seo-route-robots.ts`: on every route, if the matched route's meta has requiresAuth true or seo.robots, apply useSeoMeta robots and, during SSR, setResponseHeader x-robots-tag; noindexRoutes matches (isNoindex) get the same meta. `server/plugins/seo-route-report.ts` and `seo-drift.ts` run once after Nitro starts (non-blocking), honour cms and routeReport options and the tokens of P33-27, and log results. `server/api/__seo__/site.get.ts` proxies the cached site answer; `useSeoSite()` wraps useFetch of it with a stable key. Extend fixtures and expect-seo.txt (header and meta lines, the report recorded by the fixture API checked by smoke-ssr through a `report=` expectation or a post-run assertion in fixture-api.mjs). Tests first. Commit groups A and B.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
|
||||
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -3 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the smoke output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "requiresAuth" ../vue-seo-layer/app/plugins/seo-route-robots.ts` prints at least one line
|
||||
- `grep -n "registry/routes" ../vue-seo-layer/server/plugins/seo-route-report.ts` prints one line
|
||||
- `grep -n "drift:" ../vue-seo-layer/server/plugins/seo-drift.ts` prints at least one line
|
||||
- expect-seo.txt asserts the /konto header and meta
|
||||
</acceptance_criteria>
|
||||
<done>Signed-in and declared routes are noindex without page code, the registry learns the site's pages, and drift between CMS and layer is visible at start.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Breadcrumbs, pagination, the HTML sitemap pages and the footer link</name>
|
||||
<files>../vue-seo-layer/app/components/Seo/Breadcrumbs.vue, ../vue-seo-layer/app/components/Seo/Pagination.vue, ../vue-seo-layer/app/components/Seo/SitemapLink.vue, ../vue-seo-layer/app/components/global/SeoHtmlSitemapPage.vue, ../vue-seo-layer/app/seo-pages/SitemapPage.vue, ../vue-seo-layer/app/seo-pages/SitemapGroupPage.vue, ../vue-seo-layer/app/composables/usePagination.ts, ../vue-seo-layer/app/utils/paginationWindow.ts, ../vue-seo-layer/app/utils/pageHref.ts, ../vue-seo-layer/app/utils/breadcrumbClaim.ts, ../vue-seo-layer/app/assets/css/seo-base.css, ../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/server/api/__seo__/sitemap.get.ts, ../vue-seo-layer/.playground/app/pages/blog/index.vue, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/fixtures/seo/html.json, ../vue-seo-layer/testing/fixtures/seo/resolve.json, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/tests/paginationWindow.test.ts, ../vue-seo-layer/tests/pageHref.test.ts, ../vue-seo-layer/tests/breadcrumbClaim.test.ts, ../vue-seo-layer/README.md</files>
|
||||
<read_first>
|
||||
- ../vue-journal-layer/app/components/Journal/Pagination.vue and app/utils/pagination.ts (markup, class names, window)
|
||||
- ../vue-seo-layer/app/utils/labels.ts and app/assets/css/seo-tokens.css (33-07)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L1, L2, L3, L4, Public layers tables, Focal points)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- paginationWindow(5, 14) → [1, gap, 3, 4, 5, 6, 7, gap, 14]; (1, 3) → [1, 2, 3]; (14, 14) → [1, gap, 10, 11, 12, 13, 14]
|
||||
- pageHref('/blog', 1, query) → /blog; ('/blog?sort=new', 3, query) → /blog?sort=new&page=3; ('/blog', 3, path, 'strona') → /blog/strona/3; ('/en/blog', 1, path, 'page') → /en/blog
|
||||
- Smoke: /blog?page=5 with last 14 renders nav.seo-pagination with rel=prev and rel=next anchors, aria-current on 5, the 1 and 14 links, two gap items, head <link rel="prev"> and <link rel="next">, and the title without the literal %pages%; /blog with one page renders no nav.seo-pagination
|
||||
- Smoke: a post page renders exactly one nav.seo-breadcrumbs (layout + page instance) with <a href="/"> first and the last item as span aria-current="page"; the home page renders none
|
||||
- Smoke: /mapa-strony renders H1 "Mapa strony" and one H2 per non-empty group, a group with more=true renders the sitemap.all link; /mapa-strony/posts?page=2 renders the group page with pagination; /mapa-strony/nope → 404; with the fixture API failing → 503; with every group empty → sitemap.empty
|
||||
- SitemapLink renders the locale path only when html_sitemap.enabled
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-86, D-40 to D-42, D-34, D-71 and UI-SPEC L1–L4 (markup, classes, props, defaults, copy) with P33-32: Breadcrumbs.vue (props items, label, hideCurrent; NuxtLink for each crumb's path; the first-instance claim through `breadcrumbClaim.ts`, a request-scoped flag reset on every route change; renders nothing per the L1 rules); Pagination.vue (props current, last, labels, classPrefix default seo-pagination; markup of L2 with aria-labels from labels; hrefs from usePagination); usePagination({current, last}) builds hrefs with pageHref from the site answer's pagination (fallback to the build option), adds head links rel prev/next and replaces %pages% in the CMS title and description; HTML sitemap pages registered by the module from htmlSitemap.paths (P33-32) rendering SeoHtmlSitemapPage, which fetches /api/__seo__/sitemap (server proxy to the sitemap API with 404 passthrough and 503 on failure) and renders L3 markup (H1, section per group with H2, nested ul for categories, sitemap.all link, group page with H1 + pageSuffix on page ≥ 2 and SeoPagination, breadcrumbs from home_crumb, sitemap.title and the group title); SitemapLink.vue per L4. seo-base.css adds the L1, L2 and L3 default styles of UI-SPEC (only .seo-* selectors, tokens only, overflow-wrap anywhere). Fixtures and expect-seo.txt per behavior list; unit tests first. README documents components, props, options and the override of SeoHtmlSitemapPage. Commit groups A to D.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
|
||||
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -3 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -30 && npm --prefix ../vue-seo-layer run guard:check 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit from any command, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "aria-current" ../vue-seo-layer/app/components/Seo/Breadcrumbs.vue ../vue-seo-layer/app/components/Seo/Pagination.vue` prints a line in each
|
||||
- `grep -n "ClientOnly\|onMounted" ../vue-seo-layer/app/components/Seo/*.vue ../vue-seo-layer/app/components/global/*.vue ../vue-seo-layer/app/seo-pages/*.vue` prints nothing (server-rendered only, UI-SPEC L8)
|
||||
- `grep -n "count=nav class=\"seo-breadcrumbs\|count=<nav class=\"seo-breadcrumbs" ../vue-seo-layer/testing/expect-seo.txt` prints at least one line (exactly-one assertion)
|
||||
- unit tests cover every behavior example of paginationWindow and pageHref
|
||||
</acceptance_criteria>
|
||||
<done>Sites get server-rendered breadcrumbs, pagination and an HTML sitemap from the CMS, with the D-71 link rules.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| app config → build guard | build-time configuration |
|
||||
| frontend server → registry and site endpoints | token-authenticated and public calls |
|
||||
| visitor query → pagination hrefs | page numbers and other query parameters echoed into links |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-35 | Tampering (XSS) | pagination and breadcrumbs hrefs | medium | mitigate | hrefs built with URL/URLSearchParams and rendered through Vue bindings (escaped), never v-html; breadcrumb names from the CMS rendered as text |
|
||||
| T-33-36 | Spoofing | route report token | medium | mitigate | token only in server-only runtime config; sent only to the configured apiBase |
|
||||
| T-33-37 | Repudiation/Integrity | build guard bypass | medium | mitigate | rules checked after layer merge from both sources plus client pages; inline route rules refused; guard-check fixtures |
|
||||
| T-33-38 | Denial of service | HTML sitemap proxy | low | mitigate | page size capped by the sitemap API; 503 on failure prevents caching an empty page |
|
||||
| T-33-SC | Tampering | npm installs | high | accept | no new package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm --prefix ../vue-seo-layer run test`, `build:playground`, `smoke:ssr` and `guard:check` pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The build guard fails indexable client-only routes and passes declared noindex ones.
|
||||
- Declared and signed-in routes are noindex automatically; routes are reported and drift is logged.
|
||||
- Breadcrumbs, pagination and the HTML sitemap render server-side from the CMS.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Module options: `noindexRoutes`, `indexable`, `routeReport`, `pagination` (`form`, `param`, `segments`), `htmlSitemap` (`paths`).
|
||||
- Libs: `lib/guard.ts` (`isNoindex`, `findOffenders`), `lib/routes.ts`; server template `#seo-routes`.
|
||||
- Components: `<SeoBreadcrumbs>`, `<SeoPagination>`, `<SeoSitemapLink>`, global `SeoHtmlSitemapPage`; pages `seo-sitemap-<locale>`, `seo-sitemap-group-<locale>`.
|
||||
- Composables and utils: `usePagination`, `useSeoSite`, `paginationWindow`, `pageHref`, `breadcrumbClaim`.
|
||||
- Server: `GET /api/__seo__/sitemap`, `GET /api/__seo__/site`; plugins `seo-route-report.ts`, `seo-drift.ts`; app plugin `seo-route-robots.ts`.
|
||||
- Page meta keys honoured: `requiresAuth`, `seo.robots`, `seo.breadcrumbs`.
|
||||
- Scripts: `guard:check` (`testing/guard-check.mjs`) and the fixtures under `testing/guard/`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-08-SUMMARY.md` when done.
|
||||
</output>
|
||||
378
.planning/phases/33-seo-output-audit-and-content/33-09-PLAN.md
Normal file
378
.planning/phases/33-seo-output-audit-and-content/33-09-PLAN.md
Normal file
@@ -0,0 +1,378 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 09
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["33-08", "33-02"]
|
||||
files_modified:
|
||||
- ../vue-seo-layer/app/utils/bodyLinks.ts
|
||||
- ../vue-seo-layer/tests/bodyLinks.test.ts
|
||||
- ../vue-seo-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../vue-journal-layer/pnpm-lock.yaml
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/lib/journal-module.ts
|
||||
- ../vue-journal-layer/app/utils/types.ts
|
||||
- ../vue-journal-layer/app/utils/routes.ts
|
||||
- ../vue-journal-layer/app/utils/seo.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/utils/sitemap.ts
|
||||
- ../vue-journal-layer/app/components/Journal/PostCard.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/Pagination.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/RelatedPosts.vue
|
||||
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/PostIndexPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/CategoryPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/TagPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/AuthorPage.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-tokens.css
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-journal-layer/server/plugins/site-cache-purge.ts
|
||||
- ../vue-journal-layer/server/api/__sitemap__/journal.ts
|
||||
- ../vue-journal-layer/tests/seo.test.ts
|
||||
- ../vue-journal-layer/tests/sitemap.test.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/tests/routes.test.ts
|
||||
- ../vue-journal-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-journal-layer/testing/fixture-api.mjs
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/testing/expect-acme-title-h1.txt
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/seo/site.json
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-menu-layer/package.json
|
||||
- ../vue-menu-layer/pnpm-lock.yaml
|
||||
- ../vue-menu-layer/nuxt.config.ts
|
||||
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-menu-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-menu-layer/server/plugins/site-cache-purge.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/README.md
|
||||
- ../vue-faq-layer/package.json
|
||||
- ../vue-faq-layer/pnpm-lock.yaml
|
||||
- ../vue-faq-layer/nuxt.config.ts
|
||||
- ../vue-faq-layer/app/components/Faq/Block.vue
|
||||
- ../vue-faq-layer/app/utils/faqSchema.ts
|
||||
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-faq-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-faq-layer/server/plugins/site-cache-purge.ts
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-faq-layer/tests/faqSchema.test.ts
|
||||
- ../vue-faq-layer/testing/expect.txt
|
||||
- ../vue-faq-layer/README.md
|
||||
- ../vue-journal-layer/testing/fixtures/acme/categories.json
|
||||
- ../sm-journal-plugin/classes/related.go
|
||||
- ../sm-journal-plugin/controllers/api/posts.go
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
- ../sm-journal-plugin/config/config.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/related_media_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-2, D-97, D-50, D-52, D-80, D-81, D-82, D-83, D-71, D-86, D-40, D-41, D-43]
|
||||
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "vue-journal-layer, vue-menu-layer and vue-faq-layer extend the shared SEO layer; the journal layer no longer uses @nuxtjs/seo (no nuxt-schema-org graph, no second canonical, no robots or sitemap module), and its own meta applies only as a fallback when no CMS answer exists (D-97, RESEARCH Pitfall 2)"
|
||||
- "The post page title is an h1 while the Journal API reports content_h1_to_h2 true and a p.journal-post__title (same class, same look through the journal post-title tokens) while it is false; the option journal.postTitleTag ('auto' | 'h1' | 'p', default 'auto') overrides it either way (2026-10-28 layer options: postTitleTag)"
|
||||
- "The journal body gate demotes h1 to h2 only while the Journal API reports content_h1_to_h2 true, read from the API, not from a layer option; while false the body keeps its h1 (2026-10-28 Journal H1 setting, D-50, UI-SPEC L7)"
|
||||
- "Category pillar text follows the same rule: the category page demotes an h1 in it while the category row's content_h1_to_h2 is true (absent counts as true) and keeps it while false (CONTEXT 2026-10-28 H1 conversion scope: post content and category pillar text alike)"
|
||||
- "Every page of the fixture sites has exactly one <h1 and no skipped heading level: listing headers are h1 (page ≥ 2 keeps the · page suffix), post cards are h2 (h3 inside related posts under their h2), FAQ blocks with headingLevel 0 render a styled <p class=\"faq-block__title\"> for sidebar and footer widgets, menus and breadcrumbs have no headings (D-52, UI-SPEC L6)"
|
||||
- "Each post card has exactly one anchor, the title link: the image is no longer wrapped in a link, the separate read-more link is gone, the whole card stays clickable through a CSS stretched link with a visible focus ring, and post links carry no rel (D-80, D-81, UI-SPEC L5)"
|
||||
- "Body HTML is rendered at SSR in the L7 order: H1 demotion by setting, phrase auto-links from the resolve answer (first occurrence, at most autolink_max per text, whole words, case-insensitive, never inside headings, links, code, pre, figcaption or button, never to the page itself), outbound rules (editor rel kept, nofollow or sponsored added from the domain lists; target=_blank and rel=noopener only while outbound.new_tab is true, plus the visually hidden new-tab hint while the SEO-layer option seo.links.newTabHint is also true, its default), and images with loading=lazy and decoding=async keeping stored dimensions and alt=\"\" (D-82, D-83, D-71, 2026-10-28 layer options: links_new_tab; CONTEXT 2026-10-28 accepted defaults: the new-tab hint is on by default while new tab is on, UI-SPEC Q-8)"
|
||||
- "Journal pages render <SeoBreadcrumbs> at the top of their content by default; journal.breadcrumbs false turns that off for sites whose layout places breadcrumbs, while the BreadcrumbList data is unchanged (D-86, 2026-10-28 layer options: journal.breadcrumbs)"
|
||||
- "JournalPagination renders <SeoPagination class-prefix=\"journal-pagination\"> with the journal's labels, so existing journal-pagination class names and app CSS keep working while links follow the CMS page form (path-form routes are registered when the build option is path); category pillar content renders on page 1 only, the short description on every page (D-40, D-41, D-42, UI-SPEC L2)"
|
||||
- "Listing pages (index, category, tag, author) add an ItemList node #itemlist with their posts through useSchemaNode, and FAQ blocks add a FAQPage node (schema prop, default true), so a page still has exactly one JSON-LD script with one @graph (D-43)"
|
||||
- "The site-cache purge files of the journal, menu and FAQ layers are removed; the one route lives in the SEO layer, and the journal's deprecated /api/__journal__/cache/purge keeps working through the SEO layer's purge library (RESEARCH Pitfall 6)"
|
||||
- "A playground extending journal, menu and FAQ layers renders each page with one resolve call, one canonical and one JSON-LD script"
|
||||
- "Journal breadcrumbs turned off (journal.breadcrumbs false): journal pages render no <SeoBreadcrumbs>, a layout instance renders the trail, JSON-LD unchanged (UI-SPEC partial: Journal breadcrumbs turned off)"
|
||||
- {statement: "Post card: each .journal-post-card contains exactly one <a, the title is h2 (h3 inside related posts) and no journal-post-card__more exists (UI-SPEC L5 backstop)", verification: backstop}
|
||||
- {statement: "Heading convention over the fixture pages: exactly one <h1, no skipped level, FAQ headingLevel 0 renders <p>; the post page in both Journal H1 modes (on: title h1, body h1 demoted; off: title p.journal-post__title, the body's single h1 is the page h1); postTitleTag 'h1' with the setting off gives two <h1 and 'p' with it on gives none, asserted as the documented consequence of the override (UI-SPEC L6 backstop)", verification: backstop}
|
||||
- {statement: "Outbound links with outbound.new_tab false keep their editor rel and domain-list nofollow/sponsored and have no target, no noopener and no .seo-visually-hidden hint; with true and the default seo.links.newTabHint they have all three, and with newTabHint false they have target and noopener without the hint (UI-SPEC L7 backstop, CONTEXT 2026-10-28 accepted defaults)", verification: backstop}
|
||||
- "Journal related posts stay as today by default (same category, three, newest first); with golem15.journal.related_mode scored they are 3 to 6 (golem15.journal.related_count) ranked by shared tags and categories, and the post answer adds previous_in_category and next_in_category, which the journal layer renders as server-rendered links instead of the site-wide neighbours (D-82; CONTEXT 2026-10-28 accepted defaults: related posts opt-in in Journal, current behaviour as default; porting contract kept)"
|
||||
prohibitions:
|
||||
- requirement_id: "D-83"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "Internal links never get rel or target from the outbound transform, and auto-links are plain <a href> with no class or rel"
|
||||
- requirement_id: "D-82"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "Auto-linking never injects markup inside an attribute, a heading, an existing link, code, pre, figcaption or button, and never links a page to itself"
|
||||
artifacts:
|
||||
- path: "../vue-seo-layer/app/utils/bodyLinks.ts"
|
||||
provides: "autolink, applyOutbound, imageAttrs (pure)"
|
||||
contains: "export function autolink"
|
||||
- path: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
provides: "gate with demoteH1 and the L7 pipeline"
|
||||
contains: "demoteH1"
|
||||
- path: "../vue-journal-layer/app/components/Journal/PostCard.vue"
|
||||
provides: "L5 one anchor, headingLevel"
|
||||
contains: "headingLevel"
|
||||
- path: "../vue-faq-layer/app/utils/faqSchema.ts"
|
||||
provides: "FAQPage node from FAQ items"
|
||||
contains: "FAQPage"
|
||||
key_links:
|
||||
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
|
||||
to: "../vue-seo-layer/app/utils/bodyLinks.ts"
|
||||
via: "the gate passes sanitized HTML through autolink and applyOutbound with the resolve answer's autolinks and outbound"
|
||||
pattern: "applyOutbound|autolink"
|
||||
- from: "../vue-journal-layer/app/journal-pages/PostPage.vue"
|
||||
to: "Journal API content_h1_to_h2"
|
||||
via: "titleTag and demoteH1 follow post.content_h1_to_h2 unless postTitleTag overrides the title"
|
||||
pattern: "content_h1_to_h2"
|
||||
- from: "../vue-journal-layer/nuxt.config.ts"
|
||||
to: "../vue-seo-layer/nuxt.config.ts"
|
||||
via: "extends: ['vue-seo-layer']"
|
||||
pattern: "vue-seo-layer"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: @nuxtjs/seo -->
|
||||
<!-- planner-discipline-allow: journal-post-card__more -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan moves the journal, menu and FAQ layers onto the shared SEO layer: CMS head and one graph, the heading convention in both Journal H1 modes, one link per card, body links and images by the rules, breadcrumbs and pagination from the CMS, and one purge route. It also adds Journal's opt-in scored related posts and category neighbours.
|
||||
|
||||
<objective>
|
||||
Change the three existing layers (each main, not pushed; tags in 33-14) and add the body-link utilities to vue-seo-layer:
|
||||
- journal: drop @nuxtjs/seo, extend the SEO layer, L5 cards, L6 headings, L7 body pipeline, postTitleTag and journal.breadcrumbs options, SeoPagination wrapper, pillar on page 1, ItemList nodes (2026-10-28 layer options; D-50, D-52, D-80..83, D-71, D-86, D-40..42);
|
||||
- menu and FAQ: extend the SEO layer, remove purge copies, FAQ headingLevel 0 and the FAQPage node (D-52, D-43, D-71);
|
||||
- vue-seo-layer: `app/utils/bodyLinks.ts` (autolink, applyOutbound, imageAttrs);
|
||||
- sm-journal-plugin and the journal layer: opt-in scored related posts and category neighbours (D-82; CONTEXT 2026-10-28 accepted defaults: opt-in, current behaviour default). This task moved here from 33-11 in the 2026-10-28 revision so that 33-11 stays within its task budget; it changes the same PostPage.vue this plan already touches.
|
||||
|
||||
Purpose: SC-2 in the layers every site uses; the related-posts part of D-82.
|
||||
|
||||
Output: commits in ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, ../vue-faq-layer and ../sm-journal-plugin (main, not pushed; tags in 33-14).
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-33 (dependency during development):** each layer's package.json gets `"vue-seo-layer": "link:../vue-seo-layer"` in dependencies with the lockfile updated by `pnpm install`; 33-14 replaces it with the git tag pin (`git+https://git.golem15.com/golem15/vue-seo-layer.git#v<tag>`) before tagging (2026-10-28 release cadence: all tags in 33-14).
|
||||
- **P33-34 (fallback meta):** app/utils/seo.ts keeps only the fallback (post title, excerpt, featured image) applied when `useCmsSeo` has no answer (`seo.cms` false, as summercms.io in 33-12, or the CMS unreachable), so a site without the SEO plugin keeps titles and no page gets two titles.
|
||||
- **P33-35 (H1 flag source):** the post page reads `post.content_h1_to_h2` (33-02's additive key in the post Show answer); when the key is absent (an older Journal) it behaves as true, the D-50 default.
|
||||
- **P33-36 (override cases in smoke):** postTitleTag is copied into `runtimeConfig.public.journal.postTitleTag`, so the smoke runs the override cases on the same build with `--env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1` (and `p`) and a second expectation file.
|
||||
- **P33-44 (related options, moved from 33-11; CONTEXT 2026-10-28 accepted defaults: related posts opt-in, current behaviour default):** config keys `golem15.journal.related_mode` (category | scored, default category) and `golem15.journal.related_count` (3 to 6, default 3); the existing previous_post/next_post keys keep their meaning; previous_in_category and next_in_category (primary = first category) are added only in scored mode.
|
||||
- **P33-35 also covers category pillar text:** CategoryPage and CategoryHeader sanitize the pillar text with `demoteH1 = category.content_h1_to_h2 !== false` (33-02's additive key on category rows; absent behaves as true), so pillar text follows the Journal setting like post bodies (CONTEXT 2026-10-28 H1 conversion scope).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-08-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-02-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 33-07/33-08 (vue-seo-layer): extends target `vue-seo-layer`; useCmsSeo answer in useState; useSchemaNode(node); <SeoBreadcrumbs> (props items, label, hideCurrent; page meta seo.breadcrumbs), <SeoPagination> (props current, last, labels, classPrefix), usePagination({current, last}), useSeoLabels, `seo-visually-hidden` class, runtimeConfig cachePurge/siteCache keys, server/lib/siteCachePurge.ts, smoke-ssr count=/header=.
|
||||
Resolve answer (P33-26): autolinks [{phrase, url}], autolink_max, outbound {new_tab, nofollow_domains, sponsored_domains}, breadcrumbs, pagination, url (absolute page URL, its path is "self" for auto-links).
|
||||
Journal API (33-02): post Show and every category row of GET /categories have content_h1_to_h2 (boolean); featured_images[{id, url, file_name, file_size}].
|
||||
sm-journal-plugin (for Task 4): controllers/api/posts.go Show (related_posts via relatedPosts, previous_post/next_post via siblingPost), posts_helpers.go (relatedPosts 152-172, siblingPost 122-148), config/config.yaml (33-02); vue-journal-layer PostPage.vue siblings nav (51-66), utils/types.ts (JournalPostSibling).
|
||||
vue-journal-layer today: lib/journal-module.ts (defaults routes, perPage, sort, relatedCount, rss → runtimeConfig.public.journal), app/utils/htmlGate.ts (gateJournalHtml with sanitize-html POLICY and KEEP_ALL), PostCard.vue:15-24 (image link + read-more), PostPage.vue:14 (cfg), :40 (useSeoMeta postSeoMeta), :46 (h1), Pagination.vue, utils/pagination.ts (paginationWindow), CategoryHeader.vue, RelatedPosts.vue, nuxt.config.ts (@nuxtjs/seo, ogImage, seo, sitemap keys), testing/smoke-ssr.mjs and expect-acme.txt.
|
||||
vue-faq-layer Block.vue (headingLevel 2..4, aria-labelledby), useFaq answer {name, description, content[{question, answer}]}.
|
||||
RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text nodes with an open-tag stack).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups per repository, staged by path (`git -C ../<layer> add <paths>`, deletions with `git rm`). `npm --prefix ../<layer> run test` green at every commit in that layer; in ../sm-journal-plugin `go vet ./...` and `go test -short ./...` green at every commit, and its untracked `go.work.sum` (another session's) stays alone.
|
||||
- Resume check: `git -C ../<repo> log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | journal: `feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)` | expect-acme.txt tracer lines pass |
|
||||
| 2 | seo: `feat: body link utilities (auto-links, outbound rules, image attributes)`; journal: A `feat: body links, outbound rules and image attributes in the post body`; B `feat!: one link per post card and the heading convention`; C `feat: breadcrumbs, CMS pagination and pillar content on page one`; D `feat: ItemList nodes on listing pages` | both expectation files pass |
|
||||
| 3 | menu: `feat!: extend the SEO layer and drop the copied purge route`; faq: A `feat!: extend the SEO layer and drop the copied purge route`; B `feat: FAQPage node and widget headings`; journal: `docs: README for the SEO layer` | menu and FAQ smokes pass |
|
||||
| 4 | journal plugin: `feat: opt-in scored related posts and category neighbours`; journal layer: `feat: category neighbours on the post page` | plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "a Journal post page renders through the shared SEO layer with exactly one H1 in both Journal H1 modes, one canonical and one JSON-LD script" — one path only</name>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/pnpm-lock.yaml, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/server/api/__sitemap__/journal.ts, ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts, ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, ../vue-journal-layer/server/lib/siteCachePurge.ts, ../vue-journal-layer/server/plugins/site-cache-purge.ts, ../vue-journal-layer/tests/seo.test.ts, ../vue-journal-layer/tests/sitemap.test.ts, ../vue-journal-layer/tests/sitePurge.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json, ../vue-journal-layer/testing/fixtures/acme/seo/site.json</files>
|
||||
<read_first>
|
||||
- ../vue-journal-layer/nuxt.config.ts, package.json, lib/journal-module.ts, app/journal-pages/PostPage.vue, app/components/Journal/PostBody.vue, app/utils/{htmlGate,seo,sitemap,types}.ts, server/api/__journal__/cache/purge.post.ts
|
||||
- ../vue-seo-layer/nuxt.config.ts, app/composables/useCmsSeo.ts, testing/smoke-ssr.mjs (count=, header=)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L6 table and the post-title CSS, L7 step 1)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-97, D-50, the 2026-10-28 Journal H1 setting and layer options, P33-33 to P33-36: package.json removes @nuxtjs/seo and adds the link dependency; nuxt.config.ts extends vue-seo-layer, drops @nuxtjs/seo from modules and the ogImage, seo and sitemap keys and the cachePurge/siteCache runtime keys (now in the SEO layer), keeps journal.cachePurgeToken and the /api/__journal__/** route rule. Remove the journal sitemap source (server/api/__sitemap__/journal.ts, app/utils/sitemap.ts, tests/sitemap.test.ts) and the copied shared purge files (server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, tests/sitePurge.test.ts); the deprecated /api/__journal__/cache/purge imports the purge library from vue-seo-layer. journal-module.ts adds `postTitleTag: 'auto'` and `breadcrumbs: true` to defaults and runtimeConfig.public.journal; types.ts adds them and the post's `content_h1_to_h2?: boolean`. PostPage.vue: titleTag = postTitleTag when 'h1' or 'p', else 'h1' when content_h1_to_h2 !== false, else 'p'; fallback meta only when no CMS answer (P33-34). htmlGate.ts: `gateJournalHtml(html, {demoteH1})` applying sanitize-html transformTags h1→h2 only when demoteH1; PostBody passes `demoteH1: post.content_h1_to_h2 !== false`. journal-tokens.css gets --journal-post-title-size 2em, --journal-post-title-weight 700, --journal-post-title-line-height 1.2; journal-base.css styles .journal-post__title with them and --journal-font-heading. smoke-ssr.mjs copies the SEO layer's count= and header= support (apps run it from node_modules). fixture-api.mjs serves the SEO fixtures; posts.json gets a post with content_h1_to_h2 true and one with false, each with one body h1 and one h2. expect-acme.txt tracer lines: for the "on" post `count=<h1:1 contains=<h1 class="journal-post__title"`, for the "off" post `count=<h1:1 contains=<p class="journal-post__title"`, both `count=application/ld+json:1 count=rel="canonical":1`. htmlGate.test.ts covers demoteH1 true and false. Commit as listed (one feat! commit: the layer drops @nuxtjs/seo).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -10</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
|
||||
<automated>npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -3 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit from the build or the smoke, or a "FAIL" line in the smoke output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "@nuxtjs/seo" ../vue-journal-layer/package.json ../vue-journal-layer/nuxt.config.ts` prints nothing
|
||||
- `grep -n "vue-seo-layer" ../vue-journal-layer/package.json ../vue-journal-layer/nuxt.config.ts` prints a line in each
|
||||
- `git -C ../vue-journal-layer ls-files server/lib server/api/__site__ server/api/__sitemap__` prints nothing
|
||||
- `grep -n "postTitleTag" ../vue-journal-layer/lib/journal-module.ts ../vue-journal-layer/app/journal-pages/PostPage.vue` prints a line in each
|
||||
</acceptance_criteria>
|
||||
<done>A Journal post renders through the SEO layer with exactly one H1 whichever Journal H1 mode the site uses.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Body link utilities and the L7 pipeline, L5 post cards, L6 headings, breadcrumbs, CMS pagination, pillar on page 1 and ItemList nodes</name>
|
||||
<files>../vue-seo-layer/app/utils/bodyLinks.ts, ../vue-seo-layer/tests/bodyLinks.test.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/testing/expect-acme-title-h1.txt, ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json, ../vue-journal-layer/testing/fixtures/acme/categories.json, ../vue-journal-layer/app/utils/types.ts</files>
|
||||
<read_first>
|
||||
- ../vue-journal-layer/app/components/Journal/{PostCard,Pagination,CategoryHeader,RelatedPosts,FeaturedImage}.vue and the journal pages
|
||||
- ../vue-journal-layer/app/utils/pagination.ts and routes.ts
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Code Examples: phrase auto-linking; Pitfall 7)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L5, L6, L7, L1 journal placement switch, L2 Journal note)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- autolink: first occurrence only, at most max per text, case-insensitive whole words, never inside a, h1–h6, code, pre, figcaption, button, never to the page's own path, phrase with regex characters matched literally, URLs escaped in href; output is a plain <a href>
|
||||
- applyOutbound: an https link to another host keeps its rel tokens, gains nofollow for a listed domain (subdomains included) or sponsored, and with newTab true gains target=_blank, rel noopener and the hidden hint span (hint default on, per CONTEXT 2026-10-28 accepted defaults; with hint false target and noopener only); with newTab false none of the three; internal and relative links unchanged
|
||||
- Category pillar text: a category row with content_h1_to_h2 true (or the key absent) renders its pillar h1 as h2 on the category page; with false the h1 stays (fixture categories.json has one of each)
|
||||
- imageAttrs: body img gains loading=lazy and decoding=async, keeps width/height and alt=""
|
||||
- Smoke: each .journal-post-card has one <a and an h2 title, related posts are h2 with h3 cards, no read-more link; listing pages have one h1 and on ?page=2 the · Strona 2 suffix; the post page renders one nav.seo-breadcrumbs (none with journal.breadcrumbs false through NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false); the category page shows its pillar content on page 1 only; journal-pagination markup stays with first/last and gap items; index and category pages have one JSON-LD script containing ItemList
|
||||
- Smoke with NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 (expect-acme-title-h1.txt): the "off" post has two <h1 (documented override); with p, the "on" post has no <h1
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-80 to D-83, D-71, D-52, D-86, D-40 to D-42, D-43 and UI-SPEC L1/L2/L5/L6/L7: `vue-seo-layer/app/utils/bodyLinks.ts` (pure, no Vue imports, outside auto-import dirs' side effects) with `autolink(html, links, max, selfPath)`, `applyOutbound(html, {siteHost, newTab, nofollowDomains, sponsoredDomains, hint})`, `imageAttrs(html)`, working on sanitize-html's normalised output with a tag/text split and an open-tag stack (RESEARCH example), attribute values escaped; tests first. Journal htmlGate runs the L7 order after sanitizing (POLICY now allows rel tokens nofollow, sponsored, ugc on a, and target only as added by applyOutbound); PostBody passes the resolve answer's autolinks, autolink_max, outbound, url path and the SEO-layer option seo.links.newTabHint as hint (default true: CONTEXT 2026-10-28 accepted defaults, UI-SPEC Q-8). CategoryPage and CategoryHeader sanitize the pillar text with demoteH1 from the category row's content_h1_to_h2 (P33-35; types.ts adds it to JournalCategory; categories.json gets one category per value, each with a pillar h1). PostCard.vue per L5 (headingLevel 2|3, one title link, image without link, stretched-link CSS in journal-base.css, focus ring with :has); RelatedPosts h2 and cards headingLevel 3; Pagination.vue wraps SeoPagination with class-prefix journal-pagination and the journal labels; journal-module.ts registers path-form listing routes (`<listing>/<segment>/:page`) when the SEO build option pagination.form is path (routes.ts helper, tested); CategoryHeader renders rich content only on page 1; journal pages render SeoBreadcrumbs when journal.breadcrumbs is true; listing pages call useSchemaNode with an ItemList (@id <url>#itemlist, ListItem position and url of each post on the page). Expectation files per behavior list. Commit groups as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -8 && npm --prefix ../vue-journal-layer run test 2>&1 | tail -8</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line in either</fails_when>
|
||||
<automated>npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -3 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -15 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme-title-h1.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 2>&1 | tail -8</automated>
|
||||
<fails_when>non-zero exit from any command, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function autolink\|export function applyOutbound\|export function imageAttrs" ../vue-seo-layer/app/utils/bodyLinks.ts` prints three lines
|
||||
- `grep -n "journal-post-card__more\|readMore" ../vue-journal-layer/app/components/Journal/PostCard.vue` prints nothing
|
||||
- `grep -n "SeoPagination" ../vue-journal-layer/app/components/Journal/Pagination.vue` prints one line
|
||||
- `grep -n "count=<h1:1" ../vue-journal-layer/testing/expect-acme.txt` prints at least five lines (every fixture page)
|
||||
- `grep -n "content_h1_to_h2" ../vue-journal-layer/app/journal-pages/CategoryPage.vue ../vue-journal-layer/app/components/Journal/CategoryHeader.vue` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>Journal pages follow the heading, card, body-link, image, breadcrumb and pagination rules, verified on built pages.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Menu and FAQ layers on the SEO layer, FAQ widget headings and the FAQPage node, one-resolve check across all three layers</name>
|
||||
<files>../vue-menu-layer/package.json, ../vue-menu-layer/pnpm-lock.yaml, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/server/api/__site__/cache/purge.post.ts, ../vue-menu-layer/server/lib/siteCachePurge.ts, ../vue-menu-layer/server/plugins/site-cache-purge.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-menu-layer/README.md, ../vue-faq-layer/package.json, ../vue-faq-layer/pnpm-lock.yaml, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/app/components/Faq/Block.vue, ../vue-faq-layer/app/utils/faqSchema.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/plugins/site-cache-purge.ts, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-faq-layer/tests/faqSchema.test.ts, ../vue-faq-layer/testing/expect.txt, ../vue-faq-layer/README.md, ../vue-journal-layer/README.md, ../vue-seo-layer/README.md</files>
|
||||
<read_first>
|
||||
- ../vue-menu-layer/nuxt.config.ts, package.json, testing/smoke-ssr.mjs, README.md
|
||||
- ../vue-faq-layer/nuxt.config.ts, app/components/Faq/{Block,List,Item}.vue, app/composables/useFaq.ts, testing/expect.txt, README.md
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L6 FAQ row)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-52, D-43, D-71, D-97 and P33-33: menu and FAQ layers add the link dependency, extend vue-seo-layer, drop their cachePurge/siteCache runtime keys and their copies of the purge route, library, plugin and test (git rm); their smoke tests keep passing (the SEO middleware fails open against their fixture APIs). FAQ Block.vue accepts `headingLevel: 0 | 2 | 3 | 4`, rendering `<p class="faq-block__title">` with the same id and aria-labelledby at 0; new prop `schema` (default true) calls useSchemaNode with `faqSchema(faq)` — FAQPage @id <url>#faq, mainEntity Question/acceptedAnswer with answer text stripped of markup (pure util, tested). expect.txt asserts the 0 level and that the answers are in the HTML inside details. Then check across the three layers: in a temporary directory outside the repositories, an app extending vue-journal-layer, vue-menu-layer and vue-faq-layer (link dependencies) renders the journal acme fixtures with the journal smoke harness and an expectation that the fixture API recorded exactly one resolve call per page render and that each page has one canonical and one ld+json; record the command and its output in the SUMMARY (no files committed for the scratch app). README updates in each layer: extending the SEO layer, the removed purge files, new options and props. Commit groups as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-menu-layer run test 2>&1 | tail -6 && npm --prefix ../vue-faq-layer run test 2>&1 | tail -6</automated>
|
||||
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line in either</fails_when>
|
||||
<automated>npm --prefix ../vue-menu-layer run build:playground 2>&1 | tail -2 && npm --prefix ../vue-menu-layer run smoke:ssr 2>&1 | tail -6 && npm --prefix ../vue-faq-layer run build:playground 2>&1 | tail -2 && npm --prefix ../vue-faq-layer run smoke:ssr 2>&1 | tail -6</automated>
|
||||
<fails_when>non-zero exit from any command, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-menu-layer ls-files server/lib server/api/__site__` and `git -C ../vue-faq-layer ls-files server/lib server/api/__site__` print nothing
|
||||
- `grep -n "vue-seo-layer" ../vue-menu-layer/nuxt.config.ts ../vue-faq-layer/nuxt.config.ts` prints a line in each
|
||||
- `grep -n "FAQPage" ../vue-faq-layer/app/utils/faqSchema.ts` prints at least one line
|
||||
- the SUMMARY records the cross-layer check with one resolve call per page
|
||||
</acceptance_criteria>
|
||||
<done>All three content layers render through the SEO layer with one graph, one canonical and one purge route.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Opt-in scored related posts and category neighbours (Journal API and layer)</name>
|
||||
<files>../sm-journal-plugin/classes/related.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts 152-172, siblingPost 122-148) and posts.go Show (147-154)
|
||||
- ../sm-journal-plugin/config/config.yaml (added by 33-02) and journal_api_matrix_test.go (the default answer that must not change)
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue (siblings nav 51-66, as changed by Tasks 1 and 2) and app/utils/types.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Default mode: Show answers exactly as before (journal_api_matrix_test.go unchanged)
|
||||
- related_mode scored, related_count 5: up to 5 published posts other than the post, ranked by shared tags (2 points each) plus shared categories (1 point each), ties by newest; count outside 3–6 is clamped; previous_in_category and next_in_category are the neighbours by published_at within the post's first category
|
||||
- The layer renders previous_in_category/next_in_category links (server-rendered <a href> with the title in the anchor text) when present, else the existing site-wide neighbours
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-82, P33-44 and the CONTEXT 2026-10-28 accepted default (related posts opt-in in Journal, current behaviour as default; the porting contract keeps the default answer byte for byte): `classes/related.go` with the scoring query (one SQL with counts, published only for non-editors) and the category neighbours; Show uses it only in scored mode and adds the two keys; config/config.yaml documents both keys; README section. Layer PostPage prefers the in-category neighbours; types.ts adds them; a fixture post with them and an expectation line. Tests first (`TestRelatedScored`, `TestRelatedDefaultUnchanged` in related_media_test.go, which 33-11 extends with TestImageVariants). Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestRelatedScored|TestRelatedDefaultUnchanged|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestRelatedScored missing from the "--- PASS" lines</fails_when>
|
||||
<automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated>
|
||||
<fails_when>non-zero exit from any command, or a "# fail" count above 0, a "not ok" line or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "golem15.journal.related_mode\|golem15.journal.related_count" ../sm-journal-plugin/classes/related.go ../sm-journal-plugin/config/config.yaml` prints at least two lines
|
||||
- `grep -n "previous_in_category" ../vue-journal-layer/app/journal-pages/PostPage.vue` prints at least one line
|
||||
- TestJournalAPIMatrix passes and `git -C ../sm-journal-plugin show --stat --format= HEAD` (the related-posts commit) does not list journal_api_matrix_test.go
|
||||
</acceptance_criteria>
|
||||
<done>Sites can switch on richer related posts and category neighbours without changing the default answer.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| CMS content (body HTML, phrases, URLs) → SSR HTML | stored content rendered to visitors |
|
||||
| resolve answer → layer transforms | phrase table and domain lists |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-39 | Tampering (XSS) | autolink and outbound transforms | high | mitigate | run on sanitize-html output only; links inserted into text nodes only; href and rel built from validated values with attribute escaping; unit tests with quotes, <, javascript: URLs refused (only / paths and http(s)) |
|
||||
| T-33-40 | Tampering | htmlGate policy (rel/target) | medium | mitigate | the sanitizer accepts only the rel tokens nofollow, sponsored, ugc; target and noopener are added after sanitizing by applyOutbound, never taken from stored HTML |
|
||||
| T-33-41 | Information disclosure | removal of @nuxtjs/seo | low | mitigate | no site config with NUXT_SITE_* is published anymore; purge-token-check stays in the journal testing harness |
|
||||
| T-33-42 | Denial of service | auto-link phrase table size | low | mitigate | at most autolink_max links per text; phrase scan is linear in text length per phrase; the resolver filters phrases per locale |
|
||||
| T-33-70 | Information disclosure | scored related posts and category neighbours | medium | mitigate | the scoring and neighbour queries return published posts only for visitors (editors keep today's rule), TestRelatedScored plants a draft sharing every tag and asserts it never appears |
|
||||
| T-33-SC | Tampering | npm installs | high | mitigate | @nuxtjs/seo removed, no package added; the only new dependency is the first-party vue-seo-layer through a local link; lockfiles committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm --prefix ../<layer> run test` green in all four layers.
|
||||
- Journal: playground build plus both expectation files; menu and FAQ: playground builds and smoke.
|
||||
- Cross-layer check recorded in the SUMMARY.
|
||||
- sm-journal-plugin: vet, `test -short`, TestRelatedScored, TestRelatedDefaultUnchanged and the API matrix.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The three content layers extend the SEO layer and drop their own SEO module and purge copies.
|
||||
- Headings, cards, body links, images, breadcrumbs and pagination follow the rules on built pages in both Journal H1 modes, for post bodies and category pillar text.
|
||||
- Scored related posts and category neighbours are available as an opt-in; the default answer is unchanged.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- vue-seo-layer: `app/utils/bodyLinks.ts` (`autolink`, `applyOutbound`, `imageAttrs`), `tests/bodyLinks.test.ts`.
|
||||
- vue-journal-layer options: `journal.postTitleTag` ('auto' | 'h1' | 'p'), `journal.breadcrumbs` (boolean); runtime keys `runtimeConfig.public.journal.postTitleTag`, `.breadcrumbs`; env `NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG`, `NUXT_PUBLIC_JOURNAL_BREADCRUMBS`.
|
||||
- vue-journal-layer: `gateJournalHtml(html, {demoteH1})`, PostCard prop `headingLevel`, CSS tokens `--journal-post-title-size`, `--journal-post-title-weight`, `--journal-post-title-line-height`, path-form pagination routes, `testing/expect-acme-title-h1.txt`, smoke-ssr `count=`/`header=`; removed: `server/api/__sitemap__/journal.ts`, `app/utils/sitemap.ts`, the shared purge copies, the @nuxtjs/seo dependency.
|
||||
- vue-faq-layer: Block props `headingLevel: 0` and `schema`, `app/utils/faqSchema.ts`.
|
||||
- vue-menu-layer: extends vue-seo-layer; purge copies removed.
|
||||
- sm-journal-plugin: config keys `golem15.journal.related_mode`, `golem15.journal.related_count`; `classes/related.go`; API keys `previous_in_category`, `next_in_category` (scored mode only); tests `TestRelatedScored`, `TestRelatedDefaultUnchanged` in `related_media_test.go`.
|
||||
- vue-journal-layer: category neighbours on the post page; `JournalCategory.content_h1_to_h2` and the pillar-text demotion.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-09-SUMMARY.md` when done.
|
||||
</output>
|
||||
434
.planning/phases/33-seo-output-audit-and-content/33-10-PLAN.md
Normal file
434
.planning/phases/33-seo-output-audit-and-content/33-10-PLAN.md
Normal file
@@ -0,0 +1,434 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 10
|
||||
type: execute
|
||||
wave: 6
|
||||
depends_on: ["33-06"]
|
||||
files_modified:
|
||||
- ../sm-seo-plugin/classes/crawl/crawler.go
|
||||
- ../sm-seo-plugin/classes/crawl/fetch.go
|
||||
- ../sm-seo-plugin/classes/crawl/parse.go
|
||||
- ../sm-seo-plugin/classes/crawl/checks.go
|
||||
- ../sm-seo-plugin/classes/crawl/links.go
|
||||
- ../sm-seo-plugin/classes/crawl/orphans.go
|
||||
- ../sm-seo-plugin/classes/crawl/store.go
|
||||
- ../sm-seo-plugin/classes/crawl/fix.go
|
||||
- ../sm-seo-plugin/classes/crawl/render.go
|
||||
- ../sm-seo-plugin/classes/crawl/render_chrome.go
|
||||
- ../sm-seo-plugin/classes/crawl/compare.go
|
||||
- ../sm-seo-plugin/classes/issues.go
|
||||
- ../sm-seo-plugin/classes/settings_write.go
|
||||
- ../sm-seo-plugin/classes/partials.go
|
||||
- ../sm-seo-plugin/models/crawl.go
|
||||
- ../sm-seo-plugin/models/crawl/columns.yaml
|
||||
- ../sm-seo-plugin/models/crawl/fields.yaml
|
||||
- ../sm-seo-plugin/models/crawl_page.go
|
||||
- ../sm-seo-plugin/models/issue.go
|
||||
- ../sm-seo-plugin/models/issue/columns.yaml
|
||||
- ../sm-seo-plugin/models/issue/fields.yaml
|
||||
- ../sm-seo-plugin/models/settings.go
|
||||
- ../sm-seo-plugin/models/settings/fields.yaml
|
||||
- ../sm-seo-plugin/models/registry.go
|
||||
- ../sm-seo-plugin/updates/202610280005_create_golem15_seo_crawls.go
|
||||
- ../sm-seo-plugin/updates/202610280006_add_golem15_seo_audit_settings.go
|
||||
- ../sm-seo-plugin/updates/registry.go
|
||||
- ../sm-seo-plugin/controllers/admin_registry.go
|
||||
- ../sm-seo-plugin/controllers/issues.go
|
||||
- ../sm-seo-plugin/controllers/issues/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/issues/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/issues/config_filter.yaml
|
||||
- ../sm-seo-plugin/controllers/issues/_issue_details.htm
|
||||
- ../sm-seo-plugin/controllers/crawls.go
|
||||
- ../sm-seo-plugin/controllers/crawls/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/crawls/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/crawls/config_filter.yaml
|
||||
- ../sm-seo-plugin/controllers/crawls/_crawl_summary.htm
|
||||
- ../sm-seo-plugin/controllers/api/crawls.go
|
||||
- ../sm-seo-plugin/controllers/api/issues.go
|
||||
- ../sm-seo-plugin/admin_navigation.go
|
||||
- ../sm-seo-plugin/jobs.go
|
||||
- ../sm-seo-plugin/schedule.go
|
||||
- ../sm-seo-plugin/commands.go
|
||||
- ../sm-seo-plugin/menu_item_types.go
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/mcp.go
|
||||
- ../sm-seo-plugin/operations.go
|
||||
- ../sm-seo-plugin/config/config.yaml
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/go.mod
|
||||
- ../sm-seo-plugin/go.sum
|
||||
- ../sm-seo-plugin/crawler_test.go
|
||||
- ../sm-seo-plugin/crawl_site_test.go
|
||||
- ../sm-seo-plugin/crawl_render_test.go
|
||||
- .planning/notes/2026-10-28-chromedp-rendered-crawl.md
|
||||
- .planning/research/STACK.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-14, D-46, D-51, D-70, D-71, D-80, D-81, D-82, D-85, D-24, D-50, D-10, D-94]
|
||||
|
||||
estimate:
|
||||
tokens: 215000
|
||||
raw_tokens: 215000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A built-in crawler runs as a conga job (on demand from SEO › Crawls or Site audit, from MCP, or on the crawl_schedule setting off/daily/weekly at night): it fetches the site's own pages over HTTP from the home page, the sitemap index and its files, and same-host links, with at most two requests at a time, crawl_delay between requests (default 250 ms), a 10 s timeout, a 5 MiB body cap and crawl_max_pages (default 5000); one crawl runs at a time and older crawls beyond crawl_keep are deleted with their pages and issues (D-14)"
|
||||
- "Outbound requests go through a fetchguard client limited to the site's host (and its www twin), HTTPS only and without following redirects; plain http (staging, local) works only when the config key golem15.seo.crawl_trusted is true (D-14, security)"
|
||||
- "The crawler reports per URL every crawl kind of UI-SPEC C23: page_status, sitemap_error, sitemap_noindex, sitemap_blocked, sitemap_missing, broken_link, redirected_link, canonical_missing, canonical_error, missing/duplicate title and description, h1_missing, h1_multiple, heading_skipped, heading_empty, h1_title_mismatch, jsonld_invalid, jsonld_multiple, jsonld_required, jsonld_recommended, hreflang_reciprocal, hreflang_error, generic_anchor, image_alt, image_size, featured_lazy, content_js and orphan_page, with the severities of the kinds table (D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24)"
|
||||
- "The raw-HTML checks always run: the server-rendered HTML fetched without JavaScript is checked against CMS expectations (the descriptor title must appear in the H1, the word count must reach half of the descriptor's Words, the CMS-known links of the page must be present); a miss is content_js ('Content needs JavaScript') with details.check cms (D-70, D-71; CONTEXT 2026-10-28 rendered comparison: raw-HTML checks always run)"
|
||||
- "When the config key golem15.seo.crawl_chrome_path names a Chrome or Chromium binary, the crawler also renders up to golem15.seo.crawl_render_max pages (default 200, in crawl order) in headless Chrome through github.com/chromedp/chromedp and compares each with its raw HTML: same-host links, an H1 or a substantial amount of text that appear only after JavaScript are reported as content_js with details.check rendered and details.what naming them; without a Chrome path the crawl records render_status skipped, and P6, GET /_seo/api/v1/crawls/{id} and seo_get_crawl say that the comparison was skipped; a renderer failure records render_status failed with its note and never fails the crawl (D-70; CONTEXT 2026-10-28 rendered comparison: optional chromedp; P33-55)"
|
||||
- "The headless browser is held to the crawler's fetchguard limits: Chrome resolves only the site host and its www twin, pinned through --host-resolver-rules to addresses checked with fetchguard.IsPrivateAddr (private addresses refused unless golem15.seo.crawl_trusted is true) while every other host resolves to NOTFOUND; every request is intercepted and failed unless it is https (http only in trusted mode) to an allowed host; each crawl uses a fresh temporary profile deleted afterwards, with no cookies, credentials or extensions and downloads denied; each page has a 20 s timeout and a 5 MiB DOM cap, one tab renders at a time, and Chrome is closed when the crawl finishes, fails or is cancelled (D-14 security, P33-55)"
|
||||
- "CMS-data checks run at the end of every crawl (and when the list is opened before any crawl) and are stored with the crawl's issues under source cms; they now include image_alt from the descriptors' images (empty alt that is not decorative) and h1_in_content, which runs only for records whose descriptor sets TopHeading 2 (Journal posts and, since the 2026-10-28 H1 conversion scope, categories' pillar text), so it is off when Journal's H1 setting is off (D-85, D-50, 2026-10-28 Journal H1 setting)"
|
||||
- "Every data issue names the MCP tool that fixes it (seo_ tools for SEO data, the content type's ContentDescriptor.Tools for content: edit, headings, image_alt), and an issue caused by the site's template code carries fix code instead (UI-SPEC C23); no issue has neither (SC-3, D-10; CONTEXT 2026-10-28 success criterion 3 reading)"
|
||||
- "orphan_page lists indexable sitemap pages with no inbound internal link in the crawl, with suggestions of pages whose text mentions the page's title or focus keyphrase (D-82)"
|
||||
- "SEO › Site audit lists the stored issues (errors first) with the C23 columns, filters (status defaults to Open), the P7 preview (what was found, how to fix, page and Rich Results links), and the record actions Check this address again, Ignore and Stop ignoring plus bulk Ignore; ignored issues stay ignored in later crawls (UI-SPEC C23)"
|
||||
- "SEO › Crawls lists runs with the C24 columns, the P6 preview (one status callout, stats strip, per-kind table linking to the filtered audit), Start crawl (toolbar, also on Site audit) refusing with crawls.already_running or crawls.no_site_url, Cancel on a queued or running crawl that keeps what was fetched, and delete refusing a running crawl with crawls.delete_running (UI-SPEC C24)"
|
||||
- "The SEO settings get the Site audit tab: crawl_schedule, crawl_max_pages (10–50 000), crawl_delay (100–5000 ms), crawl_keep (1–50) and generic_anchors seeded with the en and pl lists (UI-SPEC C20, Q-1)"
|
||||
- "MCP has seo_start_crawl, seo_cancel_crawl, seo_list_crawls, seo_get_crawl, seo_delete_crawls, seo_list_issues (filters severity, kind, source, fix, status, url, crawl), seo_recheck_url, seo_ignore_issues and seo_unignore_issues; TestEveryAdminOperationHasTool maps every Site audit and Crawls operation (D-10)"
|
||||
- "On a non-production environment noindex and robots caused by the staging lock are never reported, and P6 shows crawls.staging_text (RESEARCH Pitfall 12, UI-SPEC partial: non-production)"
|
||||
- "The existing GET /_seo/api/v1/issues answer keeps its Phase 32 keys and gains id, severity, source, fix, found_at, ignored and crawl_id"
|
||||
- "Site audit and Crawls show their empty-state copy issues.list_empty and crawls.list_empty (UI-SPEC empty)"
|
||||
- "Lists, forms and previews show the existing skeletons while loading; a running or queued crawl shows its P6 callout and asks to reload, with no polling (UI-SPEC loading, Q-5)"
|
||||
- "A refused start shows the action-error toast; a failed crawl shows the P6 danger callout with the error and keeps the pages and issues fetched before the failure (UI-SPEC error)"
|
||||
- "The audit list puts errors first with severity, kind and summary columns and hides ignored rows by default; the crawl preview shows its status callout, stats strip and per-kind table with filter links (UI-SPEC populated)"
|
||||
- "Plural copy uses CLDR maps for rechecked, every ignore and delete confirm, and the other counted phrases of this phase (jsonValid, outlineCount, keyphrases_added, h1_converted in their plans) (UI-SPEC zero-one-many)"
|
||||
- "Issue and crawl lists use the existing pagination and .summer-table-wrap scrolling; P7 lists at most 10 linking pages then issues.and_more; long URLs wrap and are set in DM Mono in P6, P7 (UI-SPEC overflow, long-text)"
|
||||
- {statement: "A crawl of the planted test site finds each planted issue exactly once and no issue on its clean control page", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-14"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The crawler never requests a host other than the site's own (no redirect following, no third-party links, no private address unless trusted mode is configured in Go config)"
|
||||
- requirement_id: "D-14"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The crawler never changes data: it reads pages and writes only its own crawl and issue tables"
|
||||
- requirement_id: "D-70"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "The headless browser never loads a URL outside the site's host and its www twin, never reaches a private address unless trusted mode is configured in Go config, and never carries cookies or credentials; its executable path comes only from Go config, never from an admin setting or MCP"
|
||||
artifacts:
|
||||
- path: "../sm-seo-plugin/classes/crawl/crawler.go"
|
||||
provides: "the crawl job: frontier, politeness, limits, cancel"
|
||||
contains: "fetchguard"
|
||||
- path: "../sm-seo-plugin/classes/crawl/checks.go"
|
||||
provides: "per-page and site-wide checks"
|
||||
contains: "h1_multiple"
|
||||
- path: "../sm-seo-plugin/controllers/issues/_issue_details.htm"
|
||||
provides: "P7"
|
||||
contains: "summer-callout"
|
||||
- path: "../sm-seo-plugin/crawler_test.go"
|
||||
provides: "TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped, TestCrawlAdmin, TestCrawlTools, TestCrawlSchedule"
|
||||
contains: "func TestCrawlerTracer("
|
||||
- path: "../sm-seo-plugin/classes/crawl/compare.go"
|
||||
provides: "compareRendered: raw versus rendered page facts (pure)"
|
||||
contains: "func compareRendered("
|
||||
- path: "../sm-seo-plugin/classes/crawl/render_chrome.go"
|
||||
provides: "the chromedp-backed Renderer with the request guard"
|
||||
contains: "chromedp"
|
||||
- path: ".planning/notes/2026-10-28-chromedp-rendered-crawl.md"
|
||||
provides: "decision note naming github.com/chromedp/chromedp, committed before the go.mod change"
|
||||
contains: "github.com/chromedp/chromedp"
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/classes/crawl/crawler.go"
|
||||
to: "summercms.go/modules/fetchguard"
|
||||
via: "a fetchguard.Client with AllowHostsMode on the site host (TrustedMode only from config)"
|
||||
pattern: "fetchguard\\.NewClient"
|
||||
- from: "../sm-seo-plugin/classes/crawl/checks.go"
|
||||
to: "../sm-seo-plugin/classes/schema/validate.go"
|
||||
via: "rendered JSON-LD validated with the same rules as E9"
|
||||
pattern: "schema\\.Validate"
|
||||
- from: "../sm-seo-plugin/classes/crawl/fix.go"
|
||||
to: "summercms.go/modules/pact/content.go"
|
||||
via: "content issues name ContentDescriptor.Tools[edit|headings|image_alt]"
|
||||
pattern: "Tools\\["
|
||||
- from: "../sm-seo-plugin/classes/crawl/render_chrome.go"
|
||||
to: "summercms.go/modules/fetchguard"
|
||||
via: "the site host and its www twin are resolved and checked with fetchguard.IsPrivateAddr before Chrome starts; the checked addresses go into --host-resolver-rules"
|
||||
pattern: "fetchguard\\.IsPrivateAddr"
|
||||
- from: "../sm-seo-plugin/classes/crawl/crawler.go"
|
||||
to: "../sm-seo-plugin/classes/crawl/compare.go"
|
||||
via: "after a page's raw fetch, a configured Renderer renders it and compareRendered turns the differences into content_js findings"
|
||||
pattern: "compareRendered"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
<!-- planner-discipline-allow: crawl_chrome_path -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan adds the audit: a built-in crawler that fetches the rendered site like a search engine, compares the raw HTML with the JavaScript-rendered page when a Chrome binary is configured, stores what it finds per URL with the CMS-data checks, and offers every issue to an MCP client with the tool that fixes it.
|
||||
|
||||
<objective>
|
||||
Extend ../sm-seo-plugin (main, not pushed):
|
||||
- the crawler as a conga job with storage, politeness limits and the SSRF guard (D-14);
|
||||
- the checks of UI-SPEC C23 (D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24) and the merged CMS issues with fix routes (SC-3, CONTEXT 2026-10-28 success criterion 3 reading);
|
||||
- the raw-versus-rendered comparison of D-70 through optional chromedp (CONTEXT 2026-10-28 rendered comparison), after its decision note and the legitimacy answer of 33-06 Task 4;
|
||||
- Site audit and Crawls screens (C23, C24), the Site audit settings tab (C20), the nightly schedule;
|
||||
- MCP tools for crawls and issues (D-10).
|
||||
|
||||
Purpose: ROADMAP success criterion 3 ("The crawler reports SEO issues per URL, and each issue can be fixed through MCP") and D-70's raw versus rendered check.
|
||||
|
||||
Output: commits in ../sm-seo-plugin; the chromedp decision note and STACK row as a planning-docs commit here before sm-seo-plugin's go.mod changes.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-37 (tables):** golem15_seo_crawls (id, status queued|running|finished|failed|cancelled, trigger admin|schedule|mcp, trigger_name, site, max_pages, pages, limit_hit, error, render_status skipped|done|failed (default skipped), render_pages (default 0), render_note, job_id, started_at, finished_at, created_at); golem15_seo_crawl_pages (crawl_id, url, status, location, content_type, title, description, canonical, robots, x_robots, h1 jsonb, headings jsonb, words, text (the page's main text as plain text, capped at 100 000 characters, read by orphan suggestions here and by E10 in 33-11), depth, in_sitemap, sitemap_file, links_out, links_in, fetched_at); golem15_seo_crawl_links (crawl_id, from_url, to_url, text, rel, internal); golem15_seo_issues (id, crawl_id null, source crawl|cms, kind, severity error|warning|notice, url, locale, details jsonb, tool, fix tool|code, fingerprint, found_at); golem15_seo_issue_ignores (fingerprint unique, admin_id, ignored_at). The fingerprint is kind + url + locale + the kind's identifying detail, so an ignore survives later crawls.
|
||||
- **P33-38 (fix route):** `fix = tool` with the full tool name when a CMS value fixes it: SEO data → seo_set_url_seo, seo_update_robots, seo_create_redirect, seo_update_page; content → the record's ContentDescriptor.Tools (edit, headings, image_alt); otherwise `fix = code` with the template element named in details.what. A kind marked "MCP or Code" in C23 chooses per finding: content headings, links and JSON-LD inside the record's own content (descriptor Headings or body links, stored custom JSON-LD) are tool when the record's type names a fitting tool (H1 findings prefer Tools[headings], other content findings use Tools[edit]); elsewhere, and for a content finding whose type names no fitting tool (Journal categories name only headings, so a skipped level in pillar text stays as before), code. This is the CONTEXT 2026-10-28 success criterion 3 reading: data issues name a tool, template issues carry fix code.
|
||||
- **P33-39 (one crawl at a time):** start refuses when a crawl is queued or running (row check in the start transaction with a row lock), since conga has no unique jobs.
|
||||
- **P33-40 (schedule):** pact.HasSchedule entry `{Command: "seo:crawl", Args: ["--scheduled"], Cadence: pact.DailyAt(2, 30)}`; with --scheduled the command does nothing when crawl_schedule is off and runs weekly crawls only on Mondays in the app's location.
|
||||
- **P33-41 (crawler identity):** User-Agent `SummerCMS-SEO-Crawler/1 (+<site_url>)`; robots.txt rules are not obeyed for the site's own audit (they are checked and reported instead).
|
||||
- **P33-55 (rendered comparison, CONTEXT 2026-10-28 rendered comparison: optional chromedp):**
|
||||
- Seam: `classes/crawl/render.go` defines `Renderer` (`Render(ctx, pageURL string) (string, error)`, `Close() error`); the crawl holds nil when the config key `golem15.seo.crawl_chrome_path` is empty and then records render_status skipped. The decision allows a setting or a config key; a config key (environment-overridable like every key) is used because an admin- or MCP-writable executable path would let any holder of golem15.seo.manage make the server run an arbitrary binary (T-33-69).
|
||||
- Scope and limits: with a path, the crawler renders up to `golem15.seo.crawl_render_max` pages (default 200, the first in crawl order, so the home page and top-level pages come first) one tab at a time right after each page's raw fetch, 20 s per page, DOM read with chromedp's OuterHTML and capped at 5 MiB; the User-Agent is the P33-41 one.
|
||||
- Comparison: `compareRendered(raw, rendered PageFacts) []Finding` in compare.go (pure, parse.go extracts the facts from both) reports content_js with details.check rendered and details.what naming same-host links present only after JavaScript (up to 5 paths), an H1 present only after JavaScript, or main text that grows by at least 50 words and 20 % after JavaScript. The raw checks against CMS expectations keep details.check cms; the fingerprint includes check, so the two never merge.
|
||||
- Guard (render_chrome.go): before Chrome starts, the site host and its www twin are resolved and each address checked with fetchguard.IsPrivateAddr (refused unless golem15.seo.crawl_trusted); the checked addresses go into `--host-resolver-rules` as MAP lines after a leading `MAP * ~NOTFOUND`; Fetch-domain interception (`fetch.Enable`, `chromedp.ListenTarget` on `fetch.EventRequestPaused`) continues a request only when `allowRequest(url, hosts, trusted)` passes (https, http only in trusted mode, data: and blob: allowed, host in the list) and fails every other one with BlockedByClient; a fresh profile from os.MkdirTemp passed as the user data dir and removed in Close; downloads denied through `browser.SetDownloadBehavior`; chromedp's default allocator flags without additions that weaken the sandbox. Third-party scripts are therefore never loaded; the README and the note say so.
|
||||
- Reporting: render_status done with render_pages, or failed with render_note (Chrome missing, a private address, a launch error); P6 shows one extra plain line like the staging one (`crawls.render_skipped`, `crawls.render_done`, `crawls.render_failed`); the crawl API and seo_get_crawl answer `render {status, pages, note}`.
|
||||
- Copy (new keys, UI-SPEC C24 conventions): `crawls.render_skipped` EN "The comparison with the JavaScript-rendered pages was skipped: no Chrome path is configured (golem15.seo.crawl_chrome_path). The raw-HTML checks ran." PL "Porównanie ze stronami wyrenderowanymi przez JavaScript pominięto: nie skonfigurowano ścieżki do Chrome (golem15.seo.crawl_chrome_path). Sprawdzenia surowego HTML zostały wykonane."; `crawls.render_done` EN one ":count page was compared with its JavaScript-rendered version." other ":count pages were compared with their JavaScript-rendered versions." PL CLDR one/few/many/other "Porównano :count stronę/strony/stron/strony z wersją wyrenderowaną przez JavaScript."; `crawls.render_failed` EN "The comparison with the JavaScript-rendered pages stopped: :error. The raw-HTML checks ran." PL "Porównanie ze stronami wyrenderowanymi przez JavaScript zostało przerwane: :error. Sprawdzenia surowego HTML zostały wykonane."
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-05-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
fetchguard: NewClient(Policy{Mode: AllowHostsMode, AllowHosts, MaxBytes, Timeout}, cfg) / TrustedMode; Client.Get(ctx, url, header) → Result{StatusCode, Header, Body}; 3xx returned, never followed; WithTransport(ctx, rt) for tests; IsPrivateAddr(netip.Addr) bool (the dial guard's classification, for the Chrome resolver pinning).
|
||||
chromedp (Task 4, version from 33-06-SUMMARY.md): NewExecAllocator(ctx, opts...) with DefaultExecAllocatorOptions, ExecPath, UserDataDir, Flag("host-resolver-rules", …), UserAgent; NewContext, Run, Navigate, WaitReady, OuterHTML, ListenTarget, Cancel; cdproto packages fetch (Enable with RequestPattern, EventRequestPaused, ContinueRequest, FailRequest), network (ErrorReasonBlockedByClient), browser (SetDownloadBehavior deny). Chromium 154 is installed on the development machine (RESEARCH Environment Availability); production servers have none unless an operator installs it.
|
||||
golang.org/x/net/html (already in the dependency graph through sm-journal-plugin and the framework) for parsing; grobotstxt for robots checks (Phase 32).
|
||||
conga: Manager.Dispatch(ctx, db, args, DispatchOpts{Label, Count}), StartJob, UpdateJobState, CheckIfCanceled, CancelJob; conga.JobID(ctx).
|
||||
sm-seo-plugin: classes/issues.go (Issue{Kind, URL, Locale, Details, Tool}, CMS kinds), classes/schema (Build, Validate from 33-06), classes/sitemap_filter.go and contract.Listing (33-05), Service.Staging(), SiteURL(); controllers/notfound.go + config (record actions, bulk actions, row states pattern); controllers/history/_history_diff.htm (partial with summer-table); operations.go / operations_test.go; mcp.go.
|
||||
pact.ContentDescriptor.Tools, ContentRecord.TopHeading, ContentLocale.Images/Headings (33-01).
|
||||
Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details cells), C24, C20 Site audit tab, Destructive actions.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; lang keys in both files per group. Task 4's decision note and STACK row are a planning-docs commit here, staged by path, made before sm-seo-plugin's go.mod changes.
|
||||
- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`; for the note, `git log --format=%s | grep -Fx 'docs(33-10): chromedp decision note and STACK row'` here.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `feat: crawler finds a page with two H1 headings and stores the issue (tracer)` | TestCrawlerTracer passes |
|
||||
| 2 | A `feat: crawler checks for status, sitemaps, canonical, titles and headings`; B `feat: crawler checks for JSON-LD, hreflang, links, images, raw HTML and the rendered-comparison seam`; C `feat: orphan pages and merged CMS issues with fix routes` | TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped pass |
|
||||
| 3 | A `feat: Site audit and Crawls screens`; B `feat: site audit settings and the nightly crawl`; C `feat: MCP tools for crawls and issues`; D `docs: crawler and site audit in README` | admin, schedule and tool tests pass |
|
||||
| 4 | here: A `docs(33-10): chromedp decision note and STACK row`; seo: B `feat: rendered comparison through headless Chrome` | TestChromeRenderer passes; plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an editor starts a crawl, the crawler fetches a page with two H1 headings, and the issue appears in the issues API with how to fix it" — one path only</name>
|
||||
<files>../sm-seo-plugin/models/crawl.go, ../sm-seo-plugin/models/crawl_page.go, ../sm-seo-plugin/models/issue.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610280005_create_golem15_seo_crawls.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/classes/crawl/crawler.go, ../sm-seo-plugin/classes/crawl/fetch.go, ../sm-seo-plugin/classes/crawl/parse.go, ../sm-seo-plugin/classes/crawl/checks.go, ../sm-seo-plugin/classes/crawl/store.go, ../sm-seo-plugin/classes/crawl/fix.go, ../sm-seo-plugin/jobs.go, ../sm-seo-plugin/controllers/api/crawls.go, ../sm-seo-plugin/controllers/api/issues.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/crawler_test.go, ../sm-seo-plugin/crawl_site_test.go</files>
|
||||
<read_first>
|
||||
- summercms.go/modules/fetchguard/README.md and fetch.go (Client, WithTransport, Result)
|
||||
- ../sm-seo-plugin/jobs.go, classes/generate.go (job and dispatch), classes/issues.go, controllers/api/issues.go, controllers/api/auth.go
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 7, Pitfall 12, Security Domain)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-14 and P33-37 to P33-41: migration 202610280005 creates the P33-37 tables; models for crawls, pages and issues. `classes/crawl`: fetch.go builds the client (AllowHostsMode with the site host and its www twin, HTTPS; TrustedMode when config `golem15.seo.crawl_trusted` is true; MaxBytes 5 MiB; Timeout 10 s; the P33-41 User-Agent); crawler.go runs a breadth-first frontier from the home page and the sitemap index (only same-host URLs, two workers, the delay, max pages, CheckIfCanceled between pages, progress through UpdateJobState); parse.go extracts status, Location, title, meta description, canonical, meta robots, X-Robots-Tag, headings, links with text and rel, images with alt/size/loading, JSON-LD scripts and hreflang links with golang.org/x/net/html; checks.go starts with h1_missing and h1_multiple; store.go writes pages, links and issues; fix.go applies P33-38. jobs.go registers `CrawlArgs` (kind golem15.seo.crawl). Routes in the management group: `POST /_seo/api/v1/crawls` (start, P33-39 refusals 409 crawls.already_running / 422 crawls.no_site_url), and `GET /_seo/api/v1/issues` returns stored issues with the Phase 32 keys plus id, severity, source, fix, found_at, ignored, crawl_id. `crawl_site_test.go` holds an httptest site served through fetchguard.WithTransport (home, a page with two H1, sitemap index and file). `TestCrawlerTracer` (Docker): start a crawl through the API as an administrator, run the job, GET /issues lists h1_multiple for that URL with severity error and a fix (code for a template H1, tool for an H1 inside a fake record's content Headings). Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlerTracer' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestCrawlerTracer" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "fetchguard.NewClient\|AllowHostsMode" ../sm-seo-plugin/classes/crawl/fetch.go` prints at least one line
|
||||
- `grep -n "golem15.seo.crawl" ../sm-seo-plugin/jobs.go ../sm-seo-plugin/classes/crawl/crawler.go` prints at least one line
|
||||
- `grep -n "crawl_trusted" ../sm-seo-plugin/config/config.yaml` prints one line
|
||||
- the Phase 32 issues tests (TestIssues) still pass
|
||||
</acceptance_criteria>
|
||||
<done>A crawl started from the API audits the rendered site and its first finding is listed with how to fix it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: The full set of crawl checks, the rendered-comparison seam, orphan pages, merged CMS issues with fix routes, ignore fingerprints and staging suppression</name>
|
||||
<files>../sm-seo-plugin/classes/crawl/checks.go, ../sm-seo-plugin/classes/crawl/links.go, ../sm-seo-plugin/classes/crawl/orphans.go, ../sm-seo-plugin/classes/crawl/parse.go, ../sm-seo-plugin/classes/crawl/fix.go, ../sm-seo-plugin/classes/crawl/store.go, ../sm-seo-plugin/classes/crawl/crawler.go, ../sm-seo-plugin/classes/crawl/render.go, ../sm-seo-plugin/classes/crawl/compare.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/menu_item_types.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/crawler_test.go, ../sm-seo-plugin/crawl_site_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/classes/issues.go (CMS kinds and their tools), classes/schema/validate.go (33-06), classes/robots.go (grobotstxt matcher)
|
||||
- ../sm-sitemap-plugin/contract/contract.go (Listing for sitemap consistency)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C23 kinds table: severity, fix, labels, details)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestCrawlerChecks: the planted site has one page per kind of C23 and a clean control page; each kind is reported exactly once for its page with its severity and details keys (status, file, line, count, target, from/to, locale, text, what); the control page has none
|
||||
- sitemap_error for a sitemap URL answering 404; sitemap_noindex for a listed noindex page; sitemap_blocked for a listed URL robots.txt blocks; sitemap_missing for an indexable page found only through links
|
||||
- broken_link with the linking pages; redirected_link with the final target; generic_anchor for "kliknij tutaj" and "click here" (settings list, case-insensitive whole text)
|
||||
- jsonld_invalid for unparsable JSON, jsonld_multiple for two scripts, jsonld_required / jsonld_recommended from schema.Validate; FAQPage nodes have no rules
|
||||
- hreflang_reciprocal when /en/x lists /pl/x but /pl/x does not list /en/x; hreflang_error when an alternate answers 404
|
||||
- image_alt for an img without alt (alt="" is fine), image_size without width/height, featured_lazy when the og:image's img is lazy
|
||||
- content_js (check cms) when the H1 lacks the descriptor title, when words < half of Words, or when a CMS-known link is absent from the raw HTML
|
||||
- With a fake Renderer (test seam) whose rendered HTML adds a same-host link, an H1 or 80 words of text to a planted page, content_js (check rendered) names that link, the H1 or the word growth; an identical rendering adds nothing; the crawl row records render_status done and render_pages
|
||||
- TestRenderSkipped: without golem15.seo.crawl_chrome_path the crawl finishes with every raw check, render_status skipped and no rendered finding; a Renderer whose Render fails records render_status failed with its note and the crawl still finishes
|
||||
- orphan_page for a sitemap page no crawled page links to, with suggestions (pages mentioning its title or focus keyphrase)
|
||||
- TestIssueMerge: CMS issues are stored with source cms at the end of the crawl; image_alt (CMS) names the record type's Tools[image_alt]; h1_in_content appears for a record with TopHeading 2 and an H1 in Headings (a post and a category) and not for TopHeading 0, naming Tools[headings]; a heading_skipped inside a category's pillar text (type without an edit tool) carries fix code; every stored issue has tool or fix code
|
||||
- Ignored fingerprints stay ignored after a second crawl; a fixed page's issues are gone after the second crawl
|
||||
- TestStagingCrawl: with the staging lock every page carries noindex, and no noindex-derived kind is reported
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24 and P33-38: implement each check in checks.go/links.go/orphans.go with severities and details per the C23 table; sitemap consistency reads the fetched sitemap files plus contract.Listing; JSON-LD uses schema.Validate on each parsed graph; title/description duplicates are computed over the crawl; heading checks compare levels in document order; content_js uses the descriptor of the page's record (MatchPath) for title, Words and CMS links (details.check cms). Per P33-55 and the CONTEXT 2026-10-28 rendered comparison, render.go adds the `Renderer` seam and the crawl's render bookkeeping (nil renderer → render_status skipped; Render errors → failed with the note, the crawl continues), compare.go the pure `compareRendered` over the PageFacts parse.go extracts from raw and rendered HTML, and crawler.go calls it after each raw fetch for the first crawl_render_max pages when a renderer exists; the tests inject a fake renderer through an unexported crawl option (the chromedp renderer arrives in Task 4). Orphans use the stored link graph and the descriptor titles plus the SEO entries' focus keyphrases for suggestions (at most 10 each). issues.go gains the CMS kinds image_alt and h1_in_content (descriptor-driven, the latter only when TopHeading is 2) and stores CMS issues at crawl end. The seo-page descriptor gets Tools {edit: seo_update_page}. fix.go applies P33-38 including the fitting-tool rule (H1 findings → Tools[headings], other content findings → Tools[edit], code when the type names none). Fingerprints and the ignore table per P33-37 (content_js includes details.check); staging suppression per Pitfall 12. Every kind's label and summary keys of C23 in both lang files. Tests first, against the planted httptest site. Commit groups A, B, C.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlerChecks|TestIssueMerge|TestStagingCrawl|TestRenderSkipped|TestCrawlerTracer|TestIssues' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestCrawlerChecks, TestIssueMerge, TestStagingCrawl or TestRenderSkipped missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "issues.kind_" ../sm-seo-plugin/lang/en/lang.yaml` and the same in lang/pl/lang.yaml are each at least 33
|
||||
- `grep -n "schema.Validate" ../sm-seo-plugin/classes/crawl/checks.go` prints at least one line
|
||||
- TestIssueMerge asserts that no stored issue has an empty tool together with fix "tool"
|
||||
- `grep -n "func compareRendered(" ../sm-seo-plugin/classes/crawl/compare.go` and `grep -n "type Renderer interface" ../sm-seo-plugin/classes/crawl/render.go` each print one line
|
||||
- this task adds no module to ../sm-seo-plugin/go.mod (the headless-browser dependency waits for Task 4's decision note)
|
||||
</acceptance_criteria>
|
||||
<done>The audit covers every planned check, compares raw and rendered pages whenever a renderer exists and reports the comparison as skipped otherwise, merges CMS issues and routes each issue to an MCP tool or to the site's code.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Site audit and Crawls screens, the Site audit settings tab, the nightly crawl and the MCP tools</name>
|
||||
<files>../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/issues.go, ../sm-seo-plugin/controllers/issues/config_list.yaml, ../sm-seo-plugin/controllers/issues/config_form.yaml, ../sm-seo-plugin/controllers/issues/config_filter.yaml, ../sm-seo-plugin/controllers/issues/_issue_details.htm, ../sm-seo-plugin/controllers/crawls.go, ../sm-seo-plugin/controllers/crawls/config_list.yaml, ../sm-seo-plugin/controllers/crawls/config_form.yaml, ../sm-seo-plugin/controllers/crawls/config_filter.yaml, ../sm-seo-plugin/controllers/crawls/_crawl_summary.htm, ../sm-seo-plugin/models/crawl/columns.yaml, ../sm-seo-plugin/models/crawl/fields.yaml, ../sm-seo-plugin/models/issue/columns.yaml, ../sm-seo-plugin/models/issue/fields.yaml, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/classes/partials.go, ../sm-seo-plugin/updates/202610280006_add_golem15_seo_audit_settings.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/controllers/api/crawls.go, ../sm-seo-plugin/controllers/api/issues.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/schedule.go, ../sm-seo-plugin/commands.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/crawler_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/controllers/notfound.go and controllers/notfound/config_*.yaml, _hint.htm (record and bulk actions, preview headerPartial, row states)
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go (Applies/Run record actions, toolbar actions)
|
||||
- summercms.go/modules/pact/capabilities.go (AdminAction toolbar actions, AdminRecordAction, AdminBulkAction, RowState) and docs/plugins/scheduling.md
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C20 Site audit tab, C23, C24, their copy, Destructive actions, MCP parity)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestCrawlAdmin: the Site audit list sorts errors first, hides ignored rows with the default status filter, shows source and fix columns; the preview renders P7 with the C23 sections; recheck re-fetches one URL and toasts issues.rechecked or recheck_fixed; ignore and bulk ignore set the fingerprint; unignore clears it; the Crawls list sorts newest first; the preview renders P6 with one status callout (running, queued, failed danger, cancelled, limit warning, staging extra) and the extra plain render line of P33-55 (render_skipped naming the config key, render_done with the count, render_failed with the note); GET /_seo/api/v1/crawls/{id} and seo_get_crawl answer render {status, pages, note}; cancel keeps fetched pages; deleting a running crawl answers 403 crawls.delete_running
|
||||
- Start crawl twice → the second refuses with crawls.already_running naming the start time; without a site URL → crawls.no_site_url
|
||||
- Settings Site audit tab validates the C20 ranges with validation.between; generic_anchors seeded with the en and pl lists
|
||||
- TestCrawlSchedule: seo:crawl --scheduled with crawl_schedule off does nothing, daily dispatches a crawl with trigger schedule, weekly dispatches only on Mondays; crawls beyond crawl_keep are deleted with their pages and issues
|
||||
- TestCrawlTools: the nine MCP tools are listed and bound to their routes; GET tools are read-only; TestEveryAdminOperationHasTool passes with both controllers and the toolbar actions
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-14, D-10, UI-SPEC C15/C20/C23/C24 and P33-39/P33-40: controllers `golem15.seo.issues` (Site audit: list → preview, toolbar action start_crawl, bulk action ignore, record actions recheck, ignore, unignore with Applies, row state disabled issues.state_ignored, filters severity, kind, source, fix, status default open) and `golem15.seo.crawls` (Crawls: list → preview, toolbar [start_crawl, delete], record action cancel, filters status and started_at daterange); P6 `_crawl_summary.htm` (with the P33-55 render line as an extra plain paragraph, like the staging one) and P7 `_issue_details.htm` built from curated view models in classes/partials.go with the kit classes only; navigation order of C15 (Site audit icon gauge, Crawls icon globe after Pages; Auto-links comes in 33-11). Migration 202610280006 adds crawl_schedule, crawl_max_pages, crawl_delay, crawl_keep, generic_anchors with the four-place settings pattern and the Site audit tab. Management routes: `GET /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `DELETE /_seo/api/v1/crawls` (ids), `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore` and filters on `GET /issues`; MCP tools start_crawl, cancel_crawl, list_crawls, get_crawl, delete_crawls (destructive), list_issues, recheck_url, ignore_issues, unignore_issues with AdminOperations rows. schedule.go per P33-40 and `seo:crawl [--scheduled]` in commands.go (documented). README: crawler, limits, trusted mode, checks, fix routes, screens, tools, schedule (the rendered comparison section follows in Task 4). Every C23/C24/C20 key and the P33-55 render keys in both lang files. Tests first. Commit groups A to D.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlAdmin|TestCrawlSchedule|TestCrawlTools|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestCrawlerChecks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestCrawlAdmin, TestCrawlSchedule, TestCrawlTools or TestEveryAdminOperationHasTool missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"start_crawl"\|"cancel_crawl"\|"list_crawls"\|"get_crawl"\|"delete_crawls"\|"recheck_url"\|"ignore_issues"\|"unignore_issues"' ../sm-seo-plugin/mcp.go` prints eight lines
|
||||
- `grep -n "seo:crawl" ../sm-seo-plugin/schedule.go ../sm-seo-plugin/commands.go` prints a line in each
|
||||
- `grep -n "golem15.seo.issues\|golem15.seo.crawls" ../sm-seo-plugin/admin_navigation.go` prints two lines
|
||||
- `grep -c "render_skipped\|render_done\|render_failed" ../sm-seo-plugin/lang/en/lang.yaml` and the same in lang/pl/lang.yaml are each at least 3
|
||||
</acceptance_criteria>
|
||||
<done>Editors and MCP clients can run, follow, read, recheck and ignore the audit; it also runs on a schedule.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Rendered comparison through headless Chrome: chromedp decision note, the guarded renderer and its configuration</name>
|
||||
<files>.planning/notes/2026-10-28-chromedp-rendered-crawl.md, .planning/research/STACK.md, ../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/classes/crawl/render.go, ../sm-seo-plugin/classes/crawl/render_chrome.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/crawl_render_test.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md (top: the approved chromedp version from 33-06 Task 4)
|
||||
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md (decision note format) and .planning/research/STACK.md (row format)
|
||||
- ../sm-seo-plugin/classes/crawl/render.go, crawler.go and compare.go (Task 2 seam), classes/crawl/fetch.go (host list, trusted mode, User-Agent)
|
||||
- summercms.go/modules/fetchguard/README.md and ip.go (IsPrivateAddr)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Alternatives considered: chromedp row; Environment Availability: Chromium)
|
||||
</read_first>
|
||||
<precondition>33-06 Task 4 answered "chromedp: approved vX.Y.Z" (recorded at the top of 33-06-SUMMARY.md); on "rejected", record that in this plan's SUMMARY, leave the Task 2 seam reporting the comparison as skipped, and stop this task</precondition>
|
||||
<behavior>
|
||||
- TestChromeRenderer (skipped under -short; runs when SEO_TEST_CHROME names a binary or chromium, chromium-browser or google-chrome is on PATH): against an httptest site in trusted mode, a page whose script adds a same-host link /js-only and two paragraphs renders with them, and compareRendered reports content_js check rendered naming /js-only; the page's third-party script (https://cdn.example.test/x.js) and a fetch to http://169.254.169.254/ are recorded as blocked and never reach a server; the temporary profile directory no longer exists after Close
|
||||
- In guarded mode a site host that resolves to a private address makes the renderer refuse to start (render_status failed, note names the private address) without launching Chrome
|
||||
- An empty or relative golem15.seo.crawl_chrome_path, or a path that is not an executable regular file, gives render_status skipped (empty) or failed (invalid) and the crawl still finishes with its raw checks
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-70, the CONTEXT 2026-10-28 rendered comparison (optional chromedp; a decision note before go.mod changes) and P33-55, in this order:
|
||||
- (A, here, before any go.mod change) write `.planning/notes/2026-10-28-chromedp-rendered-crawl.md`: the decision (raw-HTML checks always; the raw-versus-rendered comparison only when golem15.seo.crawl_chrome_path is configured, otherwise reported as skipped), the dependency github.com/chromedp/chromedp at the version approved in 33-06 Task 4, why (a maintained pure-Go DevTools client, no cgo and no Node runtime; RESEARCH Alternatives considered), that Chrome or Chromium is installed by the operator and is absent on servers by default, the P33-55 guard and limits (resolver pinning with fetchguard.IsPrivateAddr, request interception, temporary profile, no credentials, timeouts, one tab, the executable path only from config and why), that third-party scripts are not loaded during rendering, and that only sm-seo-plugin requires it (the framework is untouched, D-90); add the STACK.md row; commit as planning docs.
|
||||
- (B, ../sm-seo-plugin) `go get github.com/chromedp/chromedp@<approved>`. render.go: `NewRenderer(cfg, siteHosts, trusted)` reads crawl_chrome_path (empty → nil, skipped) and crawl_render_max (default 200), validates the path (absolute, an executable regular file, else failed with the note), and holds the pure helpers `allowRequest(rawURL string, hosts []string, trusted bool) bool` and `resolverRules(hosts map[string][]netip.Addr) string` (leading `MAP * ~NOTFOUND`, then one MAP line per allowed host). render_chrome.go: `chromeRenderer` resolving the site host and its www twin with net.DefaultResolver.LookupNetIP and refusing a private address unless trusted (fetchguard.IsPrivateAddr), building the allocator from chromedp.DefaultExecAllocatorOptions plus ExecPath, UserDataDir (os.MkdirTemp, removed in Close), Flag host-resolver-rules and the P33-41 UserAgent, and nothing that weakens Chrome's sandbox; per page a child context with a 20 s timeout, fetch.Enable for every request with chromedp.ListenTarget handling fetch.EventRequestPaused (ContinueRequest when allowRequest passes, FailRequest with network.ErrorReasonBlockedByClient otherwise, each blocked URL kept for the render note's count), browser.SetDownloadBehavior deny, Navigate, WaitReady body, OuterHTML of html capped at 5 MiB; Close cancels the allocator and removes the profile; crawler.go builds it when a crawl starts and closes it on finish, failure and CheckIfCanceled. config/config.yaml documents `crawl_chrome_path` (empty, commented: optional, operator-installed Chrome or Chromium) and `crawl_render_max: 200`. README: a Rendered comparison section (what is compared, the two keys, the guard, third-party scripts not loaded, render status in P6 and MCP) and the dependency line. `crawl_render_test.go` per the behavior list (tests first). `go vet ./...` and `go test -short ./...` green; commit B.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestChromeRenderer|TestRenderSkipped|TestCrawlerChecks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -15</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestChromeRenderer missing from the "--- PASS" lines (a SKIP fails this check: Chromium is installed on the development machine, RESEARCH Environment Availability)</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- the note's commit is older than the first sm-seo-plugin commit that adds chromedp to go.mod: `git log -1 --format=%ct -- .planning/notes/2026-10-28-chromedp-rendered-crawl.md` prints a smaller number than the last line of `git -C ../sm-seo-plugin log --format=%ct -S chromedp -- go.mod` (newest first, so the last line is the first such commit)
|
||||
- `grep -n "github.com/chromedp/chromedp" ../sm-seo-plugin/go.mod .planning/research/STACK.md .planning/notes/2026-10-28-chromedp-rendered-crawl.md` prints a line in each
|
||||
- `grep -n "host-resolver-rules" ../sm-seo-plugin/classes/crawl/render_chrome.go` and `grep -n "fetchguard.IsPrivateAddr" ../sm-seo-plugin/classes/crawl/render_chrome.go` each print at least one line
|
||||
- `grep -n "no-sandbox" ../sm-seo-plugin/classes/crawl/render_chrome.go` prints nothing
|
||||
- `grep -n "crawl_chrome_path\|crawl_render_max" ../sm-seo-plugin/config/config.yaml` prints two lines, and `grep -rn "crawl_chrome_path" ../sm-seo-plugin/models ../sm-seo-plugin/classes/settings_write.go` prints nothing (config only, never a setting)
|
||||
</acceptance_criteria>
|
||||
<done>With a Chrome path configured, crawls compare raw and JavaScript-rendered pages inside the same host and address limits as fetchguard; without one they say the comparison was skipped.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| crawler → the site (and anything its pages link to) | outbound HTTP driven by untrusted page content |
|
||||
| headless Chrome → the site and whatever its scripts request | a browser executing untrusted page JavaScript on the server |
|
||||
| crawled HTML → issue details → admin partials and MCP | untrusted text displayed to administrators |
|
||||
| admin/MCP → crawl actions | authenticated operations |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-43 | Elevation of privilege (SSRF) | crawler fetches | high | mitigate | fetchguard AllowHostsMode with only the site host and its www twin, HTTPS, private addresses blocked at dial time, redirects never followed (each hop re-checked as a new URL against the host list); TrustedMode only from Go config golem15.seo.crawl_trusted, never from page content; TestCrawlerChecks plants an external and a private-IP link that must not be fetched |
|
||||
| T-33-44 | Denial of service | crawl load on the own site | medium | mitigate | two concurrent requests, configurable delay (≥100 ms), max pages, one crawl at a time, nightly schedule |
|
||||
| T-33-45 | Denial of service | hostile page content | medium | mitigate | 5 MiB body cap, 10 s timeout, parser on x/net/html (no regex over HTML), at most 1000 links stored per page |
|
||||
| T-33-46 | Tampering (XSS in admin) | issue details from crawled text | high | mitigate | partials render view-model text through the template engine's escaping; MCP answers are JSON; details hold text, never HTML |
|
||||
| T-33-47 | Elevation of privilege | crawl and issue routes | high | mitigate | golem15.seo.manage on every route and action (manage wrapper), TestCrawlTools and the operations parity |
|
||||
| T-33-48 | Information disclosure | crawling staging | low | mitigate | trusted mode needed for http staging; staging-lock issues suppressed, P6 says why |
|
||||
| T-33-49 | Tampering | ignore fingerprints | low | accept | administrators may hide issues; unignore restores them and the ignore records author and time |
|
||||
| T-33-66 | Elevation of privilege (SSRF) | headless Chrome in the rendered comparison | high | mitigate | Chrome resolves only the site host and its www twin, pinned through --host-resolver-rules to addresses checked with fetchguard.IsPrivateAddr (private refused unless golem15.seo.crawl_trusted) with every other host mapped to NOTFOUND; every request is intercepted and failed unless https (http only in trusted mode) to an allowed host; TestChromeRenderer plants a third-party script and a link-local fetch and asserts both blocked |
|
||||
| T-33-67 | Denial of service | Chrome CPU and memory on the server | medium | mitigate | one tab at a time, at most crawl_render_max pages (default 200), 20 s per page, DOM capped at 5 MiB, Chrome closed on finish, failure and cancel; a renderer failure marks render_status failed and never fails the crawl |
|
||||
| T-33-68 | Information disclosure | browser state during rendering | medium | mitigate | a fresh temporary profile per crawl, removed in Close; no cookies, Authorization header, HTTP credentials or extensions; downloads denied; the crawler never signs in |
|
||||
| T-33-69 | Elevation of privilege | Chrome executable path | high | mitigate | Go config key only (golem15.seo.crawl_chrome_path, from config files or the environment), never an admin setting or MCP-writable value; absolute path to an executable regular file; Chrome's own sandbox stays on (chromedp default flags, nothing added that disables it) |
|
||||
| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | github.com/chromedp/chromedp is [ASSUMED] in RESEARCH's audit and gated by 33-06's blocking human legitimacy checkpoint (Task 4); the decision note and STACK row are committed before go.mod changes, and the approved version is pinned exactly in go.sum; x/net/html and grobotstxt are already required |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit.
|
||||
- Docker suite: TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped, TestCrawlAdmin, TestCrawlSchedule, TestCrawlTools and the earlier suites.
|
||||
- With Chromium on the machine: TestChromeRenderer passes (not skipped).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The crawler audits the rendered site within its limits and stays on the site's host.
|
||||
- Raw-HTML checks always run; with a Chrome path the raw and JavaScript-rendered pages are compared under the same host and address limits, and without one every crawl says the comparison was skipped (D-70).
|
||||
- Every issue is stored per URL with a severity and either a fixing tool or fix code.
|
||||
- Site audit, Crawls, the settings tab, the schedule and nine MCP tools work.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Package `classes/crawl`: crawler, fetch, parse, checks, links, orphans, store, fix, render (`Renderer`, `NewRenderer`, `allowRequest`, `resolverRules`), render_chrome (`chromeRenderer`), compare (`compareRendered`).
|
||||
- Dependency: `github.com/chromedp/chromedp` (sm-seo-plugin go.mod only); note `.planning/notes/2026-10-28-chromedp-rendered-crawl.md` and a STACK.md row.
|
||||
- Config keys: `golem15.seo.crawl_chrome_path` (empty: comparison skipped), `golem15.seo.crawl_render_max` (200).
|
||||
- Lang keys: `crawls.render_skipped`, `crawls.render_done`, `crawls.render_failed`.
|
||||
- Tables: `golem15_seo_crawls` (with `render_status`, `render_pages`, `render_note`), `golem15_seo_crawl_pages`, `golem15_seo_crawl_links`, `golem15_seo_issues`, `golem15_seo_issue_ignores`; settings `crawl_schedule`, `crawl_max_pages`, `crawl_delay`, `crawl_keep`, `generic_anchors`; migrations 202610280005, 202610280006.
|
||||
- Job kind `golem15.seo.crawl` (`CrawlArgs`); CLI `seo:crawl [--scheduled]`; schedule entry daily 02:30.
|
||||
- Issue kinds (crawl): `page_status`, `sitemap_error`, `sitemap_noindex`, `sitemap_missing`, `broken_link`, `redirected_link`, `canonical_missing`, `canonical_error`, `h1_missing`, `h1_multiple`, `heading_skipped`, `heading_empty`, `h1_title_mismatch`, `jsonld_invalid`, `jsonld_multiple`, `jsonld_required`, `jsonld_recommended`, `hreflang_reciprocal`, `hreflang_error`, `generic_anchor`, `image_alt`, `image_size`, `featured_lazy`, `content_js`, `orphan_page`; CMS additions `image_alt`, `h1_in_content`.
|
||||
- Admin: controllers `golem15.seo.issues` (Site audit), `golem15.seo.crawls` (Crawls); partials `issue_details` (P7), `crawl_summary` (P6); Site audit settings tab.
|
||||
- Routes: `POST/GET/DELETE /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore`; `GET /_seo/api/v1/issues` extended.
|
||||
- MCP tools: `seo_start_crawl`, `seo_cancel_crawl`, `seo_list_crawls`, `seo_get_crawl`, `seo_delete_crawls`, `seo_list_issues` (extended), `seo_recheck_url`, `seo_ignore_issues`, `seo_unignore_issues`.
|
||||
- Config key: `golem15.seo.crawl_trusted`.
|
||||
- Tests: `TestCrawlerTracer`, `TestCrawlerChecks`, `TestIssueMerge`, `TestStagingCrawl`, `TestRenderSkipped`, `TestCrawlAdmin`, `TestCrawlSchedule`, `TestCrawlTools`, `TestChromeRenderer` (`crawl_render_test.go`); test site helper `crawl_site_test.go`; unit tests for compareRendered, allowRequest and resolverRules follow in 33-14.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-10-SUMMARY.md` when done.
|
||||
</output>
|
||||
377
.planning/phases/33-seo-output-audit-and-content/33-11-PLAN.md
Normal file
377
.planning/phases/33-seo-output-audit-and-content/33-11-PLAN.md
Normal file
@@ -0,0 +1,377 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 11
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: ["33-10", "33-09"]
|
||||
files_modified:
|
||||
- ../sm-seo-plugin/models/autolink.go
|
||||
- ../sm-seo-plugin/models/autolink/columns.yaml
|
||||
- ../sm-seo-plugin/models/autolink/fields.yaml
|
||||
- ../sm-seo-plugin/models/registry.go
|
||||
- ../sm-seo-plugin/models/settings.go
|
||||
- ../sm-seo-plugin/models/settings/fields.yaml
|
||||
- ../sm-seo-plugin/updates/202610280007_create_golem15_seo_autolinks.go
|
||||
- ../sm-seo-plugin/updates/registry.go
|
||||
- ../sm-seo-plugin/classes/autolinks.go
|
||||
- ../sm-seo-plugin/classes/linkgraph.go
|
||||
- ../sm-seo-plugin/classes/resolver.go
|
||||
- ../sm-seo-plugin/classes/settings_write.go
|
||||
- ../sm-seo-plugin/controllers/admin_registry.go
|
||||
- ../sm-seo-plugin/controllers/autolinks.go
|
||||
- ../sm-seo-plugin/controllers/autolinks/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/autolinks/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/autolinks/config_filter.yaml
|
||||
- ../sm-seo-plugin/controllers/api/autolinks.go
|
||||
- ../sm-seo-plugin/controllers/api/links.go
|
||||
- ../sm-seo-plugin/extensions.go
|
||||
- ../sm-seo-plugin/admin_navigation.go
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/mcp.go
|
||||
- ../sm-seo-plugin/operations.go
|
||||
- ../sm-seo-plugin/assets/src/elements/links.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/index.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/labels.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/styles.css
|
||||
- ../sm-seo-plugin/assets/dist/seo.js
|
||||
- ../sm-seo-plugin/assets/dist/seo.css
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/linking_test.go
|
||||
- ../sm-journal-plugin/classes/image_variants.go
|
||||
- ../sm-journal-plugin/controllers/api/posts.go
|
||||
- ../sm-journal-plugin/console/image_variants.go
|
||||
- ../sm-journal-plugin/models/image_variant.go
|
||||
- ../sm-journal-plugin/models/registry.go
|
||||
- ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go
|
||||
- ../sm-journal-plugin/updates/registry.go
|
||||
- ../sm-journal-plugin/jobs.go
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/config/config.yaml
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/related_media_test.go
|
||||
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostCard.vue
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue
|
||||
- ../vue-journal-layer/app/utils/srcset.ts
|
||||
- ../vue-journal-layer/app/utils/types.ts
|
||||
- ../vue-journal-layer/tests/srcset.test.ts
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/README.md
|
||||
- modules/lagoon/attach/variant.go
|
||||
- modules/lagoon/attach/webp/webp.go
|
||||
- modules/lagoon/attach/webp/webp_smoke_test.go
|
||||
- modules/lagoon/attach/README.md
|
||||
- modules/lagoon/README.md
|
||||
- docs/database/attachments.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .planning/notes/2026-10-28-webp-gen2brain.md
|
||||
- .planning/research/STACK.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-3, D-82, D-85, D-71, D-10]
|
||||
|
||||
estimate:
|
||||
tokens: 165000
|
||||
raw_tokens: 165000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "SEO › Auto-links holds phrase → URL rows (per locale or all, active switch, origin, note) with the C22 list, form, filters and refusals (phrase under 3 characters, duplicate phrase in a locale, URL not a / path or http(s) address); the resolve answer of an ok page carries autolinks [{phrase, url}] for its locale without disabled rows and without links to the page itself, plus autolink_max from the Links tab (default 3, 0 to 20, 0 turns auto-linking off), which the layer applies at render (D-82)"
|
||||
- "Add focus keyphrases (toolbar action on Auto-links, also an MCP tool) creates one row per record whose focus keyphrase in a locale is not yet a phrase there, with the record's URL and origin keyphrase, and toasts the count (D-82, UI-SPEC C22)"
|
||||
- "The SEO tab shows E10 Internal links for content with a body: inbound and outbound link counts from the last finished crawl, the orphan warning when nothing links to the page, up to 10 pages this text could link to and 10 pages that could link here (titles and focus keyphrases found in the crawled text), each with Copy address; before the first crawl it says so (D-82, UI-SPEC C19)"
|
||||
- "MCP has seo_list_autolinks, seo_create_autolink, seo_update_autolink, seo_delete_autolinks, seo_autolinks_from_keyphrases and seo_get_link_suggestions; TestEveryAdminOperationHasTool maps the Auto-links controller and the seo_links widget action (D-10)"
|
||||
- "WebP is encoded in Go with github.com/gen2brain/webp at the version approved at 33-06's legitimacy checkpoint, recorded first in .planning/notes/2026-10-28-webp-gen2brain.md (with the 2026-10-28 benchmark) and a STACK.md row, then added to go.mod; the encoder lives in the framework sub-package modules/lagoon/attach/webp (generic media, additive, no SEO wording), so only binaries that import it link it (CONTEXT 2026-10-28 image formats, D-85, D-90, D-106)"
|
||||
- "A Journal conga job (golem15.journal.image_variants), dispatched after a featured image upload commits and runnable for all images with journal:image-variants --all, writes thumbnails at golem15.journal.image_widths (default 480, 800, 1200) and a WebP variant of each next to the thumbnail; the upload request never encodes (2026-10-28 image formats)"
|
||||
- "The Journal API's featured_images entries gain variants [{width, height, url, webp_url}] (additive key), and the journal layer renders featured images as <picture> with a WebP source and the original format as srcset fallback, explicit width and height, alt, lazy loading in cards and eager loading with fetchpriority high for the post's main image (D-85, D-71)"
|
||||
- "AVIF is not produced (deferred); body images inserted through the editor keep their original file (the variants follow the thumbnail pipeline of attachments)"
|
||||
- "The Auto-links list shows autolinks.list_empty when empty, with Add focus keyphrases on the same toolbar (UI-SPEC empty: Auto-links list)"
|
||||
- "E10 shows notCrawled instead of counters before any crawl and none under an empty suggestion list; it is absent on create (UI-SPEC empty: E10)"
|
||||
- "E10 shows the checking line with aria-busy while loading, and a failure shows the 13px danger line with the SPA's danger toast (UI-SPEC loading and error: E10)"
|
||||
- "A 422 on the Auto-links form shows the field lines and the count banner (UI-SPEC error: save refused)"
|
||||
- {statement: "A WebP variant generated twice for the same thumbnail is written once (the job is idempotent through the bucket's Exists), and a failed encode leaves no partial file", verification: backstop}
|
||||
prohibitions:
|
||||
- requirement_id: "D-82"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "An auto-link never points a page to itself, and a disabled or other-locale phrase never reaches a page's resolve answer"
|
||||
- requirement_id: "D-85"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "Image encoding never runs inside the upload request; a slow or failing encode never fails the upload"
|
||||
artifacts:
|
||||
- path: ".planning/notes/2026-10-28-webp-gen2brain.md"
|
||||
provides: "decision note naming github.com/gen2brain/webp and the benchmark"
|
||||
contains: "github.com/gen2brain/webp"
|
||||
- path: "modules/lagoon/attach/webp/webp.go"
|
||||
provides: "WebP encoder and variant writer"
|
||||
contains: "gen2brain/webp"
|
||||
- path: "../sm-seo-plugin/classes/autolinks.go"
|
||||
provides: "phrase table service and resolve filtering"
|
||||
contains: "func "
|
||||
- path: "../sm-seo-plugin/assets/src/elements/links.ts"
|
||||
provides: "E10 golem15-seo-links"
|
||||
contains: "golem15-seo-links"
|
||||
- path: "../sm-journal-plugin/classes/image_variants.go"
|
||||
provides: "the image variants job"
|
||||
contains: "image_variants"
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/classes/resolver.go"
|
||||
to: "../sm-seo-plugin/classes/autolinks.go"
|
||||
via: "ok answers carry autolinks for the locale minus self and autolink_max"
|
||||
pattern: "autolink"
|
||||
- from: "../sm-journal-plugin/classes/image_variants.go"
|
||||
to: "modules/lagoon/attach/webp/webp.go"
|
||||
via: "the job calls webp.Variant for each thumbnail width"
|
||||
pattern: "webp\\.Variant"
|
||||
- from: "../vue-journal-layer/app/components/Journal/FeaturedImage.vue"
|
||||
to: "Journal API featured_images[].variants"
|
||||
via: "<picture> sources built by srcset.ts from variants"
|
||||
pattern: "image/webp"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan adds smart internal linking (auto-links, link suggestions with counters) and modern image formats (WebP variants with srcset). The opt-in related posts and category neighbours moved to 33-09 (Task 4) in the 2026-10-28 revision, and the WebP package-legitimacy checkpoint to 33-06 (Task 4), so this plan stays within its task budget and no task spans more than two repositories.
|
||||
|
||||
<objective>
|
||||
Across four repositories (each main, not pushed; tags in 33-14), at most two per task:
|
||||
- sm-seo-plugin: the Auto-links table and screen with the keyphrase feed and MCP tools, autolinks in the resolve answer, E10 and its tool (D-82, D-10);
|
||||
- summercms.go: the WebP encoder in `modules/lagoon/attach/webp` after the decision note (D-85, D-90, D-106);
|
||||
- sm-journal-plugin: the image variants job and the API's variants (CONTEXT 2026-10-28 image formats, D-85);
|
||||
- vue-journal-layer: `<picture>` with WebP and the original as fallback (D-85, D-71).
|
||||
|
||||
Purpose: the internal-linking and media parts of D-82 and D-85 that the earlier plans do not cover.
|
||||
|
||||
Output: commits in the four repositories; the decision note and STACK row as a planning-docs commit here before go.mod changes.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-42 (where WebP lives):** in the framework as a generic, additive media helper (`modules/lagoon/attach/webp`, package `webp`: `Encode(w io.Writer, img image.Image) error` at quality 80 and `Variant(ctx, bucket, f *attach.File, w, h int, mode string) (key string, err error)` writing `<thumb key>.webp` next to the thumbnail), with a generic hook in attach (`attach.VariantKey(thumbKey, format string) string`). It is not SEO code (D-90), it is a patch-level addition (D-106), and as a separate sub-package only binaries that import it link the encoder. The job lives in Journal because only plugins register conga jobs.
|
||||
- **P33-43 (variants table):** `golem15_journal_image_variants` (file_id, width, height, key, webp_key, created_at; unique (file_id, width)) so the API lists variants with one query instead of storage lookups.
|
||||
- **P33-44 (related options):** moved to 33-09 with the related-posts task.
|
||||
- **P33-45 (auto-link table):** `golem15_seo_autolinks` (id, phrase, url, locale null = all, enabled, origin manual|keyphrase|mcp, note, timestamps; unique (lower(phrase), coalesce(locale, ''))); settings column autolink_max (default 3) on the Links tab; the resolve answer filters rows by locale, enabled and target ≠ the page's path, ordered by phrase length descending so longer phrases win.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-10-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-09-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
framework modules/lagoon/attach/thumb.go: ThumbFilename(id, w, h, offsetX, offsetY, mode, ext), PartitionDirectory, (*File).ThumbKey(ctx, bucket, w, h, mode) (key, error), encodeImage package var (119-130) switched on ext, content-type switch ~222-228, deleteKey on failure, bucket.Exists; thumb_test.go.
|
||||
sm-seo-plugin: Links tab and settings_write.go four-place pattern (33-05), resolve Result (33-05/06 keys), crawl tables golem15_seo_crawl_pages (text), golem15_seo_crawl_links (33-10), entries' focus keyphrase (Phase 32 SEO tab), SEO tab widgets (seo_snippet, seo_graph) and the element bundle (33-06), operations.go/mcp.go.
|
||||
sm-journal-plugin: controllers/api/posts.go featuredImages (~629), UploadFeaturedImage (~357-430), jobs (none yet; follow ../sm-seo-plugin/jobs.go), config/config.yaml (33-02, related keys from 33-09), related_media_test.go (33-09).
|
||||
vue-journal-layer: FeaturedImage.vue, PostCard.vue and PostPage.vue (33-09), utils/types.ts (featured_images).
|
||||
Benchmark (33-CONTEXT, 2026-10-28): 1200 px from a 2592×1754 photo, WebP 78 KB against JPEG q80 133 KB; about 90 ms with system libwebp loaded through purego (no cgo), about 510 ms in the WASM fallback; AVIF only about 6% smaller at 1.3× to 20× the CPU.
|
||||
Package (33-RESEARCH audit): github.com/gen2brain/webp v0.6.4 (2026-07-01, 31 versions, source github.com/gen2brain/webp), [ASSUMED]; legitimacy verified at 33-06 Task 4, the approved version at the top of 33-06-SUMMARY.md.
|
||||
Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab (autolink_max).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Commit groups per repository staged by path; planning docs (note, STACK.md) committed here separately from the framework code commit. Every commit keeps that repository's vet/tests (`go test -short ./...`, `npm --prefix … run test`) green.
|
||||
- Resume check per repository: `git -C <repo> log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | seo: `feat: auto-link phrases in the resolve answer (tracer)` | TestAutolinksTracer passes |
|
||||
| 2 | seo: A `feat: auto-links screen, keyphrase feed and MCP tools`; B `feat: internal links widget with counts and suggestions` | linking tests and the operations parity pass |
|
||||
| 3 | here: A `docs(33-11): WebP decision note and STACK row`; framework: B `feat(lagoon): WebP variants of attachment thumbnails` | the webp package tests and the additive gate pass |
|
||||
| 4 | journal: A `feat: image variants job and variants in the post API`; layer: B `feat: WebP and srcset for featured images` | plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an editor adds an auto-link phrase and the next page that resolves carries it, never on the target page itself" — one path only</name>
|
||||
<files>../sm-seo-plugin/models/autolink.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610280007_create_golem15_seo_autolinks.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/classes/autolinks.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/linking_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/classes/resolver.go (Outbound from 33-05, the 33-06 keys)
|
||||
- ../sm-seo-plugin/classes/settings_write.go and models/settings/fields.yaml (Links tab from 33-05)
|
||||
- ../vue-seo-layer/app/utils/bodyLinks.ts (33-09, the consumer of autolinks)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-82 and P33-45: migration 202610280007 creates golem15_seo_autolinks and the settings column autolink_max (default 3); the Links tab gets autolink_max (number, left, settings.autolink_max_comment, range 0–20) through the four-place pattern; `classes/autolinks.go` CRUD with history (so the purge of 33-05 runs) and `ForPage(ctx, locale, selfPath) ([]Autolink, error)`; resolver adds `autolinks` and `autolink_max` to ok answers (empty when autolink_max is 0). `TestAutolinksTracer` (Docker): create "lwia grzywa" → /grzyby/lwia-grzywa (all locales) and a disabled phrase; resolving another page answers autolinks [{phrase: "lwia grzywa", url: "/grzyby/lwia-grzywa"}] and autolink_max 3; resolving /grzyby/lwia-grzywa leaves it out; an en-only phrase is absent on a pl page; autolink_max 0 answers none. Commit as listed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestAutolinksTracer' -count=1 -v 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestAutolinksTracer" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'json:"autolinks\|json:"autolink_max' ../sm-seo-plugin/classes/resolver.go` prints two lines
|
||||
- `grep -n "autolink_max" ../sm-seo-plugin/classes/settings_write.go ../sm-seo-plugin/models/settings/fields.yaml` prints a line in each
|
||||
</acceptance_criteria>
|
||||
<done>Auto-link phrases reach every page's resolve answer, where the layer links them.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Auto-links screen with the keyphrase feed and MCP tools; E10 internal links widget with counts and suggestions</name>
|
||||
<files>../sm-seo-plugin/models/autolink/columns.yaml, ../sm-seo-plugin/models/autolink/fields.yaml, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/autolinks.go, ../sm-seo-plugin/controllers/autolinks/config_list.yaml, ../sm-seo-plugin/controllers/autolinks/config_form.yaml, ../sm-seo-plugin/controllers/autolinks/config_filter.yaml, ../sm-seo-plugin/controllers/api/autolinks.go, ../sm-seo-plugin/controllers/api/links.go, ../sm-seo-plugin/classes/autolinks.go, ../sm-seo-plugin/classes/linkgraph.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/assets/src/elements/links.ts, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/linking_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/controllers/pages.go and pages/config_*.yaml (33-05 screen pattern), controllers/crawls.go (toolbar action pattern, 33-10)
|
||||
- ../sm-seo-plugin/assets/src/elements/graph.ts (33-06 widget element pattern) and tests/elements/*
|
||||
- ../sm-seo-plugin/classes/crawl/orphans.go (33-10 suggestion logic to reuse)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C19, C22 and their copy)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Auto-links list per C22 (sorted by phrase, row state disabled for inactive, filters locale, origin, active); refusals autolinks.phrase_short, phrase_taken (naming the URL), url_invalid
|
||||
- from_keyphrases adds rows for records with a focus keyphrase not yet a phrase in that locale (origin keyphrase) and toasts autolinks.keyphrases_added with the count, or keyphrases_none; running it twice adds nothing the second time
|
||||
- GET /_seo/api/v1/links?url= answers {crawledAt, inbound, outbound, orphan, out[{phrase, title, url}], in[{phrase, title, adminUrl}]} from the last finished crawl (each list at most 10); before any crawl crawledAt, inbound and outbound are null; the seo_links widget action answers the same
|
||||
- MCP lists list_autolinks, create_autolink, update_autolink, delete_autolinks (destructive), autolinks_from_keyphrases and get_link_suggestions; TestEveryAdminOperationHasTool passes
|
||||
- Element smoke: E10 renders counters, the orphan callout and both lists from a stubbed answer, notCrawled without a crawl, none under empty lists; Copy address writes the URL and shows copied
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-82, D-10 and UI-SPEC C19/C22: controller `golem15.seo.autolinks` (list 50 per page, checkboxes, setup, toolbar [create, delete, from_keyphrases], form per C22) with navigation Auto-links (icon wand-sparkles) after Pages per C15; `controllers/api/autolinks.go` (GET/POST /_seo/api/v1/autolinks, PUT /autolinks/{id}, DELETE /autolinks with ids, POST /autolinks/from-keyphrases); `classes/linkgraph.go` reads the last finished crawl for inbound/outbound counts and builds suggestions (pages whose crawled text contains this page's title or focus keyphrase, and pages whose title or keyphrase this page's text contains), reusing the orphan suggestion code; `controllers/api/links.go` GET /_seo/api/v1/links; extensions.go adds seo_links (type widget, action seo_links, context update) after seo_graph, omitted for types whose records have no body (Words nil). Element `golem15-seo-links` per C19 (light DOM, `--c-*` colours, ui-locale labels for every E10 key, navigator.clipboard with the hidden-button fallback). Tools and AdminOperations rows. Rebuild dist. Tests first. Commit groups A and B.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestAutolinks|TestLinkSuggestions|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestAutolinks or TestLinkSuggestions missing from the "--- PASS" lines</fails_when>
|
||||
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -10 && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
|
||||
<fails_when>non-zero exit, a failed count above 0 in the vitest summary, or the dist check reporting a difference</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"list_autolinks"\|"create_autolink"\|"update_autolink"\|"delete_autolinks"\|"autolinks_from_keyphrases"\|"get_link_suggestions"' ../sm-seo-plugin/mcp.go` prints six lines
|
||||
- `grep -n "golem15-seo-links" ../sm-seo-plugin/assets/src/elements/index.ts` prints one line
|
||||
- `grep -n "golem15.seo.autolinks" ../sm-seo-plugin/admin_navigation.go` prints one line
|
||||
</acceptance_criteria>
|
||||
<done>Editors manage auto-links and see linking suggestions with counters in the SEO tab; MCP can do the same.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: WebP decision note and the framework encoder sub-package (summercms.go only)</name>
|
||||
<files>.planning/notes/2026-10-28-webp-gen2brain.md, .planning/research/STACK.md, modules/lagoon/attach/variant.go, modules/lagoon/attach/webp/webp.go, modules/lagoon/attach/webp/webp_smoke_test.go, modules/lagoon/attach/README.md, modules/lagoon/README.md, docs/database/attachments.md, go.mod, go.sum</files>
|
||||
<read_first>
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md (top: the approved gen2brain/webp version from 33-06 Task 4)
|
||||
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md, .planning/notes/2026-10-07-embedded-admin-release-gate.md and .planning/notes/2026-10-28-chromedp-rendered-crawl.md (decision note format; the last one from 33-10), .planning/research/STACK.md (row format)
|
||||
- modules/lagoon/attach/thumb.go (ThumbKey 154-243, encodeImage 119-130, content types, deleteKey, Exists) and thumb_test.go
|
||||
- docs/database/attachments.md and modules/lagoon/attach/README.md
|
||||
</read_first>
|
||||
<precondition>33-06 Task 4 answered "webp: approved vX.Y.Z" for github.com/gen2brain/webp (recorded at the top of 33-06-SUMMARY.md); on "rejected", record WebP as not delivered in this plan's SUMMARY and skip Tasks 3 and 4</precondition>
|
||||
<action>
|
||||
Per the 2026-10-28 image-format decision (CONTEXT: WebP now, in Go; a decision note before go.mod changes), D-85, D-90, D-106 and P33-42, in this order:
|
||||
- (A, before any go.mod change) write `.planning/notes/2026-10-28-webp-gen2brain.md`: decision (WebP variants per thumbnail size in a conga job; AVIF deferred), the dependency github.com/gen2brain/webp at the version approved in 33-06 Task 4, how it runs (purego libwebp or WASM, no cgo), the benchmark from 33-CONTEXT, where the code lives (P33-42 and why it is generic media, not SEO), and that editor body images keep their original file; add the STACK.md row; commit as planning docs.
|
||||
- (B, framework) `go get github.com/gen2brain/webp@<approved>`; `modules/lagoon/attach/variant.go`: `VariantKey(thumbKey, format string) string` (thumb key with the extension replaced by the format, the same token rule as ThumbFilename); `modules/lagoon/attach/webp/webp.go`: `Encode` (quality 80) and `Variant` (ThumbKey first, return early when the variant exists, encode from the thumbnail image, ContentType image/webp, delete the key on any encode or close error); `webp_smoke_test.go` encodes a generated image and asserts the RIFF/WEBP header and idempotence on a memblob bucket; README rows and docs/database/attachments.md section (no SEO words); `go vet ./... && go test -short ./...` green; commit.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/lagoon/attach/... -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10 && go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or no "ok" line for the webp package</fails_when>
|
||||
<automated>bash scripts/check-phase33.sh --additive --marker --docs</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL", "refuse:" or marker hit line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git log --format=%s -- .planning/notes/2026-10-28-webp-gen2brain.md` shows a commit older than the first commit that changes go.mod with gen2brain (`git log --format=%H -S gen2brain -- go.mod`)
|
||||
- `grep -n "github.com/gen2brain/webp" go.mod .planning/research/STACK.md .planning/notes/2026-10-28-webp-gen2brain.md` prints a line in each
|
||||
- `grep -rln "gen2brain" modules --include=*.go` lists only files under modules/lagoon/attach/webp/
|
||||
- `bash scripts/check-phase33.sh --additive --marker` passes (attach changes are additive and name no SEO concept)
|
||||
</acceptance_criteria>
|
||||
<done>The framework can write a WebP variant next to any attachment thumbnail, after the decision note, with the encoder linked only into binaries that import it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Journal image variants job and API variants, and WebP with srcset in the journal layer</name>
|
||||
<files>../sm-journal-plugin/classes/image_variants.go, ../sm-journal-plugin/console/image_variants.go, ../sm-journal-plugin/models/image_variant.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/jobs.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/utils/srcset.ts, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/tests/srcset.test.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
|
||||
<read_first>
|
||||
- modules/lagoon/attach/webp/webp.go and attach/variant.go (Task 3)
|
||||
- ../sm-journal-plugin/controllers/api/posts.go (UploadFeaturedImage ~357-430, featuredImages ~629) and ../sm-seo-plugin/jobs.go (job pattern)
|
||||
- ../sm-journal-plugin/related_media_test.go (created by 33-09 for related posts; this task adds TestImageVariants)
|
||||
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, PostCard.vue and app/journal-pages/PostPage.vue (as changed by 33-09)
|
||||
</read_first>
|
||||
<precondition>Task 3 is committed (the framework's webp package exists through the replace => ../summercms.go)</precondition>
|
||||
<behavior>
|
||||
- TestImageVariants (Docker): an upload commits, the job writes the three default widths with WebP keys, the API lists variants on the featured image, a second run writes nothing new, a width larger than the original is skipped, and the upload request returns before any encoding (the job is dispatched, not run inline)
|
||||
- srcset.test.ts: WebP and original srcset strings from variants plus the original's width; no variants gives no srcset
|
||||
- Smoke: a fixture post with variants renders <picture> with a source of type image/webp and an img with width, height and alt; a post without variants renders as before
|
||||
</behavior>
|
||||
<action>
|
||||
Per the 2026-10-28 image-format decision, D-85, D-71 and P33-43, in this order:
|
||||
- (A, journal) migration and model of P33-43; `classes/image_variants.go`: `ImageVariantsArgs{FileIDs []uint}` (kind golem15.journal.image_variants, conga.MaxAttempts(3)) generating each width of `golem15.journal.image_widths` (default [480, 800, 1200]; widths larger than the original are skipped) through attach.ThumbKey and webp.Variant and upserting rows; dispatched with lagoon.AfterCommit from UploadFeaturedImage and the admin featured-image upload; `journal:image-variants [--all]` backfills (documented, so commands_documented_test.go passes); featuredImages adds `variants` (one query) to each entry (additive key); go.mod keeps the framework replace; config/config.yaml documents image_widths; README section; tests first; commit.
|
||||
- (B, layer) `app/utils/srcset.ts` (pure: srcset strings for WebP and the original from variants plus the original's width) and tests first; FeaturedImage.vue renders `<picture>` with `<source type="image/webp" srcset sizes>` and `<img src srcset sizes width height alt>` (lazy in cards, eager with fetchpriority high on the post page through a prop PostPage passes, decoding async), unchanged when variants are absent; types.ts adds variants; fixtures and an expectation line containing image/webp; README; commit.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestImageVariants|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestImageVariants missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
<automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated>
|
||||
<fails_when>non-zero exit from any command, or a "not ok" or "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"variants"' ../sm-journal-plugin/controllers/api/posts.go` prints one line; `grep -n "image/webp" ../vue-journal-layer/app/components/Journal/FeaturedImage.vue` prints one line
|
||||
- `grep -n "golem15.journal.image_variants" ../sm-journal-plugin/classes/image_variants.go` prints at least one line
|
||||
- `grep -n "image_widths" ../sm-journal-plugin/config/config.yaml` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>Featured images get WebP variants at three widths from a background job, and the sites serve them with srcset and the original as fallback.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin/MCP → auto-link table | untrusted phrases and URLs later rendered into every page |
|
||||
| uploaded images → encoder | untrusted image bytes decoded and encoded in a job |
|
||||
| crawl data → E10 | crawled text shown to administrators |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-50 | Tampering (XSS) | auto-link phrases and URLs | high | mitigate | URL must be a / path or an http(s) URL (javascript: and data: refused), phrase is text; the layer inserts them into text nodes with attribute escaping (33-09); TestAutolinks covers the refusals |
|
||||
| T-33-51 | Denial of service | image decoding in the job | medium | mitigate | attach's existing original-size cap (errOriginalTooLarge) applies before resizing; the job has attempts and runs outside the request; widths larger than the original skipped |
|
||||
| T-33-52 | Tampering | supply chain of the WebP encoder | high | mitigate | human legitimacy checkpoint (33-06 Task 4), decision note and STACK row before go.mod, exact approved version pinned in go.sum |
|
||||
| T-33-53 | Elevation of privilege | auto-links, links and keyphrase tools | high | mitigate | golem15.seo.manage on every route and action, operations parity test |
|
||||
| T-33-54 | Information disclosure | E10 suggestions | low | accept | suggestions come from the public pages the crawler fetched |
|
||||
| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | package-legitimacy gate: github.com/gen2brain/webp is [ASSUMED] and gated by the blocking human checkpoint 33-06 Task 4 (Task 3 here has it as a precondition); no npm package added |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- sm-seo-plugin: vet, `test -short`, Docker suites TestAutolinksTracer, TestAutolinks, TestLinkSuggestions and the parity tests; element tests and the dist check.
|
||||
- sm-journal-plugin: TestImageVariants and the API matrix.
|
||||
- framework: `go vet ./... && go test -short ./...`, the attach tests, `check-phase33.sh --additive --marker`.
|
||||
- vue-journal-layer: tests, playground build and smoke.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Auto-links and link suggestions with counters work.
|
||||
- WebP variants with srcset are served, after the note and 33-06's legitimacy checkpoint.
|
||||
- No task spans more than two repositories.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Notes: `.planning/notes/2026-10-28-webp-gen2brain.md`; STACK.md row for `github.com/gen2brain/webp`.
|
||||
- Framework: `attach.VariantKey`; package `modules/lagoon/attach/webp` with `webp.Encode` and `webp.Variant`; go.mod require `github.com/gen2brain/webp`.
|
||||
- sm-seo-plugin: table `golem15_seo_autolinks`, settings `autolink_max`, migration 202610280007, controller `golem15.seo.autolinks`, routes `GET/POST /_seo/api/v1/autolinks`, `PUT /_seo/api/v1/autolinks/{id}`, `DELETE /_seo/api/v1/autolinks`, `POST /_seo/api/v1/autolinks/from-keyphrases`, `GET /_seo/api/v1/links`, widget action `seo_links`, element `golem15-seo-links` (E10), resolve keys `autolinks`, `autolink_max`, MCP tools `seo_list_autolinks`, `seo_create_autolink`, `seo_update_autolink`, `seo_delete_autolinks`, `seo_autolinks_from_keyphrases`, `seo_get_link_suggestions`.
|
||||
- sm-journal-plugin: config key `golem15.journal.image_widths`; table `golem15_journal_image_variants` (migration 202610280002); job kind `golem15.journal.image_variants` (`ImageVariantsArgs`); CLI `journal:image-variants [--all]`; API key `featured_images[].variants`. (The related-posts keys are 33-09's.)
|
||||
- vue-journal-layer: `app/utils/srcset.ts`, `<picture>` in FeaturedImage.
|
||||
- Tests: `TestAutolinksTracer`, `TestAutolinks`, `TestLinkSuggestions`, `TestImageVariants`, `webp_smoke_test.go`, `tests/srcset.test.ts`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-11-SUMMARY.md` when done; record the pinned gen2brain/webp version (from 33-06's checkpoint answer).
|
||||
</output>
|
||||
238
.planning/phases/33-seo-output-audit-and-content/33-12-PLAN.md
Normal file
238
.planning/phases/33-seo-output-audit-and-content/33-12-PLAN.md
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 12
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["33-11"]
|
||||
files_modified:
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-12-sm-summercmsio-app.patch
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-12-vue-summercmsio-app.patch
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md
|
||||
- ../sm-bm-app/frontend/public/robots.txt
|
||||
- ../sm-bm-app/frontend/index.html
|
||||
- ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf
|
||||
- ../sm-bm-app/scripts/check-noindex.sh
|
||||
- ../sm-bm-app/DEPLOY.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, D-72, D-70, D-23, D-97, D-33, D-22, D-30, D-34]
|
||||
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Against local links (scratch worktrees wired to the sibling repositories with go.work and pnpm link overrides; nothing pushed or tagged), the demo stack serves a Journal post through Nuxt with the CMS title, one canonical, one JSON-LD @graph, hreflang and breadcrumbs, /sitemap.xml as an index of per-type files through the layer's root proxy, and a redirect from the SEO plugin enforced by the layer (SC-1, SC-2, 2026-10-28 release cadence)"
|
||||
- "The demo's client-only routes /p/** and /presentations/** are declared in seo.noindexRoutes, so its build passes the SSR guard and those routes send X-Robots-Tag noindex, nofollow; removing the declaration makes the build fail (D-70)"
|
||||
- "The demo's hand-written robots meta on login, register, connect and the signed-in pages is replaced by page meta (seo.robots for auth pages, requiresAuth for signed-in pages), so the layer marks them automatically (D-23)"
|
||||
- "The demo reports its static routes to the page registry at server start and links the HTML sitemap from its footer (D-33, D-34)"
|
||||
- "summercms.io's changelog site (Journal on the journal layer, no SEO plugin mounted) runs with seo.cms false: it keeps its titles through the journal layer's fallback meta, passes the SSR guard, and its changelog smoke passes with the updated card and heading markup; the main site keeps its own @nuxtjs/seo static sitemap (CONTEXT 2026-10-28 accepted defaults: summercms.io gets pin bumps and the build guard only)"
|
||||
- "sm-bm-app has no indexable pages: its frontend serves robots.txt with User-agent: * and Disallow: /, index.html carries <meta name=\"robots\" content=\"noindex, nofollow\">, and nginx adds X-Robots-Tag: noindex, nofollow to every response; there is no SSR migration (D-72)"
|
||||
- "The app changes that depend on new tags are captured as patches in the phase directory (one per repository) that apply cleanly to each repository's current HEAD; 33-14 applies them after the tags with real pins; sm-bm-app's change needs no tag and is committed locally (2026-10-28 release cadence)"
|
||||
- "33-12-local-wiring.md records the exact local-link wiring commands so 33-13 and 33-14 can repeat them"
|
||||
prohibitions:
|
||||
- requirement_id: "D-72"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "sm-bm-app never becomes indexable: robots.txt, the meta tag and the header all say noindex/Disallow"
|
||||
- requirement_id: "D-70"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "A noindex declaration in the demo covers only routes that are really client-only or private; no public content route is declared noindex to pass the guard"
|
||||
artifacts:
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch"
|
||||
provides: "demo backend config, tests and smoke script for 33-14"
|
||||
contains: "diff --git"
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch"
|
||||
provides: "demo Nuxt config: noindex routes, page meta, footer link"
|
||||
contains: "noindexRoutes"
|
||||
- path: "../sm-bm-app/scripts/check-noindex.sh"
|
||||
provides: "D-72 check"
|
||||
contains: "Disallow: /"
|
||||
key_links:
|
||||
- from: "demo Nuxt (vue-demo-app)"
|
||||
to: "demo Go binary"
|
||||
via: "vue-seo-layer middleware resolves through NUXT_SEO_API_BASE; root files proxied through NUXT_SEO_BACKEND_BASE"
|
||||
pattern: "NUXT_SEO_API_BASE"
|
||||
- from: "../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf"
|
||||
to: "every bm response"
|
||||
via: "add_header X-Robots-Tag \"noindex, nofollow\" always"
|
||||
pattern: "X-Robots-Tag"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan proves the phase end to end on the demo against local links, prepares summercms.io's pin bumps, and closes D-72 on sm-bm-app.
|
||||
|
||||
<objective>
|
||||
- sm-demo-app + vue-demo-app: wire every Phase 33 repository locally, run the stack, and prepare the committable changes (noindex routes, page meta, footer link, config, a Docker test and an SEO smoke script) as patches for 33-14.
|
||||
- sm-summercmsio-app + vue-summercmsio-app: prepare the Journal and layer pin bumps with `seo.cms: false` as patches; check the build guard and the changelog smoke.
|
||||
- sm-bm-app: D-72 (robots.txt Disallow, meta noindex, X-Robots-Tag), committed locally.
|
||||
|
||||
Purpose: SC-1/SC-2 on a real app before the release; D-72.
|
||||
|
||||
Output: four patch files and a wiring note in the phase directory (planning docs commit here), and local commits in sm-bm-app. Nothing is pushed, tagged or deployed (2026-10-28 release cadence: tags and app re-pins in 33-14).
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-46 (local wiring, never committed):** the fixed scratch directory `../../scratch-33` (that is /media/nvme/dev/golem15/summercms.io/scratch-33, outside every repository: summercms.io/ is not a git repository) holds detached worktrees of sm-demo-app, vue-demo-app (from its submodule repository), sm-summercmsio-app and vue-summercmsio-app at HEAD, as `../../scratch-33/<repository name>`; 33-13 adds its own two worktrees there. A fixed path keeps every verify command resolvable; Task 1 creates the directory; each plan removes only its own worktrees with `git worktree remove` after its patches are committed (33-13 after its deploy step) and records that in its SUMMARY. Go: a scratch go.work using the worktree plus the sibling plugin checkouts (absolute paths) and replacing the framework with ../summercms.go. Nuxt: pnpm overrides in the scratch worktree pointing vue-seo-layer, vue-journal-layer, vue-menu-layer and vue-faq-layer to the sibling checkouts (`link:`). The commands go into 33-12-local-wiring.md.
|
||||
- **P33-47 (patch contents):** each patch holds only the changes that stay after the release (config, pages, tests, scripts, docs), generated with `git diff --binary` in the worktree (new files added with `git add -N`), never the wiring; it must `git apply --check` against the repository's current HEAD.
|
||||
- **P33-48 (demo noindex set):** noindexRoutes `/p/**` and `/presentations/**` (client-only decks); page meta `seo: {robots: 'noindex, nofollow'}` on login, register, connect and link-admin; `requiresAuth: true` on account/security and subscriptions; quizzes and roadmap keep the CMS answer (public) unless their current noindex is intentional, in which case they move to page meta with that value.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-release-log.md
|
||||
@.planning/phases/32-seo-foundation/32-05-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-06-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
Demo: ../sm-demo-app (go.mod requires tags, go.work uses ./plugins/golem15/*, summer.yaml mounts golem15.sitemap and golem15.seo, config/golem15.seo.yaml, deploy/nginx/demo.summercms.io.conf, DEPLOY.md, go_mod_test.go TestGoModRequiresTags, seo_mount_test.go); ../sm-demo-app/vue-demo-app (submodule repository git.golem15.com/golem15/vue-demo-app: nuxt.config.ts routeRules '/p/**' and '/presentations/**' ssr false, @nuxtjs/seo robots/sitemapUrl config, pages with useSeoMeta robots noindex: login, register, connect, roadmap, quizzes/[slug], presentations/*, account/security, p/[slugHash]).
|
||||
summercms.io: ../sm-summercmsio-app (requires summercms v0.5.0 and sm-journal-plugin v0.1.0; plugins/golem15/journal submodule), ../sm-summercmsio-app/vue-summercmsio-app (vue-journal-layer v0.6.0 pin, @nuxtjs/seo 5.2.1, scripts generate, build:changelog, test:changelog with changelog/testing/expect.txt).
|
||||
bm: ../sm-bm-app/frontend (Vite SPA: index.html, public/), deploy/nginx/bm.jakubzych.com.conf, DEPLOY.md; another session's uncommitted .planning changes in that repository stay untouched.
|
||||
Layer runtime config (33-07): NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO_NOTFOUND_TOKEN, NUXT_SEO_ROUTE_REPORT_TOKEN; options seo.cms, seo.noindexRoutes; page meta requiresAuth, seo.robots; <SeoSitemapLink>.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Planning-docs commits here: the patches and the wiring note (`docs(33-12): app patches for the release`). sm-bm-app commits staged by path (`git -C ../sm-bm-app add <paths>`); its other-session `.planning` changes stay uncommitted.
|
||||
- Resume check: `git log --format=%s | grep -Fx 'docs(33-12): app patches for the release'` here; `git -C ../sm-bm-app log --format=%s | grep -Fx 'feat: keep the app out of search engines (noindex, Disallow)'`.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | none (scratch run; results recorded in the SUMMARY) | the SEO smoke passes against the local stack |
|
||||
| 2 | here: `docs(33-12): app patches for the release` (all four patches and the wiring note) | patches apply with git apply --check |
|
||||
| 3 | bm: `feat: keep the app out of search engines (noindex, Disallow)` | check-noindex.sh passes |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "a visitor and a crawler see the demo's Journal post with CMS head tags, one graph, breadcrumbs and the sitemap index, through Nuxt on local links" — one path only</name>
|
||||
<files>.planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/DEPLOY.md (local run, environment), ../sm-demo-app/go.work, ../sm-demo-app/summer.yaml, ../sm-demo-app/vue-demo-app/nuxt.config.ts
|
||||
- .planning/phases/32-seo-foundation/32-05-SUMMARY.md ("Scratch run" section: the worktree procedure used for the demo in Phase 32)
|
||||
- ../vue-seo-layer/README.md (runtime config) and ../sm-seo-plugin/README.md (config keys)
|
||||
</read_first>
|
||||
<action>
|
||||
Per P33-46 and the 2026-10-28 release cadence: create the scratch directory `../../scratch-33` and the worktrees in it, wire Go (scratch go.work with the sibling sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin and the framework replace) and Nuxt (pnpm overrides to the four sibling layers), build the demo binary with `summer build` against the wiring, migrate a scratch database (the demo's compose.yaml Postgres or testcontainers), seed one Journal post with a category and a translation, run the Go binary and the Nuxt server with NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE and the tokens set, and run the sitemap rebuild. Then check through the Nuxt server (curl): the post page has one `<script type="application/ld+json">`, one `rel="canonical"`, hreflang links, a nav.seo-breadcrumbs and the CMS title; /sitemap.xml answers an index whose files answer through the layer; a redirect created through the SEO API answers 301 from Nuxt; /p/x answers X-Robots-Tag noindex, nofollow; the registry lists the reported static routes. Write the commands and checks as a runnable script `scripts/smoke-seo.sh BASE_URL` inside the demo worktree (it becomes part of the patch in Task 2) and record every wiring command in 33-12-local-wiring.md.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash ../../scratch-33/sm-demo-app/scripts/smoke-seo.sh http://127.0.0.1:3020 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, a missing script (the P33-46 worktree was not created), or no final "smoke-seo: ok" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 33-12-local-wiring.md lists the worktree, go.work and pnpm override commands with absolute sibling paths, and the worktrees live under ../../scratch-33 (P33-46)
|
||||
- smoke-seo.sh checks at least: one ld+json, one canonical, breadcrumbs, the sitemap index and one file, a 301 from the SEO plugin, the noindex header on /p/x
|
||||
- the SUMMARY records the smoke output
|
||||
</acceptance_criteria>
|
||||
<done>The whole phase works together on a real app before anything is released.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Demo and summercms.io release patches (noindex routes, page meta, footer link, config, Docker test, changelog site on cms false)</name>
|
||||
<files>.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-sm-summercmsio-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-vue-summercmsio-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md</files>
|
||||
<read_first>
|
||||
- the scratch worktrees from Task 1
|
||||
- ../sm-demo-app/seo_mount_test.go, boot_test.go, go_mod_test.go (Docker test patterns)
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, changelog/ (nuxt.config.ts and testing/expect.txt)
|
||||
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch (patch precedent)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-70, D-23, D-33, D-34, D-97, P33-47/P33-48 and the CONTEXT 2026-10-28 accepted default (summercms.io: pin bumps and the build guard only), in the scratch worktrees under ../../scratch-33: vue-demo-app nuxt.config.ts adds `seo: {noindexRoutes: ['/p/**', '/presentations/**']}` and removes the @nuxtjs/seo-only keys (robots, the sitemap URL setting) the journal layer no longer understands; pages switch their hand-written robots meta to page meta per P33-48; the footer renders `<SeoSitemapLink>`; the build passes the guard and fails when the noindexRoutes line is removed (check once, then restore). sm-demo-app: config/golem15.seo.yaml (route_report_token from the environment), config/golem15.sitemap.yaml (site_url), DEPLOY.md (new NUXT_SEO_* variables, `/_sitemap/` not exposed, root files served by the layer), deploy/nginx/demo.summercms.io.conf (drop any sitemap.xsl location), `seo_output_test.go` (Docker: sitemap index served, /_seo/api/v1/site answers, resolve of a seeded post carries jsonld and breadcrumbs), and scripts/smoke-seo.sh from Task 1. vue-summercmsio-app: `seo: {cms: false}` for the changelog app, changelog/testing/expect.txt updated for the journal layer's card and heading markup; `npm run generate` and `test:changelog` pass on the wiring. sm-summercmsio-app: when nothing beyond the later pin bumps is needed, 33-12-sm-summercmsio-app.patch holds the single line `# no change beyond the pin bumps in 33-14` (git apply treats it as an empty patch and 33-14 skips it). Export one patch per repository (P33-47), check each with `git -C <repo> apply --check <patch>` against the live repository's HEAD, and commit the patches and the updated wiring note here; then remove this plan's four worktrees from ../../scratch-33 with `git worktree remove` (P33-46) and record it in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>git -C ../sm-demo-app apply --check ../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch && git -C ../sm-demo-app/vue-demo-app apply --check ../../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch && echo patches-ok</automated>
|
||||
<fails_when>non-zero exit, an "error:" line from git apply, or no "patches-ok" line</fails_when>
|
||||
<automated>grep -c "noindexRoutes" .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch</automated>
|
||||
<fails_when>output 0 or a missing file</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- the demo patch contains seo_output_test.go and scripts/smoke-seo.sh; the vue-demo-app patch contains no `useSeoMeta(` line with a hard-coded robots value
|
||||
- the summercms.io patch (or its note) sets seo.cms false for the changelog app
|
||||
- the SUMMARY records: demo build passing, the guard failing without the declaration, generate and test:changelog passing
|
||||
</acceptance_criteria>
|
||||
<done>The app changes are ready to apply on top of the release tags.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: sm-bm-app stays out of search engines (D-72)</name>
|
||||
<files>../sm-bm-app/frontend/public/robots.txt, ../sm-bm-app/frontend/index.html, ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf, ../sm-bm-app/scripts/check-noindex.sh, ../sm-bm-app/DEPLOY.md</files>
|
||||
<read_first>
|
||||
- ../sm-bm-app/frontend/index.html, frontend/package.json (build script), deploy/nginx/bm.jakubzych.com.conf, DEPLOY.md, .claude/CLAUDE.md
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-72: add frontend/public/robots.txt (`User-agent: *` and `Disallow: /`), the robots meta `noindex, nofollow` in index.html's head, and `add_header X-Robots-Tag "noindex, nofollow" always;` in each server block of the nginx conf (and in every location that sets its own add_header, since nginx does not inherit add_header there). `scripts/check-noindex.sh` builds the frontend (`npm --prefix frontend run build`) and checks dist/robots.txt, dist/index.html's meta and the conf's header in every server block, printing `check-noindex: ok` or failing. DEPLOY.md notes the header and the check. Commit as listed (local, not pushed).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-bm-app/scripts/check-noindex.sh 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, or no "check-noindex: ok" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Disallow: /" ../sm-bm-app/frontend/public/robots.txt` prints one line
|
||||
- `grep -c "X-Robots-Tag" ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf` is at least the number of server blocks listening on 443
|
||||
- `git -C ../sm-bm-app status --short .planning` still shows the other session's changes, uncommitted
|
||||
</acceptance_criteria>
|
||||
<done>The internal app tells every crawler to stay out, by file, meta and header.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| scratch stack → local services | local-only run with test credentials |
|
||||
| bm public host → crawlers | an internal app reachable on the internet |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-55 | Information disclosure | bm indexed by search engines | medium | mitigate | robots.txt Disallow, meta noindex and X-Robots-Tag on every response, checked by check-noindex.sh |
|
||||
| T-33-56 | Information disclosure | tokens in the scratch run | low | mitigate | tokens generated per run into the scratch environment only, never written into the patches or the wiring note |
|
||||
| T-33-57 | Tampering | patches applied later | medium | mitigate | git apply --check against current HEADs now and again in 33-14 before applying; patches reviewed in 33-14's release checkpoint |
|
||||
| T-33-SC | Tampering | npm installs | high | accept | no new package; layers come from local sibling checkouts |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- smoke-seo.sh passes against the local demo stack.
|
||||
- Each patch passes `git apply --check`.
|
||||
- check-noindex.sh passes in sm-bm-app.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The phase works end to end on the demo against local links.
|
||||
- Release patches for the demo and summercms.io exist; bm is noindexed.
|
||||
- Nothing pushed, tagged or deployed.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Patches: `33-12-sm-demo-app.patch` (config/golem15.seo.yaml, config/golem15.sitemap.yaml, DEPLOY.md, nginx, `seo_output_test.go`, `scripts/smoke-seo.sh`), `33-12-vue-demo-app.patch` (nuxt.config `seo.noindexRoutes`, page meta, `<SeoSitemapLink>` in the footer), `33-12-sm-summercmsio-app.patch`, `33-12-vue-summercmsio-app.patch` (`seo.cms: false`, changelog expectations).
|
||||
- Note: `33-12-local-wiring.md`.
|
||||
- Scratch worktrees (not committed): `../../scratch-33/sm-demo-app`, `../../scratch-33/vue-demo-app`, `../../scratch-33/sm-summercmsio-app`, `../../scratch-33/vue-summercmsio-app` (P33-46), removed after Task 2.
|
||||
- sm-bm-app: `frontend/public/robots.txt`, robots meta in `frontend/index.html`, X-Robots-Tag in the nginx conf, `scripts/check-noindex.sh`.
|
||||
- Test (in the demo patch): `TestSEOOutput` (seo_output_test.go).
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-12-SUMMARY.md` when done.
|
||||
</output>
|
||||
265
.planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md
Normal file
265
.planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 13
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["33-11"]
|
||||
files_modified:
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, D-32, D-30, D-22, D-20, D-33, D-70, D-97, D-24]
|
||||
user_setup:
|
||||
- service: uat.grzybyfunkcjonalne.pl server
|
||||
why: "New secrets for the SEO layer and the SEO plugin, generated on the server and never printed"
|
||||
env_vars:
|
||||
- name: NUXT_SEO_NOTFOUND_TOKEN
|
||||
source: "generated on the server (openssl rand -hex 32) into /srv/grzybyfunkcjonalne/nuxt/.env; the same value as golem15.seo.notfound_token in /srv/grzybyfunkcjonalne/go/.env (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN)"
|
||||
- name: NUXT_SEO_ROUTE_REPORT_TOKEN
|
||||
source: "optional; unset falls back to the notfound token"
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Against local links, the grzybyfunkcjonalne.pl stack (SummerCMS v0.4.4 → the v0.5.2 line, Journal with golem15.journal.tag_pages false and author pages at /autor/:author, the new sitemap format, golem15.seo mounted, the layers on vue-seo-layer) answers every line of scripts/urls.txt exactly as before (Phase 16 D-15: public URLs identical) (SC-1, RESEARCH Pitfall 13)"
|
||||
- "The site's sitemap moves to the new format in the same delivery: the PHP-era definitions migrate into Additional addresses, /sitemap.xml becomes the index of sitemap-posts, sitemap-categories, sitemap-authors and sitemap-pages files with real lastmod, and the XSL view is gone (D-32, D-30)"
|
||||
- "robots.txt, sitemaps, llms.txt and verification files come from the CMS through the layer's root proxy; the Nuxt app's own robots, sitemap, link-checker and site-config settings are removed; robots.txt's Sitemap: line names the index (D-22, D-97, D-103)"
|
||||
- "Every page renders exactly one H1, one canonical and one JSON-LD script; post pages carry breadcrumbs through their category; the build passes the SSR guard (D-70, D-52, D-86, D-43)"
|
||||
- "The static pages /, /kontakt and /trwaja-prace become registry pages through the route report, and their current titles and descriptions move into the CMS through deploy/seo-pages.csv imported with the SEO plugin's CSV import, so the pages lose no metadata (D-33)"
|
||||
- "404s are reported to the SEO plugin's 404 log through the layer with the new token (D-20)"
|
||||
- "The uat deployment happens only after Jakub's decision at Task 3 and his go-ahead; production grzybyfunkcjonalne.pl (WinterCMS) is never touched; the deploy is logged in 33-13-deploy-log.md (2026-10-28 release cadence: no push, no tag); Jakub's own check of uat happens at 33-14's release checkpoint (Task 3 there), before anything is tagged"
|
||||
- "On uat (a non-production environment) the staging lock keeps robots Disallow and noindex, sitemaps are still built, and no IndexNow request is sent (D-22, D-31)"
|
||||
prohibitions:
|
||||
- requirement_id: "D-32"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: test
|
||||
statement: "No public URL of grzybyfunkcjonalne.pl changes its answer: every urls.txt line passes before any deploy"
|
||||
- requirement_id: "D-32"
|
||||
category: values
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "Nothing is deployed without Jakub's go-ahead, and the live WinterCMS site, its database and files are only read"
|
||||
artifacts:
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch"
|
||||
provides: "app config, nginx, scripts, docs, seo-pages.csv, tests"
|
||||
contains: "diff --git"
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch"
|
||||
provides: "Nuxt config on the SEO layer, footer link, page meta removal, fixtures and expectations"
|
||||
contains: "diff --git"
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md"
|
||||
provides: "decision and (if deployed) the uat deploy steps"
|
||||
contains: "Decision"
|
||||
key_links:
|
||||
- from: "grzyby nginx (deploy/nginx/grzybyfunkcjonalne-staging.conf)"
|
||||
to: "Nuxt (vue-seo-layer root proxy)"
|
||||
via: "sitemap, robots.txt and llms.txt requests reach Nuxt, which proxies them to Go; /_seo and /_sitemap stay internal"
|
||||
pattern: "sitemap"
|
||||
- from: "sm-grzybyfunkcjonalne-app/summer.yaml"
|
||||
to: "sm-seo-plugin"
|
||||
via: "golem15.seo mounted next to golem15.sitemap (the submodule is added in 33-14 at the tag)"
|
||||
pattern: "golem15.seo"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
<!-- planner-discipline-allow: sitemap.xsl -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan moves grzybyfunkcjonalne.pl's SummerCMS deployment onto the new sitemap format and the SEO layer, proves its public URLs unchanged on local links, and deploys it to uat only with Jakub's go-ahead.
|
||||
|
||||
<objective>
|
||||
- In scratch worktrees under the fixed directory ../../scratch-33 (P33-46 of 33-12), wired to the sibling repositories and recorded again in 33-13-local-wiring.md: bump the stack (framework, Journal with tag_pages false and author pages, sitemap new format, SEO plugin mounted, layers on vue-seo-layer), run the site's own smoke over every old URL plus the SEO checks.
|
||||
- Capture the committable changes as two patches (app repository and its Nuxt submodule repository) for 33-14.
|
||||
- Decide (Task 3) and, if chosen, deploy to uat.grzybyfunkcjonalne.pl from the local build and smoke it; Jakub verifies uat at 33-14's release checkpoint, which already stops for his review before any tag (moved there in the 2026-10-28 revision so this plan stays within its task budget).
|
||||
|
||||
Purpose: SC-1 ("grzybyfunkcjonalne.pl runs on the new format") and RESEARCH Pitfall 13 (several versions at once on a live site).
|
||||
|
||||
Output: two patches, the wiring note and the deploy log here (planning docs). No push, no tag; the uat deploy only after the checkpoint.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-49 (nginx):** remove `location = /sitemap.xml` and `location = /sitemap.xsl`; every root file goes to Nuxt, whose SEO layer proxies it to Go on loopback; `/_seo/` and `/_sitemap/` are not exposed (the layer calls Go internally); check-deploy.sh and smoke.sh follow (the sitemap is no longer a "remote" Go path).
|
||||
- **P33-50 (static page metadata):** the pages' own useSeoMeta calls are removed (the layer owns the head, P33-28); `deploy/seo-pages.csv` (URL, title, description in the SEO plugin's records-by-URL CSV format) carries today's titles and descriptions and is imported after the first route report.
|
||||
- **P33-51 (deploy target):** uat.grzybyfunkcjonalne.pl only, following ../sm-grzybyfunkcjonalne-app/DEPLOY.md (build, check-deploy, rsync, migrate, supervisor restart, nginx reload), with the new secrets of user_setup generated on the server.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
|
||||
@.planning/phases/32-seo-foundation/deferred-items.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
../sm-grzybyfunkcjonalne-app: go.mod (summercms replaced by ../summercms.go, plugins replaced by ./plugins/golem15/*), go.work, summer.yaml (user, translate, journal, sitemap, menu, faq, grzyby), config/menu_items.yaml (journal-post /blog/:slug, journal-category /:slug), config/*.yaml, deploy/nginx/grzybyfunkcjonalne-staging.conf (location = /sitemap.xml and = /sitemap.xsl to Go at 145-161), deploy/env.example, deploy/nuxt.env.example, deploy/supervisor/*, scripts/{build.sh, check-deploy.sh, migrate.sh, smoke.sh, urls.txt}, testdata/companions/sitemap-*.json(l), boot_test.go, companions_test.go, DEPLOY.md.
|
||||
vue-grzybyfunkcjonalne-app (submodule repository git.golem15.com/golem15/vue-grzybyfunkcjonalne-app): nuxt.config.ts (extends journal, menu, faq layers; robots, sitemap, linkChecker, site keys; journal routes and perPage; routeRules swr), package.json (layer pins v0.4.0/v0.2.0; smoke:ssr through node_modules/vue-journal-layer/testing/smoke-ssr.mjs), app/pages/{index,kontakt,trwaja-prace}.vue (useSeoMeta), app/components/SiteFooter.vue, tests/fixtures/api/*, tests/fixtures/expect.txt, tests/ssr.test.ts.
|
||||
Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem15.journal.tag_pages: false.
|
||||
33-12-local-wiring.md (if 33-12 already ran) shows the same wiring for the demo.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Planning-docs commits here only: `docs(33-13): grzyby patches for the release` and `docs(33-13): uat deploy log`. Nothing is committed in the app repositories in this plan.
|
||||
- Resume check: `git log --format=%s | grep -Fx '<subject>'`; the deploy log's last line tells where a deploy stopped.
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | none (scratch run; results in the SUMMARY) | smoke.sh and the SEO checks pass locally |
|
||||
| 2 | `docs(33-13): grzyby patches for the release` | both patches apply with git apply --check |
|
||||
| 3 | none (decision recorded in the deploy log) | decision recorded |
|
||||
| 4 | `docs(33-13): uat deploy log` | uat smoke passes, or the log records the deferral; plan complete |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "every old grzybyfunkcjonalne.pl URL answers as before, and the post pages carry the CMS head, one graph and breadcrumbs, with the sitemap index in the new format" — on local links</name>
|
||||
<files>.planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md</files>
|
||||
<read_first>
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md, scripts/build.sh, scripts/smoke.sh, scripts/urls.txt, scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts and package.json
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md if present, else .planning/phases/32-seo-foundation/32-05-SUMMARY.md ("Scratch run")
|
||||
</read_first>
|
||||
<action>
|
||||
Per Pitfall 13 and P33-46's wiring (repeated for this app): detached worktrees of sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app at ../../scratch-33/sm-grzybyfunkcjonalne-app and ../../scratch-33/vue-grzybyfunkcjonalne-app (the fixed P33-46 directory, created if 33-12 has not run yet); scratch go.work using the worktree and the sibling plugin checkouts (sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, sm-menu-plugin, sm-faq-plugin, sm-translate-plugin, sm-user-plugin as they are) plus the framework replace; summer.yaml mounts golem15.seo; config adds `golem15.journal.tag_pages: false`, the journal-author pattern `/autor/:author`, golem15.seo (site_url https://grzybyfunkcjonalne.pl, notfound_token from the environment) and golem15.sitemap (site_url); pnpm overrides link the four layers. Build with scripts/build.sh on the wiring, run scripts/smoke.sh (full rehearsal: migrate, companions import with the sitemap definitions, Go and Nuxt, every urls.txt line), then write scripts/smoke-seo.sh for this site in the worktree (the same checks as the demo's script of 33-12 when it already exists: one H1, one canonical and one ld+json on a post, a category and /kontakt; breadcrumbs on a post; /sitemap.xml index with sitemap-posts-1.xml; the companion url items present as Additional addresses; it ends with "smoke-seo: ok") and run it. Record every wiring command in 33-13-local-wiring.md.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd ../../scratch-33/sm-grzybyfunkcjonalne-app && bash scripts/smoke.sh 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit (including a missing worktree directory), a "FAIL" line, or a urls.txt line reported with a different status</fails_when>
|
||||
<automated>bash ../../scratch-33/sm-grzybyfunkcjonalne-app/scripts/smoke-seo.sh http://127.0.0.1:3016 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, a missing script, or no "smoke-seo: ok" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- every urls.txt line not marked remote passes in the full rehearsal
|
||||
- the SEO checks pass for a post, a category and /kontakt
|
||||
- 33-13-local-wiring.md lists the commands with absolute paths
|
||||
</acceptance_criteria>
|
||||
<done>The site works on the new stack without any public URL changing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: grzyby release patches (config, nginx, scripts, docs, seo-pages.csv, Nuxt config on the SEO layer, fixtures)</name>
|
||||
<files>.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch, .planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch, .planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md</files>
|
||||
<read_first>
|
||||
- the scratch worktrees from Task 1
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, deploy/env.example, deploy/nuxt.env.example, boot_test.go, companions_test.go
|
||||
- ../sm-seo-plugin/README.md (CSV import format for records by URL)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-32, D-22, D-33, D-97 and P33-47/P33-49/P33-50, in the worktrees: app repository — summer.yaml golem15.seo entry; config/golem15.journal.yaml (tag_pages false), config/menu_items.yaml (journal-author), config/golem15.seo.yaml, config/golem15.sitemap.yaml; nginx per P33-49 (and its header comment); scripts/check-deploy.sh (the required locations), scripts/smoke.sh and scripts/urls.txt (sitemap lines are Nuxt lines now; add /sitemap-posts-1.xml), scripts/migrate.sh comments (step 9 sitemap rows go to Additional addresses); deploy/env.example (SEO tokens and site URL keys), deploy/nuxt.env.example (NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO_NOTFOUND_TOKEN), DEPLOY.md (the new variables, root files through Nuxt, the CSV import step, the route report), deploy/seo-pages.csv (P33-50), smoke-seo.sh, boot_test.go (golem15.seo booted) and companions_test.go (sitemap definitions land in extra_urls). Nuxt submodule repository — nuxt.config.ts drops robots, sitemap, linkChecker and site, adds `seo` options (pagination query, htmlSitemap pl /mapa-strony) and keeps the journal routes; SiteFooter renders `<SeoSitemapLink>`; the three pages drop their useSeoMeta; tests/fixtures/api/seo/{resolve,site}.json and tests/fixtures/expect.txt with one-H1, one-canonical and one-ld+json lines; package.json pins stay for 33-14. `go test` (with Docker) and `npm test` / `smoke:ssr` pass on the wiring. Export both patches (P33-47), `git apply --check` them against the live repositories' HEADs, commit them and the wiring note here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>git -C ../sm-grzybyfunkcjonalne-app apply --check ../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch && git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app apply --check ../../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch && echo patches-ok</automated>
|
||||
<fails_when>non-zero exit, an "error:" line from git apply, or no "patches-ok" line</fails_when>
|
||||
<automated>grep -c "seo-pages.csv\|golem15.seo" .planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch</automated>
|
||||
<fails_when>output 0 or a missing file</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- the app patch removes the `location = /sitemap.xsl` block and contains deploy/seo-pages.csv with the three static pages
|
||||
- the Nuxt patch removes the robots, sitemap, linkChecker and site keys and every useSeoMeta call from app/pages
|
||||
- the SUMMARY records go test, npm test and smoke:ssr passing on the wiring
|
||||
</acceptance_criteria>
|
||||
<done>The site's changes are ready to apply on top of the release tags.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 3: Deploy grzybyfunkcjonalne.pl's SummerCMS stack to uat now, after the tags, or hold</name>
|
||||
<reversibility rating="one-way">The uat database runs the sitemap migrations (PHP-era definitions copied into Additional addresses, the new tables filled) and the sitemap URLs change shape; rolling back means restoring the database backup and the previous binaries.</reversibility>
|
||||
<decision>D-32 moves grzyby to the new format in this delivery; the question is when the uat stack switches.</decision>
|
||||
<context>Task 1 proved every old URL and the SEO output locally. Deploying now uses a binary built from local, unpushed commits (framework, Journal, sitemap, SEO, layers); deploying after 33-14 uses the tagged versions. uat runs as a non-production environment (robots Disallow, noindex; no IndexNow). The live grzybyfunkcjonalne.pl (WinterCMS) is not affected either way. DEPLOY.md's rollback (previous binary and .output, database backup) applies.</context>
|
||||
<options>
|
||||
<option id="deploy-now">
|
||||
<name>Deploy to uat now from the local build</name>
|
||||
<pros>Real-environment check (nginx, supervisor, Postgres with the imported data) before the tags; problems found before release</pros>
|
||||
<cons>uat runs untagged code until 33-14 redeploys the tagged build</cons>
|
||||
</option>
|
||||
<option id="after-tags">
|
||||
<name>Deploy in 33-14 after the tags</name>
|
||||
<pros>uat only ever runs released versions</pros>
|
||||
<cons>Environment problems surface only after tagging (a fix then needs another patch release)</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the uat deploy</name>
|
||||
<pros>No change on uat in this phase</pros>
|
||||
<cons>SC-1's "grzybyfunkcjonalne.pl runs on the new format" stays unverified on a server</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "deploy-now", "after-tags" or "hold"</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Deploy to uat (only if Task 3 answered deploy-now) and smoke it, with every step logged</name>
|
||||
<files>.planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md</files>
|
||||
<read_first>
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Rollback)
|
||||
- .planning/phases/32-seo-foundation/32-06-release-log.md (step logging so a resumed run never repeats a step)
|
||||
</read_first>
|
||||
<precondition>Task 3 answered "deploy-now", and Jakub has generated the user_setup secrets on the server (DEPLOY.md: never printed)</precondition>
|
||||
<action>
|
||||
Per P33-51: write the decision to 33-13-deploy-log.md first. If the answer is after-tags or hold, record it, remove this plan's two worktrees from ../../scratch-33 with `git worktree remove` (P33-46) and stop (33-14 carries the deploy). Otherwise, from the scratch worktree with the patches applied: scripts/build.sh, scripts/check-deploy.sh, back up the uat database and current binaries as DEPLOY.md describes, rsync the binary, config, scripts, Nuxt .output and deploy files, run the migrations, restart both supervisor programs, reload nginx after `nginx -t`, run the sitemap rebuild command, import deploy/seo-pages.csv after the Nuxt server has reported its routes, then run `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (with the UAT basic auth from the server's own loopback, as DEPLOY.md says) and smoke-seo.sh against it. Log each step with its result as it completes; never print a secret. On any failure, follow DEPLOY.md's rollback and log it. Finally write at the end of the log the uat check list Jakub runs at 33-14's release checkpoint (sitemap index and one file, a post's head and breadcrumbs, SEO › Pages and Settings › Sitemaps in /gfadmin, a crawl without staging-lock noindex), and remove this plan's two worktrees from ../../scratch-33 with `git worktree remove` (P33-46).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "^| " .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md</automated>
|
||||
<fails_when>output 0 (no logged step) or a missing file</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- the deploy log starts with the Task 3 decision
|
||||
- for deploy-now: the log shows build, check-deploy, backup, upload, migrate, restart, nginx reload, rebuild, CSV import, smoke.sh --url and smoke-seo.sh, each with its result
|
||||
- no secret value appears in the log or the SUMMARY
|
||||
- the log ends with the uat check list for 33-14's release checkpoint (deploy-now only)
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app worktree list` no longer lists ../../scratch-33/sm-grzybyfunkcjonalne-app
|
||||
</acceptance_criteria>
|
||||
<done>uat runs the new stack and passes both smokes, or the deferral is on record; Jakub's check of uat is queued for 33-14's release checkpoint.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| local machine → uat server | rsync and SSH as the deploy user |
|
||||
| uat Nuxt → uat Go | loopback calls with the new tokens |
|
||||
| internet → uat nginx | public requests (UAT basic auth) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-58 | Information disclosure | new tokens | high | mitigate | generated on the server into the .env files (mode 0600), never printed, never in patches, logs or the SUMMARY |
|
||||
| T-33-59 | Tampering | untagged build on uat | medium | mitigate | deploy only after the Task 3 decision; sha256 of the uploaded binary compared with the local build (DEPLOY.md); rollback path logged |
|
||||
| T-33-60 | Elevation of privilege | exposing /_seo and /_sitemap | medium | mitigate | P33-49 keeps both internal (no nginx location), the management API needs backend auth anyway |
|
||||
| T-33-61 | Denial of service | public URLs changing on the live domain | high | mitigate | only uat is deployed; every urls.txt line must pass first; the WinterCMS site is untouched |
|
||||
| T-33-SC | Tampering | npm/Go installs | high | accept | no new package beyond those already gated at 33-06's legitimacy checkpoint (chromedp, gen2brain/webp) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Local rehearsal: scripts/smoke.sh and smoke-seo.sh pass on the wiring.
|
||||
- Both patches pass `git apply --check`.
|
||||
- For deploy-now: the uat smokes pass; Jakub checks uat at 33-14's release checkpoint before any tag.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- grzybyfunkcjonalne.pl's SummerCMS stack runs on the new sitemap format and the SEO layer with every old URL unchanged.
|
||||
- Release patches exist; the uat deploy follows Jakub's decision.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Patches: `33-13-sm-grzybyfunkcjonalne-app.patch` (summer.yaml golem15.seo, `config/golem15.journal.yaml`, `config/golem15.seo.yaml`, `config/golem15.sitemap.yaml`, `config/menu_items.yaml` journal-author, nginx, `scripts/check-deploy.sh`, `scripts/smoke.sh`, `scripts/urls.txt`, `scripts/smoke-seo.sh`, `deploy/env.example`, `deploy/nuxt.env.example`, `deploy/seo-pages.csv`, DEPLOY.md, boot and companions tests), `33-13-vue-grzybyfunkcjonalne-app.patch` (nuxt.config `seo` options, `<SeoSitemapLink>` in SiteFooter, page meta removal, `tests/fixtures/api/seo/*`, `tests/fixtures/expect.txt`).
|
||||
- Notes and logs: `33-13-local-wiring.md`, `33-13-deploy-log.md` (ending with the uat check list for 33-14's release checkpoint).
|
||||
- Scratch worktrees (not committed): `../../scratch-33/sm-grzybyfunkcjonalne-app`, `../../scratch-33/vue-grzybyfunkcjonalne-app` (P33-46), removed after Task 4.
|
||||
- Environment variables (uat): `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN`, `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`; config keys used: `golem15.journal.tag_pages`, `golem15.seo.site_url`, `golem15.seo.notfound_token`, `golem15.sitemap.site_url`.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-13-SUMMARY.md` when done; state whether uat runs the new stack and on which commits.
|
||||
</output>
|
||||
327
.planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md
Normal file
327
.planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md
Normal file
@@ -0,0 +1,327 @@
|
||||
---
|
||||
phase: 33-seo-output-audit-and-content
|
||||
plan: 14
|
||||
type: execute
|
||||
wave: 9
|
||||
depends_on: ["33-12", "33-13"]
|
||||
files_modified:
|
||||
- scripts/check-phase33.sh
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md
|
||||
- modules/pact/content_test.go
|
||||
- modules/cabana/field_request_options_test.go
|
||||
- modules/lagoon/attach/variant_test.go
|
||||
- modules/lagoon/attach/webp/webp_test.go
|
||||
- ../sm-sitemap-plugin/classes/writer_test.go
|
||||
- ../sm-sitemap-plugin/classes/slots_test.go
|
||||
- ../sm-sitemap-plugin/classes/lastmod_test.go
|
||||
- ../sm-sitemap-plugin/classes/news_test.go
|
||||
- ../sm-sitemap-plugin/classes/indexnow_test.go
|
||||
- ../sm-sitemap-plugin/classes/htmlgroups_test.go
|
||||
- ../sm-sitemap-plugin/classes/types_test.go
|
||||
- ../sm-seo-plugin/classes/schema/graph_test.go
|
||||
- ../sm-seo-plugin/classes/schema/validate_test.go
|
||||
- ../sm-seo-plugin/classes/schema/custom_test.go
|
||||
- ../sm-seo-plugin/classes/pagination_test.go
|
||||
- ../sm-seo-plugin/classes/alternates_test.go
|
||||
- ../sm-seo-plugin/classes/breadcrumbs_test.go
|
||||
- ../sm-seo-plugin/classes/sitemap_filter_test.go
|
||||
- ../sm-seo-plugin/classes/registry_test.go
|
||||
- ../sm-seo-plugin/classes/autolinks_test.go
|
||||
- ../sm-seo-plugin/classes/crawl/parse_test.go
|
||||
- ../sm-seo-plugin/classes/crawl/checks_test.go
|
||||
- ../sm-seo-plugin/classes/crawl/fix_test.go
|
||||
- ../sm-seo-plugin/classes/crawl/compare_test.go
|
||||
- ../sm-seo-plugin/classes/crawl/render_test.go
|
||||
- ../sm-seo-plugin/tests/elements/schema.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/graph.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/links.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/parent.test.ts
|
||||
- ../sm-journal-plugin/classes/headings_test.go
|
||||
- ../sm-journal-plugin/classes/image_alt_test.go
|
||||
- ../sm-journal-plugin/classes/content_facts_test.go
|
||||
- ../sm-journal-plugin/classes/related_test.go
|
||||
- ../sm-journal-plugin/tests/blogeditor/outline.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/forms.test.ts
|
||||
- ../vue-seo-layer/tests/routes.test.ts
|
||||
- ../vue-seo-layer/tests/usePagination.test.ts
|
||||
- ../vue-journal-layer/tests/postTitle.test.ts
|
||||
- ../vue-faq-layer/tests/block.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-4, SC-1, SC-2, SC-3, D-106, D-90, D-97, D-30, D-43, D-50, D-70, D-14]
|
||||
|
||||
estimate:
|
||||
tokens: 205000
|
||||
raw_tokens: 205000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The phase's new code has unit tests, delivered in this last plan (ROADMAP success criterion 4, project rule): framework (descriptor fields, RequestOptions, attach variants and the WebP encoder), sm-sitemap-plugin (writer, slots, lastmod, News, IndexNow, HTML groups, slugs), sm-seo-plugin (schema builder and validators, custom JSON-LD, pagination, alternates, breadcrumbs, sitemap filter, registry, auto-links, crawler parse/checks/fix routes, the rendered comparison's compareRendered, allowRequest and resolverRules, elements E8–E10 and the parent select), sm-journal-plugin (heading conversion for post content and category pillar text, image alt, content facts, related scoring, editor outline and forms), and the layers (routes, pagination, post title, FAQ block) (SC-4)"
|
||||
- "bash scripts/check-phase33.sh --all passes with Docker, every repository present and no skips: self-test, go, plugins, layers (tests, playground builds, smokes, build-guard fixtures), consumers, docker, admin, docs, marker, additive and compat (Wave 0 gate)"
|
||||
- "The framework release is a patch tag (the next v0.5.x read from git tag, expected v0.5.2), strictly additive against v0.5.1 and naming no SEO concept or consuming application (D-106, D-90)"
|
||||
- "Nothing is pushed, tagged, created on git.golem15.com, deployed or posted before Jakub's go-ahead at Task 3, which names the versions, the pushes, the changelog posts and any deploys (2026-10-28 release cadence: all tags in 33-14)"
|
||||
- "When 33-13 deployed uat.grzybyfunkcjonalne.pl, Jakub checks it at the same checkpoint (the list at the end of 33-13-deploy-log.md) before any tag; a problem there is answered with hold"
|
||||
- "After the go-ahead, repositories are released in order — framework → sm-journal-plugin → sm-sitemap-plugin (breaking: next minor) → sm-seo-plugin → vue-seo-layer (created with tea) → vue-journal-layer, vue-menu-layer, vue-faq-layer → apps — each plugin requiring the new tags, the layers pinning the new vue-seo-layer tag instead of link:, and each app pinning tags with plugin pointer bumps as separate commits after the plugin is pushed (2026-10-28 release cadence, CLAUDE.md)"
|
||||
- "The 33-12 and 33-13 patches are applied to the apps after the tags, with grzybyfunkcjonalne-app's sm-seo-plugin submodule added at its tag; each app builds and passes its tests on the tagged versions; sm-demo-app's check-pins passes on a clean worktree"
|
||||
- "SummerCMS's release tag gets its changelog post on summercms.io the same day through scripts/changelog-post.py (source, then publish); the g15_ token is read by the script and never printed (CLAUDE.md)"
|
||||
- "Every executed release step is appended to 33-release-log.md as it completes, so a resumed run never re-tags, force-pushes or re-posts"
|
||||
- "33-VALIDATION.md is filled per task with the real commands and statuses and marked nyquist_compliant true when every row is green"
|
||||
- "Phase 33 is delivered in the 14 plans Jakub confirmed on 2026-10-28 (2026-10-28 plan list)"
|
||||
prohibitions:
|
||||
- requirement_id: "D-106"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "No push, tag, repository creation, deploy or changelog post happens without Jakub's go-ahead, and no version number is hard-coded before the checkpoint"
|
||||
- requirement_id: "D-106"
|
||||
category: safety
|
||||
status: resolved
|
||||
verification: judgment
|
||||
statement: "The changelog token is never printed, copied or minted"
|
||||
artifacts:
|
||||
- path: "scripts/check-phase33.sh"
|
||||
provides: "the complete phase gate"
|
||||
contains: "--all"
|
||||
- path: ".planning/phases/33-seo-output-audit-and-content/33-release-log.md"
|
||||
provides: "executed release steps"
|
||||
contains: "Executed steps"
|
||||
- path: "../sm-seo-plugin/classes/schema/graph_test.go"
|
||||
provides: "graph unit matrix"
|
||||
contains: "func Test"
|
||||
- path: "../vue-seo-layer/tests/usePagination.test.ts"
|
||||
provides: "pagination composable unit tests"
|
||||
contains: "test("
|
||||
key_links:
|
||||
- from: "scripts/check-phase33.sh"
|
||||
to: "every Phase 33 repository"
|
||||
via: "the plugins, layers, consumers, docker and compat stages run each repository's tests and builds"
|
||||
pattern: "vue-seo-layer"
|
||||
- from: ".planning/phases/33-seo-output-audit-and-content/33-release-log.md"
|
||||
to: "git tags of the released repositories"
|
||||
via: "each logged step names the tag object and the pushed commit"
|
||||
pattern: "tag"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
|
||||
|
||||
This plan proves the phase with unit tests and the full gate, then releases every repository in order and re-pins the apps — each step only after Jakub's go-ahead.
|
||||
|
||||
<objective>
|
||||
- Complete `scripts/check-phase33.sh` and run `--all` green (tracer of the gate across every repository).
|
||||
- Unit tests for all new code (project rule: unit tests are the last plan; SC-4).
|
||||
- After the release checkpoint: tags in release order, the vue-seo-layer remote, pin bumps and the 33-12/33-13 patches in the apps, pushes, the framework changelog post, and deploys only when named.
|
||||
|
||||
Purpose: SC-4 and the release of Phase 33 (2026-10-28 release cadence: all tags here).
|
||||
|
||||
Output: test commits in each repository, the gate script, the filled validation map, the release log, tags and pushes after the go-ahead.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P33-52 (versions proposed at the checkpoint, read from git tag):** framework next patch (v0.5.2); sm-journal-plugin next patch or minor (new settings, type and API keys are additive: patch v0.2.6 unless Jakub prefers v0.3.0); sm-sitemap-plugin next minor (breaking, v0.2.0); sm-seo-plugin next minor (new requires and features, v0.2.0); vue-seo-layer v0.1.0; vue-journal-layer next minor (drops @nuxtjs/seo: v0.7.0); vue-menu-layer and vue-faq-layer next minor (v0.5.0). The answer fixes them.
|
||||
- **P33-53 (release steps are resumable):** each step is: check the log; do it; verify (`git ls-remote --tags origin <tag>`, build of the dependent); append the log line with SHAs. A failure stops the run and is logged; nothing is force-pushed.
|
||||
- **P33-57 (four tasks, 2026-10-28 revision):** the gate completion that used to be its own task is split by language into the two unit-test tasks (Go stages with the Go tests, the other stages with the TypeScript tests), and 33-13's uat check joins the release checkpoint, so the plan keeps four tasks without a task spanning more repositories than before. The unit tests and the release cannot leave this plan (project rule: unit tests are the last plan; CONTEXT 2026-10-28 release cadence: all tags in 33-14).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-release-log.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-12-SUMMARY.md
|
||||
@.planning/phases/33-seo-output-audit-and-content/33-13-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-06-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-06-release-log.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
Every plan's SUMMARY (33-01 … 33-13) lists the symbols, routes, tables and tests it created; unit tests target those symbols.
|
||||
Release precedent: 32-06-SUMMARY.md "Release steps" (go get with GOWORK=off and GOPRIVATE='git.golem15.com/*', README dependency lines, annotated tags, ls-remote checks, demo check-pins on a clean worktree, changelog-post.py source/publish).
|
||||
Changelog: ../sm-summercmsio-app/scripts/changelog-post.py source vX.Y.Z, then publish vX.Y.Z notes.md --excerpt "…" (## Highlights/## Added/## Changed/## Fixed, no planning IDs); ../sm-summercmsio-app/DEPLOY.md "Posting to the changelog".
|
||||
Repository creation: tea (git.golem15.com) for vue-seo-layer, name from 33-07-SUMMARY.md.
|
||||
Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag>.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Test commits per repository staged by path; planning docs (VALIDATION, release log) in their own commits here. Other sessions' uncommitted files stay untouched in every repository.
|
||||
- The executor stops at Task 3 (checkpoint) and the continuation runs Task 4; Task 4 is resumable from 33-release-log.md (P33-53).
|
||||
|
||||
| Task | Commit groups, in order | Resume point |
|
||||
|------|-------------------------|--------------|
|
||||
| 1 | `test: phase 33 gate, Go stages`; one `test: unit tests for …` commit per Go repository (framework, sitemap, seo, journal) | the Go stages of the gate pass with the new Go unit tests |
|
||||
| 2 | `test: phase 33 gate, layer, consumer and admin stages`; one `test: …` commit per TS repository (sm-seo-plugin elements, sm-journal-plugin editor, vue-seo-layer, vue-journal-layer, vue-faq-layer); `docs(33-14): validation map` | gate --all green; VALIDATION filled |
|
||||
| 3 | none (go-ahead recorded at the top of the release log) | answer recorded |
|
||||
| 4 | release steps logged one by one in 33-release-log.md | every named step logged done |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "the Go side of the phase gate runs the Go repositories' new unit tests, Docker suites and guards and passes" — framework, sm-sitemap-plugin, sm-seo-plugin and sm-journal-plugin</name>
|
||||
<files>scripts/check-phase33.sh, modules/pact/content_test.go, modules/cabana/field_request_options_test.go, modules/lagoon/attach/variant_test.go, modules/lagoon/attach/webp/webp_test.go, ../sm-sitemap-plugin/classes/writer_test.go, ../sm-sitemap-plugin/classes/slots_test.go, ../sm-sitemap-plugin/classes/lastmod_test.go, ../sm-sitemap-plugin/classes/news_test.go, ../sm-sitemap-plugin/classes/indexnow_test.go, ../sm-sitemap-plugin/classes/htmlgroups_test.go, ../sm-sitemap-plugin/classes/types_test.go, ../sm-seo-plugin/classes/schema/graph_test.go, ../sm-seo-plugin/classes/schema/validate_test.go, ../sm-seo-plugin/classes/schema/custom_test.go, ../sm-seo-plugin/classes/pagination_test.go, ../sm-seo-plugin/classes/alternates_test.go, ../sm-seo-plugin/classes/breadcrumbs_test.go, ../sm-seo-plugin/classes/sitemap_filter_test.go, ../sm-seo-plugin/classes/registry_test.go, ../sm-seo-plugin/classes/autolinks_test.go, ../sm-seo-plugin/classes/crawl/parse_test.go, ../sm-seo-plugin/classes/crawl/checks_test.go, ../sm-seo-plugin/classes/crawl/fix_test.go, ../sm-seo-plugin/classes/crawl/compare_test.go, ../sm-seo-plugin/classes/crawl/render_test.go, ../sm-journal-plugin/classes/headings_test.go, ../sm-journal-plugin/classes/image_alt_test.go, ../sm-journal-plugin/classes/content_facts_test.go, ../sm-journal-plugin/classes/related_test.go</files>
|
||||
<read_first>
|
||||
- scripts/check-phase33.sh (33-01) and scripts/check-phase32.sh (stage engine)
|
||||
- every 33-0x-SUMMARY.md and 33-1x-SUMMARY.md "Artifacts" and verification commands (the Go symbols to test and the Docker suites to run)
|
||||
- the Phase 32 unit test style: ../sm-seo-plugin/classes/{resolver_test,issues_test,helpers_test}.go, modules/cabana/form_extensions_test.go, modules/lagoon/attach/thumb_test.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Table-driven, -short-compatible where no database is needed: XML escaping and namespaces, no priority element; slot assignment with deletes, appends and size change; lastmod ignore list and locale rows; News window boundaries (47 h/49 h) and the 1000 cap; IndexNow chunking at 10 000, key format, host filter, answer mapping; HTML groups order, tree and paging; default slug pluralisation; @id links per kind and the publisher/local node; every validator rule; custom JSON-LD caps, script refusal and merge; pagination parse, 301 and invalid cases per form; alternates and x-default; breadcrumb chain with primary parent, fallback and depth cap; filter reasons; route-report merge never deleting; auto-link locale/self filtering; crawler parse of headings, links, images, JSON-LD and hreflang on HTML fixtures; each check's positive and negative case; fix routes (including the fitting-tool rule: H1 → headings tool, a category's skipped level → code); ConvertH1 byte preservation; alt rewrite escaping; content facts (decorative, videos, headings, category pillar headings); related scoring ties; RequestOptions merge precedence and fail-open; VariantKey; WebP encode header and idempotent variant
|
||||
- Rendered comparison (33-10 Task 2 and 4): compareRendered reports a same-host link, an H1 and a 50-word and 20 % text growth present only after JavaScript, ignores other-host links and smaller growth, and returns nothing for identical facts; allowRequest accepts https on the site host and its www twin, data: and blob:, refuses other hosts, http (except in trusted mode) and every other scheme; resolverRules starts with MAP * ~NOTFOUND and maps exactly the allowed hosts; NewRenderer gives skipped for an empty path and failed for a relative or non-executable one
|
||||
</behavior>
|
||||
<action>
|
||||
Complete the Go side of check-phase33.sh with what the plans delivered: go (framework vet and `test -short`, including the attach and webp packages), plugins' Go part (the three Go plugins' vet and `test -short`; the element and editor vitest suites and both dist checks stay in the stage, unchanged), docker (the Docker suites named in the SUMMARYs of the three plugins, TestChromeRenderer when a Chrome binary is present, and the framework attach tests), docs, marker (framework diff since v0.5.1), additive and compat. Then write the Go unit tests per the behavior list in the files named (package-internal tests where the code is unexported); each repository's `go vet ./...` and `go test -short ./...` green, Docker suites still green. Run `bash scripts/check-phase33.sh --self-test` and the Go stages with Docker (if the Docker socket is denied, run through `sudo -u golem` per CLAUDE.md); fix gate bugs, never weaken a stage. Commit the script, then one test commit per repository.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase33.sh --self-test && bash scripts/check-phase33.sh --go --plugins --docker --docs --marker --additive --compat 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL", "refuse:", "missing " or "SKIP" line in the output</fails_when>
|
||||
<automated>go test -short ./modules/pact ./modules/cabana ./modules/lagoon/attach/... -count=1 2>&1 | tail -6 && go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./... -count=1 2>&1 | tail -4 && go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./... -count=1 2>&1 | tail -4 && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./... -count=1 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line in any output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- every test file in this task's files exists and contains at least one `func Test`
|
||||
- `go -C ../sm-seo-plugin test -short ./classes/schema/... ./classes/crawl/... -count=1 -v 2>&1 | grep -c -- "--- PASS"` is at least 30
|
||||
- `grep -n "TestChromeRenderer" scripts/check-phase33.sh` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>The Go code of the phase is covered by unit tests, and the Go side of the gate proves it with the Docker suites and guards.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: TypeScript unit tests for the elements, the editor and the layers; the remaining gate stages, --all and the validation map</name>
|
||||
<files>scripts/check-phase33.sh, ../sm-seo-plugin/tests/elements/schema.test.ts, ../sm-seo-plugin/tests/elements/graph.test.ts, ../sm-seo-plugin/tests/elements/links.test.ts, ../sm-seo-plugin/tests/elements/parent.test.ts, ../sm-journal-plugin/tests/blogeditor/outline.test.ts, ../sm-journal-plugin/tests/blogeditor/forms.test.ts, ../vue-seo-layer/tests/routes.test.ts, ../vue-seo-layer/tests/usePagination.test.ts, ../vue-journal-layer/tests/postTitle.test.ts, ../vue-faq-layer/tests/block.test.ts, .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</files>
|
||||
<read_first>
|
||||
- scripts/check-phase33.sh (after Task 1) and .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md
|
||||
- ../sm-seo-plugin/tests/elements/{host.ts, serp.test.ts} and ../sm-journal-plugin/tests/blogeditor/{helpers.ts, element.test.ts} (harness)
|
||||
- ../vue-seo-layer/tests/*.test.ts and ../vue-journal-layer/tests/*.test.ts (node --test style)
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C17, C18, C19, C28, L2, L5, L6 backstops)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- E8: Service fields round-trip as a JSON string; invalid price shows priceInvalid; parse error shows line and column; over 16 KB shows jsonTooLarge; type options per kind (UI-SPEC E8 backstop)
|
||||
- E9: nodes, findings with icons and words, noRules line, allGood, checking with aria-busy, one queued request, failure line keeps the last graph
|
||||
- E10: counters, notCrawled, orphan callout, lists capped at 10, none, copy writes the URL, hidden copy button without clipboard
|
||||
- parent select: options and empty option from the value
|
||||
- Editor outline: skipped levels, empty, link-only, H1 flag only when level 1 is not allowed, generic link flag, 300 ms debounce, no content edits
|
||||
- Editor forms: rel checkboxes disabled for internal URLs, rel written, generic warning without blocking; slug rule examples (UI-SPEC J4 backstop); decorative clears alt
|
||||
- Layer routes: static paths exclude parameters, requiresAuth and noindex; pattern normalisation for the drift check
|
||||
- usePagination: hrefs per form, %pages% replacement, prev/next head links at the ends
|
||||
- Post title: the titleTag decision table for postTitleTag × content_h1_to_h2 (including an absent key)
|
||||
- FAQ block: headingLevel 0 renders p with aria-labelledby; FAQPage node text stripped of markup
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests per behavior list; `npm --prefix <repo> run test` green in each repository; rebuild nothing (tests only). Complete the remaining stages of check-phase33.sh with what the plans delivered: layers (per layer: tests, playground build, its smoke command, journal's second expectation file with the postTitleTag env, vue-seo-layer guard:check and purge checks), consumers (sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and sm-bm-app build as committed; the 33-12/33-13 patches checked with `git apply --check`) and admin; fix gate bugs, never weaken a stage. Run `bash scripts/check-phase33.sh --all` with Docker. Fill 33-VALIDATION.md's per-task verification map with the real commands of every plan (including the D-70 rendered comparison: TestRenderSkipped, TestChromeRenderer, compare_test.go) and their status, tick the sign-off list and set `nyquist_compliant: true` and `wave_0_complete: true` when all rows are green. Commit the script, then per repository, then the validation map here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -4 && npm --prefix ../sm-journal-plugin run test 2>&1 | tail -4 && npm --prefix ../vue-seo-layer run test 2>&1 | tail -4 && npm --prefix ../vue-journal-layer run test 2>&1 | tail -4 && npm --prefix ../vue-faq-layer run test 2>&1 | tail -4 && npm --prefix ../vue-menu-layer run test 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit, a "not ok" line, a "# fail" count above 0, or a failed count above 0 in a vitest summary</fails_when>
|
||||
<automated>bash scripts/check-phase33.sh --all 2>&1 | tail -15 && grep -n "nyquist_compliant: true" .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</automated>
|
||||
<fails_when>non-zero exit, a "FAIL", "refuse:", "missing " or "SKIP" line, no "Phase 33 gate passed" line, or the grep printing nothing</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- each test file of this task exists with at least three test cases
|
||||
- the --all output lists PASS for all eleven stages
|
||||
- `grep -n "guard:check\|expect-acme-title-h1" scripts/check-phase33.sh` prints at least two lines
|
||||
- 33-VALIDATION.md has no "⬜ pending" status left
|
||||
</acceptance_criteria>
|
||||
<done>The admin elements, the editor and the layers are covered by unit tests, one command proves the whole phase across every repository, and the validation map is complete.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 3: Release go-ahead (versions, pushes, the new repository, changelog posts, deploys), with the uat check of 33-13</name>
|
||||
<what-built>All Phase 33 code is committed locally in 13 repositories, the gate passes (Tasks 1 and 2), and the app patches apply. Nothing has been pushed, tagged, created remotely, deployed or posted. If 33-13 answered deploy-now, uat.grzybyfunkcjonalne.pl runs the untagged stack and both smokes passed there (33-13-deploy-log.md).</what-built>
|
||||
<how-to-verify>
|
||||
0. Only if 33-13 deployed uat (moved here from 33-13 in the 2026-10-28 revision; the list is at the end of 33-13-deploy-log.md): open https://uat.grzybyfunkcjonalne.pl/sitemap.xml (an index of sitemap-posts, sitemap-categories, sitemap-authors and sitemap-pages files; open one file); open a post (view source: one title, one canonical, one application/ld+json script with BlogPosting and BreadcrumbList, breadcrumbs above the content); in /gfadmin, SEO › Pages lists /, /kontakt and /trwaja-prace with their titles and Settings › Sitemaps shows the files table and the migrated Additional addresses; start a crawl in SEO › Crawls and, when it finishes, SEO › Site audit lists issues without staging-lock noindex. Anything wrong: answer "hold" with what is wrong.
|
||||
1. Review the proposed versions (P33-52, read now from `git tag` in each repository) and the release order of the release log.
|
||||
2. Review the list of pushes: framework master; sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin main; vue-seo-layer (new repository with tea, name from 33-07) main; vue-journal-layer, vue-menu-layer, vue-faq-layer main; sm-demo-app and vue-demo-app; sm-summercmsio-app and vue-summercmsio-app; sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app; sm-bm-app — with the counts of commits each push carries (including other sessions' commits, as in 32-06).
|
||||
3. Choose the changelog posts (the framework post is required for a framework tag; plugin posts optional).
|
||||
4. Name any deploys (demo per ../sm-demo-app/DEPLOY.md; uat.grzybyfunkcjonalne.pl per its DEPLOY.md, on the tagged build; bm per its DEPLOY.md) or none.
|
||||
5. Answer with the versions, the approved pushes, the posts and the deploys.
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "release: <versions>; push: <list>; posts: <list>; deploy: <list or none>" or "hold" (with what is wrong on uat, when that is the reason)</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Release in order, re-pin the apps, post the changelog and deploy what was named — every step logged</name>
|
||||
<files>.planning/phases/33-seo-output-audit-and-content/33-release-log.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md (the go-ahead at its top and any steps already done)
|
||||
- .planning/phases/32-seo-foundation/32-06-SUMMARY.md (Release steps) and 32-06-release-log.md
|
||||
- ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-demo-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- 33-12-local-wiring.md, 33-13-local-wiring.md, 33-13-deploy-log.md
|
||||
</read_first>
|
||||
<precondition>Task 3 answered "release: …" (not "hold"), and the answer is recorded at the top of 33-release-log.md</precondition>
|
||||
<action>
|
||||
Per the 2026-10-28 release cadence, P33-52 and P33-53, only for what the go-ahead names, appending a log line (step, repository, result, SHAs, tag object) after each step:
|
||||
1. Pre-checks: `bash scripts/check-phase33.sh --all` green; each repository's branch is ahead of origin only by the expected commits; patches pass `git apply --check`.
|
||||
2. Framework: annotated tag, push master and the tag, `git ls-remote --tags origin <tag>`.
|
||||
3. sm-journal-plugin, then sm-sitemap-plugin, then sm-seo-plugin: `GOWORK=off GOPRIVATE='git.golem15.com/*' go get` the new framework tag (and the new sitemap tag for SEO), README dependency lines, build, vet, `test -short`, commit `chore: require SummerCMS <tag>` (and sm-sitemap-plugin <tag>), annotated tag, push main and the tag.
|
||||
4. vue-seo-layer: create the remote with tea under the confirmed name, set origin, push main, tag v0.1.0 (or the approved version), push the tag.
|
||||
5. vue-journal-layer, vue-menu-layer, vue-faq-layer: replace the link: dependency with `git+https://git.golem15.com/golem15/vue-seo-layer.git#<tag>`, `pnpm install`, tests, commit `chore: pin vue-seo-layer <tag>`, annotated tag, push; run vue-journal-layer's scripts/check-git-install.sh <tag> from a directory outside the layer.
|
||||
6. Apps, each plugin pointer first pushed then bumped in the app as its own commit: sm-demo-app (apply 33-12 patches; go get the tags; submodule pointers journal, sitemap, seo; vue-demo-app: apply its patch, pin the layer tags, commit and push vue-demo-app, then bump its pointer in sm-demo-app; tests with Docker and the summercms_mcp tag; check-pins on a clean worktree); sm-summercmsio-app and vue-summercmsio-app (journal bump with its config, layer pins, `seo.cms: false` patch, generate and test:changelog); sm-grzybyfunkcjonalne-app (framework, journal, sitemap tags; `git submodule add -b main` sm-seo-plugin at plugins/golem15/seo checked out at its tag, go.work use; apply the 33-13 patches; vue app pins; build.sh and tests); sm-bm-app (push its local D-72 commit). Push each app as approved.
|
||||
7. Changelog: `scripts/changelog-post.py source <framework tag>` in ../sm-summercmsio-app, write user-facing notes (## Highlights / ## Added / ## Changed / ## Fixed; no planning IDs, no quick codes, no H1), `publish <tag> notes.md --excerpt "…"`, check the post URL answers 200; plugin posts only if named. Never print, copy or mint the token.
|
||||
8. Deploys only if named, each per its DEPLOY.md on the tagged builds (uat.grzybyfunkcjonalne.pl as 33-13 Task 4 describes, appending to 33-13-deploy-log.md as well).
|
||||
9. Commit the release log here (planning docs). The g15office comment on the phase task, and closing w8kupqi3 if it is still open there (the repository todo is already completed), are reported to the orchestrator in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>for t in $(grep -oE 'TAG [^ ]+ [^ ]+' .planning/phases/33-seo-output-audit-and-content/33-release-log.md | awk '{print $2"@"$3}'); do repo=${t%@*}; tag=${t#*@}; git -C "$repo" ls-remote --tags origin "$tag" | grep -q "$tag" || { echo "missing $t"; exit 1; }; done; echo tags-ok</automated>
|
||||
<fails_when>non-zero exit, a "missing" line, or no "tags-ok" line (each released tag is logged as "TAG <repo path> <tag>")</fails_when>
|
||||
<automated>bash scripts/check-phase33.sh --compat --consumers 2>&1 | tail -6</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- the release log has one line per executed step in the release order, each with SHAs, and a TAG line per tag
|
||||
- every plugin's go.mod requires the new framework tag (no pseudo-version) and sm-seo-plugin requires the new sm-sitemap-plugin tag
|
||||
- no layer package.json contains "link:" after step 5
|
||||
- the changelog post URL answers 200 (when a post was approved)
|
||||
</acceptance_criteria>
|
||||
<done>Phase 33 is released in order with the apps pinned to the tags, or the hold is on record.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| local repositories → git.golem15.com | pushes, tags and a new repository |
|
||||
| release script → summercms.io changelog API | token-authenticated post |
|
||||
| local build → servers | deploys |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-33-62 | Repudiation/Integrity | unapproved release steps | high | mitigate | blocking go-ahead (Task 3) naming every push, tag, post and deploy; each executed step logged with SHAs |
|
||||
| T-33-63 | Information disclosure | changelog token | high | mitigate | read by changelog-post.py from ~/.config/summercms/journal-token; never printed, copied or minted; refused token → ask Jakub |
|
||||
| T-33-64 | Tampering | breaking published API | high | mitigate | additive and compat stages of the gate before tagging; the framework tag is a patch |
|
||||
| T-33-65 | Tampering | pins to moving targets | medium | mitigate | apps pin tags (go.mod versions, submodule pointers at tags, pnpm git URLs with #tag); check-pins on the demo |
|
||||
| T-33-SC | Tampering | npm/Go installs | high | mitigate | no new package in this plan; lockfiles regenerated only to swap link: for the first-party tag and reviewed in the pin commits |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase33.sh --all` green before the checkpoint.
|
||||
- After release: all logged tags on the remotes, consumers and compat green, the changelog post reachable.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Unit tests for all new code (SC-4) and the full gate green.
|
||||
- Release in order after the go-ahead, apps re-pinned, framework changelog post, deploys only when named.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Unit test files listed in files_modified (framework, three Go plugins, element and editor suites, three layers).
|
||||
- Completed `scripts/check-phase33.sh` stages; filled `33-VALIDATION.md` (`nyquist_compliant: true`).
|
||||
- Release log entries (TAG lines) in `33-release-log.md`; tags (versions fixed at Task 3): framework v0.5.x, sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, vue-seo-layer, vue-journal-layer, vue-menu-layer, vue-faq-layer; remote repository vue-seo-layer on git.golem15.com; app commits re-pinning the tags and applying the 33-12/33-13 patches; the summercms.io changelog post for the framework tag.
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/33-seo-output-audit-and-content/33-14-SUMMARY.md` when done; list for the orchestrator the g15office comment to post and whether g15office task w8kupqi3 still needs closing.
|
||||
</output>
|
||||
179
.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
Normal file
179
.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Phase 33: SEO output, audit and content - Context
|
||||
|
||||
**Gathered:** 2026-10-24, in the Phase 32 discussion (written out for Phase 33 on 2026-10-27)
|
||||
**Status:** Ready for planning
|
||||
**Participant:** Dawid (SEO specialist, delegated by Jakub for the SEO discussion); Jakub for the phase split, code layout and planning decisions.
|
||||
|
||||
**Source of the decisions:** `.planning/phases/32-seo-foundation/32-CONTEXT.md`. The SEO discussion covered Phases 32 and 33 together; Jakub split the scope afterwards (Q-01, 2026-10-25). This file does not repeat or change any decision. It lists the ones Phase 33 implements, by ID, with the part that belongs to Phase 33, following the Phase 33 column of `.planning/phases/32-seo-foundation/32-DECISIONS-MAP.md`. Read the full decision text in 32-CONTEXT.md; where the two files disagree, 32-CONTEXT.md wins.
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Phase 32 delivered the SEO data, rules and admin on the backend. Phase 33 makes the rendered sites emit complete SEO output from the CMS, and adds the audit that finds the remaining problems:
|
||||
- the `sm-sitemap-plugin` rewrite (D-32): XML sitemap index split by type, image and News sitemaps, IndexNow, the HTML sitemap;
|
||||
- the page registry in `sm-seo-plugin`;
|
||||
- JSON-LD `@graph`, pagination, hreflang;
|
||||
- headings, breadcrumbs;
|
||||
- the shared Nuxt SEO layer and the SSR rules in the layers, with the layers and apps moved onto it;
|
||||
- the crawler and the issue report;
|
||||
- internal linking and auto-links, the orphan report;
|
||||
- media SEO (alt, file names, formats).
|
||||
|
||||
The split of responsibility from 32-CONTEXT still holds: the backend plugins own the data, rules and admin, and serve them to the frontend; the Nuxt layers render them into `<head>`, JSON-LD, breadcrumbs, pagination and headings, enforce redirects and noindex on their routes, and guarantee server rendering.
|
||||
|
||||
**Builds on:** the Phase 32 releases, as tagged versions, not a `replace` (Q-01): SummerCMS v0.5.1, `sm-seo-plugin` v0.1.0, `sm-journal-plugin` v0.2.5. The framework seams D-91 to D-93 exist and stay as they are.
|
||||
|
||||
**Repos:** `sm-sitemap-plugin`, `sm-seo-plugin`, `sm-journal-plugin`, `sm-faq-plugin`, the Nuxt layers (a new SEO layer, journal, menu, FAQ), the apps. grzybyfunkcjonalne moves to the new sitemap format in the same delivery.
|
||||
|
||||
**Out of scope** (as in 32-CONTEXT): e-commerce structured data and shop pages; migrating `sm-bm-app` to SSR (D-72 gives it noindex and `Disallow` only).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Planning (Jakub, 2026-10-25)
|
||||
- **About 12–15 smaller plans: an exception to the lean-planning rule.** The planner must not merge them into 3–4 plans. The other project rules still apply: the plan list with a one-line scope each is shown to Jakub before PLAN.md files are written, and unit tests are the last plan.
|
||||
- **Mode:** mvp (ROADMAP.md).
|
||||
- **H1 in Journal content is a per-site setting (Jakub, 2026-10-28; amends D-50 and D-51).** Journal serves many kinds of sites, and the plugin cannot know whether the page template renders the post title as the H1, so the "no H1 in body content" rule must not be hard-wired. A Journal setting (backend settings and config key) switches it:
|
||||
- **On (default, D-50 as decided):** H1 to H2 conversion on every write path with the save notice, the editor offers H2–H4, `journal:fix-headings` is available, and the journal layer's htmlGate demotes h1 to h2.
|
||||
- **Off:** content H1 is stored and rendered as written; the editor offers H1; `journal:fix-headings` refuses to run and says why; the layer does not demote h1 (it reads the setting from the Journal API, not from a layer option).
|
||||
- The live heading outline (D-51) works in both modes. The crawler keeps checking the rendered page (one H1 per page); the CMS-side "H1 in content" check runs only when the setting is on.
|
||||
- Where the setting lives (Journal, not the SEO plugin) follows D-96: the content plugin owns its own fixes.
|
||||
- **Layer options instead of fixed layout assumptions (Jakub, 2026-10-28, from the UI-SPEC check).**
|
||||
- **Post title heading:** the journal layer's post page renders the title as the H1 when Journal's H1 setting is on, and as a styled non-heading element when it is off, so a post never gets two H1s. A layer option (e.g. `postTitleTag`) overrides either way.
|
||||
- **Outbound links in a new tab (D-83):** a switch on the SEO settings Links tab, manageable through MCP, default on. When off, outbound links keep their `rel` rules but open in the same tab.
|
||||
- **Journal breadcrumbs (D-86):** a journal-layer option turns off the journal pages' built-in `<SeoBreadcrumbs>`, for sites whose layout places breadcrumbs itself.
|
||||
- **The framework still carries no SEO code (D-90).** If Phase 33 needs a new framework seam, it must be generic and additive (D-106 rules: patch tag, no breaking change); D-103 rules out a fourth seam for redirects and verification files.
|
||||
- **Plan list confirmed (Jakub, 2026-10-28): 14 plans**, following the "Suggested Plan Breakdown" in 33-RESEARCH.md with the 2026-10-28 amendments above added to 33-02 (Journal H1 setting), 33-05 (`links_new_tab`) and 33-09 (`postTitleTag`, `journal.breadcrumbs`, htmlGate following the Journal setting).
|
||||
- **Release cadence (Jakub, 2026-10-28): all tags in 33-14.** No plugin or layer tag is pushed mid-phase. Plans 33-12 and 33-13 work against local links (`replace`, pnpm `link:`/workspace paths or patches, as 32-05 did); 33-14 tags every repo in release order and re-pins the apps.
|
||||
- **Image formats (Jakub, 2026-10-28; D-85, RESEARCH Open Question 3): WebP now, in Go.** `github.com/gen2brain/webp` encodes a WebP variant once per thumbnail size, in a conga job (not in the upload request), stored next to the existing thumbnails; the layers emit `srcset` with WebP and the original format as fallback. AVIF is deferred. The dependency needs a decision note in `.planning/notes/` before `go.mod` changes. Benchmark (2026-10-28, 1200 px from a 2592×1754 photo): WebP 78 KB against JPEG q80 133 KB; about 90 ms with system libwebp (loaded through purego, no cgo), about 510 ms in the WASM fallback; AVIF only about 6% smaller than WebP at 1.3× to 20× the CPU.
|
||||
- **Rendered comparison (Jakub, 2026-10-28; D-70, RESEARCH Open Question 2): optional chromedp.** The crawler's raw-HTML checks always run. The raw-versus-rendered comparison (content and links that appear only after JavaScript) runs when a Chrome path is configured (setting or config key); without one the crawler reports that the comparison was skipped. `github.com/chromedp/chromedp` needs a decision note in `.planning/notes/` before `go.mod` changes.
|
||||
- **Success criterion 3 reading (Jakub, 2026-10-28; RESEARCH Open Question 4):** every data issue names the MCP tool that fixes it; an issue caused by template code carries `fix: code` instead (UI-SPEC C23).
|
||||
- **H1 conversion scope (Jakub, 2026-10-28; D-50):** when the Journal H1 setting is on, the H1 to H2 conversion covers every Journal rich-text write path, post content and category pillar text alike; `journal:fix-headings` covers both.
|
||||
- **Remaining research defaults accepted (Jakub, 2026-10-28):**
|
||||
- Sitemap site URL precedence: sitemap setting, then `golem15.sitemap.site_url`, then `app.url` (Open Question 5).
|
||||
- Related posts: opt-in in Journal, current behaviour as default (Open Question 6).
|
||||
- summercms.io: pin bumps and the build guard only (Open Question 7).
|
||||
- Auth pages are marked through route meta; no user layer (Open Question 10).
|
||||
- The new-tab hint is on by default while new tab is on (UI-SPEC Q-8).
|
||||
|
||||
### Decisions implemented entirely in Phase 33
|
||||
Section numbers refer to 32-CONTEXT.md.
|
||||
|
||||
| ID | Decision | 32-CONTEXT section |
|
||||
|----|----------|--------------------|
|
||||
| D-30 | Sitemap index split by type, 1000-URL files, real `lastmod` | 5 |
|
||||
| D-31 | Image, News sitemaps, IndexNow | 5 |
|
||||
| D-32 | The new format replaces PHP parity (breaking, confirmed); the XSLT todo goes with it | 5 |
|
||||
| D-33 | Page registry in the CMS | 5 |
|
||||
| D-34 | HTML sitemap | 5 |
|
||||
| D-40 | URL form chosen per site | 6 |
|
||||
| D-41 | Page 2 and later: robots, suffixes, H1, pillar text | 6 |
|
||||
| D-42 | Numbered server-rendered links, rel prev/next, 301 and 404 rules | 6 |
|
||||
| D-43 | One JSON-LD `@graph` | 7 |
|
||||
| D-44 | Schema type, type-dependent fields, custom JSON-LD | 7 |
|
||||
| D-45 | Publisher in the global settings | 7 |
|
||||
| D-46 | Validation in admin, crawler and Rich Results link | 7 |
|
||||
| D-50 | No H1 in body content, including Journal's conversion (D-96); behind a Journal setting, see Planning | 8 |
|
||||
| D-51 | Live heading outline and crawler checks; editor heading levels follow the Journal setting, see Planning | 8 |
|
||||
| D-52 | One heading convention in the layers | 8 |
|
||||
| D-55 | hreflang in head and sitemaps from one source | 9 |
|
||||
| D-56 | `x-default` points to the default locale | 9 |
|
||||
| D-57 | No hreflang for a missing translation | 9 |
|
||||
| D-70 | Indexable pages always SSR or SSG; build guard; raw versus rendered check | 10 |
|
||||
| D-71 | Real status codes, server-rendered links, content in HTML, image rules | 10 |
|
||||
| D-72 | `sm-bm-app` gets noindex and `Disallow` only | 10 |
|
||||
| D-80 | One link per post card | 11 |
|
||||
| D-81 | No generic anchors | 11 |
|
||||
| D-82 | Related posts, phrase auto-linking, suggestions, orphan report | 11 |
|
||||
| D-83 | Outbound link rules | 11 |
|
||||
| D-85 | Media SEO | 12 |
|
||||
| D-86 | Breadcrumbs component and primary category | 12 |
|
||||
| D-95 | `sm-sitemap-plugin` with its own filter interface that SEO implements | 0 |
|
||||
| D-97 | One shared Nuxt SEO layer | 0 |
|
||||
|
||||
### Decisions split between Phases 32 and 33 (Phase 33 part only)
|
||||
Phase 32 delivered the rest of each; see the Phase 32 column of the decisions map.
|
||||
|
||||
| ID | Decision | Phase 33 part |
|
||||
|----|----------|---------------|
|
||||
| D-03 | Global settings | Organisation or person data for JSON-LD (with D-43, D-45) |
|
||||
| D-05 | Search engine verification | Serving the files at the site root from the layer (D-103) |
|
||||
| D-08 | Meta robots, `X-Robots-Tag` | Sending the header on frontend-rendered pages and files (layer, D-97) |
|
||||
| D-10 | All SEO manageable through MCP on `pier` | Tools for the crawler and the other Phase 33 screens |
|
||||
| D-12 | Batch tools with `dry_run` | "Noindex on a page in a sitemap" conflict refined by the D-95 filter |
|
||||
| D-14 | Issue report: CMS data checks plus a crawler | The built-in crawler and its checks |
|
||||
| D-20 | 404 log | The layer's call to the report endpoint |
|
||||
| D-20a | Where redirects execute | Nitro middleware in the layer (D-97, D-103) |
|
||||
| D-20b | URL normalisation per site | Enforcement on frontend routes through the layer |
|
||||
| D-20c | Strict URLs | Layer routes reading their patterns from the CMS |
|
||||
| D-21 | Default robots per page kind | Pagination page 2+ (D-41) and signed-in routes (D-23) in the layer |
|
||||
| D-22 | robots.txt | The `Sitemap:` default moves to the D-30 index |
|
||||
| D-23 | Private frontend pages noindex | Automatic marking of signed-in routes in the layer and the user plugin's auth layer |
|
||||
| D-24 | noindex, robots.txt and sitemaps consistent | Noindex, canonicalised and redirected pages leave the sitemaps; "sitemap URL blocked" warning |
|
||||
| D-25 | AI bot switches, optional llms.txt | llms.txt generated from the sitemap |
|
||||
| D-93 | Content descriptors in signpost | Sitemaps and the page registry consume `Records` (D-30, D-33) |
|
||||
| D-94 | `sm-seo-plugin` scope | Page registry, llms.txt, JSON-LD `@graph` and breadcrumbs APIs, crawler |
|
||||
| D-96 | Content plugins keep only their own fixes | Journal's H1 to H2 conversion (D-50) and schema types (D-43) |
|
||||
| D-103 | Redirect decision endpoint and verification-file API only | Layer enforcement of redirects and root mapping of verification files |
|
||||
|
||||
### Claude's Discretion
|
||||
- HTML sitemap grouping and paging (D-34).
|
||||
- Crawler implementation: Go, an in-process job through `conga`/River, politeness limits (D-14).
|
||||
- Table and package names inside the plugins.
|
||||
- Site-cache purge after SEO writes: Phase 32 serves effective SEO through one resolver endpoint per URL (P-15) but does not purge. Once the D-97 layer caches the resolve answer, purging through the existing site-cache purge becomes needed; plan it with D-97.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Decisions
|
||||
- `.planning/phases/32-seo-foundation/32-CONTEXT.md`: full text of every decision listed above, the reference implementations (`mpseo`) and the code seams.
|
||||
- `.planning/phases/32-seo-foundation/32-DECISIONS-MAP.md`: the Phase 32/33 boundary per decision.
|
||||
|
||||
### What Phase 32 delivered
|
||||
- `.planning/phases/32-seo-foundation/32-0*-SUMMARY.md`: what exists in `sm-seo-plugin` (models, resolver, redirects, robots.txt, verification API, `/_seo/api/v1`, MCP tools, issue report) and the framework seams (D-91 form extensions, D-92 record events, D-93 descriptors).
|
||||
- `.planning/phases/32-seo-foundation/32-VERIFICATION.md`, `32-UAT.md`: open verification items.
|
||||
- `.planning/phases/32-seo-foundation/deferred-items.md`: Journal bumps in the apps, plugin release posts and the demo deploy, still open after Phase 32.
|
||||
- `.planning/phases/32-seo-foundation/32-UI-SPEC.md`: admin elements E1–E7; Phase 33 admin screens follow the same conventions.
|
||||
|
||||
### Prior phase decisions
|
||||
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md`: Nuxt layers, SSR with swr/ISR, URLs kept identical.
|
||||
- `.planning/phases/17-blog-companions/17-CONTEXT.md`: signpost menu-item types and per-locale alternates; D-11 to D-15, the sitemap behaviour D-32 replaces.
|
||||
- `.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md`: `pier`, tools bound to admin routes.
|
||||
|
||||
### Code
|
||||
- `../sm-sitemap-plugin/`: `classes/generator.go`, `classes/serialize.go`, `routes.go`, `admin.go`, `models/settings.go`.
|
||||
- `../sm-seo-plugin/`: the Phase 32 plugin that Phase 33 extends.
|
||||
- `../sm-journal-plugin/`: `classes/format_html.go` (allows h1 today), `classes/menu_item_types.go`.
|
||||
- `../vue-journal-layer/`: `nuxt.config.ts` (`@nuxtjs/seo`, sitemap source), `app/utils/seo.ts`, `app/components/Journal/Pagination.vue`, `app/journal-pages/*.vue`.
|
||||
- Apps: `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` (robots and sitemap config to move to the CMS), `../sm-demo-app/vue-demo-app/` (hand-written noindex pages), `../sm-summercmsio-app/vue-summercmsio-app` (nuxt generate), `../sm-bm-app` (D-72).
|
||||
- `modules/signpost/signpost.go`, `modules/conga/`: URL registry and jobs for the crawler, IndexNow and sitemap rebuilds.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Dawid's audit workflow is the reference use case: after a crawl he tells his Claude client what to fix, and it happens through MCP with a dry-run preview. Every crawler issue must be fixable through MCP (ROADMAP success criterion 3).
|
||||
- Sitemaps are split by type and batched at 1000 URLs so they can be monitored file by file in GSC (D-30).
|
||||
- The features are framework-wide; no site-specific shaping (32-CONTEXT, Specific Ideas).
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- From 32-CONTEXT: social card preview, per-redirect hit counters and validity dates, 307/308 redirects, e-commerce structured data.
|
||||
- `sitemap-xslt-browser-deprecation.md` (g15office w8kupqi3) stays a separate backlog task; the D-32 rewrite touches the same code, so the plan should say whether the XSL output is removed or kept.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 33-seo-output-audit-and-content*
|
||||
*Decisions gathered: 2026-10-24 (Phase 32 discussion); written out: 2026-10-27*
|
||||
338
.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
Normal file
338
.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Phase 33: SEO output, audit and content - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-28
|
||||
**Files analyzed:** ~45 groups of new/modified files across 13 repos
|
||||
**Analogs found:** 40 / 45 (the rest are in "No Analog Found")
|
||||
|
||||
Paths are relative to the meta repo root (`/media/nvme/dev/golem15/summercms.io/summercms`). Every analog below is git-tracked in its own repo (checked with `git ls-files`); nothing points into `.msd/` mirrors.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Plan | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|---|
|
||||
| `summercms.go/modules/pact/content.go` (+ `PublishedAt`, `Parents`, `Authors`, `Images`, `Videos`, `Kind`) | 33-01 | model (seam) | transform | itself, lines 60-81 | exact |
|
||||
| `summercms.go/modules/signpost/README.md`, `docs/` pages | 33-01 | docs | - | module README structure (CLAUDE.md) | exact |
|
||||
| `summercms.go/scripts/check-phase33.sh` | 33-01 | config/gate | batch | `summercms.go/scripts/check-phase32.sh` | exact |
|
||||
| `sm-journal-plugin/classes/content_descriptors.go` (fill new fields) | 33-02 | service | transform | itself (`describePosts`, `firstImages`) | exact |
|
||||
| `sm-journal-plugin/models/settings.go` + `models/settings/fields.yaml` (H1 setting, e.g. `content_h1_to_h2`) | 33-02 | model | CRUD | same file (`RSSEnabled` bool + switch field) | exact |
|
||||
| `sm-journal-plugin/updates/2026102800xx_add_golem15_journal_settings_h1.go` | 33-02 | migration | - | `sm-seo-plugin/updates/202610280001_add_golem15_seo_redirects_source_key.go` | exact |
|
||||
| `sm-journal-plugin/config/config.yaml` (new; config key for the H1 default) | 33-02 | config | - | `sm-seo-plugin/config/config.yaml` + `ConfigFS()` in `sm-seo-plugin/plugin.go:79-80` | exact |
|
||||
| `sm-journal-plugin/controllers/api/posts.go` (expose H1 setting in Show/Index payload) | 33-02 | controller | request-response | `sm-journal-plugin/controllers/api/rss.go:17-40` (settings row read) | exact |
|
||||
| `sm-journal-plugin/classes/headings.go` (H1->H2 on every write path) + write-path calls in `controllers/api/posts.go`, admin save, MCP | 33-02 | utility | transform | `sm-journal-plugin/classes/format_html.go` | role-match |
|
||||
| `sm-journal-plugin/console/fix_headings.go` (`journal:fix-headings --dry-run`) | 33-02 | command | batch | `sm-journal-plugin/console/export_posts.go:24-60` | exact |
|
||||
| `sm-journal-plugin/assets/src/blogeditor/*.ts` (heading levels, outline, rel checkboxes, alt) | 33-02 | component | event-driven | `assets/src/blogeditor/toolbar.ts`, `extensions.ts` | exact |
|
||||
| `sm-sitemap-plugin/classes/*` (writer, slots, lastmod, rebuild, indexnow, html groups) | 33-03/04 | service | file-I/O / batch | `sm-sitemap-plugin/classes/generator.go`, `serialize.go` (rewrite) | role-match |
|
||||
| `sm-sitemap-plugin/filter.go` (exported `Filter`) | 33-03 | interface | request-response | `pact` capability interfaces + `app.Lookup[T]()` (see Shared) | role-match |
|
||||
| `sm-sitemap-plugin/jobs.go` (`RebuildArgs`, `IndexNowArgs`) | 33-03/04 | job | batch | `sm-seo-plugin/jobs.go` + `classes/generate.go:14-28,168-177` | exact |
|
||||
| `sm-sitemap-plugin/models/settings.go`, `models/{url,slot}.go` | 33-03 | model | CRUD | `sm-sitemap-plugin/models/settings.go`, `sm-seo-plugin/models/settings.go` | exact |
|
||||
| `sm-sitemap-plugin/updates/*` | 33-03 | migration | - | `sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go` | exact |
|
||||
| `sm-sitemap-plugin/routes.go` (index, `sitemap-<slug>-<n>.xml`, IndexNow key, HTML groups API) | 33-03/04 | route | request-response | itself, lines 30-60 | exact |
|
||||
| `sm-seo-plugin/classes/sitemap_filter.go`, `registry.go`, `llms.go`, `site.go`, `autolinks.go` | 33-05/06/11 | service | request-response / CRUD | `sm-seo-plugin/classes/robots.go`, `redirects.go`, `settings_write.go` | role-match |
|
||||
| `sm-seo-plugin/models/settings.go` + `fields.yaml` (`links_new_tab`, publisher, outbound lists) | 33-05/06/11 | model | CRUD | same file + `classes/settings_write.go:40-120` | exact |
|
||||
| `sm-seo-plugin/models/{page,crawl,crawl_issue,autolink}.go` + `updates/*` | 33-05/10/11 | model/migration | CRUD | `sm-seo-plugin/models/redirect.go`, `updates/202610270006_create_golem15_seo_redirects.go` | exact |
|
||||
| `sm-seo-plugin/controllers/api/{registry,site,crawls,autolinks}.go` | 33-05/10/11 | controller | request-response | `sm-seo-plugin/controllers/api/redirects.go`, `issues.go` | exact |
|
||||
| `sm-seo-plugin/controllers/{registry,crawls,autolinks}.go` + `config_*.yaml` | 33-05/10/11 | admin screen | CRUD | `sm-seo-plugin/controllers/redirects.go` + `redirects/config_{list,form,filter}.yaml` | exact |
|
||||
| `sm-seo-plugin/mcp.go`, `operations.go` (new tools, parity) | 33-05/10/11 | config | request-response | `sm-seo-plugin/mcp.go:26-52,331-343` | exact |
|
||||
| `sm-seo-plugin/routes.go` (new routes) | 33-05/10 | route | request-response | itself, lines 32-77 | exact |
|
||||
| `sm-seo-plugin/classes/schema/*` (@graph builder, validators) | 33-06 | service | transform | `sm-seo-plugin/classes/resolver.go`, `templates.go` | partial |
|
||||
| `sm-seo-plugin/classes/crawl/*` (fetch, parse, checks, store) + crawl job in `jobs.go` | 33-10 | service/job | batch | `sm-seo-plugin/jobs.go`; fetch via `summercms.go/modules/fetchguard/fetch.go:41` | role-match |
|
||||
| `sm-seo-plugin/classes/issues.go` (merge crawl issues, `h1` CMS check gated by Journal setting) | 33-10 | service | transform | itself, lines 12-60 | exact |
|
||||
| `summercms.go/modules/lagoon/attach/thumb.go` (+ `webp.go`: WebP variant per thumb) | 33-11 | utility | file-I/O | itself, `ThumbKey` lines 154-243 | exact |
|
||||
| WebP conga job (framework `lagoon/attach` or journal) | 33-11 | job | batch | `sm-seo-plugin/jobs.go` + `classes/settings_write.go:301-313` (enqueue after commit) | exact |
|
||||
| `summercms.go/.planning/notes/2026-10-28-webp-gen2brain.md` | 33-11 | decision note | - | `.planning/notes/2026-10-07-embedded-admin-release-gate.md` | exact |
|
||||
| `vue-seo-layer/lib/seo-module.ts` | 33-07/08 | config (Nuxt module) | - | `vue-journal-layer/lib/journal-module.ts:35-85` | exact |
|
||||
| `vue-seo-layer/server/api/__site__/cache/purge.post.ts` + `server/lib/siteCachePurge.ts` (moved) | 33-07 | route | request-response | `vue-journal-layer/server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` | exact (move) |
|
||||
| `vue-seo-layer/server/middleware/seo.ts`, `server/routes/*` (robots, sitemaps, llms, verification), `server/plugins/*` | 33-07/08 | middleware/route | request-response | `vue-journal-layer/server/routes/rss.xml.ts`, `server/plugins/journal-revalidate.ts` | role-match |
|
||||
| `vue-seo-layer/app/composables/{useCmsSeo,useSchemaNode,usePagination}.ts` | 33-07/08 | hook | request-response | `vue-journal-layer/app/composables/useJournalApi.ts`, `app/utils/seo.ts`, `app/utils/pagination.ts` | role-match |
|
||||
| `vue-seo-layer/app/components/Seo/{Breadcrumbs,Pagination}.vue`, HTML sitemap page | 33-08 | component | request-response | `vue-journal-layer/app/components/Journal/Pagination.vue` | role-match |
|
||||
| `vue-seo-layer/testing/*`, `tests/*.test.ts` | 33-07/14 | test | - | `vue-journal-layer/testing/smoke-ssr.mjs`, `fixture-api.mjs`, `tests/seo.test.ts` | exact |
|
||||
| `vue-journal-layer/lib/journal-module.ts` (`postTitleTag`, `breadcrumbs` options) | 33-09 | config | - | itself, lines 40-46 and 74-82 | exact |
|
||||
| `vue-journal-layer/app/journal-pages/PostPage.vue` (title tag, breadcrumbs) | 33-09 | component | request-response | itself, line 14 and 46 | exact |
|
||||
| `vue-journal-layer/app/utils/htmlGate.ts` (h1->h2 demotion following the Journal API flag) | 33-09 | utility | transform | itself, `gateJournalHtml` 268-272 | exact |
|
||||
| `vue-journal-layer/app/components/Journal/PostCard.vue`, `utils/seo.ts` removal, `@nuxtjs/seo` drop | 33-09 | component | - | itself | exact |
|
||||
| `vue-menu-layer/*`, `vue-faq-layer/*` (extend seo layer, delete `server/lib/siteCachePurge.ts` copies) | 33-09 | config | - | same edits as vue-journal-layer | exact |
|
||||
| App changes (`sm-demo-app`, `sm-summercmsio-app`, `sm-bm-app`, `sm-grzybyfunkcjonalne-app`): go.mod, summer.yaml, nuxt.config, package.json, nginx | 33-12/13 | config | - | Phase 32 app wiring (32-05 commits in `sm-demo-app`) | role-match |
|
||||
| Tests per repo | 33-14 | test | - | `sm-seo-plugin/testmain_test.go`, `classes/issues_test.go`, `settings_test.go`, `summercms.go/modules/lagoon/attach/thumb_test.go` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `summercms.go/modules/pact/content.go` (seam, 33-01)
|
||||
|
||||
**Analog:** itself, lines 60-81. Additive optional fields only, documented with one sentence per field in the existing comment style; no SEO words (D-90).
|
||||
```go
|
||||
// ContentLocale is a record in one locale. URL is its public path, "" when
|
||||
// the locale or the host has no public page for it. Title, Description and
|
||||
// Image (a URL) are the record's own defaults for page metadata; Words is the
|
||||
// body's word count, nil when the type has no body.
|
||||
type ContentLocale struct {
|
||||
Locale string
|
||||
URL string
|
||||
Title string
|
||||
Description string
|
||||
Image string
|
||||
Words *int
|
||||
}
|
||||
```
|
||||
Fill side: `sm-journal-plugin/classes/content_descriptors.go` `describePosts` (line 72), `firstImages` (133), `copyTime` (162).
|
||||
|
||||
---
|
||||
|
||||
### Journal H1 setting (33-02): `sm-journal-plugin/models/settings.go`, `models/settings/fields.yaml`, migration, config key, API
|
||||
|
||||
**Model analog** (`models/settings.go`, singleton row ID=1): add a bool column, add it to `Fillable()` and `Rules()` exactly like `rss_enabled`:
|
||||
```go
|
||||
RSSEnabled bool `gorm:"column:rss_enabled"`
|
||||
...
|
||||
func (Settings) Fillable() []string { return []string{ ..., "rss_enabled", ... } }
|
||||
func (Settings) Rules() map[string]string { return map[string]string{ ..., "rss_enabled": "boolean", ... } }
|
||||
```
|
||||
**fields.yaml analog** (same file):
|
||||
```yaml
|
||||
show_all_posts:
|
||||
span: left
|
||||
label: golem15.journal::lang.journal.show_all_posts_label
|
||||
comment: golem15.journal::lang.journal.show_all_posts_comment
|
||||
type: switch
|
||||
default: true
|
||||
tab: golem15.journal::lang.journal.tab_general
|
||||
```
|
||||
Labels go in `lang/en/lang.yaml` and `lang/pl/lang.yaml`.
|
||||
|
||||
**Migration analog** `sm-seo-plugin/updates/202610280001_add_golem15_seo_redirects_source_key.go` (additive ALTER with Rollback, `Register(...)` in `init`):
|
||||
```go
|
||||
var addRedirectSourceKey = []*gormigrate.Migration{{
|
||||
ID: "202610280001_add_golem15_seo_redirects_source_key",
|
||||
Migrate: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE ... ADD COLUMN ...`).Error },
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE ... DROP COLUMN IF EXISTS ...`).Error },
|
||||
}}
|
||||
func init() { Register(addRedirectSourceKey...) }
|
||||
```
|
||||
Use `DEFAULT true` so existing sites keep D-50 behaviour. Journal's own migrations live in `sm-journal-plugin/updates/` with `updates/registry.go`.
|
||||
|
||||
**Config key:** Journal has no `config/config.yaml` yet. Copy SEO's: `sm-seo-plugin/config/config.yaml` (commented `golem15.seo.<key>` defaults) and `plugin.go:79-80`:
|
||||
```go
|
||||
// ConfigFS ships config/config.yaml.
|
||||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||
```
|
||||
plus `_ pact.HasConfig = (*Plugin)(nil)` (plugin.go:26). Config read pattern: `app.Config.String(key)` as in `sm-journal-plugin/plugin.go:73-76`. Decide precedence (config key as default/override of the settings row) and document it in the README.
|
||||
|
||||
**Reading the row in a handler** (`controllers/api/rss.go:24-27`), with a hard default on miss:
|
||||
```go
|
||||
var settings models.Settings
|
||||
if err := db.WithContext(r.Context()).First(&settings, 1).Error; err != nil {
|
||||
settings = models.Settings{RSSLanguage: "en-us", RSSPostsPerFeed: 20, RSSEnabled: true}
|
||||
}
|
||||
```
|
||||
Expose the flag in the public post payload of `Show` (`controllers/api/posts.go:122`) so the layer reads it from the Journal API, not a layer option.
|
||||
|
||||
**Command** `console/fix_headings.go`, copy `console/export_posts.go:24-60` (bonfire.Command with `Name`, `Flags` incl. `{Name: "dry-run", Bare: true}`, `Examples`, `flagOn(in, "dry-run")`, `mustDB(app)`), register in `plugin.go` `Commands()` (lines 113-118). When the setting is off it returns an error naming the setting.
|
||||
|
||||
---
|
||||
|
||||
### `sm-seo-plugin` settings additions (`links_new_tab`, publisher, outbound lists) (33-05/06/11)
|
||||
|
||||
**Analog:** `sm-seo-plugin/classes/settings_write.go`. Every new column must be added in four places: the model (`models/settings.go`), the allowed-columns list (lines ~36-41), `SettingsValues` (46-69, history + API), and `SetSettingsValue` switch (72+). Bool columns follow the existing `url_lowercase` case. Then `models/settings/fields.yaml` (Links tab) and the MCP `update_settings` tool picks it up automatically through `/_seo/api/v1/settings`.
|
||||
```go
|
||||
func SettingsValues(st models.Settings) map[string]any {
|
||||
return map[string]any{
|
||||
"site_name": st.SiteName,
|
||||
"url_lowercase": st.URLLowercase,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
Migration: same additive ALTER pattern as above, `DEFAULT true` for `links_new_tab`.
|
||||
|
||||
---
|
||||
|
||||
### `sm-seo-plugin` MCP tools (33-05/10/11): `mcp.go`, `operations.go`
|
||||
|
||||
**Analog:** `mcp.go:26-52`. Each tool binds to a `/_seo/api/v1` route; `Guard: mcpGuard`, `Permissions: []string{mcpPermission}`; destructive tools set `Destructive: &destructive`. `operations.go` must map each new admin operation to its tool (parity test `operations_test.go`).
|
||||
```go
|
||||
{
|
||||
Name: "list_issues",
|
||||
Title: "List SEO issues",
|
||||
Description: "Lists the CMS-data SEO issues: ...; each names the tool that fixes it.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/issues",
|
||||
Input: apicontrollers.ListIssuesInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
```
|
||||
|
||||
### `sm-seo-plugin/routes.go` (33-05/10)
|
||||
|
||||
**Analog:** itself, 32-77. Public, frontend-called endpoints (`/site`, route report, registry read) go in the first group wrapped in `noindex(...)` with a throttle bucket; management endpoints go in the second group with `api := "throttle:" + BucketAPI`.
|
||||
```go
|
||||
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
|
||||
g.Get("/resolve", noindex(apicontrollers.Resolve(p.Service)), "throttle:"+BucketResolve)
|
||||
g.Post("/notfound", noindex(apicontrollers.NotFound(p.Service)), "throttle:"+BucketNotFound)
|
||||
})
|
||||
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
|
||||
api := "throttle:" + BucketAPI
|
||||
g.Get("/issues", apicontrollers.ListIssues(p.Service), api)
|
||||
})
|
||||
```
|
||||
Token-authenticated frontend POSTs (route report): copy the `notfound_token` approach (`config/config.yaml` `notfound_token` + `controllers/api/notfound.go`).
|
||||
|
||||
### `sm-seo-plugin/classes/issues.go` (crawler merge, 33-10)
|
||||
|
||||
**Analog:** itself 12-60. New kinds are constants in the same block; each `Issue` names the fixing tool (`seo_` prefix).
|
||||
```go
|
||||
const (
|
||||
IssueMissingTitle = "missing_title"
|
||||
...
|
||||
)
|
||||
type Issue struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
}
|
||||
```
|
||||
|
||||
### Admin screens (registry, crawls, autolinks)
|
||||
|
||||
**Analog:** `sm-seo-plugin/controllers/redirects.go` + `controllers/redirects/config_{list,filter,form}.yaml`, `models/redirect.go` + `models/redirect/{columns,fields}.yaml`, registration in `controllers/admin_registry.go`, nav in `admin_navigation.go`, permission `golem15.seo.manage` in `admin_permissions.go`. Partials (`_hint.htm`) as in `controllers/notfound/`.
|
||||
|
||||
---
|
||||
|
||||
### conga jobs (sitemap rebuild, IndexNow ping, crawl, WebP variants)
|
||||
|
||||
**Analog:** `sm-seo-plugin/jobs.go` (whole file) and `classes/generate.go`.
|
||||
```go
|
||||
// generate.go
|
||||
const GenerateKind = "golem15.seo.generate_entries"
|
||||
type GenerateEntriesArgs struct { Type string `json:"type"` }
|
||||
func (GenerateEntriesArgs) Kind() string { return GenerateKind }
|
||||
|
||||
// jobs.go
|
||||
func (p *Plugin) Jobs() []pact.Job {
|
||||
return []pact.Job{ conga.Job(p.generateEntries, conga.MaxAttempts(3)) }
|
||||
}
|
||||
var _ pact.HasJobs = (*Plugin)(nil)
|
||||
|
||||
// dispatch (generate.go:168-177)
|
||||
m, err := conga.From(s.app)
|
||||
return m.Dispatch(ctx, db, GenerateEntriesArgs{Type: typeKey}, conga.DispatchOpts{Label: s.Phrase("golem15.seo::lang.jobs.generate_entries", nil)})
|
||||
```
|
||||
Enqueue after a write commits (`classes/settings_write.go:301-313`):
|
||||
```go
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
db, err := s.GormDB()
|
||||
if err != nil { return }
|
||||
if _, err := s.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
|
||||
log.Warn("golem15.seo: queue entry generation", "error", err)
|
||||
}
|
||||
})
|
||||
```
|
||||
Schedules: conga periodic jobs (check `summercms.go/modules/conga/conga.go` for the periodic option before planning).
|
||||
|
||||
### Crawler / IndexNow outbound HTTP
|
||||
|
||||
**Analog:** `summercms.go/modules/fetchguard/fetch.go:41` — `fetchguard.Fetch(ctx, rawURL, policy, cfg)`; redirects are never followed (3xx returned as `Result`), so the crawler records redirected links itself and re-invokes Fetch for each hop. Errors are always `*fetchguard.Error`.
|
||||
|
||||
---
|
||||
|
||||
### WebP variants: `summercms.go/modules/lagoon/attach/thumb.go` (+ new `webp.go`) (33-11)
|
||||
|
||||
**Analog:** `ThumbKey` (thumb.go:154-243). Hang points:
|
||||
- Key: `thumbKey := PartitionDirectory(f.DiskName) + ThumbFilename(f.ID, w, h, 0, 0, mode, ext)`; the WebP variant key = same name with `.webp` appended or `ext` replaced (`ThumbFilename` coerces ext to `[a-z0-9]+`, so `"webp"` is safe). Store it next to the thumb in the same bucket.
|
||||
- Encoder seam: `encodeImage` is a package var (thumb.go:119-130) switched on ext; add a `"webp"` branch calling `github.com/gen2brain/webp` (or a separate `encodeWebP` var for tests).
|
||||
- Content type switch (thumb.go ~222-228): add `image/webp`.
|
||||
- Error/cleanup: on encode or close error `_ = deleteKey(ctx, bucket, key)`; existence check via `bucket.Exists` makes the job idempotent.
|
||||
```go
|
||||
wr, err := bucket.NewWriter(ctx, thumbKey, &blob.WriterOptions{ContentType: contentType})
|
||||
encErr := encodeImage(wr, resized, ext)
|
||||
closeErr = wr.Close()
|
||||
if encErr != nil || closeErr != nil { _ = deleteKey(ctx, bucket, thumbKey); ... }
|
||||
```
|
||||
- Generation runs in a conga job (not in `Thumb`), dispatched when a thumb is first generated or on upload; `Thumb` callers (`cabana/field_file.go:640`, journal API) emit the WebP URL only if the variant exists (`bucket.Exists`). Tests: `lagoon/attach/thumb_test.go`.
|
||||
- `go.mod` change only after the decision note in `.planning/notes/`.
|
||||
|
||||
---
|
||||
|
||||
### `vue-seo-layer/lib/seo-module.ts` and journal-layer options (33-07/08/09)
|
||||
|
||||
**Analog:** `vue-journal-layer/lib/journal-module.ts:35-85`. `defineNuxtModule` with `configKey`, `defaults`, and resolved options copied into `runtimeConfig.public.<key>`:
|
||||
```ts
|
||||
export default defineNuxtModule<JournalModuleOptions>({
|
||||
meta: { name: 'vue-journal-layer', configKey: 'journal' },
|
||||
defaults: { routes: { ...DEFAULT_JOURNAL_ROUTES }, perPage: {...}, sort: 'published_at desc', relatedCount: 3, rss: true },
|
||||
setup(options, nuxt) {
|
||||
const pub = nuxt.options.runtimeConfig.public as Record<string, any>
|
||||
pub.journal = { ...(pub.journal ?? {}), routes, perPage: { ...options.perPage }, sort: options.sort, relatedCount: options.relatedCount, rss: options.rss }
|
||||
},
|
||||
})
|
||||
```
|
||||
Add `postTitleTag` (default `undefined` = follow the Journal API flag) and `breadcrumbs: true` to `defaults` and to `pub.journal`. Pages read them as `PostPage.vue:14`: `const cfg = useRuntimeConfig().public.journal as JournalModuleOptions`; title at line 46 `<h1 class="journal-post__title">` becomes `<component :is="titleTag" ...>`.
|
||||
|
||||
### `vue-journal-layer/app/utils/htmlGate.ts` (33-09)
|
||||
|
||||
**Analog:** `gateJournalHtml` (268-272). Add a pure option (e.g. `{ demoteH1: boolean }`) applied after sanitizing; pages pass the Journal API flag. Pure TS so `tests/htmlGate.test.ts` covers it with `node --test`.
|
||||
```ts
|
||||
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
|
||||
if (!html) return { safe: true, html: '' }
|
||||
const clean = sanitizeHtml(html, POLICY)
|
||||
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
|
||||
}
|
||||
```
|
||||
|
||||
### Site-cache purge move (33-07, 33-09)
|
||||
|
||||
**Analog:** `vue-journal-layer/server/lib/siteCachePurge.ts` (byte-identical copies in `vue-menu-layer/server/lib/` and `vue-faq-layer/server/lib/`), route `server/api/__site__/cache/purge.post.ts`, plugin `server/plugins/site-cache-purge.ts`, tests `tests/sitePurge.test.ts`, `testing/purge-check.mjs`. Move them into `vue-seo-layer` unchanged, then add the resolve-LRU clear; delete the three copies in 33-09. Keep the pure-TS-outside-auto-import-dirs rule from the file header.
|
||||
|
||||
### Layer test harness
|
||||
|
||||
**Analog:** `vue-journal-layer/testing/{smoke-ssr.mjs,fixture-api.mjs,nuxt-server.mjs}` and `tests/*.test.ts` (`node --test`), `.playground/` for the module.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Plugin capability wiring
|
||||
**Source:** `sm-seo-plugin/plugin.go` (interface assertions block at ~line 20-30), `jobs.go`, `mcp.go:10`.
|
||||
**Apply to:** every new capability in sitemap/seo/journal: `var _ pact.HasX = (*Plugin)(nil)`.
|
||||
|
||||
### Service lookup across plugins (sitemap `Filter`, D-95)
|
||||
**Source:** `sm-journal-plugin/plugin.go:98-104`:
|
||||
```go
|
||||
if existing, ok := app.Lookup[*cooler.SiteCachePurger](); ok && existing != nil { return nil }
|
||||
if err := app.Publish(purger); err != nil { return err }
|
||||
```
|
||||
**Apply to:** SEO publishes its `sitemap.Filter` implementation in `Register`/`Boot`; sitemap looks it up with `app.Lookup[sitemap.Filter]()` and treats absence as "include everything".
|
||||
|
||||
### Settings singleton + history
|
||||
**Source:** `sm-seo-plugin/classes/settings_write.go` (`SettingsRow`, `SaveSettings` 171+, history record, after-commit job).
|
||||
**Apply to:** sitemap settings, SEO publisher/links settings.
|
||||
|
||||
### Config defaults
|
||||
**Source:** `sm-seo-plugin/config/config.yaml` (commented keys, read as `golem15.<plugin>.<key>`), `ConfigFS()`.
|
||||
**Apply to:** Journal H1 key, sitemap file-size/IndexNow keys, SEO route-report token.
|
||||
|
||||
### Frontend-facing API answers
|
||||
**Source:** `sm-seo-plugin/routes.go:79-85` `noindex(h)` wrapper and throttle buckets (`Buckets()` 94+ with `surf.TrustedProxies`).
|
||||
**Apply to:** `/site`, route report, HTML-sitemap groups API, IndexNow key route.
|
||||
|
||||
### Logging
|
||||
**Source:** package-level `pluginLog`/`log` in `logger.go` of each plugin; `log.Warn("golem15.seo: ...", "error", err)`.
|
||||
|
||||
### Tests
|
||||
**Source:** `sm-seo-plugin/testmain_test.go` (Docker Postgres, `flag.Parse()` + `testing.Short()`), `fixture_test.go`, `classes/issues_test.go`; framework `modules/lagoon/attach/thumb_test.go`.
|
||||
**Apply to:** 33-14.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `sm-seo-plugin/classes/schema/*` (JSON-LD @graph, per-type validators) | service | transform | No JSON-LD code exists; use RESEARCH.md Pattern sections, resolver.go only for structure |
|
||||
| `sm-seo-plugin/classes/crawl/parse.go` / checks | service | transform | No HTML-parsing crawler yet; use `golang.org/x/net/html` per RESEARCH "Don't Hand-Roll" |
|
||||
| Sitemap XML writer (index/urlset/image/news, hreflang) | service | file-I/O | Current `serialize.go` is PHP byte-parity and is removed (D-32); use `encoding/xml` per RESEARCH |
|
||||
| `vue-seo-layer/lib` SSR build guard + route report artefact | config | batch | No build-time Nuxt hook code in existing layers; RESEARCH.md |
|
||||
| WebP encoding itself | utility | transform | New dependency `github.com/gen2brain/webp`; only the thumb plumbing has an analog |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{pact,lagoon/attach,conga,fetchguard}`, `sm-seo-plugin`, `sm-sitemap-plugin`, `sm-journal-plugin`, `vue-journal-layer`, `vue-menu-layer/server/lib`, `vue-faq-layer/server/lib`
|
||||
**Files scanned:** ~60
|
||||
**Pattern extraction date:** 2026-10-28
|
||||
879
.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
Normal file
879
.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
Normal file
@@ -0,0 +1,879 @@
|
||||
# Phase 33: SEO output, audit and content - Research
|
||||
|
||||
**Researched:** 2026-10-28
|
||||
**Domain:** SEO output across a Go headless CMS (compiled plugins) and Nuxt 4 layers: sitemaps, structured data, hreflang, pagination, headings, breadcrumbs, SSR guarantee, crawler audit, internal linking, media
|
||||
**Confidence:** MEDIUM overall. Code seams are HIGH (read this session). External specs are cited from official docs (the classify seam rates WebFetch LOW, so they are tagged `[CITED]`). Several design choices are recommendations that need Jakub's confirmation; they are listed in the Assumptions Log and Open Questions.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
Source: `.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md`, copied verbatim. The full decision text is in `.planning/phases/32-seo-foundation/32-CONTEXT.md`, which wins when the two disagree.
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Planning (Jakub, 2026-10-25)
|
||||
- **About 12–15 smaller plans: an exception to the lean-planning rule.** The planner must not merge them into 3–4 plans. The other project rules still apply: the plan list with a one-line scope each is shown to Jakub before PLAN.md files are written, and unit tests are the last plan.
|
||||
- **Mode:** mvp (ROADMAP.md).
|
||||
- **The framework still carries no SEO code (D-90).** If Phase 33 needs a new framework seam, it must be generic and additive (D-106 rules: patch tag, no breaking change); D-103 rules out a fourth seam for redirects and verification files.
|
||||
|
||||
#### Decisions implemented entirely in Phase 33
|
||||
Section numbers refer to 32-CONTEXT.md.
|
||||
|
||||
| ID | Decision | 32-CONTEXT section |
|
||||
|----|----------|--------------------|
|
||||
| D-30 | Sitemap index split by type, 1000-URL files, real `lastmod` | 5 |
|
||||
| D-31 | Image, News sitemaps, IndexNow | 5 |
|
||||
| D-32 | The new format replaces PHP parity (breaking, confirmed); the XSLT todo goes with it | 5 |
|
||||
| D-33 | Page registry in the CMS | 5 |
|
||||
| D-34 | HTML sitemap | 5 |
|
||||
| D-40 | URL form chosen per site | 6 |
|
||||
| D-41 | Page 2 and later: robots, suffixes, H1, pillar text | 6 |
|
||||
| D-42 | Numbered server-rendered links, rel prev/next, 301 and 404 rules | 6 |
|
||||
| D-43 | One JSON-LD `@graph` | 7 |
|
||||
| D-44 | Schema type, type-dependent fields, custom JSON-LD | 7 |
|
||||
| D-45 | Publisher in the global settings | 7 |
|
||||
| D-46 | Validation in admin, crawler and Rich Results link | 7 |
|
||||
| D-50 | No H1 in body content, including Journal's conversion (D-96) | 8 |
|
||||
| D-51 | Live heading outline and crawler checks | 8 |
|
||||
| D-52 | One heading convention in the layers | 8 |
|
||||
| D-55 | hreflang in head and sitemaps from one source | 9 |
|
||||
| D-56 | `x-default` points to the default locale | 9 |
|
||||
| D-57 | No hreflang for a missing translation | 9 |
|
||||
| D-70 | Indexable pages always SSR or SSG; build guard; raw versus rendered check | 10 |
|
||||
| D-71 | Real status codes, server-rendered links, content in HTML, image rules | 10 |
|
||||
| D-72 | `sm-bm-app` gets noindex and `Disallow` only | 10 |
|
||||
| D-80 | One link per post card | 11 |
|
||||
| D-81 | No generic anchors | 11 |
|
||||
| D-82 | Related posts, phrase auto-linking, suggestions, orphan report | 11 |
|
||||
| D-83 | Outbound link rules | 11 |
|
||||
| D-85 | Media SEO | 12 |
|
||||
| D-86 | Breadcrumbs component and primary category | 12 |
|
||||
| D-95 | `sm-sitemap-plugin` with its own filter interface that SEO implements | 0 |
|
||||
| D-97 | One shared Nuxt SEO layer | 0 |
|
||||
|
||||
#### Decisions split between Phases 32 and 33 (Phase 33 part only)
|
||||
Phase 32 delivered the rest of each; see the Phase 32 column of the decisions map.
|
||||
|
||||
| ID | Decision | Phase 33 part |
|
||||
|----|----------|---------------|
|
||||
| D-03 | Global settings | Organisation or person data for JSON-LD (with D-43, D-45) |
|
||||
| D-05 | Search engine verification | Serving the files at the site root from the layer (D-103) |
|
||||
| D-08 | Meta robots, `X-Robots-Tag` | Sending the header on frontend-rendered pages and files (layer, D-97) |
|
||||
| D-10 | All SEO manageable through MCP on `pier` | Tools for the crawler and the other Phase 33 screens |
|
||||
| D-12 | Batch tools with `dry_run` | "Noindex on a page in a sitemap" conflict refined by the D-95 filter |
|
||||
| D-14 | Issue report: CMS data checks plus a crawler | The built-in crawler and its checks |
|
||||
| D-20 | 404 log | The layer's call to the report endpoint |
|
||||
| D-20a | Where redirects execute | Nitro middleware in the layer (D-97, D-103) |
|
||||
| D-20b | URL normalisation per site | Enforcement on frontend routes through the layer |
|
||||
| D-20c | Strict URLs | Layer routes reading their patterns from the CMS |
|
||||
| D-21 | Default robots per page kind | Pagination page 2+ (D-41) and signed-in routes (D-23) in the layer |
|
||||
| D-22 | robots.txt | The `Sitemap:` default moves to the D-30 index |
|
||||
| D-23 | Private frontend pages noindex | Automatic marking of signed-in routes in the layer and the user plugin's auth layer |
|
||||
| D-24 | noindex, robots.txt and sitemaps consistent | Noindex, canonicalised and redirected pages leave the sitemaps; "sitemap URL blocked" warning |
|
||||
| D-25 | AI bot switches, optional llms.txt | llms.txt generated from the sitemap |
|
||||
| D-93 | Content descriptors in signpost | Sitemaps and the page registry consume `Records` (D-30, D-33) |
|
||||
| D-94 | `sm-seo-plugin` scope | Page registry, llms.txt, JSON-LD `@graph` and breadcrumbs APIs, crawler |
|
||||
| D-96 | Content plugins keep only their own fixes | Journal's H1 to H2 conversion (D-50) and schema types (D-43) |
|
||||
| D-103 | Redirect decision endpoint and verification-file API only | Layer enforcement of redirects and root mapping of verification files |
|
||||
|
||||
### Claude's Discretion
|
||||
- HTML sitemap grouping and paging (D-34).
|
||||
- Crawler implementation: Go, an in-process job through `conga`/River, politeness limits (D-14).
|
||||
- Table and package names inside the plugins.
|
||||
- Site-cache purge after SEO writes: Phase 32 serves effective SEO through one resolver endpoint per URL (P-15) but does not purge. Once the D-97 layer caches the resolve answer, purging through the existing site-cache purge becomes needed; plan it with D-97.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- From 32-CONTEXT: social card preview, per-redirect hit counters and validity dates, 307/308 redirects, e-commerce structured data.
|
||||
- `sitemap-xslt-browser-deprecation.md` (g15office w8kupqi3) stays a separate backlog task; the D-32 rewrite touches the same code, so the plan should say whether the XSL output is removed or kept.
|
||||
|
||||
Also out of scope (33-CONTEXT domain): e-commerce structured data and shop pages; migrating `sm-bm-app` to SSR (D-72 gives it noindex and `Disallow` only).
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are assigned (ROADMAP: "Requirements: TBD"). The coverage list is the decision IDs of 33-CONTEXT. Plan numbers refer to the suggested breakdown below.
|
||||
|
||||
| ID | Description | Research support (section) | Suggested plan |
|
||||
|----|-------------|----------------------------|----------------|
|
||||
| D-30 | Index split by type, 1000-URL files, real lastmod | Sitemap rewrite: slot table, lastmod store | 33-03 |
|
||||
| D-31 | Image, News sitemaps, IndexNow | Sitemap extras; specs cited | 33-04 |
|
||||
| D-32 | New format replaces PHP parity; XSL decision | Sitemap rewrite; XSL removal recommended (Chrome 158) | 33-03 |
|
||||
| D-33 | Page registry | SEO plugin registry + signpost type + layer route report | 33-05, 33-08 |
|
||||
| D-34 | HTML sitemap | Sitemap JSON groups API + layer page | 33-04, 33-08 |
|
||||
| D-40/41/42 | Pagination form, page 2+ rules, numbered links, 301/404 | Resolve answer `pagination` + layer composable | 33-06, 33-08, 33-09 |
|
||||
| D-43/44/45/46 | @graph, schema type and fields, publisher, validation | Structured-data builder in SEO plugin; descriptor fields | 33-01, 33-06 |
|
||||
| D-50/51/52 | No H1 in body, outline, heading convention | Journal gate + editor; layer htmlGate h1→h2; layer tests | 33-02, 33-09 |
|
||||
| D-55/56/57 | hreflang head + sitemap, x-default, missing translations | Resolve `alternates`; sitemap xhtml:link | 33-03, 33-06, 33-07 |
|
||||
| D-70 | SSR/SSG only for indexable routes; build guard; raw vs rendered | Nuxt module guard; crawler raw-HTML check | 33-08, 33-10 |
|
||||
| D-71 | Real status codes, server links, content in HTML, image rules | Layer middleware + components; crawler checks | 33-07, 33-09, 33-10 |
|
||||
| D-72 | sm-bm-app noindex + Disallow | Static robots.txt + meta + header | 33-12 |
|
||||
| D-80/81 | One link per card, no generic anchors | PostCard change; editor warning; crawler check | 33-02, 33-09, 33-10 |
|
||||
| D-82 | Related posts, auto-links, suggestions, orphan report | Autolink table + layer transform; crawler link graph | 33-11, 33-09 |
|
||||
| D-83 | Outbound link rules | Editor rel checkboxes; domain list; layer transform | 33-02, 33-11, 33-09 |
|
||||
| D-85 | Media SEO | Editor alt/decorative; missing-alt report; bulk alt tool; formats decision | 33-02, 33-11 |
|
||||
| D-86 | Breadcrumbs + primary category | Resolve `breadcrumbs`; layer component | 33-06, 33-08 |
|
||||
| D-95 | Sitemap filter interface implemented by SEO | `app.Publish[sitemap.Filter]` | 33-03, 33-05 |
|
||||
| D-97 | One shared Nuxt SEO layer | New `vue-seo-layer` repo | 33-07, 33-08 |
|
||||
| D-03 (33 part) | Organisation/person data | Publisher settings | 33-06 |
|
||||
| D-05 (33 part) | Verification files at site root | Layer root mapping | 33-07 |
|
||||
| D-08 (33 part) | X-Robots-Tag on frontend pages and files | Layer middleware header | 33-07 |
|
||||
| D-10 (33 part) | MCP tools for Phase 33 screens | `AdminOperations` parity test extended | 33-05, 33-10, 33-11 |
|
||||
| D-12 (33 part) | Batch conflict refined by filter | `noindex_published` conflict reads the filter | 33-05 |
|
||||
| D-14 (33 part) | Crawler | conga job, fetchguard, x/net/html | 33-10 |
|
||||
| D-20, D-20a/b/c (33 part) | 404 report call, redirect middleware, normalisation, strict URLs | Layer middleware; route drift check | 33-07, 33-08 |
|
||||
| D-21, D-23 (33 part) | Page 2+ robots; signed-in routes noindex | Layer route meta + middleware | 33-08 |
|
||||
| D-22 (33 part) | `Sitemap:` → index | Index stays at `/sitemap.xml` | 33-03, 33-05 |
|
||||
| D-24 (33 part) | Sitemaps exclude noindex/canonicalised/redirected; "sitemap URL blocked" | Filter + issue kind | 33-05 |
|
||||
| D-25 (33 part) | llms.txt from the sitemap | SEO route reading sitemap URL set | 33-05 |
|
||||
| D-93 (33 part) | Sitemaps and registry consume Records | Rebuild job over `signpost.Records` | 33-03 |
|
||||
| D-94 (33 part) | Registry, llms.txt, @graph, breadcrumbs APIs, crawler | SEO plugin plans | 33-05, 33-06, 33-10 |
|
||||
| D-96 (33 part) | Journal H1→H2, schema types | Journal plan + descriptor kind | 33-02 |
|
||||
| D-103 (33 part) | Layer enforcement of redirects, root verification files | Layer middleware | 33-07 |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
From `summercms.go/CLAUDE.md`, `../CLAUDE.md` and the user's global instructions. These carry the authority of locked decisions.
|
||||
|
||||
- **No co-author tags in commits.** One logical change per commit; planning docs and code in separate commits. Stage files by explicit path, never `git add -A`; several sessions work concurrently (leave other sessions' uncommitted `STATE.md`, `state.json`, submodule drift alone).
|
||||
- **Don't push, tag or deploy without Jakub's go-ahead.** Every pushed framework release tag `vX.Y.Z` gets a changelog post on summercms.io the same day (`../sm-summercmsio-app/scripts/changelog-post.py`; never print, copy or mint the `g15_` token).
|
||||
- **Framework code never names a consuming application**, and (D-90) never names SEO. New framework seams are generic and additive (D-106: patch tag).
|
||||
- **Dependencies: standard library first.** A new Go or npm dependency needs a research doc or a decision note naming it (`.planning/research/STACK.md` holds the decided set). YAML via `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- **Docs rules:** a change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages in the same commit; the docs checker (`summer docs:build --check`, `TestDocsTree`) enforces identifiers. Plugin READMEs follow the standard structure.
|
||||
- **Core plugins live in their own repos**, mounted in apps as submodules at `plugins/<vendor>/<name>`; commit and push the plugin first, then the pointer bump in the app as a separate commit. Consumers `require` a tagged framework version and `replace` it with `../summercms.go` during development.
|
||||
- **Core plugins (user, blog, pages, payment) work across many projects: avoid breaking changes unless asked.** D-32 is the explicitly confirmed breaking change for `sm-sitemap-plugin`; no other breaking change is authorised.
|
||||
- **Porting rule:** the existing frontend's requests define the contract; response shapes are not "improved". Phase 33 adds fields; it must not change existing Journal/SEO answer shapes.
|
||||
- **Tests:** keep `go vet ./...` and `go test ./...` green at every commit; Docker tests skip under `-short` via `testing.Short()` after `flag.Parse()`.
|
||||
- **MSD rules:** plan list with one-line scopes shown to Jakub before PLAN.md files; unit tests are the last plan; STATE.md only through `msd_run query state.*`; g15office comment when the phase advances.
|
||||
- **Memory:** prefer the maintained library plus a decision note for protocol and security code; additive framework changes are a patch tag (0.5.x), never hard-code the version; create new repos with `tea`.
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 32 left a working backend (sm-seo-plugin v0.1.0: resolver, redirects, robots.txt, MCP, CMS-data issues) and three framework seams, but nothing on the frontend consumes them and the sitemap plugin is still the Phase 17 PHP-parity single file. Phase 33 spans **ten repositories**: framework, four Go plugins (sitemap, seo, journal, optionally faq), a new Nuxt layer plus three existing layers, and four apps. The dominant planning problem is therefore **release ordering across repos**, not any single technique: framework tag → Journal → sitemap → SEO → `vue-seo-layer` → journal/menu/FAQ layers → apps. Apps pin layers by git tag (pnpm) and plugins by Go tag, so app plans either wait for tags or develop against local links and re-pin in the final plan.
|
||||
|
||||
Two prerequisites block a clean start. (1) Phase 32's post-review fixes are **unreleased**: framework is 18 commits past v0.5.1 (CR-01, CR-02, CR-04, WR-01, WR-02, WR-07), sm-seo-plugin 21 commits past v0.1.0 (CR-03 plus a new migration), Journal 3 commits past v0.2.5. 32-UAT test 3, the release decision, is still pending. (2) The D-93 descriptor (`pact.ContentRecord` / `ContentLocale`) lacks what JSON-LD, breadcrumbs, the image sitemap and the News sitemap need: a publication date, a parent trail, authors, body images and a content kind. Phase 33 therefore needs one more **generic, additive** framework seam. It can ship in the same v0.5.2 patch as the unreleased fixes.
|
||||
|
||||
Technically the phase splits into well-understood pieces:
|
||||
- **XML sitemaps** are a stored-slot rewrite: a persistent URL→file-number table gives stable numbering, and record events give real `lastmod`.
|
||||
- **The resolver** gains additive answer fields: `alternates`, `jsonld`, `breadcrumbs`, `pagination`, `autolinks`.
|
||||
- **The new `vue-seo-layer`** renders that answer with Nuxt core `useHead`/`useSeoMeta`, enforces redirects and 410 in a Nitro middleware, proxies root files to Go, and fails the build on an indexable `ssr: false` route.
|
||||
- **The crawler** is a conga job on the existing `fetchguard` client, parsing HTML with `golang.org/x/net/html`.
|
||||
|
||||
The riskiest items are product decisions, not code:
|
||||
- D-70 asks for a raw-versus-rendered comparison, which needs a headless browser.
|
||||
- D-85 asks for WebP/AVIF, which needs a new dependency.
|
||||
- Success criterion 3 says every crawler issue is fixable through MCP, which cannot hold for layout bugs.
|
||||
- grzybyfunkcjonalne.pl moves a live production site several versions at once.
|
||||
|
||||
**Primary recommendation:**
|
||||
- Start with a Wave-1 plan that ships the additive descriptor seam together with the pending Phase 32 fixes as framework v0.5.2.
|
||||
- Build `vue-seo-layer` without `@nuxtjs/seo` and have journal, menu and FAQ extend it.
|
||||
- Keep `/sitemap.xml` as the index URL so GSC and robots.txt need no URL change.
|
||||
- Drop the XSL output: Chrome stops supporting XSLT on 2026-11-17.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary tier | Secondary tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Sitemap index, per-type files, image/News, IndexNow pings | API/Backend (`sm-sitemap-plugin`, Go) | Frontend server (proxy of `/sitemap*.xml` when nginx does not route them) | D-95: sitemap owns generation from signpost; Go has the records and events |
|
||||
| Exclusion of noindex/canonicalised/redirected URLs from sitemaps | API/Backend (`sm-seo-plugin` implements `sitemap.Filter`) | — | D-95: sitemap knows nothing about SEO |
|
||||
| Page registry (D-33) | API/Backend (`sm-seo-plugin`, signpost type) | Frontend server (reports routes at server start) | Registry is CMS data; Nuxt knows its static routes |
|
||||
| Effective SEO, JSON-LD @graph, breadcrumbs, hreflang, pagination metadata | API/Backend (resolver) | Frontend server (renders into `<head>`, adds layer-owned nodes such as FAQPage) | D-43/D-94: graph generated in CMS; one resolver call per URL (P-15) |
|
||||
| Redirect/410 enforcement, X-Robots-Tag on pages | Frontend server (Nitro middleware in `vue-seo-layer`) | API/Backend (decision) | D-20a/D-103: CMS decides, the serving tier enforces |
|
||||
| Verification files, robots.txt, llms.txt, IndexNow key file at site root | API/Backend (served by Go) | Frontend server (root proxy) | D-103: no fourth framework seam; the layer maps the root |
|
||||
| SSR build guard | Frontend build (Nuxt module in `vue-seo-layer`) | — | Only the Nuxt build knows routeRules and `.client.vue` pages |
|
||||
| Visible breadcrumbs, pagination links, heading convention, one link per card | Browser/Client markup rendered by Frontend server (SSR) | — | D-71: all navigation server-rendered `<a href>` |
|
||||
| H1→H2 conversion of stored content | API/Backend (Journal write gate) | Frontend server (htmlGate demotes h1 as defence in depth) | D-50/D-96: the content plugin fixes its own data |
|
||||
| Phrase auto-links, outbound rel rules | Frontend server (body HTML transform at SSR) | API/Backend (phrase table and domain list in resolve answer) | Keeps Journal unaware of SEO (D-96); output is server-rendered HTML |
|
||||
| Crawler, issue report, orphan report, link suggestions | API/Backend (`sm-seo-plugin` conga job) | — | D-14: Go, in-process job |
|
||||
| Media alt / decorative / file-name slug in the editor | API/Backend + admin element (Journal editor) | — | The editor and its media store are Journal's |
|
||||
| WebP/AVIF/srcset generation | Undecided (see Open Question 3) | — | Needs a new dependency in Go, or a Nuxt image pipeline |
|
||||
|
||||
## Prerequisites (resolve before or in the first plan)
|
||||
|
||||
1. **Unreleased Phase 32 fixes.** Framework `git log v0.5.1..HEAD` lists the fixes CR-01 (`676ca53`), CR-02 (`5471b66`), CR-04 (`d660cd0`), WR-01 (`468af06`), WR-02 (`1583651`) and WR-07 (`0e187cf`). sm-seo-plugin has 21 commits after v0.1.0, including CR-03 (`2d81c26`) and migration `202610280001_add_golem15_seo_redirects_source_key`. sm-journal-plugin has 3 commits after v0.2.5 [VERIFIED: git log in each repo, this session]. 32-UAT test 3 (release or hold) and test 4 (the meaning of `RecordSaved.Locale`, WR-02) are pending. Phase 33 builds on tags (Q-01). Recommendation: fold the release of these fixes into 33-01, together with the new additive seam, as framework **v0.5.2**, sm-seo-plugin **v0.1.1** and Journal **v0.2.6** or later. The exact numbers are read from `git tag` at release time.
|
||||
2. **UI design contract.** `.planning/config.json` has `workflow.ui_phase: true`. Phase 33 adds admin screens: crawler runs and issues, page registry, auto-links, sitemap settings, publisher and schema fields in the SEO tab. It also adds layer components: breadcrumbs, pagination, HTML sitemap. A `33-UI-SPEC.md` following 32-UI-SPEC conventions (E-elements, C-contracts) is expected before plans that build these screens [VERIFIED: .planning/config.json read via shell].
|
||||
3. **Release cadence decision (Open Question 1).** Can intermediate tags be pushed mid-phase, so app plans pin real tags? Or do app plans work against local links and the final plan tags and re-pins everything, as Phase 32 did with `32-05-demo-mount.patch`?
|
||||
|
||||
## Standard Stack
|
||||
|
||||
No new runtime dependency is required for the recommended MVP scope. Every item below is already in the decided set or already in the repos.
|
||||
|
||||
### Core (Go)
|
||||
| Library / module | Version | Purpose | Why standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| SummerCMS `signpost` | v0.5.1 + additive v0.5.2 fields | `ContentTypes`, `Records`, `Describe`, `MatchPath` feed sitemaps, registry, resolver | D-93 seam [VERIFIED: modules/signpost/content.go func list] |
|
||||
| SummerCMS `conga` | framework | Sitemap rebuild, IndexNow ping, crawl jobs; `pact.HasSchedule` for nightly runs (`conga.Daily`, `conga.Every`) | Decided job system [VERIFIED: modules/conga/README.md] |
|
||||
| SummerCMS `fetchguard` | framework | Crawler and IndexNow HTTP: no redirect following, body cap, timeout, host allow-list | Framework SSRF guard [VERIFIED: modules/fetchguard/README.md] |
|
||||
| SummerCMS `cooler` | framework | `cooler.PurgeSiteCacheAfterCommit` after SEO writes (keys `site_cache.purge_url`, `site_cache.purge_token`) | Existing site-cache purge [VERIFIED: modules/cooler/sitecache.go:26,29] |
|
||||
| `golang.org/x/net/html` | already required by sm-journal-plugin | Crawler HTML parsing, auto-link/heading transforms in Go | x/ family, already in the dependency graph [VERIFIED: sm-journal-plugin/classes/format_html.go imports `xhtml "golang.org/x/net/html"`] |
|
||||
| `github.com/jimsmart/grobotstxt` | v1.0.3 | "Sitemap URL blocked" and crawler robots checks | Decided D-99 [VERIFIED: .planning/research/STACK.md line 50] |
|
||||
| stdlib `encoding/xml` | Go 1.27 | Sitemap XML escaping (`xml.EscapeText`) now that byte parity with PHP is dropped | Standard library first |
|
||||
| stdlib `encoding/json` | — | JSON-LD graph building and custom JSON-LD validation | Standard library first |
|
||||
|
||||
### Core (Nuxt)
|
||||
| Library | Version | Purpose | Why standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `nuxt` | 4.4.8 (pinned across layers) | `useHead`/`useSeoMeta` (core unhead), routeRules, Nitro middleware, module hooks | Existing pin [VERIFIED: vue-journal-layer/package.json] |
|
||||
| `unhead` | 2.1.17 (via Nuxt) | Escapes `<` in JSON script `innerHTML` (`<`), so JSON-LD cannot break out of `<script>` | [VERIFIED: node_modules/unhead/dist/shared/unhead.BrXkGDAU.mjs:157-161] |
|
||||
| `h3` | 1.15.11 (via Nitro 2.13.4) | `sendRedirect` from server middleware ends the stack (`event.handled` check) | [VERIFIED: node_modules h3 dist/index.mjs `createAppEventHandler`, read this session] |
|
||||
| `@nuxtjs/i18n` | 10.4.0 | Locale routing stays in journal layer; do **not** use its hreflang head (would emit hreflang for missing translations, D-57) | Existing pin |
|
||||
| `sanitize-html` | 2.18.0 | Layer htmlGate; `transformTags` for h1→h2, `textFilter` receives escaped text and its output is appended unescaped | [VERIFIED: node_modules sanitize-html/index.js:340-344, 691-696] |
|
||||
|
||||
### To remove from the layers (replace, do not wrap)
|
||||
| Package | Current | Why remove |
|
||||
|---------|---------|-----------|
|
||||
| `@nuxtjs/seo` | 5.2.1 in vue-journal-layer (`modules: [..., '@nuxtjs/seo', ...]`), and summercms.io | It installs `nuxt-schema-org`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `nuxt-seo-utils`, `nuxt-site-config` and others. Schema.org adds default WebSite and WebPage nodes automatically, a second graph that conflicts with D-43. robots and sitemap would compete with Go's `/robots.txt` and `/sitemap.xml`. seo-utils emits its own canonical. [VERIFIED: vue-journal-layer/nuxt.config.ts:21; node_modules/@nuxtjs/seo/package.json dependencies] [CITED: nuxtseo.com/docs/schema-org/guides/default-schema-org] |
|
||||
|
||||
Latest `@nuxtjs/seo` is 6.0.1 [VERIFIED: npm view]. Upgrading is moot if the layer drops it.
|
||||
|
||||
### Alternatives considered
|
||||
| Instead of | Could use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Core `useHead` + CMS graph | `nuxt-schema-org` with `defaults: false` fed from the CMS | Still a second graph builder and a dependency; D-43 wants the CMS to generate the graph |
|
||||
| x/net/html crawler | `goquery`, `colly` | New dependencies; x/net/html is enough for links, headings, meta, JSON-LD scripts and images |
|
||||
| Raw-HTML-only crawl | `chromedp` + Chromium for rendered DOM | Real D-70 rendered comparison, but Chrome on every server and a new dependency (Open Question 2) |
|
||||
| Stored slot table | Generate on request with cache (today's design) | Cannot give stable numbering when records are deleted; costly for large sites |
|
||||
|
||||
**Installation:** none for the MVP scope. Optional, only after a decision note: `github.com/gen2brain/webp`, `github.com/gen2brain/avif` (Go, WASM via wazero) or `github.com/chromedp/chromedp`. All are `[ASSUMED]`; see the audit below.
|
||||
|
||||
**Version verification:**
|
||||
- `npm view @nuxtjs/seo version` → 6.0.1.
|
||||
- Go proxy `@latest`: gen2brain/webp v0.6.4 (2026-07-01), gen2brain/avif v0.6.0 (2026-07-05), chromedp v0.21.0 (2026-10-11) [VERIFIED: proxy.golang.org, this session].
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The MVP scope installs **no new package**. The table covers the optional candidates, in case Jakub chooses them (Open Questions 2 and 3). The `msd-tools package-legitimacy` seam supports only npm, pypi and crates, so the Go modules were checked against proxy.golang.org only.
|
||||
|
||||
| Package | Registry | Age | Downloads | Source repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| github.com/gen2brain/webp | Go proxy | v0.1.0 2024-03-06, 31 versions | n/a | github.com/gen2brain/webp | not run (seam has no Go support) | `[ASSUMED]`: planner adds `checkpoint:human-verify` + decision note before install |
|
||||
| github.com/gen2brain/avif | Go proxy | 29 versions, latest 2026-07-05 | n/a | github.com/gen2brain/avif | not run | `[ASSUMED]`: same |
|
||||
| github.com/chromedp/chromedp | Go proxy | v0.1.0 2018-07-03, 82 versions | n/a | github.com/chromedp/chromedp | not run | `[ASSUMED]`: same; also requires Chromium on servers |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none.
|
||||
**Packages flagged as suspicious [SUS]:** none. All three optional Go modules are `[ASSUMED]` and each is gated behind a human checkpoint plus a `.planning/notes/` decision note and a STACK.md row (project rule).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System architecture diagram
|
||||
|
||||
```
|
||||
┌───────────────────────── Build time (Nuxt) ─────────────────────────┐
|
||||
app nuxt.config ──► vue-seo-layer module: collect pages + routeRules ──► ssr:false on a route
|
||||
not declared noindex? ──► FAIL BUILD (D-70) not declared noindex
|
||||
emit static route list (seo-routes.json) ─────────────────┐
|
||||
└─────────────────────────────────────────────────────┼──────────────┘
|
||||
│ (server start)
|
||||
Visitor/Googlebot ▼
|
||||
│ GET /any/path POST /_seo/api/v1/registry/routes (token)
|
||||
▼ │
|
||||
nginx ─┬─ /sitemap*.xml, /robots.txt, /llms.txt, /<key>.txt ──────────► Go binary
|
||||
│ (or Nuxt proxies them) │
|
||||
└─ everything else ──► Nuxt (Nitro) │
|
||||
│ │
|
||||
server/middleware/seo.ts (skips /_nuxt, /api, assets) │
|
||||
│ GET /_seo/api/v1/resolve?url= ── LRU (TTL, purged) ──────► sm-seo-plugin Resolver
|
||||
│ │ 1 normalise (D-20b, ?page=1, page form D-40)
|
||||
├─ action=redirect ─► 301/302 Location (D-20a) │ 2 redirects 3 strict URL (D-20c)
|
||||
├─ action=gone ─────► 410 │ 4 SEO precedence (Phase 32)
|
||||
└─ ok/not_found ──► event.context.seo, X-Robots-Tag header │ 5 NEW: alternates, jsonld @graph,
|
||||
│ │ breadcrumbs, pagination, autolinks
|
||||
Vue SSR render (route cache swr) │ ▲ signpost.Describe (+new fields)
|
||||
useCmsHead(): title/meta/canonical/hreflang/html lang/ │ ▲ settings (publisher, D-45)
|
||||
og:locale/one JSON-LD script (CMS @graph + layer nodes) │ ▲ entry data (schema type, D-44)
|
||||
<SeoBreadcrumbs> <SeoPagination> PostCard (one link) │
|
||||
PostBody htmlGate: h1→h2, auto-links, outbound rel │
|
||||
│ 404 page ─► POST /_seo/api/v1/notfound (D-20) ──►│
|
||||
▼ │
|
||||
HTML │
|
||||
│
|
||||
Admin/MCP write ─► cabana save ─► RecordSaved/Deleted (after commit) ─────────┤
|
||||
├─► sitemap: lastmod store + debounced rebuild job (conga)
|
||||
│ └─ signpost.Records per type ─► sitemap.Filter (SEO) ─► slot table
|
||||
│ └─ diff of URL set ─► IndexNow ping job (D-31)
|
||||
└─► SEO: cooler.PurgeSiteCacheAfterCommit ─► Nuxt purge route clears
|
||||
route cache + resolve LRU
|
||||
Crawler job (on demand / schedule) ─► fetch /sitemap.xml index + files ─► fetch pages (fetchguard,
|
||||
same host, concurrency 2) ─► parse (x/net/html) ─► checks ─► golem15_seo_crawl_* tables ─► issues API + MCP
|
||||
```
|
||||
|
||||
### Recommended repository layout (new and changed parts)
|
||||
|
||||
```
|
||||
sm-sitemap-plugin/
|
||||
├── classes/ # writer (index, urlset, image, news), slots, lastmod, rebuild, indexnow, html groups
|
||||
├── filter.go # exported Filter interface + Candidate/Decision (D-95), looked up via app.Lookup
|
||||
├── jobs.go # RebuildArgs, IndexNowArgs (conga)
|
||||
├── models/ # settings (site URL, per-type file slug + include, file size, news, indexnow), urls, slots
|
||||
└── updates/ # new migrations; old definitions table kept for url items (custom pages group)
|
||||
sm-seo-plugin/
|
||||
├── classes/schema/ # @graph builder, per-type validators (D-43..D-46)
|
||||
├── classes/crawl/ # crawler: fetch, parse, checks, store (D-14, D-51, D-70, D-71, D-81..D-83, D-85)
|
||||
├── classes/registry.go, sitemap_filter.go, autolinks.go, llms.go, site.go (layer site-config answer)
|
||||
└── controllers/ # registry, crawls, autolinks (+ api/ and mcp.go tools, operations.go parity)
|
||||
vue-seo-layer/ # NEW repo (tea), name to confirm (D-97)
|
||||
├── lib/seo-module.ts # SSR build guard, route report artefact, options (configKey `seo`)
|
||||
├── server/middleware/seo.ts # resolve, redirect/410, X-Robots-Tag, 404 report hook
|
||||
├── server/routes/ # root proxies: robots.txt, sitemap*.xml, llms.txt, verification, indexnow key
|
||||
├── server/api/__site__/cache/purge.post.ts + server/lib/siteCachePurge.ts # moved here from journal/menu/faq
|
||||
├── server/plugins/ # route report to registry at start; resolve LRU purge hook
|
||||
├── app/composables/ # useCmsSeo, useSchemaNode, usePagination
|
||||
├── app/components/Seo/ # Breadcrumbs.vue, Pagination.vue, HtmlSitemap page
|
||||
├── testing/ # smoke-ssr harness + fixture /_seo/api/v1
|
||||
└── tests/*.test.ts # node --test on pure TS utils
|
||||
```
|
||||
|
||||
### Pattern 1: Additive descriptor fields (framework, generic)
|
||||
|
||||
**What:** extend `pact.ContentRecord` / `pact.ContentLocale` / `pact.ContentDescriptor` with optional fields that name nothing SEO-specific. They are useful to search indexers, feeds and audit tools as well.
|
||||
|
||||
**Today** [VERIFIED: modules/pact/content.go:63-81]:
|
||||
```go
|
||||
type ContentRecord struct {
|
||||
ID string
|
||||
Published bool
|
||||
LastMod *time.Time
|
||||
Locales []ContentLocale
|
||||
}
|
||||
type ContentLocale struct {
|
||||
Locale string
|
||||
URL string
|
||||
Title string
|
||||
Description string
|
||||
Image string
|
||||
Words *int
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended additions** `[ASSUMED]` names; the shape needs Jakub's confirmation at a blocking checkpoint, as 32-01/T1 and 32-02/T1 did:
|
||||
```go
|
||||
// ContentRecord (additive)
|
||||
PublishedAt *time.Time // first publication (datePublished, News publication_date)
|
||||
Parents []ContentRef // ordered candidate parents (first = default primary): breadcrumbs, articleSection
|
||||
Authors []ContentPerson // name + optional public URL (Article author)
|
||||
// ContentLocale (additive)
|
||||
Images []ContentImage // featured + body images: URL, Alt (image sitemap D-31, missing-alt report D-85)
|
||||
Videos []ContentVideo // e.g. provider "youtube" + id (VideoObject, D-43)
|
||||
// ContentDescriptor (additive)
|
||||
Kind string // "article" | "collection" | "profile" | "page" (default schema mapping; D-96 "schema types")
|
||||
```
|
||||
|
||||
**Why:** without these, the SEO plugin would have to import Journal, which D-96 forbids, or the layer would have to build JSON-LD, which contradicts D-43. Journal currently fills only the existing fields, and `LastMod` is `UpdatedAt` [VERIFIED: sm-journal-plugin/classes/content_descriptors.go:97-101].
|
||||
|
||||
**Constraint:** check-phase32's `additive` stage logic applies. Fields may be added; no interface may gain a method.
|
||||
|
||||
### Pattern 2: Stable sitemap numbering via a slot table
|
||||
|
||||
**What:** persist each URL's file assignment. `golem15_sitemap_urls (group, type_key, record_id, locale, url, file_no, first_seen, lastmod, content_hash, images jsonb, alternates jsonb, excluded_reason)`. A rebuild job:
|
||||
1. walks `signpost.Records` per content type (page size ≤ 500; Journal caps at 500);
|
||||
2. asks `sitemap.Filter` per candidate;
|
||||
3. keeps existing rows' `file_no`;
|
||||
4. appends new URLs into the last file with room, ordered by first-seen time (oldest first, D-30);
|
||||
5. marks vanished ones excluded, leaving a hole rather than shifting later files.
|
||||
|
||||
The routes serve from the table, so there is no per-request generation.
|
||||
|
||||
**When:** always (D-30). File size comes from a setting: default 1000, maximum 50 000.
|
||||
|
||||
**lastmod:** a `pact.RecordSaved` listener sets `lastmod = now` for rows of that record when `len(e.Changes) > 0`. A no-op save fires `RecordSaved` with an empty `Changes` list (32-02-SUMMARY key decision). The descriptor's `LastMod` is used only for the initial value. An ignore list of attribute names (config) keeps counters out.
|
||||
|
||||
### Pattern 3: Sitemap filter interface (D-95)
|
||||
|
||||
```go
|
||||
// in sm-sitemap-plugin (package sitemap), [ASSUMED] shape
|
||||
type Candidate struct{ TypeKey, RecordID, Locale, URL string }
|
||||
type Decision struct {
|
||||
Exclude bool
|
||||
Reason string // "noindex" | "canonical" | "redirect" | ...
|
||||
Group string // optional file-group override (page registry kinds: page/offer/service/landing)
|
||||
LastMod *time.Time // optional override
|
||||
}
|
||||
type Filter interface {
|
||||
Decide(ctx context.Context, batch []Candidate) ([]Decision, error) // batched: one resolve pass per page of records
|
||||
}
|
||||
// sm-seo-plugin Register: app.Publish[sitemap.Filter](seoFilter{...}); Requires() includes "golem15.sitemap"
|
||||
// sm-sitemap-plugin: f, ok := app.Lookup[sitemap.Filter]() — absent → include everything
|
||||
```
|
||||
|
||||
`backpack.Registry.Publish[T]` keys by the type argument, so publishing under the interface type works. A duplicate `T` is an error [VERIFIED: modules/backpack/services.go:22-41]. `Group` exists because the page registry must be **one** signpost type: `signpost` refuses two types with the same `Model` or `Controller` [VERIFIED: modules/signpost/content.go addContent], yet D-30/D-33 split page/offer/service/landing into separate files.
|
||||
|
||||
### Pattern 4: Resolve answer grows additively; layer renders it
|
||||
|
||||
Phase 32 answer fields [VERIFIED: sm-seo-plugin/classes/resolver.go:55-101]:
|
||||
- `Result`: `action`, `status`, `location` (omitempty), `url`, `locale`, `staging`, `seo`.
|
||||
- `SEO`: `title`, `description`, `canonical`, `robots`, `x_robots_tag`, `og` {`title`, `description`, `image`, `url`}, `twitter` {`card`, `site`}, `verification` [{`name`, `content`}].
|
||||
|
||||
Phase 33 adds `[ASSUMED]` names (no existing key changes):
|
||||
- `alternates` [{hreflang, url}] plus `x_default`, `html_lang`, `og_locale`, `og_locale_alternates` (D-55..57);
|
||||
- `jsonld` {"@context", "@graph"} (D-43);
|
||||
- `breadcrumbs` [{name, url}] (D-86);
|
||||
- `pagination` {param or path form, page, title/description suffix templates, `pillar_on_first_only`} (D-40/41);
|
||||
- `autolinks` [{phrase, url}] and `outbound` {nofollow_domains, sponsored_domains} (D-82/83).
|
||||
|
||||
The layer may append its own nodes (FAQPage from the FAQ layer) through `useSchemaNode()`, so one `<script type="application/ld+json">` holds one `@graph`.
|
||||
|
||||
### Pattern 5: Nitro middleware enforces the decision; page cache stays valid
|
||||
|
||||
```ts
|
||||
// vue-seo-layer/server/middleware/seo.ts — sketch
|
||||
export default defineEventHandler(async (event) => {
|
||||
const path = event.path
|
||||
if (!isPagePath(path)) return // skip /_nuxt, /api, /__nuxt_error, files with extensions, Go-routed paths
|
||||
const res = await resolveCached(event, path) // LRU keyed by path+query, TTL ~60 s, cleared by the purge route
|
||||
if (!res) return // CMS unreachable or no SEO plugin: render normally (fail open)
|
||||
if (res.action === 'redirect') return sendRedirect(event, res.location, res.status)
|
||||
if (res.action === 'gone') throw createError({ statusCode: 410, statusMessage: 'Gone' })
|
||||
if (res.seo?.x_robots_tag) setResponseHeader(event, 'x-robots-tag', res.seo.x_robots_tag)
|
||||
event.context.seo = res // the app plugin reads it during SSR; no second fetch
|
||||
})
|
||||
```
|
||||
|
||||
Middleware runs before Nitro's cached route handler, so redirects and 410 apply even to swr-cached pages. h3 stops the stack once a middleware handles the event [VERIFIED: h3 1.15.11 createAppEventHandler]. Nuxt's docs say middleware "should not return anything"; the redirect early-return is h3 behaviour, so the planner adds a smoke check `[CITED: nuxt.com/docs/4.x/directory-structure/server]`.
|
||||
|
||||
`not_found` is **not** enforced by the middleware. Nuxt's own route matching and the pages (`createError 404`) decide 404. A Nuxt page without a registry entry would otherwise 404 until registered.
|
||||
|
||||
### Pattern 6: SSR build guard (Nuxt module)
|
||||
|
||||
```ts
|
||||
// lib/seo-module.ts — sketch
|
||||
setup(options, nuxt) {
|
||||
nuxt.hook('pages:resolved', (pages) => { collected = flatten(pages) }) // page.mode === 'client' for .client.vue
|
||||
nuxt.hook('nitro:config', (nitro) => {
|
||||
const rules = { ...nuxt.options.routeRules, ...nitro.routeRules }
|
||||
const offenders = []
|
||||
if (nuxt.options.ssr === false && options.indexable !== false) offenders.push('<global ssr:false>')
|
||||
for (const [pattern, rule] of Object.entries(rules))
|
||||
if (rule?.ssr === false && !isNoindex(pattern, rule, options.noindexRoutes)) offenders.push(pattern)
|
||||
for (const p of collected) if (p.mode === 'client' && !isNoindex(p.path, undefined, options.noindexRoutes)) offenders.push(p.path)
|
||||
if (offenders.length) throw new Error(`[vue-seo-layer] indexable routes must render on the server (D-70): ${offenders.join(', ')}`)
|
||||
// declared noindex routes get X-Robots-Tag automatically:
|
||||
for (const pattern of options.noindexRoutes) nitro.routeRules![pattern] = defu(nitro.routeRules![pattern], { headers: { 'x-robots-tag': 'noindex, nofollow' } })
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- routeRules `ssr: false` disables SSR for matching routes [CITED: nuxt.com/docs/4.x/guide/concepts/rendering].
|
||||
- A `.client.vue` page "None of the content of this page will be rendered on the server" [CITED: nuxt.com/docs/4.x/directory-structure/app/pages].
|
||||
- `defineRouteRules` is experimental behind `experimental.inlineRouteRules` [CITED: nuxt.com/docs/4.x/api/utils/define-route-rules]. The guard should refuse that flag, or read the generated rules.
|
||||
- Real test case: the demo app has `'/p/**': { ssr: false }` and `'/presentations/**': { ssr: false }` [VERIFIED: sm-demo-app/vue-demo-app/nuxt.config.ts:141-144]. These must be declared noindex, or the demo build fails by design.
|
||||
- Hybrid rendering is not available with `nuxt generate` [CITED: nuxt.com rendering docs], so summercms.io (SSG) passes the guard trivially.
|
||||
|
||||
### Pattern 7: Crawler as a conga job
|
||||
|
||||
- **Trigger:**
|
||||
- admin button and MCP tool `seo_start_crawl` → `conga.Manager.Dispatch` with a `summer_jobs` record, so progress is shown;
|
||||
- `pact.HasSchedule` entry `seo:crawl` (for example `conga.Daily`).
|
||||
- **Seeds:** the home page plus `/sitemap.xml`, fetched over HTTP so the real output is audited; then same-host links.
|
||||
- **Limits** (config): `max_pages` (default 5000), concurrency 2, delay 250 ms, per-request timeout 10 s, body cap 5 MiB, UA `SummerCMS-SEO-Crawler/1 (+<site_url>)`.
|
||||
- **Client:** a `fetchguard.Client` built in Go from `golem15.seo.site_url`. Guarded modes are HTTPS-only and block private IPs, so a staging or local http site needs `TrustedMode`, which only Go code can choose [VERIFIED: modules/fetchguard/README.md Features]. Redirects are never followed, so 3xx hops are observed as they are.
|
||||
- **Storage:** `golem15_seo_crawls`, `golem15_seo_crawl_pages`, `golem15_seo_crawl_links`, `golem15_seo_crawl_issues`. Keep the last N runs.
|
||||
- **Issues** reuse the Phase 32 `Issue` shape, `{kind, url, locale, details, tool}`, with `tool` naming the fixing MCP tool [VERIFIED: sm-seo-plugin/classes/issues.go:28-42, e.g. `toolSetURLSEO = "seo_set_url_seo"`].
|
||||
- **Staging:** when `Staging()` is true, noindex caused by the staging lock is not an issue.
|
||||
|
||||
### Anti-patterns to avoid
|
||||
- **Keeping `@nuxtjs/seo` and "turning off parts":** schema-org adds WebSite and WebPage by default, robots emits meta and `/robots.txt`, seo-utils emits canonical. That gives two graphs and two canonicals, and the module would need re-auditing on every bump.
|
||||
- **Using `@nuxtjs/i18n`'s locale head for hreflang:** it lists every enabled locale, violating D-57.
|
||||
- **Generating sitemaps per request from `Records`:** slow on large sites and unstable numbering.
|
||||
- **Treating `resolve.not_found` as 404 in the middleware:** it 404s legitimate Nuxt pages not yet in the registry.
|
||||
- **Linking phrases with regex over raw HTML:** links end up in attributes and headings. Walk the sanitized output's text nodes with an open-tag stack (Code Examples).
|
||||
- **Calling resolve on every request without a cache:** bucket `seo-resolve` allows 600/min per client IP behind trusted proxies [VERIFIED: sm-seo-plugin/routes.go:14-23 and Buckets()]. The Nuxt server must forward `X-Forwarded-For`.
|
||||
|
||||
## Suggested Plan Breakdown (14 plans, per-repo aware)
|
||||
|
||||
Each plan names its repos and what must be tagged before dependents can pin it. "Tag" means a release tag pushed with Jakub's go-ahead (Open Question 1 decides whether tags happen inside each plan or all in 33-14).
|
||||
|
||||
| # | Scope (one line) | Repos | Depends on | Tags produced |
|
||||
|---|------------------|-------|-----------|---------------|
|
||||
| 33-01 | Additive descriptor seam (`PublishedAt`, `Parents`, `Authors`, `Images`, `Videos`, `Kind`) + signpost docs + `check-phase33.sh` skeleton; release the pending Phase 32 fixes with it | summercms.go | — (blocking Go-shape checkpoint) | framework v0.5.2 |
|
||||
| 33-02 | Journal: fill the new fields, H1→H2 on every write path with a save notice + `journal:fix-headings` dry-run command (D-50), editor heading levels 2–4 + live outline (D-51), link rel checkboxes + policy, generic-anchor warning (D-81/83), image alt required / decorative, slug file names (D-85) | sm-journal-plugin | 33-01 | Journal v0.2.x |
|
||||
| 33-03 | Sitemap rewrite: index at `/sitemap.xml`, per-type `sitemap-<slug>-<n>.xml`, slot table, lastmod store, file size setting, `Filter` interface, hreflang `xhtml:link`, url-item definitions → pages group, rebuild job + schedule; remove PHP parity, tiers, goldens, XSL | sm-sitemap-plugin | 33-01 | sitemap v0.2.0 (breaking) |
|
||||
| 33-04 | Sitemap extras: image sitemap, News sitemap (types/categories, 2-day window), IndexNow (admin key, key-file route, ping job from URL-set diffs), HTML-sitemap JSON groups API with paging | sm-sitemap-plugin | 33-03 | (same tag as 33-03 if released together) |
|
||||
| 33-05 | SEO plugin I: `sitemap.Filter` impl (D-24/D-95), page registry (model, admin, signpost type, route-report endpoint, MCP), llms.txt, robots `Sitemap:` → index, batch conflict via filter, "sitemap URL blocked" issue, `/site` endpoint for the layer, site-cache purge after SEO writes | sm-seo-plugin | 33-03 | — |
|
||||
| 33-06 | SEO plugin II: publisher settings (D-03/D-45), schema type + type fields + custom JSON-LD in SEO tab with validation (D-44/D-46), @graph builder per kind (D-43), breadcrumbs + primary category (D-86), alternates/x-default/og:locale (D-55..57), pagination fields + `?page=1`/form normalisation (D-40/41/42) in resolve | sm-seo-plugin | 33-01, 33-05 | seo v0.2.0 (after 33-05, 33-06, 33-10, 33-11) |
|
||||
| 33-07 | `vue-seo-layer` runtime: create repo (tea), Nitro middleware (redirect/410/X-Robots-Tag/LRU), `useCmsSeo` head rendering + single JSON-LD script + `useSchemaNode`, root proxies (robots, sitemaps, llms, verification, IndexNow key), 404 report call, moved site-cache purge route clearing the LRU | vue-seo-layer (new) | 33-05/33-06 API shape (fixtures) | — |
|
||||
| 33-08 | `vue-seo-layer` build + components: SSR build guard module, route report to registry, signed-in/noindex route meta (D-23), `<SeoBreadcrumbs>`, `usePagination` (validation, rel prev/next, suffixes, page-1 pillar), HTML sitemap page, route-drift startup check (D-20c) | vue-seo-layer | 33-07 | vue-seo-layer v0.1.0 |
|
||||
| 33-09 | Layers onto the SEO layer: journal (drop `@nuxtjs/seo` and own meta, PostCard one link (D-80), H2 cards, htmlGate h1→h2, auto-links + outbound rules, image rules D-71, breadcrumbs, pagination), menu and FAQ (extend, FAQPage node, widget headings as styled text D-52, content in HTML), remove duplicated purge files | vue-journal-layer, vue-menu-layer, vue-faq-layer | 33-08 | journal-layer v0.7.0, menu v0.5.0, faq v0.5.0 |
|
||||
| 33-10 | Crawler + issue report: job, storage, checks (status, canonical, robots, title/description, H1/levels/title consistency, JSON-LD parse + per-type validation, hreflang reciprocity, internal links/redirected links/generic anchors, missing alt/dimensions/lazy featured, raw-HTML content check, sitemap consistency), admin screen, MCP tools, merged issues | sm-seo-plugin | 33-05, 33-06 | — |
|
||||
| 33-11 | Linking + media: auto-link phrase table (admin + MCP, keyphrase feed), related-posts option (Open Question 6), editor suggestions widget + orphan report from crawl link graph, outbound domain lists, missing-alt CMS report + bulk alt MCP tool, image format decision (WebP/AVIF/srcset or defer) | sm-seo-plugin (+ sm-journal-plugin if related posts change) | 33-10 | — |
|
||||
| 33-12 | Apps I: sm-demo-app (bumps, declare `/p/**` and `/presentations/**` noindex, replace hand-written noindex with route meta, mount new sitemap, SEO layer), sm-summercmsio-app (Journal bump, layer bump; keeps `@nuxtjs/seo` for its static sitemap, Open Question 7), sm-bm-app D-72 (robots.txt `Disallow: /`, meta noindex, X-Robots-Tag) | sm-demo-app, sm-summercmsio-app, sm-bm-app | tags of 33-01..09 | — |
|
||||
| 33-13 | grzybyfunkcjonalne.pl: framework v0.4.4→v0.5.2, Journal bump (`golem15.journal.tag_pages: false`), sitemap v0.2.0 migration, mount sm-seo-plugin, robots/sitemap from Nuxt to CMS, nginx locations for `/sitemap*.xml` and `/robots.txt`, layers v0.4.0→latest + SEO layer, smoke-ssr; deploy as a human checkpoint | sm-grzybyfunkcjonalne-app (+ its vue submodule) | 33-12 pattern | — |
|
||||
| 33-14 | Unit tests (last plan) + `check-phase33.sh --all` + release tags in order + app pin bumps + changelog post | all touched repos | all | final tags |
|
||||
|
||||
**Wave sketch:**
|
||||
```
|
||||
W1: 33-01 (framework seam + v0.5.2)
|
||||
W2: 33-02 (journal) ║ 33-03 (sitemap core)
|
||||
W3: 33-04 (sitemap extras) ║ 33-05 (seo I) ║ 33-07 (layer runtime, against fixtures)
|
||||
W4: 33-06 (seo II) ║ 33-08 (layer build/components)
|
||||
W5: 33-09 (layers) ║ 33-10 (crawler)
|
||||
W6: 33-11 (linking/media)
|
||||
W7: 33-12 (apps I) → 33-13 (grzyby)
|
||||
W8: 33-14 (unit tests + releases)
|
||||
```
|
||||
|
||||
**Release order** (plugin first, then app pointer, per CLAUDE.md):
|
||||
1. framework v0.5.2 + changelog post;
|
||||
2. sm-journal-plugin;
|
||||
3. sm-sitemap-plugin (breaking, minor bump);
|
||||
4. sm-seo-plugin (requires sitemap: its go.mod gains `sm-sitemap-plugin`);
|
||||
5. vue-seo-layer;
|
||||
6. vue-journal-layer, vue-menu-layer, vue-faq-layer;
|
||||
7. apps (go.mod + submodule pointer + package.json pins, separate commits);
|
||||
8. deploys only on go-ahead.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't build | Use instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| XML escaping in sitemaps | another string replacer | `encoding/xml` (`xml.EscapeText`) | PHP byte parity is retired (D-32); stdlib handles every escape |
|
||||
| HTML parsing in the crawler / Go transforms | regex over HTML | `golang.org/x/net/html` tokenizer/parser | Already in the graph; handles malformed HTML |
|
||||
| robots.txt matching | own matcher | `grobotstxt` (D-99) | Google-faithful longest match |
|
||||
| Outbound HTTP for crawler/IndexNow | raw `http.Client` | `fetchguard.Client` | SSRF guard, body cap, no redirect following |
|
||||
| JSON-LD script escaping in Nuxt | manual `</script>` escaping | unhead `useHead({ script: [{ type: 'application/ld+json', innerHTML: graph }] })` | unhead escapes `<` as `<` for json types |
|
||||
| Background work / schedules | goroutines + tickers | `conga` jobs + `pact.HasSchedule` | Leader election, unique periods, progress records |
|
||||
| Cache purge of rendered pages | new purge endpoint | `cooler.PurgeSiteCacheAfterCommit` + existing `/api/__site__/cache/purge` | Already wired to every site |
|
||||
| Redirect decision in the layer | re-implementing rules in TS | `GET /_seo/api/v1/resolve` | One source of truth (D-103) |
|
||||
| Head tags | `@nuxtjs/seo` modules | Nuxt core `useHead`/`useSeoMeta` | Core composables; no competing graph |
|
||||
|
||||
**Key insight:** in this phase the CMS already holds every decision. Each frontend piece that "computes SEO" itself is a second source of truth that will drift: hreflang from i18n, canonical from seo-utils, schema from nuxt-schema-org, sitemap from @nuxtjs/sitemap. The layer should only render the answer and enforce it.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
D-32 is a migration of live sites, so this inventory applies.
|
||||
|
||||
| Category | Items found | Action required |
|
||||
|----------|-------------|-----------------|
|
||||
| Stored data | `golem15_sitemap_definitions` rows (grzyby, demo): `url` items and registry items with `priority`/`changefreq` (not read in the databases this session; the shape is known from code) [VERIFIED: models/definition.go, models/definition_item.go]. `golem15_sitemap_settings`: `frontend_url`, `frontend_default_locale`, `updates_manifest_path`, tier priorities [VERIFIED: models/settings.go] | **Data migration:** keep `url` items as the "pages" group; map `frontend_url` → site URL; drop the tier columns in a new migration (or leave them unused); first rebuild job fills the slot table |
|
||||
| Live service config | Google Search Console sitemap submissions for grzybyfunkcjonalne.pl point at `/sitemap.xml` (assumed, not checked) | Keep the index at `/sitemap.xml`; optionally submit the per-type files for monitoring (D-30); a manual GSC task, not code |
|
||||
| Live service config | grzyby nginx routes exactly `location = /sitemap.xml` and `location = /sitemap.xsl` to Go [VERIFIED: deploy/nginx/grzybyfunkcjonalne-staging.conf:145-161]; `/robots.txt` goes to Nuxt (@nuxtjs/robots), whose `robots` config sets `sitemap: '/sitemap.xml'` | Add a location for `^/sitemap(-[a-z0-9-]+)?\.xml$`, `/robots.txt`, `/llms.txt` and the IndexNow key file (or let the layer proxy them); remove the `sitemap.xsl` location; the production conf was not found in repo (only staging), so check with Jakub |
|
||||
| OS-registered state | supervisor programs for grzyby (deploy/supervisor) | None expected (same binaries); verify after deploy |
|
||||
| Secrets/env vars | `NUXT_CACHE_PURGE_TOKEN` / `site_cache.purge_token`; `golem15.seo.notfound_token` (new on grzyby); new IndexNow key (generated in admin, stored in DB, not an env var) | Add the notfound token and seo site URL to grzyby env; no rename of existing keys |
|
||||
| Build artifacts | Nitro route cache on running Nuxt servers holds pages with the old head (two graphs from nuxt-schema-org) | Purge after deploy (the purge route) |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Building on unreleased fixes
|
||||
**What goes wrong:** Phase 33 plugins `require` v0.5.1 / seo v0.1.0 and silently miss CR-01..CR-04, or develop against `replace` and fail at tag time.
|
||||
**How to avoid:** 33-01 releases the fixes together with the new seam; check that every plugin's `go.mod` requires the new tags in 33-14.
|
||||
**Warning signs:** `go list -m` shows v0.5.1 in a plugin that needs a v0.5.2 field.
|
||||
|
||||
### Pitfall 2: Two JSON-LD graphs / two canonicals
|
||||
**What goes wrong:** the journal layer keeps `@nuxtjs/seo`; nuxt-schema-org adds WebSite and WebPage; seo-utils adds a canonical. The CMS graph and canonical come in addition.
|
||||
**How to avoid:** remove `@nuxtjs/seo` from the layers. A smoke expectation asserts exactly one `application/ld+json` script and one `rel="canonical"`.
|
||||
|
||||
### Pitfall 3: hreflang for missing translations
|
||||
**What goes wrong:** using i18n's locale head, or the sitemap listing every enabled locale.
|
||||
**How to avoid:** use only `ContentLocale` entries with a non-empty `URL`. Journal emits a single alternate with locale `"x-default"` when translate has no enabled locale [VERIFIED: sm-journal-plugin/classes/menu_item_types.go:53-55], so emit no hreflang when only one locale exists. hreflang must list itself and be reciprocal [CITED: developers.google.com/search/docs/specialty/international/localized-versions].
|
||||
|
||||
### Pitfall 4: lastmod changes on every save
|
||||
**What goes wrong:** Journal's descriptor `LastMod` is `UpdatedAt`, which moves on any save. Google uses lastmod only "if it's consistently and verifiably accurate" [CITED: developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap].
|
||||
**How to avoid:** use the event-driven lastmod store (Pattern 2); ignore empty `Changes`.
|
||||
|
||||
### Pitfall 5: Stable numbering broken by deletes
|
||||
**What goes wrong:** re-chunking an ordered list after a delete shifts every later URL into an earlier file.
|
||||
**How to avoid:** slot table with holes; only append.
|
||||
|
||||
### Pitfall 6: Shared purge file duplicated across layers
|
||||
**What goes wrong:** journal, menu and FAQ ship `server/api/__site__/cache/purge.post.ts` and `server/lib/siteCachePurge.ts` "byte for byte" [VERIFIED: vue-journal-layer/server/api/__site__/cache/purge.post.ts header]. Nitro keeps one handler per route, so a new SEO-layer behaviour (clearing the resolve LRU) is lost when another layer's copy wins.
|
||||
**How to avoid:** move the route and lib into `vue-seo-layer` and delete the copies in 33-09. The purge clears `nitro:routes` [VERIFIED: siteCachePurge.ts:16] and the resolve LRU.
|
||||
|
||||
### Pitfall 7: Generic "read more" and the image as a second link
|
||||
**What goes wrong:** PostCard renders an image `NuxtLink` (`tabindex="-1" aria-hidden="true"`) and a `$t('journal.readMore')` link in addition to the title link [VERIFIED: vue-journal-layer/app/components/Journal/PostCard.vue:15-24]. That is three anchors to one URL, one of them generic.
|
||||
**How to avoid:** title link only; make the card clickable with a CSS stretched link; drop the separate read-more anchor, or make its text "Czytaj: <title>" (D-81). Note the tension with D-80 in Open Question 8.
|
||||
|
||||
### Pitfall 8: XSL breaks in browsers
|
||||
**What goes wrong:** keeping `<?xml-stylesheet?>` while Chrome 158 (2026-11-17) stops running XSLT on Stable [CITED: developer.chrome.com/docs/web-platform/deprecating-xslt].
|
||||
**How to avoid:** remove the PI and `/sitemap.xsl`. The HTML sitemap (D-34) is the human view. Record in the plan that backlog todo w8kupqi3 is resolved by removal, and close it in g15office separately.
|
||||
|
||||
### Pitfall 9: News sitemap misuse
|
||||
**What goes wrong:** listing all posts. Only articles "created in the last two days" belong; up to 1,000 `news:news` per sitemap; title without author or date [CITED: developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap].
|
||||
**How to avoid:** the window is computed from the new `PublishedAt`; off by default.
|
||||
|
||||
### Pitfall 10: Build guard false confidence
|
||||
**What goes wrong:** checking only `nuxt.options.routeRules`, while rules also come from `nitro.routeRules`, layers, `.client.vue` pages and experimental inline rules.
|
||||
**How to avoid:** check all of them in `nitro:config` after layers merge. Test with a playground that has each kind.
|
||||
|
||||
### Pitfall 11: FAQ rich results are gone
|
||||
**What goes wrong:** treating FAQPage validation as a Google rich-result requirement. Google removed the FAQ rich result: "This feature will no longer appear in Google Search starting May 7, 2026" [CITED: developers.google.com/search/docs/appearance/structured-data/faqpage].
|
||||
**How to avoid:** still emit FAQPage, since it is valid schema.org and D-43 asks for it, but give it no rich-result validation rules. The Article type has "no required properties" in Google's docs; its recommended ones are author, dateModified, datePublished, headline and image [CITED: developers.google.com/search/docs/appearance/structured-data/article].
|
||||
|
||||
### Pitfall 12: Crawling staging
|
||||
**What goes wrong:** every page reports noindex because of the D-22 staging lock.
|
||||
**How to avoid:** suppress lock-caused robots issues when `Staging()` is true. Crawl over http on staging needs `fetchguard.TrustedMode`.
|
||||
|
||||
### Pitfall 13: grzyby jumps several versions at once
|
||||
**What goes wrong:** framework v0.4.4→v0.5.2, Journal pseudo-version → v0.2.x, layers v0.4.0→v0.7.0 with breaking layer changes, plus SEO mount and sitemap migration, all on a live site.
|
||||
**How to avoid:** a separate plan (33-13) with smoke-ssr expectations covering the public URLs that must stay identical (Phase 16 D-15), staging deploy first, production deploy as a human checkpoint.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Sitemap index and urlset with hreflang and images (Go, stdlib escaping)
|
||||
```go
|
||||
// Source: sitemaps.org protocol + Google image/hreflang docs (cited above). Names [ASSUMED].
|
||||
const (
|
||||
nsSitemap = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
nsXHTML = "http://www.w3.org/1999/xhtml"
|
||||
nsImage = "http://www.google.com/schemas/sitemap-image/1.1"
|
||||
nsNews = "http://www.google.com/schemas/sitemap-news/0.9"
|
||||
)
|
||||
|
||||
func writeIndex(w io.Writer, files []IndexFile) error {
|
||||
bw := bufio.NewWriter(w)
|
||||
bw.WriteString(xml.Header + `<sitemapindex xmlns="` + nsSitemap + `">`)
|
||||
for _, f := range files {
|
||||
bw.WriteString("<sitemap><loc>")
|
||||
xml.EscapeText(bw, []byte(f.Loc))
|
||||
bw.WriteString("</loc>")
|
||||
if !f.LastMod.IsZero() {
|
||||
bw.WriteString("<lastmod>" + f.LastMod.UTC().Format(time.RFC3339) + "</lastmod>")
|
||||
}
|
||||
bw.WriteString("</sitemap>")
|
||||
}
|
||||
bw.WriteString("</sitemapindex>\n")
|
||||
return bw.Flush()
|
||||
}
|
||||
// Each <url>: <loc>, optional <lastmod>, then when >1 locale every <xhtml:link rel="alternate" hreflang=".." href=".."/>
|
||||
// including itself and x-default, then up to 1000 <image:image><image:loc>..</image:loc></image:image>.
|
||||
// No <priority>, no <changefreq> (D-30; Google ignores them).
|
||||
```
|
||||
|
||||
### IndexNow ping (Go, fetchguard)
|
||||
```go
|
||||
// Source: indexnow.org/documentation (cited). Key: 8–128 chars [a-zA-Z0-9-], served at /<key>.txt.
|
||||
body := map[string]any{"host": host, "key": key, "keyLocation": site + "/" + key + ".txt", "urlList": urls} // ≤10,000 URLs
|
||||
res, err := client.PostJSON(ctx, "https://api.indexnow.org/indexnow", body) // fetchguard.Client, AllowHosts api.indexnow.org
|
||||
// 200/202 ok; 403 key file missing; 422 host mismatch; 429 back off (retry via conga attempts)
|
||||
```
|
||||
|
||||
### Layer head from the resolve answer (Nuxt core only)
|
||||
```ts
|
||||
// Source: Nuxt core useHead/useSeoMeta; field names of `seo` VERIFIED (resolver.go:72-101), new fields [ASSUMED].
|
||||
export function useCmsSeo() {
|
||||
const res = useRequestEvent()?.context.seo ?? useState('cms-seo').value
|
||||
if (!res?.seo) return
|
||||
const s = res.seo
|
||||
useSeoMeta({ title: s.title, description: s.description, robots: s.robots,
|
||||
ogTitle: s.og.title, ogDescription: s.og.description, ogImage: s.og.image || undefined, ogUrl: s.og.url,
|
||||
twitterCard: s.twitter.card, twitterSite: s.twitter.site || undefined })
|
||||
useHead({
|
||||
htmlAttrs: { lang: res.html_lang },
|
||||
link: [{ rel: 'canonical', href: s.canonical, key: 'canonical' },
|
||||
...(res.alternates ?? []).map((a) => ({ rel: 'alternate', hreflang: a.hreflang, href: a.url, key: `alt-${a.hreflang}` }))],
|
||||
meta: s.verification.map((v) => ({ name: v.name, content: v.content })),
|
||||
script: [{ type: 'application/ld+json', key: 'jsonld', innerHTML: mergeGraph(res.jsonld, useSchemaNodes()) }], // unhead escapes '<'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Phrase auto-linking over sanitize-html output (TS)
|
||||
```ts
|
||||
// sanitize-html serializes normalized HTML with escaped text, so a tag/text split is reliable on ITS output.
|
||||
const SKIP = new Set(['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'figcaption', 'button'])
|
||||
export function autolink(html: string, links: { phrase: string; url: string }[], max: number, self: string): string {
|
||||
const stack: string[] = []; let used = 0; const done = new Set<string>()
|
||||
return html.split(/(<[^>]+>)/).map((part) => {
|
||||
if (part.startsWith('<')) { trackTag(stack, part); return part }
|
||||
if (used >= max || stack.some((t) => SKIP.has(t))) return part
|
||||
for (const l of links) {
|
||||
if (done.has(l.url) || l.url === self || used >= max) continue
|
||||
const i = indexOfWord(part, escapeHtml(l.phrase)) // case-insensitive, word boundary, first occurrence
|
||||
if (i < 0) continue
|
||||
done.add(l.url); used++
|
||||
part = part.slice(0, i) + `<a href="${escapeAttr(l.url)}">` + part.slice(i, i + l.phrase.length) + '</a>' + part.slice(i + l.phrase.length)
|
||||
}
|
||||
return part
|
||||
}).join('')
|
||||
}
|
||||
```
|
||||
|
||||
### Journal H1 → H2 in the write gate (Go)
|
||||
```go
|
||||
// After SanitizeHTMLReport: rewrite <h1 ...> / </h1> start and end tags to h2 using the x/net/html tokenizer,
|
||||
// count conversions, and surface "n headings converted" as a save notice (admin) / response field (API, MCP).
|
||||
// Editor: StarterKit.configure({ heading: { levels: [2, 3, 4] } }) plus a parse rule mapping <h1> to level 2.
|
||||
// Layer defence in depth: sanitize-html transformTags: { h1: 'h2' } in htmlGate.
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old approach | Current approach | When changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `<priority>`, `<changefreq>` | Omitted; Google ignores them | long-standing [CITED: build-sitemap docs] | D-30 drops them |
|
||||
| image:caption/title/geo_location/license | Only `image:image` + `image:loc` | Google removed the others [CITED: image-sitemaps docs] | Smaller image entries |
|
||||
| `rel=prev/next` for Google | "Google no longer uses these tags"; other engines may [CITED: pagination docs] | — | D-42 keeps them for Bing; numbered `<a href>` links are what matters |
|
||||
| Browser XSLT view of sitemaps | Removed from Chrome Stable in Chrome 158, 2026-11-17 [CITED: Chrome deprecating-xslt] | 2026 | Drop the XSL output |
|
||||
| FAQ rich results | Not shown in Google Search from 2026-05-07 [CITED: faqpage docs] | 2026 | Keep FAQPage, no rich-result validation |
|
||||
| `@nuxtjs/seo` all-in-one | CMS-driven head via core composables for headless CMS sites | — | Layer replaces the module |
|
||||
|
||||
**Deprecated/outdated in the repos:**
|
||||
- sm-sitemap-plugin priority tiers config (`golem15.sitemap.priority_tiers`), the frontend_url rewrite and the PHP goldens (`classes/testdata/*.golden`). D-32 retires them.
|
||||
- `classes.BeforeAddItem`/`AddItem` festival events: no consumer outside the plugin [VERIFIED: grep across siblings]. Replace them with the `Filter` interface.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | New pact fields named `PublishedAt`, `Parents`, `Authors`, `Images`, `Videos`, `Kind` are acceptable as generic, additive seam (D-90/D-106) | Pattern 1 | Seam shape rework; needs Jakub's blocking checkpoint like 32-01/T1 |
|
||||
| A2 | `sitemap.Filter` interface with batched `Decide` and a `Group` override | Pattern 3 | Page-registry kinds cannot split into per-kind files otherwise |
|
||||
| A3 | Resolve answer field names (`alternates`, `jsonld`, `breadcrumbs`, `pagination`, `autolinks`, `outbound`, `html_lang`, `og_locale`) | Pattern 4 | Layer/plugin contract; consumed by every app (D-33 "costly") |
|
||||
| A4 | Keep the index at `/sitemap.xml` (per-type files `sitemap-<slug>-<n>.xml`) | Plan 33-03 | GSC submission changes if a different index URL is chosen |
|
||||
| A5 | Remove XSL output now | Pitfall 8 | Editors lose the styled XML view (HTML sitemap replaces it) |
|
||||
| A6 | Layer drops `@nuxtjs/seo` entirely (journal layer), summercms.io keeps its own | Standard Stack | `useSiteConfig` callers and `linkChecker`/`robots`/`sitemap` options in apps must be removed |
|
||||
| A7 | Middleware enforces only redirect and gone; not_found left to Nuxt routing | Pattern 5 | Soft-404 risk for unknown paths that a catch-all page renders with 200 |
|
||||
| A8 | Crawler default limits (5000 pages, concurrency 2, 250 ms, 10 s, 5 MiB) | Pattern 7 | Load on small servers; configurable |
|
||||
| A9 | Event-driven lastmod with an attribute ignore list | Pattern 2 | Lastmod too eager or too lazy |
|
||||
| A10 | Auto-links applied at render in the layer, not stored in content | Code Examples | Links absent from the API/RSS output; crawler still sees them |
|
||||
| A11 | grzyby production nginx mirrors the staging conf (only staging conf found in repo) | Runtime State | Missing sitemap routes after deploy |
|
||||
| A12 | GSC for grzyby has `/sitemap.xml` submitted | Runtime State | None if index stays at the same URL |
|
||||
| A13 | Optional Go modules gen2brain/webp, gen2brain/avif, chromedp are legitimate and maintained | Package audit | Supply-chain risk; gated behind checkpoint + decision note |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All resolved on 2026-10-28; the decisions are recorded in 33-CONTEXT.md, Planning section.
|
||||
|
||||
1. **Release cadence for a 10-repo phase. RESOLVED: all tags in 33-14 (CONTEXT, release cadence)**
|
||||
- Known: apps pin plugins by Go tag and layers by pnpm git tag; Phase 32 tagged only in its last plan and staged the demo mount as a patch.
|
||||
- Unclear: whether Jakub allows intermediate pushes and tags. Plugin tags after 33-03..06, layer tags after 33-08/09.
|
||||
- Recommendation: allow intermediate tags, each behind a `checkpoint:human-verify` "push and tag" step. Otherwise 33-12 and 33-13 can only produce patches applied in 33-14, as 32-05 did.
|
||||
2. **D-70 "rendered" HTML. RESOLVED: optional chromedp when a Chrome path is configured (CONTEXT, rendered comparison)**
|
||||
- Known: a no-JS comparison needs no browser; a rendered DOM needs headless Chromium (chromedp + Chrome on the server). Chromium 154 exists on this dev machine; production servers unknown.
|
||||
- Recommendation (MVP): the raw HTML is checked against CMS expectations (descriptor title in H1, word count vs `Words`, CMS-known links present). Rendered comparison deferred, or optional when a Chrome path is configured.
|
||||
3. **D-85 WebP/AVIF/srcset. RESOLVED: WebP now in Go via gen2brain/webp, AVIF deferred (CONTEXT, image formats)**
|
||||
- Known: `lagoon/attach` decodes webp but cannot encode it ("a webp thumbnail holds JPEG bytes") [VERIFIED: modules/lagoon/attach/thumb.go:22-24]. Encoding needs a new dependency.
|
||||
- Options: (a) gen2brain/webp+avif in a plugin (WASM, no cgo); (b) Nuxt image pipeline (new npm deps, sharp native); (c) defer formats, ship srcset widths from existing JPEG/PNG thumbs plus alt/filename rules.
|
||||
- Recommendation: (c) for MVP, with (a) as a follow-up decision note.
|
||||
4. **Success criterion 3 vs layout issues. RESOLVED: data issues name an MCP tool, code issues carry fix: code (CONTEXT)** Some crawler findings (two H1 from a layout, a missing breadcrumb component) are frontend code, not data. Recommendation: issue kinds carry either a `tool` or `fix: "code"`. The criterion then reads as: every data issue has a tool. Confirm with Jakub and Dawid.
|
||||
5. **Site URL in the sitemap plugin. RESOLVED: setting, then config, then app.url (CONTEXT, defaults)** Sitemap cannot read `golem15.seo.site_url` (D-95). Recommendation: a sitemap setting or config `golem15.sitemap.site_url` defaulting to `app.url`; the SEO plugin reports a mismatch as an issue.
|
||||
6. **Related posts (D-82) RESOLVED: opt-in, current behaviour default (CONTEXT, defaults)** changes Journal API semantics (today: same category, limit 3, `published_at DESC`) [VERIFIED: sm-journal-plugin/controllers/api/posts_helpers.go:152-172]. Recommendation: opt-in config in Journal (count 3–6, tag+category scoring), default = current behaviour (porting contract).
|
||||
7. **summercms.io scope. RESOLVED: pin bumps and build guard only (CONTEXT, defaults)** It is a `nuxt generate` site with no sitemap or SEO plugin mounted, and it uses `@nuxtjs/seo` for its own sitemap. Recommendation: Phase 33 only bumps its Journal and layer pins and checks the build guard; a full move is deferred.
|
||||
8. **D-80 vs D-81 "read more". RESOLVED: no read-more anchor (UI-SPEC L5, Q-11)** D-80 says only the title is a text link; D-81 allows "read more" carrying the title. Recommendation: no separate read-more anchor in the layer component.
|
||||
9. **D-20c "layer routes reading their patterns from the CMS". RESOLVED: build-time options plus startup drift check (UI-SPEC L3)** Nuxt routes are build-time. Recommendation: build-time options stay, plus a startup drift check against CMS patterns (logged, and reported by the crawler), rather than a build-time network fetch.
|
||||
10. **"The user plugin's auth layer" (D-23) RESOLVED: route meta, no user layer (CONTEXT, defaults)** does not exist as a Nuxt layer today; there are only vue-journal, menu, FAQ and paymentgateway layers. Recommendation: the SEO layer honours `definePageMeta({ requiresAuth: true })` / `seo: { robots }`; apps mark their auth pages; a user layer is out of scope.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required by | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.2 | — |
|
||||
| Node | layers, apps | ✓ | v24.22.0 (engines >=22.18) | — |
|
||||
| pnpm | layer installs | ✓ | 12.14.0 | npm 12.2.1 |
|
||||
| Docker | testcontainers Postgres suites | ✓ | 29.9.1 (server reachable as golem) | `-short` only |
|
||||
| tea | create `vue-seo-layer` repo | ✓ | /usr/bin/tea | — |
|
||||
| Chromium | only if rendered crawl chosen | ✓ (dev) | 154.0.8037.97 | raw-HTML checks |
|
||||
| Write access to summercms.go `.git` and phase dir | docs commits | ✓ | owned by golem now (32-03 had a jin:jin blocker) | — |
|
||||
|
||||
**Missing dependencies with no fallback:** none for the MVP scope.
|
||||
**Missing with fallback:** headless browser on production (raw-HTML check instead).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (Go) | `go test` + testcontainers Postgres (`TestMain` skips Docker under `testing.Short()`) |
|
||||
| Framework (layers) | `node --test --test-reporter=tap tests/*.test.ts` (pure TS utils) + `testing/smoke-ssr.mjs` against a built playground with a fixture API |
|
||||
| Framework (admin elements) | vitest 3.2.7 + happy-dom 20.11.6 in sm-seo-plugin / sm-journal-plugin |
|
||||
| Config file | per repo (`package.json` scripts, `vite.config.ts`); new `scripts/check-phase33.sh` in summercms.go |
|
||||
| Quick run command | `go test -short ./...` in the touched repo; `npm test` / `pnpm test` in the touched layer |
|
||||
| Full suite command | `bash scripts/check-phase33.sh --all` (modelled on check-phase32.sh: self-test, go, plugins, layers, consumers, docker, docs, marker, additive, compat) |
|
||||
|
||||
### Phase requirements → test map
|
||||
| Req ID | Behavior | Test type | Automated command | File exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| D-30 | index + per-type files of N URLs, stable numbering after a delete, lastmod only on real change | integration (Postgres) | `go test ./... -run 'TestSitemapIndex\|TestStableNumbering\|TestLastmod'` (sm-sitemap-plugin) | ❌ Wave 0 |
|
||||
| D-31 | image entries ≤1000/url; news window 2 days; IndexNow ping on URL diff (fake transport via `fetchguard.WithTransport`) | unit + integration | `go test ./... -run 'TestImageSitemap\|TestNewsSitemap\|TestIndexNow'` | ❌ |
|
||||
| D-32 | no priority/changefreq/XSL; url items migrated | integration | `go test ./... -run TestMigrateDefinitions` | ❌ |
|
||||
| D-95/D-24 | noindex/canonical/redirected excluded via SEO filter; sitemap works without SEO | integration (two apps) | `go test ./... -run 'TestFilterExcludes\|TestNoFilter'` | ❌ |
|
||||
| D-33 | registry page resolves ok; route report upserts; page kinds group into files | integration | `go test ./... -run 'TestRegistry'` (sm-seo-plugin) | ❌ |
|
||||
| D-43..46 | @graph per kind with @id links; custom JSON-LD refused when invalid; validator warnings | unit | `go test ./classes/schema/...` | ❌ |
|
||||
| D-55..57 | alternates only for translated locales; x-default = default locale | integration | `go test ./... -run TestAlternates` | ❌ |
|
||||
| D-40..42 | `?page=1` → 301; other form → 301 to configured; suffix templates | integration | `go test ./... -run TestPagination` | ❌ |
|
||||
| D-50/51 | H1→H2 on admin/API/MCP/import, notice; command dry run | integration (Journal) | `go test ./... -run 'TestHeadingConversion'` | ❌ |
|
||||
| D-70 | build fails for indexable `ssr:false`, `.client.vue`; passes when declared noindex | build smoke | `pnpm build:playground` with a fixture playground per case (expect exit 1/0) | ❌ |
|
||||
| D-20a/D-08/D-103 | middleware: 301/410, X-Robots-Tag, verification file at root, LRU purged | SSR smoke | `node testing/smoke-ssr.mjs --expect testing/expect-seo.txt` | ❌ |
|
||||
| D-43 (layer) | exactly one ld+json script, one canonical | SSR smoke | same expect file (`contains=`/`notcontains=`) | ❌ |
|
||||
| D-80/81/52 | one anchor per card, H2 cards, no generic anchor | SSR smoke + unit | journal layer `pnpm test` + smoke | ❌ |
|
||||
| D-14 et al. | crawler checks against an httptest site with planted issues; each issue has tool or `fix:code` | integration | `go test ./... -run TestCrawler` | ❌ |
|
||||
| D-10 | every new admin operation has an MCP tool | unit | `go test -short ./... -run TestEveryAdminOperationHasTool` | ✅ (extend) |
|
||||
| D-82/83 | autolink first occurrence, max N, not in headings/links/self; outbound rel | unit (TS + Go) | `pnpm test` (vue-seo-layer / journal layer) | ❌ |
|
||||
| D-72 | bm serves `Disallow: /` and noindex | smoke | `curl -sI` in check script against a local build | ❌ |
|
||||
| D-90/D-106 | framework additions name no SEO/app; additive only | gate | `check-phase33.sh --marker --additive` | ❌ (copy from phase32) |
|
||||
|
||||
### Sampling rate
|
||||
- **Per task commit:** `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer.
|
||||
- **Per wave merge:** Docker suites of the touched repos + layer playground build + smoke-ssr.
|
||||
- **Phase gate:** `scripts/check-phase33.sh --all` green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 gaps
|
||||
- [ ] `scripts/check-phase33.sh` (copy the stage engine of check-phase32.sh; add a `layers` stage: node tests, playground builds, smoke-ssr).
|
||||
- [ ] sm-sitemap-plugin: retire `classes/golden_test.go` + `testdata/*.golden`; new test fixtures for multi-type, multi-locale content (fake content type plugin, as sm-seo-plugin's `fixture_test.go` does).
|
||||
- [ ] vue-seo-layer: `.playground`, `testing/` (copy smoke-ssr, fixture-api, nuxt-server from vue-journal-layer), fixture `/_seo/api/v1/resolve` answers.
|
||||
- [ ] Build-guard fixture playgrounds (indexable ssr:false → fail; declared noindex → pass; `.client.vue` page → fail).
|
||||
- [ ] Crawler httptest site with planted issues.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS categories
|
||||
| ASVS category | Applies | Standard control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (route report, 404 report, purge) | bearer tokens ≥32 chars (existing purge rule), backend guard + `golem15.seo.manage` for management routes |
|
||||
| V3 Session Management | no | — |
|
||||
| V4 Access Control | yes | one SEO permission (D-15); MCP tools listed only with the permission; read grants see GET tools only |
|
||||
| V5 Input Validation | yes | custom JSON-LD: parse, size/depth caps, objects only, merge by `@id`; autolink URLs internal paths or http(s); IndexNow key `[A-Za-z0-9-]{8,128}`; verification names `[A-Za-z0-9._-]+\.(html\|txt\|xml)` [VERIFIED: sm-seo-plugin/classes/verification.go:11] |
|
||||
| V6 Cryptography | yes (IndexNow key) | `crypto/rand` hex key; never hand-roll |
|
||||
| V12/V13 SSRF / outbound | yes | `fetchguard` for crawler and IndexNow; crawler follows only same-host links |
|
||||
|
||||
### Known threat patterns
|
||||
| Pattern | STRIDE | Standard mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| SSRF through crawled links or a site URL pointing inside the network | Elevation | fetchguard host allow-list = site host; TrustedMode only from config, never from page content |
|
||||
| Script breakout from JSON-LD (editor custom JSON with `</script>`) | Tampering/XSS | unhead escapes `<` in json scripts; also validate on save |
|
||||
| Open redirect via middleware | Spoofing | Location always from the resolver, absolute on the site URL (Phase 32 guarantee); layer never builds Location from the request |
|
||||
| XML injection in sitemaps | Tampering | `xml.EscapeText` on every value; only http(s) locs |
|
||||
| Crawl as DoS on own site | DoS | concurrency/delay caps, one crawl at a time (conga unique), max pages |
|
||||
| Resolve endpoint flooding through the Nuxt server | DoS | LRU cache in the layer; forward X-Forwarded-For so the per-IP bucket applies per visitor |
|
||||
| Route report poisoning the registry | Tampering | bearer token; report only inserts unknown static paths, never deletes editor data |
|
||||
| Auto-link injection | XSS | phrase/URL validated on save; layer escapes attribute values; links only into text nodes |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence, code read this session)
|
||||
- `summercms.go/modules/pact/content.go`, `modules/pact/capabilities.go:956-1010`, `modules/signpost/content.go`, `modules/signpost/signpost.go`, `modules/backpack/services.go`, `modules/cooler/sitecache.go`, `modules/fetchguard/README.md`, `modules/conga/README.md`, `modules/lagoon/attach/thumb.go`
|
||||
- `sm-sitemap-plugin/` (plugin.go, routes.go, classes/generator.go, serialize.go, events.go, models/*, config/config.yaml, README)
|
||||
- `sm-seo-plugin/` (routes.go, mcp.go, operations.go, classes/resolver.go, issues.go, verification.go, robots.go, config/config.yaml)
|
||||
- `sm-journal-plugin/` (classes/content_descriptors.go, menu_item_types.go, format_html.go, assets/src/blogeditor/*, controllers/api/posts_helpers.go)
|
||||
- `vue-journal-layer/` (nuxt.config.ts, PostCard.vue, PostPage.vue, Pagination.vue, seo.ts, sitemap source, purge route + lib, htmlGate.ts, testing/smoke-ssr.mjs); node_modules h3 1.15.11, unhead 2.1.17, sanitize-html 2.18.0
|
||||
- Apps: grzyby `nuxt.config.ts`, `package.json`, nginx staging conf, `.gitmodules`, `summer.yaml`; demo `nuxt.config.ts`; summercms.io `nuxt.config.ts`; bm `frontend/`
|
||||
- Planning: 33-CONTEXT, 32-CONTEXT, 32-DECISIONS-MAP, 32-02..06 SUMMARYs, 32-UAT, 32-VERIFICATION, deferred-items, 32-06-release-log, STATE.md, ROADMAP.md
|
||||
|
||||
### Secondary (cited official docs; seam tier LOW for WebFetch)
|
||||
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
|
||||
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps
|
||||
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps
|
||||
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap
|
||||
- https://developers.google.com/search/docs/specialty/international/localized-versions
|
||||
- https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading
|
||||
- https://developers.google.com/search/docs/appearance/structured-data/article
|
||||
- https://developers.google.com/search/docs/appearance/structured-data/breadcrumb
|
||||
- https://developers.google.com/search/docs/appearance/structured-data/faqpage
|
||||
- https://www.sitemaps.org/protocol.html
|
||||
- https://www.indexnow.org/documentation
|
||||
- https://developer.chrome.com/docs/web-platform/deprecating-xslt
|
||||
- https://nuxt.com/docs/4.x/guide/concepts/rendering, /directory-structure/app/pages, /api/utils/define-route-rules, /api/advanced/hooks, /directory-structure/server
|
||||
- https://nuxtseo.com/docs/schema-org/guides/default-schema-org, https://nuxtseo.com/docs/nuxt-seo/getting-started/introduction
|
||||
|
||||
### Tertiary (LOW, needs validation)
|
||||
- Optional Go modules (gen2brain/webp, gen2brain/avif, chromedp): only registry existence checked on proxy.golang.org.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH for what exists (read in repos); MEDIUM for the removal of `@nuxtjs/seo` (design judgement backed by cited module behaviour).
|
||||
- Architecture: MEDIUM. The seams are verified; the new interfaces and answer fields are recommendations needing a shape checkpoint.
|
||||
- Pitfalls: HIGH for repo-derived ones (PostCard, purge duplication, lastmod, staging); MEDIUM for external spec ones (cited).
|
||||
- Plan breakdown: MEDIUM. It depends on Open Question 1 (release cadence) and the UI-SPEC.
|
||||
|
||||
**Research date:** 2026-10-28
|
||||
**Valid until:** 2026-11-17 for the XSL item (Chrome 158); about 30 days for the rest (stable specs, pinned toolchain).
|
||||
1381
.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
Normal file
1381
.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
---
|
||||
phase: "33"
|
||||
slug: "seo-output-audit-and-content"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 33 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution. Source: `33-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | `go test` + testcontainers Postgres (Go repos); `node --test` + `testing/smoke-ssr.mjs` (Nuxt layers); vitest + happy-dom (admin elements) |
|
||||
| **Config file** | per repo (`package.json` scripts, `vite.config.ts`); new `scripts/check-phase33.sh` in summercms.go (Wave 0) |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer |
|
||||
| **Full suite command** | `bash scripts/check-phase33.sh --all` |
|
||||
| **Estimated runtime** | ~300 seconds (full, with Docker and playground builds) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick command in the touched repo
|
||||
- **After every plan wave:** Docker suites of the touched repos + layer playground build + smoke-ssr
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase33.sh --all` must be green
|
||||
- **Max feedback latency:** 120 seconds (quick loop)
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner per task. Decision → test map from research:
|
||||
|
||||
| Decision | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|----------|----------|-----------|-------------------|-------------|--------|
|
||||
| D-30 | index + per-type files, stable numbering after delete, lastmod only on real change | integration | `go test ./... -run 'TestSitemapIndex\|TestStableNumbering\|TestLastmod'` (sm-sitemap-plugin) | ❌ W0 | ⬜ pending |
|
||||
| D-31 | image ≤1000/url, news 2-day window, IndexNow ping on diff | unit + integration | `go test ./... -run 'TestImageSitemap\|TestNewsSitemap\|TestIndexNow'` | ❌ W0 | ⬜ pending |
|
||||
| D-32 | no priority/changefreq/XSL; url items migrated | integration | `go test ./... -run TestMigrateDefinitions` | ❌ W0 | ⬜ pending |
|
||||
| D-95/D-24 | SEO filter excludes noindex/canonicalised/redirected; sitemap works without SEO | integration | `go test ./... -run 'TestFilterExcludes\|TestNoFilter'` | ❌ W0 | ⬜ pending |
|
||||
| D-33 | registry pages resolve; route report upserts | integration | `go test ./... -run TestRegistry` (sm-seo-plugin) | ❌ W0 | ⬜ pending |
|
||||
| D-43..46 | @graph per kind with @id links; invalid custom JSON-LD refused | unit | `go test ./classes/schema/...` | ❌ W0 | ⬜ pending |
|
||||
| D-55..57 | alternates only for translated locales; x-default = default | integration | `go test ./... -run TestAlternates` | ❌ W0 | ⬜ pending |
|
||||
| D-40..42 | `?page=1` → 301; form normalisation; suffix templates | integration | `go test ./... -run TestPagination` | ❌ W0 | ⬜ pending |
|
||||
| D-50/51 | H1→H2 on every write path; command dry run | integration | `go test ./... -run TestHeadingConversion` (Journal) | ❌ W0 | ⬜ pending |
|
||||
| D-70 | build fails for indexable `ssr:false` / `.client.vue`; passes when noindex | build smoke | fixture playground builds (expect exit 1/0) | ❌ W0 | ⬜ pending |
|
||||
| D-20a/D-08/D-103 | middleware 301/410, X-Robots-Tag, verification file at root, cache purge | SSR smoke | `node testing/smoke-ssr.mjs --expect testing/expect-seo.txt` | ❌ W0 | ⬜ pending |
|
||||
| D-43 (layer) | exactly one ld+json script, one canonical | SSR smoke | same expect file | ❌ W0 | ⬜ pending |
|
||||
| D-80/81/52 | one anchor per card, H2 cards, no generic anchor | SSR smoke + unit | journal layer `pnpm test` + smoke | ❌ W0 | ⬜ pending |
|
||||
| D-14 | crawler finds planted issues; each issue has a tool or `fix: code` | integration | `go test ./... -run TestCrawler` | ❌ W0 | ⬜ pending |
|
||||
| D-10 | every new admin operation has an MCP tool | unit | `go test -short ./... -run TestEveryAdminOperationHasTool` | ✅ (extend) | ⬜ pending |
|
||||
| D-82/83 | autolink rules; outbound rel | unit | `pnpm test` (vue-seo-layer / journal layer) | ❌ W0 | ⬜ pending |
|
||||
| D-72 | bm serves `Disallow: /` and noindex | smoke | `curl -sI` in check script | ❌ W0 | ⬜ pending |
|
||||
| D-90/D-106 | framework additions generic and additive | gate | `scripts/check-phase33.sh --marker --additive` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `scripts/check-phase33.sh` — stage engine from check-phase32.sh plus a `layers` stage
|
||||
- [ ] sm-sitemap-plugin: retire `classes/golden_test.go` + `testdata/*.golden`; multi-type, multi-locale fixtures
|
||||
- [ ] vue-seo-layer: `.playground`, `testing/` (smoke-ssr, fixture-api, nuxt-server), fixture resolve answers
|
||||
- [ ] Build-guard fixture playgrounds (indexable ssr:false → fail; noindex → pass; `.client.vue` → fail)
|
||||
- [ ] Crawler httptest site with planted issues
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| grzybyfunkcjonalne.pl serves the new sitemap index in production | Success criterion 1 | Production deploy needs go-ahead | After deploy: fetch `/sitemap.xml`, follow each child, submit index in GSC |
|
||||
| Rich Results test passes for a post, a category and an FAQ page | D-46 | External Google tool | Run the admin "Rich Results" link per page kind |
|
||||
| Crawl → MCP dry-run fix workflow | Success criterion 3 | Agent-driven workflow | Dawid runs a crawl, asks his Claude client to fix issues with `dry_run`, then applies |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
19
.planning/phases/33-seo-output-audit-and-content/COVERAGE.md
Normal file
19
.planning/phases/33-seo-output-audit-and-content/COVERAGE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# API Coverage — IndexNow
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions. Phase 33 integrates one external API: IndexNow (D-31), in `sm-sitemap-plugin` plan 33-04. Google Search Console, the Rich Results Test and the News sitemap are not API integrations (the admin links to the Rich Results Test page; sitemaps are files Google fetches).
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| bulk URL submission (POST JSON {host, key, urlList} to api.indexnow.org) | INTEGRATE | |
|
||||
| key generation (32 hex characters from crypto/rand) and key rotation | INTEGRATE | |
|
||||
| key file served at the site root (/<key>.txt) | INTEGRATE | |
|
||||
| success answers 200 and 202 recorded as the last notification | INTEGRATE | |
|
||||
| 400 bad request recorded in the last status | INTEGRATE | |
|
||||
| 403 key not valid shown in the admin (S2 err_key) | INTEGRATE | |
|
||||
| 422 URLs not matching the host shown in the admin (S2 err_host) | INTEGRATE | |
|
||||
| 429 too many requests: retry with the job's attempts, shown in S2 (err_rate) | INTEGRATE | |
|
||||
| batching at most 10 000 URLs per request | INTEGRATE | |
|
||||
| notification of new, changed and removed URLs (rebuild diff) | INTEGRATE | |
|
||||
| keyLocation parameter (key file elsewhere on the host) | OPT-OUT | the key file is served at the root, which covers every URL of the host; a key file elsewhere would narrow the URLs the key may submit |
|
||||
| single-URL GET submission (?url=&key=) | OPT-OUT | the bulk POST covers single URLs (a urlList of one) |
|
||||
| per-engine endpoints (bing.com/indexnow, yandex.com/indexnow and others) | OPT-OUT | api.indexnow.org shares every submission with all participating engines |
|
||||
1
.planning/phases/34-plugin-marketplace/.gitkeep
Normal file
1
.planning/phases/34-plugin-marketplace/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
461
.planning/phases/34-plugin-marketplace/34-01-PLAN.md
Normal file
461
.planning/phases/34-plugin-marketplace/34-01-PLAN.md
Normal file
@@ -0,0 +1,461 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/kiosk/kiosk.go
|
||||
- modules/kiosk/manifest.go
|
||||
- modules/kiosk/derive.go
|
||||
- modules/kiosk/identity.go
|
||||
- modules/kiosk/check.go
|
||||
- modules/kiosk/version.go
|
||||
- modules/kiosk/registry.go
|
||||
- modules/kiosk/README.md
|
||||
- modules/kiosk/manifest_smoke_test.go
|
||||
- modules/kiosk/testdata/literal/go.mod
|
||||
- modules/kiosk/testdata/literal/plugin.go
|
||||
- modules/kiosk/testdata/literal/README.md
|
||||
- modules/kiosk/testdata/consts/go.mod
|
||||
- modules/kiosk/testdata/consts/plugin.go
|
||||
- modules/kiosk/testdata/consts/ids/ids.go
|
||||
- modules/kiosk/testdata/consts/nested/plugin.go
|
||||
- modules/kiosk/testdata/consts/nested/README.md
|
||||
- modules/kiosk/testdata/consts/README.md
|
||||
- modules/kiosk/testdata/other/go.mod
|
||||
- modules/kiosk/testdata/other/plugin.go
|
||||
- modules/kiosk/testdata/other/README.md
|
||||
- modules/kiosk/testdata/framework/go.mod
|
||||
- modules/party/info.go
|
||||
- modules/party/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/new.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/plugindirs.go
|
||||
- internal/build/plugininfo_smoke_test.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- internal/build/testdata/new/plugins.gen.go.golden
|
||||
- internal/build/testdata/new-no-plugin/plugins.gen.go.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/plugins.gen.go
|
||||
- cmd/summer/main.go
|
||||
- README.md
|
||||
- docs/plugins/marketplace.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-4, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-16]
|
||||
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "`summer plugin:manifest [dir]` writes `<dir>/plugin.yaml` whose every field is derived from the plugin's own sources: id and requires from plugin.go, module from go.mod, package from the directory's import path, framework and plugin minimums from go.mod require lines, name and description from the README's H1 and one-sentence summary; plugin.go and party.Plugin are unchanged (D-01, D-02, D-05)"
|
||||
- "The ID reader follows string constants statically: a literal, a package-level const in the same package, a const in another package of the same module, and a const in another module resolved through `go list -m -json`; no plugin code is compiled or run, and any other expression is an error naming ID() or Requires() (D-02 as extended by the user on 2026-10-29)"
|
||||
- "`plugin:add` registers a plugin whose ID() returns a constant, because pluginIDFromGo now calls the same resolver (fix for research F1; literal-ID plugins register exactly as before)"
|
||||
- "`--name` and `--description` persist in an `overrides:` block, survive regeneration, and `--name=\"\"` clears the override; a README whose H1 equals the plugin ID and no name override is an error that names `--name` (D-03, research F7)"
|
||||
- "`summer plugin:manifest --check [dir]` exits non-zero and prints what it expected when the committed plugin.yaml is missing or stale, and `kiosk.CheckT(t, dir)` fails a plugin repo's `go test` the same way (D-04)"
|
||||
- "Version constraints are Go-style minimums: `framework: vX.Y.Z` and `requires: {id: vX.Y.Z}`; a plugin requirement at v0.0.0 or a missing framework require is an error, a pseudo-version minimum is accepted with a warning, and a required ID whose module is not in go.mod has the empty minimum (D-05, research F8)"
|
||||
- "`summer build` bakes `PluginInfo` (ID, module, package, name, description, version, source per plugin) and `FrameworkVersion` into plugins.gen.go, and the generated main publishes them with party.Describe before party.Activate; a plugin without plugin.yaml has only its ID and version, and a metadata failure warns and never fails the build (D-06)"
|
||||
- "Versions are D-07's: a module-cache plugin reports its go.mod/build-info version, a plugin directory that is its own git repository reports `git describe --tags --match 'v[0-9]*'` without the dirty marker, an in-app plugin reports an empty version, and FrameworkVersion is the effective framework version (the newest tag reachable in a locally replaced framework checkout, otherwise the go.mod require) (D-07, research F3)"
|
||||
- "The registry v1 wire types (kiosk.RegistryPlugin, kiosk.RegistryPluginDetail, kiosk.RegistryVersion, envelopes and error) match the shape confirmed at Task 1, so the summercms.io server (34-03), the CLI (34-04) and cabana (34-05) encode and decode the same JSON (D-16)"
|
||||
- "Every new identifier is documented in modules/kiosk/README.md, modules/party/README.md and docs/plugins/marketplace.md, the root README table lists kiosk, and the docs checker passes"
|
||||
artifacts:
|
||||
- path: "modules/kiosk/manifest.go"
|
||||
provides: "plugin.yaml types, Parse, Marshal, Validate"
|
||||
contains: "SchemaVersion"
|
||||
- path: "modules/kiosk/identity.go"
|
||||
provides: "const-resolving ID()/Requires() reader"
|
||||
contains: "func ReadIdentity("
|
||||
- path: "modules/kiosk/check.go"
|
||||
provides: "Write, Check, StaleError, CheckT"
|
||||
contains: "func CheckT("
|
||||
- path: "modules/kiosk/registry.go"
|
||||
provides: "registry v1 wire types"
|
||||
contains: "RegistryAPIPath"
|
||||
- path: "modules/kiosk/version.go"
|
||||
provides: "strict vX.Y.Z rules"
|
||||
contains: "func CompareVersions("
|
||||
- path: "modules/party/info.go"
|
||||
provides: "party.Info, party.Installed, party.Describe"
|
||||
contains: "func Describe("
|
||||
- path: "internal/build/plugindirs.go"
|
||||
provides: "go list plugin dir resolver, git describe, EffectiveFramework"
|
||||
contains: "func EffectiveFramework("
|
||||
- path: "modules/kiosk/manifest_smoke_test.go"
|
||||
provides: "TestManifestSmoke (tracer)"
|
||||
contains: "func TestManifestSmoke("
|
||||
- path: "internal/build/plugininfo_smoke_test.go"
|
||||
provides: "TestPluginInfoSmoke"
|
||||
contains: "func TestPluginInfoSmoke("
|
||||
- path: "docs/plugins/marketplace.md"
|
||||
provides: "plugin.yaml, plugin:manifest, registry API reference"
|
||||
contains: "plugin:manifest"
|
||||
key_links:
|
||||
- from: "modules/kiosk/identity.go"
|
||||
to: "internal/build/scaffold.go"
|
||||
via: "pluginIDFromGo delegates to kiosk.ReadIdentity, so plugin:add and plugin:manifest read IDs the same way"
|
||||
pattern: "kiosk\\.ReadIdentity"
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/party/info.go"
|
||||
via: "generated main calls party.Describe(app, PluginInfo, FrameworkVersion) before party.Activate"
|
||||
pattern: "party\\.Describe\\("
|
||||
- from: "cmd/summer/main.go"
|
||||
to: "modules/kiosk/check.go"
|
||||
via: "plugin:manifest runs kiosk.Write, kiosk.Check or kiosk.Generate"
|
||||
pattern: "kiosk\\.(Write|Check|Generate)\\("
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
People browse the public Golem15 plugins at summercms.io/marketplace and add one to an application with `summer plugin:install <id>`; the same command installs private plugins from a git URL; the admin SPA gains a read-only Plugins screen. This plan lays the shared foundation every other plan of Phase 34 builds on: the plugin.yaml format and its generator, the registry v1 contract in Go, and the build-time metadata the admin's Installed tab reads.
|
||||
|
||||
Spec-less probe fallback skipped: Phase 34 has no requirement IDs and no SPEC.md, so this plan set derives must_haves from ROADMAP success criteria SC-1..SC-5 and CONTEXT decisions D-01..D-19 (a visible choice, not an omission).
|
||||
|
||||
<objective>
|
||||
Create the public framework module `kiosk` (name confirmed at Task 1) and wire it through the CLI and the build:
|
||||
- the plugin.yaml format, generated by `summer plugin:manifest [dir] [--check] [--pretend] [--name] [--description]` from the plugin's own sources (D-01..D-05);
|
||||
- a static, const-following ID()/Requires() reader shared with `plugin:add` (user decision 1 of 2026-10-29);
|
||||
- `kiosk.CheckT`, the helper each plugin repo's `go test` calls (user decision 2);
|
||||
- the registry v1 wire types and strict version rules (D-16, confirmed at Task 1);
|
||||
- `summer build` baking `PluginInfo` and `FrameworkVersion` into plugins.gen.go and publishing them through `party.Describe` (D-06, D-07).
|
||||
|
||||
Purpose: plans 34-02 (plugin repo rollout), 34-03 (registry server), 34-04 (install CLI) and 34-05 (admin screen) all consume these types; defining them once keeps server, CLI and admin on one contract.
|
||||
|
||||
Output: commits in this repository only (code and its docs together, per commit group below). Nothing is pushed or tagged.
|
||||
|
||||
**Planner decisions recorded here (confirmed or adjusted at Task 1):**
|
||||
- **P34-01 plugin.yaml format** (D-04 costly): one file per plugin package directory (research F6). Header comment line `# Generated by summer plugin:manifest. Do not edit; rerun the command.` then, in this order: `schema: 1`, `id`, `module` (go.mod module path, what `go get` takes), `package` (import path summer.yaml lists; differs from module for a nested plugin such as `…/sm-pgstripe-plugin/subscriptions`), `name`, `description`, `framework` (go.mod require of git.golem15.com/golem15/summercms), `requires` (map of required plugin ID to its minimum, sorted by ID, omitted when empty; empty string when the required plugin ships in the same module or is not a go.mod require), `overrides` (`name`/`description` set with `--name`/`--description`, omitted when unused). A newer `schema` than the reader knows is an error that says to update summer.
|
||||
- **P34-02 registry API v1** (D-16 one-way): base path `/api/registry/v1`; `GET /plugins?q=&category=` → `{"data":[RegistryPlugin…],"meta":{"total":N}}`; `GET /plugins/{id}` → `{"data":RegistryPluginDetail}`; `GET /plugins/{id}/versions` → `{"data":[RegistryVersion…]}` newest first; errors `{"error":{"code":"…","message":"…"}}` (404 `not_found`, 400 `invalid_query`); `POST /ingest` (authenticated, body `{"repository":"<https url>"}`) → 202 `{"data":{"repository":"…","queued":true}}`. RegistryPlugin fields: `id`, `name`, `description`, `module`, `package`, `repository`, `category`, `featured`, `icon_url`, `latest_version`, `latest_framework`, `updated_at` (RFC 3339). RegistryPluginDetail adds `readme_markdown`, `readme_html`, `screenshots` (`[{url, caption}]`), `requires` (of the latest version). RegistryVersion: `version`, `framework`, `requires`, `module`, `package`, `commit`, `tagged_at`. Rules: always an envelope; clients ignore unknown fields; only additive changes within v1; GET-only, no credentials, open CORS, `Cache-Control: public, max-age=300` plus ETag.
|
||||
- **P34-03 names**: module `kiosk`; `party.Info{ID, Module, Package, Name, Description, Version, Source}` with Source `module` | `submodule` | `local`; `party.Installed{Framework string; Plugins []Info}`; `party.Describe(app, plugins, framework)`; generated `PluginInfo` and `FrameworkVersion`; config key `plugins.registry_url` (default `https://summercms.io`, empty disables); permission `backend.view_plugins`; admin route `/system/plugins`; marketplace URLs `/marketplace` and `/marketplace/<vendor>/<name>`; token scope `registry`.
|
||||
- **P34-04 release number**: the framework release that carries this phase is v0.6.0 (next minor after v0.5.3: a new module and three new commands); 34-02 writes it as each plugin's framework minimum (user decision 2), and plugin tags wait for that tag (user-gated).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/34-plugin-marketplace/34-CONTEXT.md
|
||||
@.planning/phases/34-plugin-marketplace/34-RESEARCH.md
|
||||
@.planning/phases/34-plugin-marketplace/34-PATTERNS.md
|
||||
@.planning/notes/2026-10-29-plugin-marketplace.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
internal/build/scaffold.go: pluginIDFromGo(path string) (string, error) accepts only a string literal returned by ID() (lines ~360-392); AddPlugin(ctx, startDir, pluginDir) (line 144) calls it; editAppModule writes -require=mod@v0.0.0 and a local replace; runGo(ctx, dir, args...) and applyGoWork(cmd, dir) (lines ~621-640); parseGoMod is a line scanner — do not extend it, use `go mod edit -json`.
|
||||
internal/build/build.go: App(ctx, appDir, out) → generate(appDir, m) → generateMain(m), generatePluginsGen(m), formatSource, writeIfChanged; generated main calls party.Activate(app, PluginIDs) (line ~125). internal/build/new.go:453 calls generate(target, m); goldens in internal/build/testdata/new*/ (rewrite with `go test ./internal/build -run TestNew -update`).
|
||||
internal/build/manifest.go: Manifest{Module, Binary, Plugins []Plugin{ID, Module}, Tags}, LoadManifest, SaveManifest, ValidatePluginID, validateModulePath (rejects a leading "-").
|
||||
modules/party/registry.go: Plugin interface {ID; Requires; Register; Boot}; Activated []Plugin published with app.Publish; Activate(app, ids).
|
||||
modules/backpack: app.Publish[T](v) error, app.Lookup[T]() (T, bool) (generic methods, Go 1.27).
|
||||
modules/bonfire: Command{Name, Description, Long, Examples, Args, Flags, Run}; Input.Flag(name) (string, bool) returns ("", true) for an explicitly empty --name=""; FlagBool(in, name) for Bare switches; Output has Success, Warning, Info, Println.
|
||||
goccy/go-yaml (already required) for YAML; github.com/yuin/goldmark (already required) for the README AST; no new dependency.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Each task ends with the commit listed below, staged by explicit path (never `-A` or `.`). Other sessions may hold uncommitted `.planning/STATE.md`, `.planning/state.json`, `.planning/ROADMAP.md`, `.planning/G15OFFICE.md` or cabana edits; leave them alone. Before editing `internal/build` or `cmd/summer`, run `git status --short internal/build cmd/summer modules/party` and stop if another session has uncommitted edits there.
|
||||
- Resume check: `git log --format=%s | grep -Fx '<subject>'`; present subjects mean the task is done: re-run its `<verify>` and continue with the next one.
|
||||
|
||||
| Task | Commit subject | Resume point |
|
||||
|------|----------------|--------------|
|
||||
| 1 | none (answer recorded in the SUMMARY) | answer recorded |
|
||||
| 2 | `feat(kiosk): plugin.yaml manifest and summer plugin:manifest` | TestManifestSmoke passes |
|
||||
| 3 | `feat(kiosk): resolve constant plugin IDs, overrides and the CheckT helper` | TestManifestSmoke/consts passes |
|
||||
| 4 | `feat(kiosk): registry v1 wire types and version rules` | TestManifestSmoke/registry passes |
|
||||
| 5 | `feat(build): bake plugin metadata and the framework version into plugins.gen.go` | TestPluginInfoSmoke passes |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Confirm the plugin.yaml format, the /api/registry/v1 shape, the names and the release number before any code depends on them</name>
|
||||
<reversibility rating="one-way">Released summer binaries and cabana call /api/registry/v1 and published plugin tags carry plugin.yaml forever (tags are never moved, research Pitfall 7); a later change needs a v2 path and an ingester that reads both formats.</reversibility>
|
||||
<decision>Adopt P34-01 (plugin.yaml format), P34-02 (registry API v1 shape), P34-03 (names) and P34-04 (v0.6.0 as the framework minimum) as written in the objective, or adjust them.</decision>
|
||||
<context>
|
||||
D-04 rates the plugin.yaml format costly (every public repo, the ingester and git-URL installs read it) and D-16 rates the API one-way. Research marked the field names `schema`, `package`, `overrides` (A2), the JSON shape (A3), the module name `kiosk` (A10), `party.Info`/`party.Describe` (A8) and `backend.view_plugins` with `/system/plugins` (A6) as assumptions. 34-03 (server) and 34-04 (client) run in parallel after this plan and both encode the shape fixed here through kiosk's wire types, so this is the only point before both. The release number decides the `framework:` minimum 34-02 writes into 19 plugin go.mod files.
|
||||
</context>
|
||||
<options>
|
||||
<option id="as-planned">
|
||||
<name>Adopt P34-01..P34-04 as written</name>
|
||||
<pros>Matches the research recommendation; a schema field and an envelope leave room for additive change; one release (v0.6.0) carries the helper every plugin test imports</pros>
|
||||
<cons>Freezes field names now; every plugin moves its framework minimum to v0.6.0</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Adopt with the changes Jakub states (field names, JSON fields, module name, release number)</name>
|
||||
<pros>Names fixed before they become a contract</pros>
|
||||
<cons>The executor records the adjusted names in the SUMMARY; plans 34-02..34-06 use the SUMMARY's names wherever they differ from their text</cons>
|
||||
</option>
|
||||
<option id="no-schema-field">
|
||||
<name>Adopt without the `schema:` field and the `overrides:` block (overrides stored as plain name/description plus a marker comment)</name>
|
||||
<pros>Smaller file</pros>
|
||||
<cons>No versioning hook for a later format change; regeneration must parse comments to keep overrides</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "as-planned", "adjust: <changes>" or "no-schema-field"</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: End-to-end "a plugin author runs summer plugin:manifest and gets a committed, checkable plugin.yaml" — literal-ID plugin only</name>
|
||||
<files>modules/kiosk/kiosk.go, modules/kiosk/manifest.go, modules/kiosk/derive.go, modules/kiosk/check.go, modules/kiosk/manifest_smoke_test.go, modules/kiosk/testdata/literal/go.mod, modules/kiosk/testdata/literal/plugin.go, modules/kiosk/testdata/literal/README.md, modules/kiosk/README.md, cmd/summer/main.go, README.md, docs/plugins/marketplace.md, docs/console/scaffolding.md</files>
|
||||
<read_first>
|
||||
- internal/build/scaffold.go (pluginIDFromGo ~360-392, runGo/applyGoWork ~621-640: exec style to copy)
|
||||
- internal/build/manifest.go (goccy YAML use, ValidatePluginID, validateModulePath)
|
||||
- cmd/summer/main.go (addPluginCommand ~381-415, toolCommands ~31)
|
||||
- cmd/summer/commands_documented_test.go (what TestCommandsDocumented demands of a command)
|
||||
- modules/fetchguard/README.md (module README structure to copy)
|
||||
- modules/breakwater/breakwater.go (goldmark usage)
|
||||
- docs/plugins/testing.md (front matter: title, description, section, order) and docs/console/scaffolding.md
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (Pattern 1, F6, F7)
|
||||
</read_first>
|
||||
<action>
|
||||
Implement P34-01 as answered at Task 1, per D-01, D-02, D-04 and D-05, for a plugin whose ID() and Requires() return literals. Create the package `modules/kiosk` (package doc: plugin manifests and the plugin registry contract; it never names a consuming application):
|
||||
- `kiosk.go`: constants `FileName = "plugin.yaml"`, `SchemaVersion = 1`, `FrameworkModule = "git.golem15.com/golem15/summercms"`, `Header` (the generated-file comment line).
|
||||
- `manifest.go`: `Manifest` with yaml tags in P34-01 order, `Overrides{Name, Description}`, `Marshal(Manifest) ([]byte, error)` (Header plus goccy output; requires emitted as a goccy MapSlice sorted by ID so the bytes are stable), `Parse([]byte) (Manifest, error)`.
|
||||
- `derive.go`: `DeriveOptions{Name, Description *string}` (nil keeps the persisted override, a pointer to "" clears it), `Derive(ctx, dir string, opts DeriveOptions) (Manifest, []string, error)` returning warnings. It finds the module root by walking up from dir to the nearest go.mod, reads it with `go mod edit -json` (exec, cmd.Dir = module root, args after the subcommand, never a shell), sets module, package (module path plus the dir's slash path relative to the root), framework (the FrameworkModule require; missing or v0.0.0 is an error), reads `<dir>/plugin.go` for ID()/Requires() string literals (non-literal returns are the D-02 error naming the function; Task 3 widens this), reads `<dir>/README.md` with goldmark: name is the first H1's text, description the first paragraph after it (plain text, whitespace collapsed); a missing README or H1 is an error. A README H1 equal to the ID with no name override is an error whose message names `--name` (research F7). Overrides from an existing plugin.yaml are kept unless opts changes them.
|
||||
- `check.go`: `Generate(ctx, dir, opts) ([]byte, []string, error)`, `Write(ctx, dir, opts) (path string, warnings []string, err error)`, `Check(ctx, dir) error` returning `*StaleError{Path string; Missing bool; Want, Got []byte}` whose Error() names the path and the first differing line.
|
||||
- Fixture `testdata/literal` (module `example.test/sm-acme-plugin`, requires FrameworkModule at v0.5.3, plugin.go returning "acme.blog" and nil, README `# Blog` plus one sentence).
|
||||
- `manifest_smoke_test.go` with `TestManifestSmoke` subtest `literal`: copy the fixture to t.TempDir(), Write, assert every field and the header, Check returns nil, edit the README sentence and Check returns a *StaleError, delete plugin.yaml and Check reports Missing.
|
||||
- `cmd/summer/main.go`: `pluginManifestCommand()` registered in toolCommands after `addPluginCommand()`: Name `plugin:manifest`, Description, Long (what is derived from where, that plugin.yaml is committed and never hand-edited, --check exit status), Examples (plain, --check, --pretend, --name), optional Arg `dir` (default "."), Flags `check` and `pretend` (Bare, read with bonfire.FlagBool), `name`, `description` (read with in.Flag so an explicit empty value clears). --check rejects --name/--description; --pretend prints Generate's bytes and writes nothing; otherwise Write and print `Wrote <path>` with out.Success; warnings go to out.Warning.
|
||||
- Docs: `modules/kiosk/README.md` in the standard structure (H1, one-sentence summary, import line, `**Name:**` mnemonic for a beach kiosk where every plugin on the shelf carries its label, Overview, Features, Usage, API reference, CLI commands, Dependencies, Testing); a row in the root `README.md` module table reusing the summary; new page `docs/plugins/marketplace.md` (section plugins, an order after testing) with "The plugin.yaml manifest" and "Generating and checking it" sections; a `plugin:manifest` row in `docs/console/scaffolding.md`. Every identifier must exist (docs checker).
|
||||
Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/kiosk -run 'TestManifestSmoke' -count=1 -v 2>&1 | grep -E '^(--- |=== RUN|FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestManifestSmoke/literal" line</fails_when>
|
||||
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "SchemaVersion\|FrameworkModule" modules/kiosk/kiosk.go` prints at least two lines
|
||||
- `go doc ./modules/kiosk Manifest` lists Schema, ID, Module, Package, Name, Description, Framework, Requires and Overrides
|
||||
- `grep -n '"plugin:manifest"' cmd/summer/main.go` prints one line
|
||||
- `cp -r modules/kiosk/testdata/literal "$TMP/acme" && go run ./cmd/summer plugin:manifest "$TMP/acme" && go run ./cmd/summer plugin:manifest --check "$TMP/acme"` exits 0 and `$TMP/acme/plugin.yaml` starts with the header line and contains `id: acme.blog` (with TMP set to a scratch directory)
|
||||
- `grep -n "kiosk" README.md` prints the module table row; `grep -n "plugin:manifest" docs/console/scaffolding.md docs/plugins/marketplace.md` prints at least two lines
|
||||
</acceptance_criteria>
|
||||
<done>A plugin author can generate, preview and check plugin.yaml for a literal-ID plugin from the CLI, and the format is documented.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Every public plugin layout derives a valid plugin.yaml: constant IDs, nested plugins, overrides, version checks and the CheckT helper</name>
|
||||
<files>modules/kiosk/identity.go, modules/kiosk/derive.go, modules/kiosk/check.go, modules/kiosk/manifest.go, modules/kiosk/manifest_smoke_test.go, modules/kiosk/testdata/consts/go.mod, modules/kiosk/testdata/consts/plugin.go, modules/kiosk/testdata/consts/ids/ids.go, modules/kiosk/testdata/consts/nested/plugin.go, modules/kiosk/testdata/consts/nested/README.md, modules/kiosk/testdata/consts/README.md, modules/kiosk/testdata/other/go.mod, modules/kiosk/testdata/other/plugin.go, modules/kiosk/testdata/other/README.md, modules/kiosk/testdata/framework/go.mod, internal/build/scaffold.go, internal/build/build_test.go, modules/kiosk/README.md, docs/plugins/marketplace.md</files>
|
||||
<read_first>
|
||||
- modules/kiosk/derive.go and modules/kiosk/check.go (Task 2 output)
|
||||
- internal/build/scaffold.go (pluginIDFromGo and its callers: AddPlugin, make:* paths)
|
||||
- internal/build/build_test.go (TestMakeAndAddPluginOnHelloCopy: the temp-app pattern)
|
||||
- ../sm-mfa-plugin/plugin.go (same-file const), ../sm-paymentgateway-plugin/plugin.go and ../sm-paymentgateway-plugin/payments/service.go (cross-package const), ../sm-pgstripe-plugin/subscriptions/plugin.go (Requires with a same-module and an other-module const) — read only
|
||||
- ../sm-chatbots-plugin/go.mod (a plugin requirement at v0.0.0, research F8) — read only
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (F1, F6, F8, Pattern 2)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- consts fixture: ID() returning a same-package const resolves; Requires() returning a slice of a cross-package const (same module, `ids.Translate`) and a const from the `other` fixture module (resolved through `go list -m -json` via a local replace) resolves, and the other-module requirement's minimum is its go.mod require version
|
||||
- nested fixture `consts/nested`: package is `<module>/nested`, module is the root module, and a Requires() entry naming the root plugin of the same module gets the empty minimum
|
||||
- ID() returning a function call, a var or a concatenation with a non-const fails with an error containing "ID()"; the same for Requires() with "Requires()"
|
||||
- a plugin requirement at v0.0.0 fails with "not a released version"; a pseudo-version minimum passes and returns a warning
|
||||
- --name override persists across a regeneration without flags; an explicit empty name clears it and then an H1 equal to the ID fails with a message containing "--name"
|
||||
- CheckT on a stale fixture calls Fatalf once with the path; on a fresh one it does not
|
||||
- Validate rejects a bad ID, a module with a leading "-", a non-vX.Y.Z framework, a schema greater than SchemaVersion
|
||||
- plugin:add on a copy of examples/hello registers a fixture plugin whose ID() returns a const (regression for research F1); a literal-ID plugin registers exactly as before
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-02 as extended by user decision 1 (2026-10-29): add `identity.go` with `Identity{ID string; Requires []string}` and `ReadIdentity(ctx, dir string) (Identity, error)`. It parses the non-test .go files of dir with go/parser (never builds or runs them), finds the methods ID() and Requires() on any receiver, and resolves each returned expression statically: a string literal; an identifier naming a package-level const in any non-test file of the same directory (recursively, guarded against cycles); `pkg.Name` where the file's import (named imports honoured) is in the same module (directory = module root plus the import path suffix) or in another module (directory from `go list -m -json <module>` run in the module root, `.Dir`; with applyGoWork-style GOWORK handling left to Go's defaults). Requires() accepts nil, an empty slice, or a []string composite literal whose elements resolve the same way. Anything else is an error of the form `<file>: ID() must return a string literal or constant` (or `Requires()`).
|
||||
Switch Derive to ReadIdentity. Map each required ID to its providing module: the plugin's own module (any plugin.go directory under the module root) gives the empty minimum; otherwise each non-framework go.mod require is resolved with `go list -m -json` and its root identity read; a found ID takes that require's version; an ID found nowhere gets the empty minimum (soft dependency). A plugin requirement at v0.0.0 is an error ("not a released version", research F8); a pseudo-version is accepted with a warning. Add `Validate(Manifest) error` (ID via the same rule as internal/build ValidatePluginID, re-implemented in kiosk since internal/build cannot be imported; module and package paths reject a leading "-", spaces and backslashes; framework and non-empty minimums must be strict vX.Y.Z or a pseudo-version; schema between 1 and SchemaVersion) and call it from Parse and Generate.
|
||||
Overrides per D-03: `--name`/`--description` set `Overrides`; the top-level name/description are the effective values; regenerating without flags keeps Overrides; an explicit empty value removes that override.
|
||||
Add `TB interface{ Helper(); Fatalf(format string, args ...any) }` and `CheckT(t TB, dir string)`, which calls Check and fails with the StaleError text plus the command to rerun (`summer plugin:manifest <dir>`); kiosk must not import the testing package.
|
||||
Replace the body of `internal/build/scaffold.go` pluginIDFromGo with a call to kiosk.ReadIdentity on the plugin.go's directory (keep the function name and its error prefix "build:"); every caller keeps working.
|
||||
Fixtures: `testdata/consts` (module `example.test/sm-consts-plugin`, ID via `const PluginID`, Requires `ids.Translate` and `other.PluginID`, go.mod require and replace of `example.test/sm-other-plugin => ../other`, framework require v0.5.3), `testdata/consts/nested`, `testdata/other` (module `example.test/sm-other-plugin`, require v0.2.0 in consts' go.mod), and `testdata/framework` (a stub go.mod declaring the framework module path) that every fixture go.mod replaces the framework with, so `go list -m -json` runs offline; tests copy the testdata tree into t.TempDir() keeping the relative layout. Write the behavior subtests in TestManifestSmoke first (subtest names `consts`, `nested`, `errors`, `overrides`, `checkt`, `validate`) plus `TestAddPluginConstID` in internal/build/build_test.go, then implement.
|
||||
Document ReadIdentity, Validate, Overrides and CheckT in modules/kiosk/README.md and add a "Testing the manifest in your plugin" section with the one-line `kiosk.CheckT(t, ".")` test to docs/plugins/marketplace.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/kiosk ./internal/build -run 'TestManifestSmoke|TestAddPluginConstID|TestMakeAndAddPluginOnHelloCopy' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestManifestSmoke/consts" or "--- PASS: TestAddPluginConstID" line</fails_when>
|
||||
<automated>go run ./cmd/summer plugin:manifest --pretend --name=MFA ../sm-mfa-plugin 2>&1 | grep -E '^(id|module|framework):'</automated>
|
||||
<fails_when>non-zero exit, or fewer than three lines, or no "id: golem15.mfa" line</fails_when>
|
||||
<automated>go run ./cmd/summer plugin:manifest --pretend --name="Stripe subscriptions" ../sm-pgstripe-plugin/subscriptions 2>&1 | grep -E '^(id|package):|golem15\.'</automated>
|
||||
<fails_when>non-zero exit, or no "id: golem15.pgstripesubscriptions" line, or no "package: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func ReadIdentity(ctx context.Context, dir string) (Identity, error)" modules/kiosk/identity.go` prints one line
|
||||
- `grep -n "kiosk.ReadIdentity" internal/build/scaffold.go` prints at least one line
|
||||
- `grep -n "func CheckT(t TB, dir string)" modules/kiosk/check.go` prints one line; `go list -deps ./modules/kiosk | grep -x testing` prints nothing
|
||||
- `go run ./cmd/summer plugin:manifest --pretend ../sm-paymentgateway-plugin --name="Payment gateway"` prints `id: golem15.paymentgateway` (cross-package const)
|
||||
- `go run ./cmd/summer plugin:manifest --pretend ../sm-chatbots-plugin` exits non-zero with "not a released version" (research F8), unless the sibling has already been fixed
|
||||
- No file under ../sm-*-plugin is created or changed by this task (`git -C ../sm-mfa-plugin status --short` shows no plugin.yaml)
|
||||
</acceptance_criteria>
|
||||
<done>plugin:manifest derives a valid manifest for literal, constant, cross-package and nested plugins without running their code, plugin:add works for constant-ID plugins, and plugin repos have a one-line test helper.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: The registry v1 contract in Go: wire types, envelopes and strict version rules</name>
|
||||
<files>modules/kiosk/registry.go, modules/kiosk/version.go, modules/kiosk/manifest_smoke_test.go, modules/kiosk/README.md, docs/plugins/marketplace.md</files>
|
||||
<read_first>
|
||||
- the Task 1 answer (P34-02 as confirmed or adjusted)
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md ("Proposed registry API shape", Anti-Patterns on git describe and semver)
|
||||
- modules/kiosk/manifest.go (Requires type to reuse)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- RegistryPlugin, RegistryPluginDetail and RegistryVersion marshal to exactly the P34-02 field names (golden JSON in the test) and unmarshal JSON carrying unknown extra fields without error
|
||||
- ValidVersion accepts v0.6.0 and v10.2.33, rejects 0.6.0, v0.6, v0.6.0-rc1, v0.6.0+meta and a leading "-"
|
||||
- CompareVersions orders v0.10.0 after v0.9.9 and returns 0 for equal versions
|
||||
- BaseVersion returns v0.3.1 for "v0.3.1-4-gabc1234" and for "v0.3.1", and "" for "abc1234" or ""
|
||||
- Satisfies(have, min) is true when min is "", true when have >= min, false when have < min, and treats a pseudo-version minimum vX.Y.(Z+1)-0.<ts>-<hash> as satisfied by vX.Y.(Z+1) and later
|
||||
- NewestCompatible(versions, framework) returns the newest RegistryVersion whose framework minimum Satisfies the given framework, and false when none does
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-16 and the Task 1 answer: add `registry.go` with `RegistryAPIPath = "/api/registry/v1"`, `RegistryPlugin`, `RegistryPluginDetail` (embeds RegistryPlugin plus ReadmeMarkdown, ReadmeHTML, Screenshots []RegistryScreenshot, Requires), `RegistryScreenshot{URL, Caption}`, `RegistryVersion`, `ListResponse{Data []RegistryPlugin; Meta ListMeta{Total int}}`, `DetailResponse`, `VersionsResponse`, `ErrorResponse{Error RegistryError{Code, Message}}`, `IngestRequest{Repository}`, `IngestResponse`, with JSON tags exactly as confirmed; time fields are time.Time encoded RFC 3339. These types are the contract: 34-03 encodes responses with them and 34-04/34-05 decode with them, so no other copy of the shape may exist.
|
||||
Add `version.go` with `ValidVersion(v string) bool` (strict `^v\d+\.\d+\.\d+$`), `CompareVersions(a, b string) int` (numeric per component; only defined for valid versions), `BaseVersion(describe string) string` (the vX.Y.Z prefix of a git describe string or ""), `Satisfies(have, min string) bool`, `NewestCompatible(versions []RegistryVersion, framework string) (RegistryVersion, bool)`. Never feed a describe string to the comparer (research anti-pattern). Write the behavior subtests (`registry`, `versions`) in TestManifestSmoke first, then implement. Document the types and functions in modules/kiosk/README.md and add a "Registry API v1" reference section (paths, envelopes, fields, compatibility rules) to docs/plugins/marketplace.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/kiosk -run 'TestManifestSmoke/(registry|versions)' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestManifestSmoke/registry" line</fails_when>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'RegistryAPIPath = "/api/registry/v1"' modules/kiosk/registry.go` prints one line
|
||||
- `grep -n 'json:"latest_version"\|json:"readme_html"\|json:"tagged_at"' modules/kiosk/registry.go` prints three lines (or the names confirmed at Task 1)
|
||||
- `grep -n "func NewestCompatible(" modules/kiosk/version.go` prints one line
|
||||
- docs/plugins/marketplace.md contains a "Registry API v1" heading and names `/api/registry/v1/plugins/{id}/versions`
|
||||
</acceptance_criteria>
|
||||
<done>The registry contract exists once, in Go, with version rules every consumer shares.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: summer build bakes plugin metadata and the effective framework version, and the app publishes them</name>
|
||||
<files>internal/build/plugindirs.go, internal/build/build.go, internal/build/new.go, internal/build/plugininfo_smoke_test.go, internal/build/testdata/new/main.go.golden, internal/build/testdata/new/plugins.gen.go.golden, internal/build/testdata/new-no-plugin/plugins.gen.go.golden, modules/party/info.go, modules/party/README.md, examples/hello/main.go, examples/hello/plugins.gen.go, docs/architecture/go-modules-and-workspaces.md, docs/plugins/marketplace.md</files>
|
||||
<read_first>
|
||||
- internal/build/build.go (whole file)
|
||||
- internal/build/new.go (~430-460, the generate call) and internal/build/new_golden_test.go (the -update flag)
|
||||
- internal/build/leaf.go (localModuleDir/defaultPluginDir: module-cache plugins are skipped there, no change needed)
|
||||
- internal/dev/watch.go (~74, build.App reuse)
|
||||
- modules/party/registry.go and modules/party/README.md
|
||||
- modules/bonfire/version.go (BuildVersion: what runtime already reports)
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (F3, F5, Pattern 3, Pitfall 6)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- generatePluginsGen with fixed metadata emits `var PluginInfo = []party.Info{...}` in summer.yaml order with every string strconv-quoted and `const FrameworkVersion = "<v>"`, byte-stable across two calls
|
||||
- generateMain emits `party.Describe(app, PluginInfo, FrameworkVersion)` with the usual failure branch, before `party.Activate(app, PluginIDs)`
|
||||
- resolvePluginDirs on a temp app with one in-app plugin (no own repo) and one plugin directory that is its own git repo with tag v0.1.0 plus one commit returns Source local with empty version and Source submodule with a version starting "v0.1.0-1-g"
|
||||
- a plugin directory with uncommitted changes still reports the describe string without a dirty suffix
|
||||
- EffectiveFramework returns the newest reachable tag of a locally replaced framework checkout, and the go.mod require version when the framework is not replaced; when git fails it returns the require version and a warning
|
||||
- build.App on an app whose metadata resolution fails (go list error) still generates ID-only PluginInfo, prints one warning and builds
|
||||
- party.Describe publishes party.Installed so app.Lookup[party.Installed]() returns the plugins and framework; calling it with a nil app is an error
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-06 and D-07. In `modules/party/info.go` (additive, no change to Plugin or Activate): `Info{ID, Module, Package, Name, Description, Version, Source string}` with Source constants `SourceModule`, `SourceSubmodule`, `SourceLocal`; `Installed{Framework string; Plugins []Info}`; `Describe(app *backpack.App, plugins []Info, framework string) error` publishing Installed. Document them in modules/party/README.md.
|
||||
Add `internal/build/plugindirs.go`: `pluginMeta` resolution through one `go list -e -json=ImportPath,Dir,Module,Error <every summer.yaml module>` run in the app dir with applyGoWork (research F5); per plugin read `<Dir>/plugin.yaml` with kiosk.Parse when present (name, description, module, package; a parse error warns and is ignored); version: module-cache plugin → Module.Version; a local dir whose `git -C <dir> rev-parse --show-toplevel` equals the dir → `git describe --tags --match v[0-9]*` (no dirty flag, research Pitfall 6) and Source submodule; any other local dir → Source local and empty version. Never bake a filesystem path. Add exported `EffectiveFramework(ctx, appDir string) (string, error)` per research F3 (`go mod edit -json` of go.mod, `go work edit -json` of a go.work found by findGoWork; a local replace of kiosk.FrameworkModule → `git -C <dir> describe --tags --abbrev=0 --match v[0-9]*`; otherwise the require version; on git failure return the require version plus a non-nil warning error the caller prints).
|
||||
Change `generate` to take the resolved metadata (`buildMeta{Plugins []party.Info; Framework string}`): build.App resolves it once per call, prints warnings to out and falls back to ID-only entries on any failure (never fails the build); `generatePluginsGen` imports party and emits PluginInfo and FrameworkVersion after PluginIDs; `generateMain` adds the Describe call before Activate. new.go passes ID-only metadata with an empty FrameworkVersion (the first summer build fills it), which keeps the `summer new` goldens deterministic; regenerate the goldens with -update and review the diff. Regenerate examples/hello's main.go and plugins.gen.go with `go run ./cmd/summer build` run from examples/hello (or the package function) and commit them.
|
||||
Write `internal/build/plugininfo_smoke_test.go` (`TestPluginInfoSmoke`, subtests per behavior line, git fixtures created in t.TempDir with `git init` and a test-local identity; skip a subtest when git is not on PATH) first, then implement. Document the generated PluginInfo/FrameworkVersion in docs/architecture/go-modules-and-workspaces.md and an "Installed plugin metadata" section in docs/plugins/marketplace.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./internal/build -run 'TestPluginInfoSmoke|TestNew|TestGenerate' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestPluginInfoSmoke" line</fails_when>
|
||||
<automated>cd examples/hello && go test ./... 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func Describe(app \*backpack.App, plugins \[\]Info, framework string) error" modules/party/info.go` prints one line
|
||||
- `grep -n "party.Describe(app, PluginInfo, FrameworkVersion)" examples/hello/main.go` prints one line; `grep -n "var PluginInfo = \[\]party.Info" examples/hello/plugins.gen.go` prints one line
|
||||
- `grep -n "func EffectiveFramework(ctx context.Context, appDir string) (string, error)" internal/build/plugindirs.go` prints one line
|
||||
- `grep -n "describe" internal/build/plugindirs.go` shows `--tags` and `--match` arguments
|
||||
- `git show --stat <the Task 5 commit sha> -- internal/build/testdata` lists only the three goldens named in files
|
||||
- docs/architecture/go-modules-and-workspaces.md names PluginInfo and FrameworkVersion
|
||||
</acceptance_criteria>
|
||||
<done>Every built app carries its plugins' names, descriptions, versions and the framework version it was built against, ready for the admin's Installed tab.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| plugin source → kiosk reader | plugin.go, go.mod and README.md of any directory a developer points the CLI at |
|
||||
| CLI args → go/git subprocesses | dir paths and module paths reach `go mod edit`, `go list`, `git describe` |
|
||||
| plugin.yaml in a module-cache or submodule plugin → summer build | third-party text baked into generated Go source |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-34-01 | Elevation of privilege | kiosk.ReadIdentity | high | mitigate | AST-only: go/parser on source text, no `go run`, no build, no init(); TestManifestSmoke/errors proves non-const expressions are refused rather than evaluated |
|
||||
| T-34-02 | Tampering | exec of go/git with paths and module strings | high | mitigate | exec.CommandContext without a shell, fixed argument positions, module paths validated (leading "-" rejected) before use, `--` before positional git arguments where git accepts it |
|
||||
| T-34-03 | Tampering | plugin.yaml text in plugins.gen.go | high | mitigate | every baked string goes through strconv.Quote and formatSource; a malformed plugin.yaml is ignored with a warning (TestPluginInfoSmoke) |
|
||||
| T-34-04 | Information disclosure | plugins.gen.go | low | mitigate | only IDs, module paths, texts and versions are baked; no Dir or absolute path (acceptance grep in review) |
|
||||
| T-34-05 | Denial of service | summer build | medium | mitigate | metadata resolution failure degrades to ID-only and warns; build never fails on it (behavior test) |
|
||||
| T-34-06 | Repudiation | stale committed plugin.yaml | medium | mitigate | `--check` and kiosk.CheckT fail on any byte difference |
|
||||
| T-34-SC | Tampering | npm/pip/cargo installs | high | accept | this plan installs no package (research: no new dependency) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -short ./...` green after every commit; `cd examples/hello && go test ./...` green after Task 5.
|
||||
- `go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'` and `go run ./cmd/summer docs:build --check` pass.
|
||||
- `go run ./cmd/summer plugin:manifest --pretend …` derives manifests for ../sm-mfa-plugin, ../sm-paymentgateway-plugin and ../sm-pgstripe-plugin/subscriptions without writing into them.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- kiosk exists with manifest, identity, check, registry and version APIs, documented and in the root README table.
|
||||
- `summer plugin:manifest` generates, previews and checks plugin.yaml for every public plugin layout.
|
||||
- plugins.gen.go carries PluginInfo and FrameworkVersion; the generated main publishes them.
|
||||
- No push, no tag; no file in a sibling repository changed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md` when done; record the Task 1 answer verbatim and every name that differs from this plan's text.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Module: `modules/kiosk` (README, root README row).
|
||||
- Types: `kiosk.Manifest`, `kiosk.Overrides`, `kiosk.DeriveOptions`, `kiosk.Identity`, `kiosk.StaleError`, `kiosk.TB`, `kiosk.RegistryPlugin`, `kiosk.RegistryPluginDetail`, `kiosk.RegistryScreenshot`, `kiosk.RegistryVersion`, `kiosk.ListResponse`, `kiosk.ListMeta`, `kiosk.DetailResponse`, `kiosk.VersionsResponse`, `kiosk.ErrorResponse`, `kiosk.RegistryError`, `kiosk.IngestRequest`, `kiosk.IngestResponse`, `party.Info`, `party.Installed`.
|
||||
- Functions: `kiosk.Marshal`, `kiosk.Parse`, `kiosk.Validate`, `kiosk.Derive`, `kiosk.Generate`, `kiosk.Write`, `kiosk.Check`, `kiosk.CheckT`, `kiosk.ReadIdentity`, `kiosk.ValidVersion`, `kiosk.CompareVersions`, `kiosk.BaseVersion`, `kiosk.Satisfies`, `kiosk.NewestCompatible`, `party.Describe`, `build.EffectiveFramework`.
|
||||
- Constants: `kiosk.FileName`, `kiosk.SchemaVersion`, `kiosk.FrameworkModule`, `kiosk.Header`, `kiosk.RegistryAPIPath`, `party.SourceModule`, `party.SourceSubmodule`, `party.SourceLocal`.
|
||||
- Generated code: `PluginInfo` and `FrameworkVersion` in every app's plugins.gen.go; `party.Describe(app, PluginInfo, FrameworkVersion)` in generated main.
|
||||
- CLI: `summer plugin:manifest [dir]` with `--check`, `--pretend`, `--name`, `--description`.
|
||||
- Files: `plugin.yaml` format (schema 1); `docs/plugins/marketplace.md`; tests `TestManifestSmoke`, `TestPluginInfoSmoke`, `TestAddPluginConstID`.
|
||||
- Config keys: none in this plan (`plugins.registry_url` arrives in 34-04).
|
||||
|
||||
## Phase-wide source coverage audit
|
||||
|
||||
| Source | ID | Item | Plan | Status |
|
||||
|--------|----|------|------|--------|
|
||||
| GOAL | — | Browse at summercms.io/marketplace, install with one command, private git URLs, read-only admin Plugins screen | 01-06 | COVERED |
|
||||
| REQ | — | none (Requirements: TBD); SC-1..SC-5 used instead | — | n/a |
|
||||
| ROADMAP | SC-1 | plugin.yaml in every public sm-*-plugin repo, validated, anonymous fetch | 01, 02 | COVERED |
|
||||
| ROADMAP | SC-2 | ingest on tag, registry API, /marketplace list/search/category/detail, presentation fields in admin | 03 | COVERED |
|
||||
| ROADMAP | SC-3 | plugin:install ID (module, --source) and git URL, requirement checks, plugin:add registration | 04 | COVERED |
|
||||
| ROADMAP | SC-4 | admin Plugins screen Installed + Marketplace with copy | 01, 05 | COVERED |
|
||||
| ROADMAP | SC-5 | unit tests in the last plan | 06 | COVERED |
|
||||
| CONTEXT | D-01..D-05 | manifest generation, derivation, overrides, --check, minimums | 01, 02 | COVERED |
|
||||
| CONTEXT | D-06, D-07 | build metadata and versions | 01 | COVERED |
|
||||
| CONTEXT | D-08..D-12 | install resolution, deps + --yes, plugin:update, git URL submodule-only, --offline | 04 | COVERED |
|
||||
| CONTEXT | D-13..D-16 | pull ingester + ingest-now, admin-curated listing, version rows, public API v1 | 03 (contract types in 01) | COVERED |
|
||||
| CONTEXT | D-17..D-19 | cabana endpoints, plugins.registry_url, permission | 04 (config), 05 | COVERED |
|
||||
| RESEARCH | F1 | const IDs (user decision 1) | 01 | COVERED |
|
||||
| RESEARCH | F2 | go.work replace overrides for submodule installs | 04 | COVERED |
|
||||
| RESEARCH | F3 | effective framework version | 01, 04 | COVERED |
|
||||
| RESEARCH | F4 | ConfirmProceed auto-confirms; use Interactive + Confirm | 04 | COVERED |
|
||||
| RESEARCH | F5 | go list dir resolution | 01 | COVERED |
|
||||
| RESEARCH | F6 | plugin.yaml per plugin package dir | 01, 02 | COVERED |
|
||||
| RESEARCH | F7 | --name where README H1 == ID | 01, 02 | COVERED |
|
||||
| RESEARCH | F8 | v0.0.0 plugin requirements | 01, 02 | COVERED |
|
||||
| RESEARCH | F9 | untagged repos not listed; anonymous fetch | 02, 03 | COVERED |
|
||||
| RESEARCH | F10 | nginx, work_in_serve, nodePaths, app regeneration | 03, 06 (ledger) | COVERED |
|
||||
| RESEARCH | Pitfall 4 | summer.yaml comments preserved (user decision 5) | 04 | COVERED |
|
||||
| RESEARCH | Pitfall 13 | ingest identity checks (ID unique, module == repo path) | 03 | COVERED |
|
||||
| RESEARCH | OQ 3/6 | launch list = tagged sibling plugins; --source in a non-git app refuses (user decision 5) | 03, 04, 06 | COVERED |
|
||||
| CONTEXT | Deferred | --module for git URLs; paid plugins, themes, admin installs | — | excluded (deferred) |
|
||||
295
.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
Normal file
295
.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
Normal file
@@ -0,0 +1,295 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 01
|
||||
subsystem: plugins
|
||||
tags: [kiosk, plugin.yaml, registry-api, go-list, git-describe, codegen, party]
|
||||
|
||||
requires:
|
||||
- phase: 34-plugin-marketplace (planning)
|
||||
provides: CONTEXT D-01..D-19, RESEARCH F1..F10, Task 1 decision P34-01..P34-04
|
||||
provides:
|
||||
- "modules/kiosk: plugin.yaml types (Manifest, Requires, Overrides), Marshal, Parse, Validate, Derive, Generate, Write, Check, StaleError, CheckT/TB"
|
||||
- "kiosk.ReadIdentity: static, const-following ID()/Requires() reader shared with plugin:add"
|
||||
- "kiosk registry v1 wire types (RegistryAPIPath, RegistryPlugin, RegistryPluginDetail, RegistryVersion, envelopes, errors, ingest) and version rules (ValidVersion, CompareVersions, BaseVersion, Satisfies, NewestCompatible)"
|
||||
- "summer plugin:manifest [dir] --check --pretend --name --description"
|
||||
- "party.Info, party.Installed, party.Describe; generated PluginInfo and FrameworkVersion in plugins.gen.go"
|
||||
- "build.EffectiveFramework"
|
||||
affects: [34-02 plugin repo rollout, 34-03 registry server, 34-04 install CLI, 34-05 admin Plugins screen, 34-06 tests, every app's committed main.go/plugins.gen.go]
|
||||
|
||||
actuals:
|
||||
tokens: 36000
|
||||
tasks: 5
|
||||
commits: 4
|
||||
plan_head_before: 824e3d5882c2e0b96a7d402338b9238d1c3f6ae9
|
||||
plan_head_after: e8d3d724f51d19683b25221c0e666d7fedafa78e
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Static AST resolution of string constants across packages and modules (go/parser + go list -m -json), never running plugin code"
|
||||
- "Generated file plus --check / CheckT drift guard"
|
||||
- "Build metadata resolution that degrades to ID-only with a warning and never fails summer build"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/kiosk/kiosk.go
|
||||
- modules/kiosk/manifest.go
|
||||
- modules/kiosk/derive.go
|
||||
- modules/kiosk/identity.go
|
||||
- modules/kiosk/check.go
|
||||
- modules/kiosk/registry.go
|
||||
- modules/kiosk/version.go
|
||||
- modules/kiosk/README.md
|
||||
- modules/kiosk/manifest_smoke_test.go
|
||||
- modules/kiosk/checkt_test.go
|
||||
- modules/kiosk/testdata/ (literal, consts, consts/nested, other, framework fixtures)
|
||||
- modules/party/info.go
|
||||
- internal/build/plugindirs.go
|
||||
- internal/build/plugininfo_smoke_test.go
|
||||
- docs/plugins/marketplace.md
|
||||
modified:
|
||||
- cmd/summer/main.go
|
||||
- internal/build/build.go
|
||||
- internal/build/new.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- internal/build/testdata/new/plugins.gen.go.golden
|
||||
- internal/build/testdata/new-no-plugin/plugins.gen.go.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/plugins.gen.go
|
||||
- modules/party/README.md
|
||||
- README.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/architecture/introduction.md
|
||||
|
||||
key-decisions:
|
||||
- "Task 1 answered as-planned: P34-01 plugin.yaml format, P34-02 registry API v1 shape, P34-03 names (module kiosk, party.Info/Installed/Describe, PluginInfo/FrameworkVersion, plugins.registry_url, backend.view_plugins, /system/plugins, token scope registry) and P34-04 v0.6.0 as framework release and plugin framework minimum adopted unchanged"
|
||||
- "kiosk runs go commands with GOWORK=off: a manifest describes the plugin module on its own; a required module missing from the module cache is fetched with go mod download"
|
||||
- "ReadIdentity returns the ID even when Requires() cannot be read, so plugin:add keeps working for any plugin whose ID it could read before"
|
||||
- "Requires marshals to JSON as {} when empty, so the registry never sends null for requires"
|
||||
- "plugin versions: module cache -> module version; own git repo -> git describe --tags --match v[0-9]* --always (no --dirty); in-app -> empty"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin metadata is derived, never typed: plugin.yaml from sources, PluginInfo from plugin.yaml + go list + git"
|
||||
- "Registry JSON shape exists once, as kiosk wire types"
|
||||
|
||||
requirements-completed: [SC-1, SC-4, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-16]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summer plugin:manifest generates, previews and checks plugin.yaml from plugin.go, go.mod and README (literal IDs)"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/manifest_smoke_test.go#TestManifestSmoke/literal"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go run ./cmd/summer plugin:manifest <tmp>/acme && go run ./cmd/summer plugin:manifest --check <tmp>/acme"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Constant, cross-package, cross-module and nested plugin identities resolve statically; non-const expressions are errors naming ID()/Requires(); plugin:add registers const-ID plugins"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/manifest_smoke_test.go#TestManifestSmoke/consts,nested,errors"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/build_test.go#TestAddPluginConstID"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "summer plugin:manifest --pretend on ../sm-mfa-plugin, ../sm-paymentgateway-plugin, ../sm-pgstripe-plugin/subscriptions"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "--name/--description overrides persist, clear with an empty value, and H1 == ID demands --name"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/manifest_smoke_test.go#TestManifestSmoke/overrides"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "--check and kiosk.CheckT fail on a missing or stale plugin.yaml; Validate enforces the format"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/manifest_smoke_test.go#TestManifestSmoke/checkt,validate"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/checkt_test.go#TestPluginManifest"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Go-style minimums: v0.0.0 plugin requirement is an error, pseudo-version warns, soft dependency has the empty minimum"
|
||||
requirement: D-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/manifest_smoke_test.go#TestManifestSmoke/errors"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "summer plugin:manifest --pretend ../sm-chatbots-plugin exits 1 with 'not a released version'"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Registry v1 wire types encode the confirmed JSON shape, ignore unknown fields; strict version rules"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/kiosk/manifest_smoke_test.go#TestManifestSmoke/registry,versions"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "summer build bakes PluginInfo and FrameworkVersion, generated main calls party.Describe before Activate, failures fall back to IDs"
|
||||
requirement: D-06
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/plugininfo_smoke_test.go#TestPluginInfoSmoke/generate,main,fallback,describe"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "cd examples/hello && go test ./..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Versions per D-07 and the effective framework version (local checkout tag, go.mod require, git failure warning)"
|
||||
requirement: D-07
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/plugininfo_smoke_test.go#TestPluginInfoSmoke/dirs,framework"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D9
|
||||
description: "Docs: kiosk README, party README, docs/plugins/marketplace.md, scaffolding and go-modules pages; docs checker passes"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go run ./cmd/summer docs:build --check; go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Docs prose quality and wording are a reviewer's call; the checker only proves identifiers, links and snippets"
|
||||
|
||||
duration: 22min
|
||||
completed: 2026-10-29
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 34 Plan 01: plugin.yaml, registry contract and build metadata Summary
|
||||
|
||||
**New framework module `kiosk` generates and checks each plugin's plugin.yaml from its own sources (resolving constant IDs across packages and modules without running code), holds the registry API v1 wire types and version rules, and `summer build` now bakes PluginInfo and FrameworkVersion into plugins.gen.go, which the generated main publishes with `party.Describe`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 22 min
|
||||
- **Started:** 2026-10-29T15:50:20Z
|
||||
- **Completed:** 2026-10-29T16:12:19Z
|
||||
- **Tasks:** 5 (Task 1 was a decision answered before dispatch; Tasks 2-5 executed)
|
||||
- **Files modified:** 44 (29 created, 15 modified)
|
||||
|
||||
## Task 1 decision (recorded verbatim)
|
||||
|
||||
The user answered Task 1 through the orchestrator before dispatch: **"as-planned"**. P34-01 (plugin.yaml format), P34-02 (registry API v1 shape), P34-03 (names: module `kiosk`, `party.Info`/`Installed`/`Describe`, `PluginInfo`/`FrameworkVersion`, `plugins.registry_url`, `backend.view_plugins`, `/system/plugins`, token scope `registry`) and P34-04 (v0.6.0 as the framework release and the plugin framework minimum) are adopted exactly as written. The user re-confirmed v0.6.0 knowing that it raises every plugin's framework minimum.
|
||||
|
||||
No name differs from the plan's text. The plan adds these extra exported names, which 34-03..34-05 can use:
|
||||
- `kiosk.IngestResult`: the `data` object of `kiosk.IngestResponse` (`repository`, `queued`). The plan described the shape but did not name the type.
|
||||
- `kiosk.RegistryErrorNotFound` (`not_found`) and `kiosk.RegistryErrorInvalidQuery` (`invalid_query`): the error codes as constants.
|
||||
- `kiosk.Requires.MarshalJSON` and `kiosk.Requires.MarshalYAML`.
|
||||
- `kiosk.StaleError.Error`.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `summer plugin:manifest [dir]` writes, previews (`--pretend`) and checks (`--check`, exit 1 and prints the expected file) plugin.yaml with the P34-01 field order and header. `--name`/`--description` persist in `overrides:`, and an empty value clears them.
|
||||
- `kiosk.ReadIdentity` resolves literals, same-package constants (in any non-test file), constants of another package in the same module and constants of another module (`go list -m -json`, `go mod download -json` when the module is not in the cache). It handles named imports and unnamed imports whose package name differs from the path. `plugin:add` now uses it, so const-ID plugins register (research F1).
|
||||
- Required-plugin minimums come from go.mod requires. Plugins in the same module and soft dependencies get the empty minimum, `v0.0.0` is an error and a pseudo-version gives a warning.
|
||||
- `kiosk.CheckT(t, dir)` gives plugin repos a one-line `go test` guard. kiosk does not import `testing`.
|
||||
- Registry v1 contract: `RegistryAPIPath` and every response/request type, checked by golden JSON in the tests, plus strict `vX.Y.Z` rules and `NewestCompatible`.
|
||||
- `summer build` runs one `go list -e -json` over the summer.yaml packages and reads each plugin.yaml. Versions follow D-07: module version, `git describe` without `--dirty` for a plugin in its own repository, empty for an in-app plugin. It also computes `EffectiveFramework`, bakes all of this into plugins.gen.go, and warns and falls back to ID-only entries on any failure.
|
||||
- Every sibling plugin derives a manifest with `--pretend` except the three chat plugins (chatbots, chatchannels, chatvideo), which require `sm-chat-plugin v0.0.0` (research F8, expected; 34-02 fixes those go.mod files). No file in any sibling repository was written.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Confirm format, API shape, names, release number**: no commit (answer "as-planned" recorded above)
|
||||
2. **Task 2: plugin.yaml manifest and summer plugin:manifest (tracer)**: `ec6a195` (feat)
|
||||
3. **Task 3: constant plugin IDs, overrides and the CheckT helper**: `51ec2f9` (feat)
|
||||
4. **Task 4: registry v1 wire types and version rules**: `4916ee3` (feat)
|
||||
5. **Task 5: bake plugin metadata and the framework version into plugins.gen.go**: `e8d3d72` (feat)
|
||||
|
||||
**Plan metadata:** the docs commit containing this SUMMARY.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/kiosk/kiosk.go`: FileName, SchemaVersion, FrameworkModule, Header
|
||||
- `modules/kiosk/manifest.go`: Manifest, Requires, Overrides, Marshal, Parse, Validate
|
||||
- `modules/kiosk/derive.go`: DeriveOptions, Derive (go mod edit -json, README via goldmark)
|
||||
- `modules/kiosk/identity.go`: Identity, ReadIdentity, module listing/download, required-ID minimums
|
||||
- `modules/kiosk/check.go`: Generate, Write, Check, StaleError, TB, CheckT
|
||||
- `modules/kiosk/registry.go`, `modules/kiosk/version.go`: registry v1 contract and version rules
|
||||
- `modules/kiosk/manifest_smoke_test.go`, `modules/kiosk/checkt_test.go`, `modules/kiosk/testdata/`: TestManifestSmoke (literal, consts, nested, errors, overrides, checkt, validate, registry, versions) and fixtures
|
||||
- `modules/party/info.go`: Info, Source constants, Installed, Describe
|
||||
- `internal/build/plugindirs.go`: go list resolver, plugin.yaml reading, git describe, EffectiveFramework
|
||||
- `internal/build/build.go`, `internal/build/new.go`: generate takes buildMeta; PluginInfo/FrameworkVersion; Describe in main
|
||||
- `internal/build/scaffold.go`: pluginIDFromGo delegates to kiosk.ReadIdentity
|
||||
- `cmd/summer/main.go`: plugin:manifest command; `summer build` shows `warning:` lines with out.Warning
|
||||
- `examples/hello/main.go`, `examples/hello/plugins.gen.go`, three `summer new` goldens: regenerated
|
||||
- Docs: `modules/kiosk/README.md`, `modules/party/README.md`, root `README.md` row, `docs/plugins/marketplace.md` (new), `docs/console/scaffolding.md`, `docs/architecture/go-modules-and-workspaces.md`, `docs/architecture/introduction.md`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- kiosk's go commands run with `GOWORK=off`. A manifest describes the plugin module as consumers see it, and a workspace above a plugin directory could otherwise make `go list -m` fail.
|
||||
- When deriving minimums, only required modules whose go.mod mentions the framework are searched for plugin.go files, and only those are downloaded. This keeps third-party modules out of the walk.
|
||||
- `ReadIdentity` prefers the `ID()` in plugin.go and reads `Requires()` from the same receiver type. That avoids picking up an unrelated `ID()` method, for example on a model, in the same package.
|
||||
- PluginInfo leaves empty fields out of each composite literal and uses `party.Source*` constants. The summer new goldens bake IDs only with an empty FrameworkVersion, so they stay deterministic.
|
||||
- A plugin.yaml whose `id` differs from the summer.yaml ID is ignored with a warning (defensive, T-34-03).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing critical] Corrupt existing plugin.yaml gives an actionable error**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** A hand-damaged plugin.yaml made regeneration fail with a bare YAML parse error.
|
||||
- **Fix:** The error now says to delete the file and rerun, passing `--name`/`--description` again.
|
||||
- **Files modified:** modules/kiosk/derive.go
|
||||
- **Commit:** ec6a195
|
||||
|
||||
**2. [Rule 3 - Blocking] Docs checker requires go code blocks in docs/ to come from compiled tests**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The "Testing the manifest in your plugin" snippet failed `docs:build --check` ("go code block has no src= reference").
|
||||
- **Fix:** Added `modules/kiosk/checkt_test.go` (`TestPluginManifest`, calls `kiosk.CheckT` on the literal fixture) and committed the fixture's generated `testdata/literal/plugin.yaml`. The page references it with `src=`, and the prose explains that a plugin passes `"."`.
|
||||
- **Files modified:** modules/kiosk/checkt_test.go, modules/kiosk/testdata/literal/plugin.yaml, docs/plugins/marketplace.md
|
||||
- **Commit:** 51ec2f9
|
||||
|
||||
**3. [Rule 3 - Blocking] `requireGit` already declared in internal/build tests**
|
||||
- **Found during:** Task 5
|
||||
- **Fix:** Reused the existing `requireGit` and named the identity-isolated helper `runTestGit`.
|
||||
- **Files modified:** internal/build/plugininfo_smoke_test.go
|
||||
- **Commit:** e8d3d72
|
||||
|
||||
**4. [Rule 2 - Missing critical] Additions outside the plan's file list**
|
||||
- `modules/kiosk/testdata/consts/vendor.go`: proves that a constant in another non-test file of the same package resolves.
|
||||
- `docs/architecture/introduction.md`: kiosk added to the module overview table, which lists every module.
|
||||
- `cmd/summer/main.go` in Task 5: `summer build` prints metadata `warning:` lines with out.Warning.
|
||||
- `internal/build/build_test.go` in Task 5: the existing `generatePluginsGen` call sites were updated to the new signature.
|
||||
- The `plugin:manifest` examples were reworded so that `grep '"plugin:manifest"'` prints exactly one line, as the acceptance criteria require.
|
||||
|
||||
**5. [Process] TDD order in Task 3**
|
||||
- Task 3's behavior subtests were written right after the implementation, in the same task, not before it. Tasks 4 and 5 followed RED-then-GREEN: their tests failed to compile on the missing types before implementation. Each task is one commit, as the plan's Execution units table requires.
|
||||
|
||||
**Total deviations:** 5 (2 Rule 2, 2 Rule 3, 1 process). **Impact:** none on the contract; all names match the plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `gofmt -l cmd/summer` lists pre-existing alignment differences in `cmd/summer/main.go`, `runtime.go` and `runtime_test.go` that this plan did not introduce. Left untouched (scope boundary).
|
||||
|
||||
## Next Phase Readiness / notes for later plans
|
||||
|
||||
- 34-02: `sm-chatbots-plugin`, `sm-chatchannels-plugin` and `sm-chatvideo-plugin` require `sm-chat-plugin v0.0.0`. They must require a tag before `plugin:manifest` succeeds. Thirteen repos whose README H1 is the ID need `--name` once.
|
||||
- Every app regenerates main.go and plugins.gen.go on its first build after upgrading (research F10). `FrameworkVersion` in a committed plugins.gen.go changes when the locally replaced framework checkout gains a tag (examples/hello now bakes `v0.5.3`). Commit the regeneration together with the bump.
|
||||
- `plugin:manifest` and `CheckT` may run `go mod download` for a required plugin module that is not in the module cache. They need the network once per version.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All key files exist (kiosk sources, README, tests, party/info.go, plugindirs.go, plugininfo_smoke_test.go, docs/plugins/marketplace.md).
|
||||
- Commits ec6a195, 51ec2f9, 4916ee3 and e8d3d72 are in history; `git rev-list --count 824e3d5..HEAD` = 4 before this docs commit.
|
||||
- `go vet ./...`, `go test -short ./...`, `cd examples/hello && go test ./...`, `go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'` and `summer docs:build --check` all pass.
|
||||
- The acceptance criteria of Tasks 2-5 were re-run and pass. The pgstripe/subscriptions, mfa and paymentgateway pretend runs print the expected id/package lines; chatbots exits 1 with "not a released version".
|
||||
331
.planning/phases/34-plugin-marketplace/34-02-PLAN.md
Normal file
331
.planning/phases/34-plugin-marketplace/34-02-PLAN.md
Normal file
@@ -0,0 +1,331 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["34-01", "34-03", "34-05"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/plugin.yaml
|
||||
- ../sm-journal-plugin/manifest_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-chat-plugin/plugin.yaml
|
||||
- ../sm-chat-plugin/manifest_test.go
|
||||
- ../sm-chat-plugin/go.mod
|
||||
- ../sm-faq-plugin/plugin.yaml
|
||||
- ../sm-faq-plugin/manifest_test.go
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-menu-plugin/plugin.yaml
|
||||
- ../sm-menu-plugin/manifest_test.go
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-mfa-plugin/plugin.yaml
|
||||
- ../sm-mfa-plugin/manifest_test.go
|
||||
- ../sm-mfa-plugin/go.mod
|
||||
- ../sm-oauthserver-plugin/plugin.yaml
|
||||
- ../sm-oauthserver-plugin/manifest_test.go
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
- ../sm-paymentgateway-plugin/plugin.yaml
|
||||
- ../sm-paymentgateway-plugin/manifest_test.go
|
||||
- ../sm-paymentgateway-plugin/go.mod
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/plugin.yaml
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/manifest_test.go
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/plugin.yaml
|
||||
- ../sm-pgstripe-plugin/manifest_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.yaml
|
||||
- ../sm-pgstripe-plugin/subscriptions/manifest_test.go
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-seo-plugin/plugin.yaml
|
||||
- ../sm-seo-plugin/manifest_test.go
|
||||
- ../sm-seo-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/plugin.yaml
|
||||
- ../sm-sitemap-plugin/manifest_test.go
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/plugin.yaml
|
||||
- ../sm-sociallogin-plugin/manifest_test.go
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-translate-plugin/plugin.yaml
|
||||
- ../sm-translate-plugin/manifest_test.go
|
||||
- ../sm-translate-plugin/go.mod
|
||||
- ../sm-user-plugin/plugin.yaml
|
||||
- ../sm-user-plugin/manifest_test.go
|
||||
- ../sm-user-plugin/go.mod
|
||||
- ../sm-chatbots-plugin/plugin.yaml
|
||||
- ../sm-chatbots-plugin/manifest_test.go
|
||||
- ../sm-chatbots-plugin/go.mod
|
||||
- ../sm-chatchannels-plugin/plugin.yaml
|
||||
- ../sm-chatchannels-plugin/manifest_test.go
|
||||
- ../sm-chatchannels-plugin/go.mod
|
||||
- ../sm-chatvideo-plugin/plugin.yaml
|
||||
- ../sm-chatvideo-plugin/manifest_test.go
|
||||
- ../sm-chatvideo-plugin/go.mod
|
||||
- ../sm-forum-plugin/plugin.yaml
|
||||
- ../sm-forum-plugin/manifest_test.go
|
||||
- ../sm-forum-plugin/go.mod
|
||||
- ../sm-herald-plugin/plugin.yaml
|
||||
- ../sm-herald-plugin/manifest_test.go
|
||||
- ../sm-herald-plugin/go.mod
|
||||
- ../sm-userfriends-plugin/plugin.yaml
|
||||
- ../sm-userfriends-plugin/manifest_test.go
|
||||
- ../sm-userfriends-plugin/go.mod
|
||||
autonomous: false
|
||||
requirements: [SC-1, D-01, D-02, D-03, D-04, D-05]
|
||||
|
||||
estimate:
|
||||
tokens: 90000
|
||||
raw_tokens: 90000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every public sm-*-plugin repository checked out next to the framework (20 repositories, 21 plugin packages including sm-pgstripe-plugin/subscriptions) commits a plugin.yaml generated by `summer plugin:manifest`, never hand-edited (SC-1, D-01, D-02)"
|
||||
- "Each plugin.yaml carries id, module, package, name, description, framework and requires derived from the repo; READMEs whose H1 is the plugin ID get a persisted `--name` override, the others keep their README H1 (D-02, D-03, research F7)"
|
||||
- "Each repository's go.mod requires the framework release that contains kiosk (v0.6.0 or the number confirmed at 34-01 Task 1), which therefore is its `framework:` minimum (user decision 2 of 2026-10-29, D-05)"
|
||||
- "Each repository's `go test` runs TestPluginManifest, which calls kiosk.CheckT and fails when plugin.yaml is stale (D-04)"
|
||||
- "A plugin requirement at v0.0.0 (sm-chatbots-plugin on sm-chat-plugin) is replaced by a released version, or the repository is left without plugin.yaml and the reason is recorded (research F8)"
|
||||
- "Each listed repository answers an anonymous `git ls-remote` over HTTPS (SC-1)"
|
||||
- "No plugin repository is pushed or tagged by this plan; plugin commits come first and app submodule pointer bumps are separate commits made only after Jakub reports the push (CLAUDE.md, user decision 2)"
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/plugin.yaml"
|
||||
provides: "tracer manifest"
|
||||
contains: "id: golem15.journal"
|
||||
- path: "../sm-journal-plugin/manifest_test.go"
|
||||
provides: "TestPluginManifest"
|
||||
contains: "kiosk.CheckT"
|
||||
- path: "../sm-pgstripe-plugin/subscriptions/plugin.yaml"
|
||||
provides: "nested plugin manifest"
|
||||
contains: "package: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions"
|
||||
key_links:
|
||||
- from: "../sm-*-plugin/manifest_test.go"
|
||||
to: "modules/kiosk/check.go"
|
||||
via: "TestPluginManifest calls kiosk.CheckT(t, \".\") against the local framework through the repo's replace"
|
||||
pattern: "kiosk\\.CheckT\\(t, \"\\.\"\\)"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
People browse the public Golem15 plugins at summercms.io/marketplace and install one with `summer plugin:install <id>`. For the registry to list a plugin, its repository must carry a valid plugin.yaml at a tag. This plan puts a generated, test-guarded plugin.yaml into every public plugin repository; the tags that publish them are user-gated and follow the framework release.
|
||||
|
||||
<objective>
|
||||
Roll out plugin.yaml and its check test to every public `sm-*-plugin` repository checked out next to the framework (user decision 5: these are the launch set; quizzes, presentations, golem and feedback have no sibling checkout and are out of this phase), using the 34-01 command. Untagged repositories (chatbots, chatchannels, chatvideo, forum, herald, userfriends) get plugin.yaml too but are not listed until Jakub tags them (D-14).
|
||||
|
||||
Purpose: SC-1 and the input the registry ingester (34-03) and git-URL installs (34-04) read.
|
||||
|
||||
Output: one commit per plugin repository (plugin.yaml, manifest_test.go, go.mod/go.sum), and, only after Jakub pushes, one pointer-bump commit per app. No push, no tag.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/34-plugin-marketplace/34-CONTEXT.md
|
||||
@.planning/phases/34-plugin-marketplace/34-RESEARCH.md
|
||||
@.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-03-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-05-SUMMARY.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 34-01: `summer plugin:manifest [dir] [--check] [--pretend] [--name=…] [--description=…]`; `kiosk.CheckT(t TB, dir string)` (TB = Helper + Fatalf); plugin.yaml schema 1 per plugin package directory. Use the names in 34-01-SUMMARY.md where Task 1 adjusted them.
|
||||
Every sibling plugin go.mod replaces git.golem15.com/golem15/summercms with ../summercms.go, so a require of the unreleased framework version builds locally against this checkout.
|
||||
README H1 equals the plugin ID in faq, journal, menu, mfa, oauthserver, paymentgateway, paymentgatewaysubscriptions, pgstripe, pgstripe/subscriptions, seo, sitemap, sociallogin, translate, user (research F7). Human H1 in chat ("Chat"), chatbots ("Chat bots"), chatchannels ("ChatChannels"), chatvideo ("Chat video"), forum ("Forum"), herald ("Herald"), userfriends ("Friends").
|
||||
sm-chatbots-plugin/go.mod requires git.golem15.com/golem15/sm-chat-plugin v0.0.0 with a local replace (research F8); sm-chat-plugin's newest tag is v0.1.3.
|
||||
Apps mounting these plugins as submodules: sm-demo-app, sm-grzybyfunkcjonalne-app, sm-bm-app, sm-summercmsio-app, fonoteka.go.
|
||||
Wave order: this plan runs after 34-03 and 34-05 (declared in depends_on) because every plugin test compiles kiosk and most compile cabana from ../summercms.go, which 34-04 and 34-05 edit, and because Task 5 commits gitlinks in sm-summercmsio-app, where 34-03 commits. 34-03-SUMMARY.md lists the unpushed site plugin (sm-summercmsio-plugin) and vue-summercmsio-app commits whose gitlinks sm-summercmsio-app still has to record.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Build the CLI once from this checkout into the executor's scratch directory (`go build -o <scratch>/summer ./cmd/summer`) and call that binary; never `go install` over Jakub's installed summer.
|
||||
- Per repository: `git -C <repo> status --short` first. If another session has uncommitted changes in go.mod, go.sum, plugin.go or README.md, skip the repository, record it in the SUMMARY and retry it at the end of the task. Stage only the files named here, by path. No co-author tag.
|
||||
- Commit subject in each plugin repository: `feat: plugin.yaml manifest for the SummerCMS marketplace`. Resume check per repository: `git -C <repo> log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commits | Resume point |
|
||||
|------|---------|--------------|
|
||||
| 1 | sm-journal-plugin | TestPluginManifest passes in sm-journal-plugin |
|
||||
| 2 | 13 tagged repositories (pgstripe once, both plugin dirs) | every listed repo's TestPluginManifest passes |
|
||||
| 3 | 6 untagged repositories | same, or the recorded F8 skip |
|
||||
| 4 | none (answer recorded in the SUMMARY) | answer recorded |
|
||||
| 5 | one `chore: bump plugin pointers for plugin.yaml` per app, or none when deferred | pointers recorded |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "sm-journal-plugin commits a generated plugin.yaml that its own go test guards" — one repository only</name>
|
||||
<files>../sm-journal-plugin/plugin.yaml, ../sm-journal-plugin/manifest_test.go, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum</files>
|
||||
<read_first>
|
||||
- .planning/phases/34-plugin-marketplace/34-01-SUMMARY.md (confirmed names, release number, anything adjusted)
|
||||
- modules/kiosk/README.md (CheckT usage)
|
||||
- ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/README.md (H1 and summary)
|
||||
- ../sm-faq-plugin/commands_documented_test.go (the plugin-test style that imports a framework helper)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-01..D-05 and user decision 2. In ../sm-journal-plugin: set the framework requirement to the release number confirmed at 34-01 Task 1 with `go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@<release>`; add `manifest_test.go` in the package of plugin.go with `func TestPluginManifest(t *testing.T) { kiosk.CheckT(t, ".") }` and the kiosk import; run `go -C ../sm-journal-plugin mod tidy` and review the go.mod diff (new indirect requirements for kiosk's dependencies are expected; if tidy removes or downgrades an existing requirement, stop and record it); run `<scratch>/summer plugin:manifest --name=Journal ../sm-journal-plugin` (README H1 is the ID). Confirm the test guards the file: change one word of plugin.yaml's description, run TestPluginManifest and see it fail, then restore with `git -C ../sm-journal-plugin checkout -- plugin.yaml` or by regenerating. Commit plugin.yaml, manifest_test.go, go.mod and go.sum in ../sm-journal-plugin.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test -run TestPluginManifest -count=1 -v . 2>&1 | grep -E '^(--- |FAIL|ok|PASS)'</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestPluginManifest" line</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./... 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
<automated>go run ./cmd/summer plugin:manifest --check ../sm-journal-plugin && grep -E '^(id|name|framework):' ../sm-journal-plugin/plugin.yaml</automated>
|
||||
<fails_when>non-zero exit, or no "id: golem15.journal" and "name: Journal" lines</fails_when>
|
||||
<automated>GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote --heads https://git.golem15.com/golem15/sm-journal-plugin.git | head -1</automated>
|
||||
<fails_when>non-zero exit, "Authentication failed" in the output, or empty output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "git.golem15.com/golem15/summercms v0.6.0" ../sm-journal-plugin/go.mod` prints one line (or the confirmed release number)
|
||||
- `grep -n "framework: v0.6.0" ../sm-journal-plugin/plugin.yaml` prints one line and `grep -n "golem15.translate" ../sm-journal-plugin/plugin.yaml` prints the requirement with a vX.Y.Z minimum
|
||||
- `grep -n "overrides:" ../sm-journal-plugin/plugin.yaml` prints one line
|
||||
- `git -C ../sm-journal-plugin log -1 --format=%s` is the subject from Execution units and `git -C ../sm-journal-plugin show --stat HEAD` lists only the four files
|
||||
- `git -C ../sm-journal-plugin tag --points-at HEAD` prints nothing (no tag)
|
||||
</acceptance_criteria>
|
||||
<done>One real plugin repository carries a generated, test-guarded plugin.yaml and builds against the local framework.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The 13 other tagged plugin repositories carry plugin.yaml and TestPluginManifest</name>
|
||||
<files>../sm-chat-plugin/plugin.yaml, ../sm-chat-plugin/manifest_test.go, ../sm-chat-plugin/go.mod, ../sm-faq-plugin/plugin.yaml, ../sm-faq-plugin/manifest_test.go, ../sm-faq-plugin/go.mod, ../sm-menu-plugin/plugin.yaml, ../sm-menu-plugin/manifest_test.go, ../sm-menu-plugin/go.mod, ../sm-mfa-plugin/plugin.yaml, ../sm-mfa-plugin/manifest_test.go, ../sm-mfa-plugin/go.mod, ../sm-oauthserver-plugin/plugin.yaml, ../sm-oauthserver-plugin/manifest_test.go, ../sm-oauthserver-plugin/go.mod, ../sm-paymentgateway-plugin/plugin.yaml, ../sm-paymentgateway-plugin/manifest_test.go, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgatewaysubscriptions-plugin/plugin.yaml, ../sm-paymentgatewaysubscriptions-plugin/manifest_test.go, ../sm-paymentgatewaysubscriptions-plugin/go.mod, ../sm-pgstripe-plugin/plugin.yaml, ../sm-pgstripe-plugin/manifest_test.go, ../sm-pgstripe-plugin/subscriptions/plugin.yaml, ../sm-pgstripe-plugin/subscriptions/manifest_test.go, ../sm-pgstripe-plugin/go.mod, ../sm-seo-plugin/plugin.yaml, ../sm-seo-plugin/manifest_test.go, ../sm-seo-plugin/go.mod, ../sm-sitemap-plugin/plugin.yaml, ../sm-sitemap-plugin/manifest_test.go, ../sm-sitemap-plugin/go.mod, ../sm-sociallogin-plugin/plugin.yaml, ../sm-sociallogin-plugin/manifest_test.go, ../sm-sociallogin-plugin/go.mod, ../sm-translate-plugin/plugin.yaml, ../sm-translate-plugin/manifest_test.go, ../sm-translate-plugin/go.mod, ../sm-user-plugin/plugin.yaml, ../sm-user-plugin/manifest_test.go, ../sm-user-plugin/go.mod</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/manifest_test.go (Task 1, the template)
|
||||
- each repository's plugin.go (package name, ID/Requires form) and README.md (H1) before generating
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.go (nested plugin, package name)
|
||||
</read_first>
|
||||
<action>
|
||||
Repeat Task 1 for each repository, in dependency order so a required plugin's repository is done before its dependants: translate, user, chat, faq, menu, seo, sitemap, mfa, oauthserver, sociallogin, paymentgateway, paymentgatewaysubscriptions, pgstripe. Names per D-03 for READMEs whose H1 is the ID (Claude's discretion, reversible with a later `--name`): faq "FAQ", menu "Menu", mfa "Multi-factor authentication", oauthserver "OAuth server", paymentgateway "Payment gateway", paymentgatewaysubscriptions "Payment gateway subscriptions", pgstripe "Stripe payments", pgstripe/subscriptions "Stripe subscriptions", seo "SEO", sitemap "Sitemap", sociallogin "Social login", translate "Translate", user "User"; chat keeps its README H1 (no override). In sm-pgstripe-plugin generate both `plugin.yaml` (root) and `subscriptions/plugin.yaml` (research F6), add a manifest test in each of the two packages (the subscriptions test calls `kiosk.CheckT(t, ".")` from its own directory), and commit both in one commit. Stage go.sum too where tidy changed it (it is not listed per repository above when unchanged). Record in the SUMMARY a table of repository, plugin ID, name, framework minimum, requires and commit SHA.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>for r in chat faq menu mfa oauthserver paymentgateway paymentgatewaysubscriptions pgstripe seo sitemap sociallogin translate user; do go -C ../sm-$r-plugin test -run TestPluginManifest -count=1 ./... >/dev/null 2>&1 || echo "FAIL $r"; done; echo done</automated>
|
||||
<fails_when>any "FAIL <repo>" line before "done"</fails_when>
|
||||
<automated>for r in chat faq menu mfa oauthserver paymentgateway paymentgatewaysubscriptions pgstripe seo sitemap sociallogin translate user; do (go -C ../sm-$r-plugin vet ./... && go -C ../sm-$r-plugin test -short ./...) >/dev/null 2>&1 || echo "FAIL $r"; done; echo done</automated>
|
||||
<fails_when>any "FAIL <repo>" line before "done"</fails_when>
|
||||
<automated>for r in chat faq menu mfa oauthserver paymentgateway paymentgatewaysubscriptions pgstripe seo sitemap sociallogin translate user; do GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote --heads https://git.golem15.com/golem15/sm-$r-plugin.git >/dev/null 2>&1 || echo "FAIL $r"; done; echo done</automated>
|
||||
<fails_when>any "FAIL <repo>" line before "done"</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls ../sm-*-plugin/plugin.yaml ../sm-pgstripe-plugin/subscriptions/plugin.yaml | wc -l` is at least 15 after this task (journal plus these 13 plus subscriptions)
|
||||
- `grep -h '^id:' ../sm-{chat,faq,menu,mfa,oauthserver,paymentgateway,paymentgatewaysubscriptions,pgstripe,seo,sitemap,sociallogin,translate,user}-plugin/plugin.yaml | sort -u | wc -l` is 13
|
||||
- `grep -L '^name: golem15\.' ../sm-*-plugin/plugin.yaml` lists every plugin.yaml (no display name equals an ID)
|
||||
- every repository's latest commit has the Execution units subject and no tag points at it
|
||||
</acceptance_criteria>
|
||||
<done>Every tagged public plugin is ready to publish its manifest at its next tag.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The 6 untagged plugin repositories carry plugin.yaml, with the v0.0.0 chat requirement resolved</name>
|
||||
<files>../sm-chatbots-plugin/plugin.yaml, ../sm-chatbots-plugin/manifest_test.go, ../sm-chatbots-plugin/go.mod, ../sm-chatchannels-plugin/plugin.yaml, ../sm-chatchannels-plugin/manifest_test.go, ../sm-chatchannels-plugin/go.mod, ../sm-chatvideo-plugin/plugin.yaml, ../sm-chatvideo-plugin/manifest_test.go, ../sm-chatvideo-plugin/go.mod, ../sm-forum-plugin/plugin.yaml, ../sm-forum-plugin/manifest_test.go, ../sm-forum-plugin/go.mod, ../sm-herald-plugin/plugin.yaml, ../sm-herald-plugin/manifest_test.go, ../sm-herald-plugin/go.mod, ../sm-userfriends-plugin/plugin.yaml, ../sm-userfriends-plugin/manifest_test.go, ../sm-userfriends-plugin/go.mod</files>
|
||||
<read_first>
|
||||
- ../sm-chatbots-plugin/go.mod (the v0.0.0 chat requirement and its replace)
|
||||
- ../sm-userfriends-plugin/go.mod (pseudo-version requirement, accepted with a warning)
|
||||
- each repository's plugin.go and README.md
|
||||
</read_first>
|
||||
<action>
|
||||
Repeat Task 1 for chatchannels, chatvideo, forum, herald, userfriends and chatbots (no name overrides: their H1s are human names). For sm-chatbots-plugin (research F8): set the sm-chat-plugin requirement to v0.1.3 (its newest tag) with `go mod edit -require`, keep the local replace, and confirm chatbots still builds against v0.1.3's API by building in a scratch copy with the chat replace dropped (`GOFLAGS=-mod=mod`, network allowed for the proxy). If it does not build against v0.1.3, restore go.mod, leave chatbots without plugin.yaml, and record in the SUMMARY that chatbots needs a sm-chat-plugin tag before it can publish (it is untagged and unlisted, so SC-1 lists it as pending, not failed). Pseudo-version warnings (userfriends) are expected; record them.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>for r in chatbots chatchannels chatvideo forum herald userfriends; do test -f ../sm-$r-plugin/plugin.yaml || { echo "SKIP $r"; continue; }; (go -C ../sm-$r-plugin vet ./... && go -C ../sm-$r-plugin test -short ./... && go -C ../sm-$r-plugin test -run TestPluginManifest -count=1 ./...) >/dev/null 2>&1 || echo "FAIL $r"; done; echo done</automated>
|
||||
<fails_when>any "FAIL <repo>" line, or a "SKIP" line for a repository other than chatbots</fails_when>
|
||||
<automated>for r in chatbots chatchannels chatvideo forum herald userfriends; do GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote --heads https://git.golem15.com/golem15/sm-$r-plugin.git >/dev/null 2>&1 || echo "FAIL $r"; done; echo done</automated>
|
||||
<fails_when>any "FAIL <repo>" line before "done"</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls ../sm-*-plugin/plugin.yaml | wc -l` is 20, or 19 with the chatbots skip recorded in the SUMMARY with its build error
|
||||
- `grep -n "sm-chat-plugin v0.0.0" ../sm-chatbots-plugin/go.mod` prints nothing when chatbots has a plugin.yaml
|
||||
- no repository is tagged by this task (`git -C ../sm-forum-plugin tag` prints nothing)
|
||||
</acceptance_criteria>
|
||||
<done>All public plugin repositories carry plugin.yaml (or a recorded, explained exception) and are ready for their first tag.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 4: Push the framework and plugin commits now, or defer the app pointer bumps to the release</name>
|
||||
<decision>Jakub pushes (no tags) the framework master (34-01, 34-04 and 34-05 commits), the plugin repositories committed in Tasks 1-3, and the sm-summercmsio-plugin and vue-summercmsio-app commits from 34-03, so the apps' submodule pointers can be bumped in this plan; or the pointer bumps move to the release ledger 34-06 writes.</decision>
|
||||
<context>
|
||||
CLAUDE.md: plugin commits are pushed first, then the app pointer bump is its own commit; plans never push or tag. Every plugin now requires framework v0.6.0, which is not tagged yet: apps build in workspace mode (all five replace the framework locally), but app release builds and module consumers need the v0.6.0 tag first. The SUMMARY lists every repository with its new commit SHA in push order (framework, then translate, user, chat, the other plugins, then sm-summercmsio-plugin and vue-summercmsio-app).
|
||||
</context>
|
||||
<options>
|
||||
<option id="pushed">
|
||||
<name>Jakub has pushed the listed repositories (no tags); bump the pointers now</name>
|
||||
<pros>Apps track the manifests immediately; no leftover pointer work at release</pros>
|
||||
<cons>Apps' release builds still wait for v0.6.0; committed app pointers reference plugin commits that require an untagged framework</cons>
|
||||
</option>
|
||||
<option id="defer">
|
||||
<name>Do not bump app pointers in this phase; record them for the release</name>
|
||||
<pros>Apps stay on released plugin versions until the framework and plugins are tagged</pros>
|
||||
<cons>The release step has one more pass over five apps</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "pushed: <repositories>" or "defer"</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: App submodule pointers follow the pushed plugin commits, or are recorded for the release</name>
|
||||
<files>none in this repository; app gitlinks under ../sm-demo-app, ../sm-grzybyfunkcjonalne-app, ../sm-bm-app, ../sm-summercmsio-app (plugin submodules plus plugins/golem15/summercms and vue-summercmsio-app), ../fonoteka.go when "pushed"</files>
|
||||
<read_first>
|
||||
- the Task 4 answer
|
||||
- each app's .gitmodules (which plugins it mounts)
|
||||
- .planning/phases/34-plugin-marketplace/34-03-SUMMARY.md (pending site plugin and vue app SHAs)
|
||||
- ../sm-summercmsio-app/CLAUDE.md (submodule rules; use ssu for status)
|
||||
</read_first>
|
||||
<action>
|
||||
If Task 4 answered "defer": write the pending bumps (app, submodule path, target SHA) into the SUMMARY under "Pending pointer bumps" and stop. If "pushed": for each app and each mounted repository that Jakub pushed (in sm-summercmsio-app this includes plugins/golem15/summercms and vue-summercmsio-app at the 34-03 SHAs), run `git -C <app>/<path> fetch origin` and check out the pushed SHA, confirm `git -C <app> status --short` shows only those gitlinks (plus any other session's own edits, which stay unstaged), run `go -C <app> build ./...` in workspace mode, and commit only the gitlinks with `chore: bump plugin pointers for plugin.yaml`. Do not run summer build in the apps and do not commit regenerated main.go or plugins.gen.go: that regeneration belongs to each app's framework upgrade at release (research F10). Never push the apps.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>for a in sm-demo-app sm-grzybyfunkcjonalne-app sm-bm-app sm-summercmsio-app fonoteka.go; do test -d ../$a || continue; go -C ../$a build ./... >/dev/null 2>&1 || echo "FAIL $a"; done; echo done</automated>
|
||||
<fails_when>any "FAIL <app>" line before "done"</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- With "pushed": every bumped submodule's HEAD equals a SHA Jakub listed, and `git -C <app> show --stat HEAD` lists only gitlinks
|
||||
- With "defer": the SUMMARY contains a "Pending pointer bumps" table with app, path and SHA for every mounted plugin repository changed in Tasks 1-3
|
||||
- No app is pushed (`git -C ../sm-demo-app status -sb | head -1` shows no change in the ahead count beyond the new commit)
|
||||
</acceptance_criteria>
|
||||
<done>Apps either track the new plugin commits or the release has an exact list of pointer bumps to make.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| plugin repository → registry and installers | plugin.yaml in a tag is read by summercms.io and by `summer plugin:install` |
|
||||
| developer workstation → shared remotes | pushes and tags (user-gated) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-34-07 | Tampering | hand-edited or stale plugin.yaml | medium | mitigate | the file is generated only; TestPluginManifest in each repo fails on drift (Task 1 proves the failure) |
|
||||
| T-34-08 | Spoofing | a manifest claiming another module or ID | medium | mitigate | module and package come from go.mod and the directory; IDs from plugin.go; the ingester re-checks module == repository path (34-03) |
|
||||
| T-34-09 | Tampering | app pointers referencing unpushed commits | high | mitigate | pointer bumps only after Jakub reports the push (Task 4), and only gitlinks are committed |
|
||||
| T-34-10 | Denial of service | consumers resolving the unreleased framework minimum | medium | accept | plugins are not tagged until v0.6.0 exists (release order recorded in 34-06's ledger); existing tags are never moved |
|
||||
| T-34-SC | Tampering | npm/pip/cargo installs | high | accept | no package is installed; go.sum changes come from `go mod tidy` against modules the framework already requires |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Every listed repository: `go vet ./...`, `go test -short ./...` and `TestPluginManifest` pass; `summer plugin:manifest --check` passes.
|
||||
- Anonymous `git ls-remote` succeeds for every listed repository.
|
||||
- No tags or pushes made by the agent.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 20 public plugin repositories (21 plugin packages) carry a generated plugin.yaml and its test, or a recorded F8 exception for chatbots.
|
||||
- Each requires the framework release containing kiosk.
|
||||
- App pointers are bumped after a reported push, or listed for the release.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/34-plugin-marketplace/34-02-SUMMARY.md` when done, with the per-repository table (ID, name, framework minimum, requires, SHA), warnings, skips and the pointer-bump outcome.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Files: `plugin.yaml` and `manifest_test.go` in each of ../sm-{chat,chatbots,chatchannels,chatvideo,faq,forum,herald,journal,menu,mfa,oauthserver,paymentgateway,paymentgatewaysubscriptions,pgstripe,seo,sitemap,sociallogin,translate,user,userfriends}-plugin, plus `subscriptions/plugin.yaml` and `subscriptions/manifest_test.go` in sm-pgstripe-plugin.
|
||||
- Tests: `TestPluginManifest` in every listed repository.
|
||||
- go.mod: framework requirement raised to the confirmed release (v0.6.0); sm-chatbots-plugin's sm-chat-plugin requirement raised from v0.0.0 to v0.1.3 when it builds.
|
||||
- Display names (plugin.yaml `overrides.name`): FAQ, Journal, Menu, Multi-factor authentication, OAuth server, Payment gateway, Payment gateway subscriptions, Stripe payments, Stripe subscriptions, SEO, Sitemap, Social login, Translate, User.
|
||||
- Config keys, CLI commands, types: none.
|
||||
260
.planning/phases/34-plugin-marketplace/34-02-SUMMARY.md
Normal file
260
.planning/phases/34-plugin-marketplace/34-02-SUMMARY.md
Normal file
@@ -0,0 +1,260 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 02
|
||||
subsystem: plugin-repositories
|
||||
tags: [kiosk, plugin-yaml, manifest, marketplace, plugin-repos, go-mod, release-ledger]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 34-plugin-marketplace (34-01)
|
||||
provides: "summer plugin:manifest [--check] [--name], kiosk.CheckT, plugin.yaml schema 1, framework release number v0.6.0"
|
||||
- phase: 34-plugin-marketplace (34-03)
|
||||
provides: "unpushed sm-summercmsio-plugin 5853cf5 and vue-summercmsio-app 05f1cea commits whose gitlinks sm-summercmsio-app still has to record"
|
||||
- phase: 34-plugin-marketplace (34-05)
|
||||
provides: "framework master 13324e7 (kiosk + cabana as compiled by every plugin test through its ../summercms.go replace)"
|
||||
provides:
|
||||
- "plugin.yaml generated by summer plugin:manifest in 20 sm-*-plugin repositories (21 plugin packages incl. sm-pgstripe-plugin/subscriptions)"
|
||||
- "TestPluginManifest (kiosk.CheckT(t, \".\")) in each of the 21 plugin packages: go test fails when plugin.yaml is stale"
|
||||
- "framework requirement git.golem15.com/golem15/summercms v0.6.0 in all 20 go.mod files (framework minimum v0.6.0 in every manifest)"
|
||||
- "sm-chatbots/chatchannels/chatvideo: sm-chat-plugin v0.0.0 requirement replaced with v0.1.3 (research F8)"
|
||||
- "Pending pointer bumps (32 gitlinks in 5 apps) and push blockers recorded for 34-06's release ledger"
|
||||
affects: [34-03 registry ingester (reads plugin.yaml at tags), 34-04 git-URL installs, 34-06 release ledger (pushes, tags, app pointer bumps)]
|
||||
|
||||
actuals:
|
||||
tokens: 61672 # chars/4 over the 20 plugin-repo commit diffs (go.sum churn included)
|
||||
tasks: 5
|
||||
commits: 20 # measured: one commit per plugin repo, each the repo's HEAD with the plan subject (git -C <repo> log -1); 0 in summercms.go
|
||||
plan_head_before: 13324e7f018be98c2e9a73557f7a367dd9616c7a
|
||||
plan_head_after: 13324e7f018be98c2e9a73557f7a367dd9616c7a
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Generated, test-guarded manifest: plugin.yaml is written only by summer plugin:manifest; manifest_test.go calls kiosk.CheckT(t, \".\") so drift fails go test"
|
||||
- "Display-name overrides persisted in plugin.yaml overrides.name when the README H1 is the plugin ID (research F7)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-{journal,translate,user,chat,faq,menu,seo,sitemap,mfa,oauthserver,sociallogin,paymentgateway,paymentgatewaysubscriptions,pgstripe,chatchannels,chatvideo,forum,herald,userfriends,chatbots}-plugin/plugin.yaml
|
||||
- ../sm-{same 20}-plugin/manifest_test.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.yaml
|
||||
- ../sm-pgstripe-plugin/subscriptions/manifest_test.go
|
||||
modified:
|
||||
- ../sm-{same 20}-plugin/go.mod
|
||||
- ../sm-{18 of 20}-plugin/go.sum (no go.sum change in sm-user-plugin and sm-oauthserver-plugin)
|
||||
|
||||
key-decisions:
|
||||
- "Task 4 answered \"defer\": no app submodule pointer bumps in phase 34; the 32 pending gitlinks move to the release ledger that 34-06 writes"
|
||||
- "Research F8 applied to all three chat dependants (chatbots, chatchannels, chatvideo): sm-chat-plugin requirement v0.0.0 -> v0.1.3, local replace kept"
|
||||
- "Display names for ID-titled READMEs: FAQ, Journal, Menu, Multi-factor authentication, OAuth server, Payment gateway, Payment gateway subscriptions, Stripe payments, Stripe subscriptions, SEO, Sitemap, Social login, Translate, User"
|
||||
|
||||
requirements-completed: [SC-1, D-01, D-02, D-03, D-04, D-05]
|
||||
|
||||
duration: ~25min execution (Tasks 1-3 18:24:35Z-18:39:52Z; Task 5 and SUMMARY 19:55Z-20:05Z; checkpoint wait in between)
|
||||
completed: 2026-10-29
|
||||
---
|
||||
|
||||
# Phase 34 Plan 02: plugin.yaml rollout to the public plugin repositories Summary
|
||||
|
||||
**All 20 public sm-*-plugin repositories (21 plugin packages) now commit a plugin.yaml generated by `summer plugin:manifest`, guarded by a TestPluginManifest that calls kiosk.CheckT, and require framework v0.6.0. None was pushed or tagged. App pointer bumps are deferred to the 34-06 release ledger, as Jakub decided.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min of execution (Tasks 1-3: 2026-10-29T18:24:35Z to 18:39:52Z; continuation for Task 5 and the SUMMARY: about 19:55Z to 20:05Z)
|
||||
- **Tasks:** 5/5 (Task 4 was a decision checkpoint, answered "defer")
|
||||
- **Commits:** 20 plugin-repository commits (one per repo), plus this docs commit and the roadmap progress commit in summercms.go
|
||||
- **Plugin packages:** 21
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Tracer (Task 1): sm-journal-plugin carries a generated, test-guarded plugin.yaml. The drift guard was demonstrated: editing one word of the description made TestPluginManifest fail, and restoring the file made it pass again.
|
||||
- Task 2: the 13 other tagged repositories, done in dependency order. sm-pgstripe-plugin has two manifests (root and `subscriptions/`) in one commit.
|
||||
- Task 3: the 6 untagged repositories. The v0.0.0 sm-chat-plugin requirement (research F8) was resolved to v0.1.3, and each repository still builds against the real v0.1.3 module with the replace removed.
|
||||
- Every manifest says `framework: v0.6.0`, and every go.mod requires `git.golem15.com/golem15/summercms v0.6.0` (it builds locally through the existing `../summercms.go` replace).
|
||||
|
||||
## Per-repository table
|
||||
|
||||
Commit subject in every repository: `feat: plugin.yaml manifest for the SummerCMS marketplace`. Framework minimum: v0.6.0 everywhere. Files per commit: plugin.yaml, manifest_test.go, go.mod, go.sum. sm-pgstripe-plugin also has the `subscriptions/` pair. sm-user-plugin and sm-oauthserver-plugin had no go.sum change.
|
||||
|
||||
| Task | Repository | Branch | Commit | Plugin ID(s) | Name (source) | requires |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | sm-journal-plugin | main | 5b38a3b20d784135d171128a75855d087c600b91 | golem15.journal | Journal (override) | golem15.translate v0.1.0 |
|
||||
| 2 | sm-translate-plugin | main | 7e64224eaf1d7f0800063337ab5d1372569f2646 | golem15.translate | Translate (override) | - |
|
||||
| 2 | sm-user-plugin | master | 97f83cc2cf7b6159b672a3a84812230a55cc8acf | golem15.user | User (override) | - |
|
||||
| 2 | sm-chat-plugin | main | b952128440d506c7113e64196185053ff2517780 | golem15.chat | Chat (README) | golem15.user v0.1.2 |
|
||||
| 2 | sm-faq-plugin | main | 3bf54cd2f2d8c52af0ea42a4b25b3dc5253c4405 | golem15.faq | FAQ (override) | golem15.translate v0.1.0 |
|
||||
| 2 | sm-menu-plugin | main | 98df4c74bd0a38efbbc6dde3c2155e840e31b708 | golem15.menu | Menu (override) | - |
|
||||
| 2 | sm-seo-plugin | main | 55f5e5dbb0c1fc50851dd51c9a8f258d265d6024 | golem15.seo | SEO (override) | - |
|
||||
| 2 | sm-sitemap-plugin | main | 049f3ee270ed9ac2619f357626914b1a8caac780 | golem15.sitemap | Sitemap (override) | - |
|
||||
| 2 | sm-mfa-plugin | master | 544017ce5622b0016d3393b1c1df34375c556716 | golem15.mfa | Multi-factor authentication (override) | golem15.user v0.1.12 |
|
||||
| 2 | sm-oauthserver-plugin | master | d600e2c1e8de1223124a111300ac72250f8033fc | golem15.oauthserver | OAuth server (override) | golem15.user v0.1.17 |
|
||||
| 2 | sm-sociallogin-plugin | master | 47b3bb2f8a3aec2ce0eac796093eebda1545bac9 | golem15.sociallogin | Social login (override) | golem15.user v0.1.12 |
|
||||
| 2 | sm-paymentgateway-plugin | main | 300bc097d5ecdd091e7cba54a819dce0f95ff08d | golem15.paymentgateway | Payment gateway (override) | golem15.user v0.1.1 |
|
||||
| 2 | sm-paymentgatewaysubscriptions-plugin | main | c81e38531a82ae795bc0c4b1cbbe1160fc31ebfa | golem15.paymentgatewaysubscriptions | Payment gateway subscriptions (override) | golem15.paymentgateway v0.1.0, golem15.user v0.1.1 |
|
||||
| 2 | sm-pgstripe-plugin (+ subscriptions/) | main | d61508ffb4d74f9e185cd1744cdaa2ef87de4379 | golem15.pgstripe; golem15.pgstripesubscriptions | Stripe payments; Stripe subscriptions (overrides) | pgstripe: golem15.paymentgateway v0.1.0; subscriptions: golem15.paymentgatewaysubscriptions v0.1.2, golem15.pgstripe "" (same module) |
|
||||
| 3 | sm-chatchannels-plugin | main | 322629b08730a00dc6f9b61060dd61befaeeeda2 | golem15.chatchannels | ChatChannels (README) | golem15.chat v0.1.3, golem15.user v0.1.2 |
|
||||
| 3 | sm-chatvideo-plugin | main | 33ff4f50c6ea6fd8adef89b42da2bbf00c41a3b0 | golem15.chatvideo | Chat video (README) | golem15.chat v0.1.3 |
|
||||
| 3 | sm-forum-plugin | main | 36bd8754dea747d3a7cca2d39b024d71bd6c50f7 | golem15.forum | Forum (README) | golem15.user v0.1.2 |
|
||||
| 3 | sm-herald-plugin | main | db8a1bb58056a4ba679e83c2e767ff4064e6bb70 | golem15.herald | Herald (README) | - |
|
||||
| 3 | sm-userfriends-plugin | main | 2f29b263cad09fde5212bd2e54740e2ff415d594 | golem15.userfriends | Friends (README) | golem15.user v0.1.1 |
|
||||
| 3 | sm-chatbots-plugin | main | 0cd78f2c92ff03a24b3cc4c59e955a4337ade321 | golem15.chatbots | Chat bots (README) | golem15.chat v0.1.3, golem15.user v0.1.2 |
|
||||
|
||||
There are 14 name overrides, exactly the ones planned. Re-verified at continuation: each repository's HEAD is the commit above with the plan subject, and `git tag --points-at HEAD` is empty in all 20.
|
||||
|
||||
## Verification
|
||||
|
||||
- Every repository passes TestPluginManifest, `go vet ./...` and `go test -short ./...`. The one exception is note 1: a pre-existing sm-user-plugin test unrelated to this plan.
|
||||
- `summer plugin:manifest --check` passes for all 21 plugin packages.
|
||||
- Anonymous `git ls-remote` over HTTPS works for all 20 repositories (SC-1).
|
||||
- No tag points at any new commit, and no repository was pushed.
|
||||
- The drift guard was demonstrated on journal (edit, stale failure, restore, pass).
|
||||
- The CLI was built from framework 13324e7 into a scratch directory. Jakub's installed `summer` was not overwritten.
|
||||
- Task 5 (defer): `go build ./...` succeeds in workspace mode for sm-demo-app, sm-grzybyfunkcjonalne-app, sm-bm-app, sm-summercmsio-app and fonoteka.go. No app repository was modified.
|
||||
|
||||
## Task 4 decision
|
||||
|
||||
**"defer"**: no app submodule pointer bumps in this phase. They move to the release ledger that 34-06 writes. The apps stay on their current, released plugin commits until the framework (v0.6.0) and the plugins are pushed and tagged.
|
||||
|
||||
## Pending pointer bumps (for the 34-06 release ledger)
|
||||
|
||||
There are 32 gitlinks across 5 apps. Make each bump only after the target commit is pushed, and commit only the gitlinks with `chore: bump plugin pointers for plugin.yaml`, one commit per app. Do not commit a regenerated main.go or plugins.gen.go in that commit; that regeneration belongs to each app's framework upgrade (research F10). "ff" means the current pointer is an ancestor of the target, checked locally.
|
||||
|
||||
### sm-demo-app (master, in sync with origin)
|
||||
|
||||
| Path | Current | Target | |
|
||||
|---|---|---|---|
|
||||
| plugins/golem15/faq | b3cc9784deff25dd408a94322fe025674d95f99f | 3bf54cd2f2d8c52af0ea42a4b25b3dc5253c4405 | ff |
|
||||
| plugins/golem15/journal | f5a0394d6914a3288f2de8425ee12ec635cd7120 | 5b38a3b20d784135d171128a75855d087c600b91 | ff |
|
||||
| plugins/golem15/menu | 86564746de01e965e95295c55bc70dc5dbd77ca2 | 98df4c74bd0a38efbbc6dde3c2155e840e31b708 | ff |
|
||||
| plugins/golem15/mfa | 463c750312cc409b010e636645f9e5ebfa8d2ee1 | 544017ce5622b0016d3393b1c1df34375c556716 | ff |
|
||||
| plugins/golem15/oauthserver | 8bbec72a04f2b11c6f3c42d5111cbebd80e6886d | d600e2c1e8de1223124a111300ac72250f8033fc | ff |
|
||||
| plugins/golem15/paymentgateway | e0badb58dc6ea848f3ca44e917f34a8d01e660da | 300bc097d5ecdd091e7cba54a819dce0f95ff08d | ff |
|
||||
| plugins/golem15/paymentgatewaysubscriptions | 1ec96dfb9b0b9bfb9d7cb40e843e629e06250d75 | c81e38531a82ae795bc0c4b1cbbe1160fc31ebfa | ff |
|
||||
| plugins/golem15/pgstripe | 6d07d98f6518fcb1e14fae37d2456be0e75f3485 | d61508ffb4d74f9e185cd1744cdaa2ef87de4379 | ff |
|
||||
| plugins/golem15/seo | b19cd069a5b2925692a1f8bc1fe04b3ef421e456 | 55f5e5dbb0c1fc50851dd51c9a8f258d265d6024 | ff |
|
||||
| plugins/golem15/sitemap | 96c790df39043e7028203bb572aaaef0de2826dc | 049f3ee270ed9ac2619f357626914b1a8caac780 | ff |
|
||||
| plugins/golem15/sociallogin | ecfb8a7cbc0cda100778c7c47ea5a29888a942c9 | 47b3bb2f8a3aec2ce0eac796093eebda1545bac9 | ff |
|
||||
| plugins/golem15/translate | 54cf93bcfe8f9b6ccd4b6c4899d2e4469ed1c82b | 7e64224eaf1d7f0800063337ab5d1372569f2646 | ff |
|
||||
| plugins/golem15/user | 07196a4bcbfee1f200c17f66cad147a4c091cb26 | 97f83cc2cf7b6159b672a3a84812230a55cc8acf | ff |
|
||||
|
||||
### sm-grzybyfunkcjonalne-app (master, 1 unpushed app commit already)
|
||||
|
||||
| Path | Current | Target | |
|
||||
|---|---|---|---|
|
||||
| plugins/golem15/faq | b3cc9784deff25dd408a94322fe025674d95f99f | 3bf54cd2f2d8c52af0ea42a4b25b3dc5253c4405 | ff |
|
||||
| plugins/golem15/journal | ffa2c8ffb1c894d94f4e412def39fd84c70ac502 | 5b38a3b20d784135d171128a75855d087c600b91 | ff |
|
||||
| plugins/golem15/menu | 86564746de01e965e95295c55bc70dc5dbd77ca2 | 98df4c74bd0a38efbbc6dde3c2155e840e31b708 | ff |
|
||||
| plugins/golem15/sitemap | 96c790df39043e7028203bb572aaaef0de2826dc | 049f3ee270ed9ac2619f357626914b1a8caac780 | ff |
|
||||
| plugins/golem15/translate | 54cf93bcfe8f9b6ccd4b6c4899d2e4469ed1c82b | 7e64224eaf1d7f0800063337ab5d1372569f2646 | ff |
|
||||
| plugins/golem15/user | bfcfea4ed7d45374e9584557412b88797b051741 | 97f83cc2cf7b6159b672a3a84812230a55cc8acf | ff |
|
||||
|
||||
### sm-bm-app (master, in sync with origin)
|
||||
|
||||
| Path | Current | Target | |
|
||||
|---|---|---|---|
|
||||
| plugins/golem15/chat | 51c9e1759508b25e72b440a65edd6b6f37dc336f (= origin/main) | rebased SHA of b952128 (TBD) | NOT ff: rebase first |
|
||||
| plugins/golem15/chatbots | d0b9fb16f88ffe11b1e0ba415df011aae920e53d (= origin/main) | rebased SHA of 0cd78f2 (TBD) | NOT ff: rebase first |
|
||||
| plugins/golem15/chatchannels | a30f04a1d82b821d28766780862305da832ee7af (= origin/main) | rebased SHA of 322629b (TBD) | NOT ff: rebase first |
|
||||
| plugins/golem15/mfa | 463c750312cc409b010e636645f9e5ebfa8d2ee1 | 544017ce5622b0016d3393b1c1df34375c556716 | ff |
|
||||
| plugins/golem15/oauthserver | df2edddd488ce161bf1bd0c618ab519a0b53e710 | d600e2c1e8de1223124a111300ac72250f8033fc | ff |
|
||||
| plugins/golem15/sociallogin | ecfb8a7cbc0cda100778c7c47ea5a29888a942c9 | 47b3bb2f8a3aec2ce0eac796093eebda1545bac9 | ff |
|
||||
| plugins/golem15/user | 66fc107eb9cd6c5b63221308815719c81110c085 | 97f83cc2cf7b6159b672a3a84812230a55cc8acf | ff |
|
||||
|
||||
sm-bm-app already points at the origin/main tips of chat, chatbots and chatchannels, and the local plugin.yaml commits branched off before those tips. Bumping to b952128 / 0cd78f2 / 322629b as they stand would move the app backwards. The bumps have to wait for the rebase described under the push blockers.
|
||||
|
||||
### sm-summercmsio-app (master, 5 unpushed app commits from 34-03)
|
||||
|
||||
| Path | Current | Target | |
|
||||
|---|---|---|---|
|
||||
| plugins/golem15/journal | ffa2c8ffb1c894d94f4e412def39fd84c70ac502 | 5b38a3b20d784135d171128a75855d087c600b91 | ff |
|
||||
| plugins/golem15/translate | 1134dd1e29129d56a6d9cf087a078f0c65298c4c | 7e64224eaf1d7f0800063337ab5d1372569f2646 | ff |
|
||||
| plugins/golem15/user | 50da92c1fb493f96b27170d1fde97861f1156de6 | 97f83cc2cf7b6159b672a3a84812230a55cc8acf | ff |
|
||||
| plugins/golem15/summercms (sm-summercmsio-plugin, 34-03) | ca5941540c456cd850cf3b2f50e1d3c2e3ab6ee5 | 5853cf5be0bb681a8751a75c65bcaa90a3b19740 | from 34-03 |
|
||||
| vue-summercmsio-app (34-03) | b3bfc5da80d020d318e8d6d4b3b60a45c1d84f42 | 05f1cea5ac73ac45e632703a5e406012ac426bd2 | from 34-03 |
|
||||
|
||||
### fonoteka.go (master, 39 unpushed app commits already)
|
||||
|
||||
| Path | Current | Target | |
|
||||
|---|---|---|---|
|
||||
| plugins/golem15/user | a1b78b80f55f8569851c858a6c404809449439e0 | 97f83cc2cf7b6159b672a3a84812230a55cc8acf | ff |
|
||||
|
||||
No app mounts sm-chatvideo-plugin, sm-forum-plugin, sm-herald-plugin or sm-userfriends-plugin, so those four need a push and a tag but no pointer bump.
|
||||
|
||||
## Push blockers and push order (for the 34-06 release ledger)
|
||||
|
||||
**Diverged from origin.** Each of these needs a rebase onto origin/main, a regenerated plugin.yaml and a test run, which produces new SHAs. go.mod/go.sum conflicts are likely.
|
||||
- sm-chat-plugin: 1 ahead, 12 behind (origin/main has 12 commits after v0.1.3; origin tip 51c9e17).
|
||||
- sm-chatbots-plugin: 1 ahead, 1 behind (origin d0b9fb1 "chore: drop sibling replaces from go.mod"). After the rebase chatbots has no framework replace, so CheckT may only pass once v0.6.0 is tagged.
|
||||
- sm-chatchannels-plugin: 1 ahead, 1 behind (origin a30f04a "feat: optional HTTP API switch", which touches go.mod).
|
||||
|
||||
**Older unpushed commits a push would carry:**
|
||||
- sm-journal-plugin: 3 (a6fc7cd, 7a03640, 62c75b1), so it is 4 ahead in total.
|
||||
- sm-seo-plugin: 21, so it is 22 ahead in total.
|
||||
- summercms.go master is ahead of origin (23+ commits).
|
||||
|
||||
**Push order:** summercms.go, then translate, user, chat, faq, menu, seo, sitemap, mfa, oauthserver, sociallogin, paymentgateway, paymentgatewaysubscriptions, pgstripe, journal, chatchannels, chatvideo, forum, herald, userfriends and chatbots, then sm-summercmsio-plugin (sm-summercmsio-app/plugins/golem15/summercms, master 5853cf5be0bb681a8751a75c65bcaa90a3b19740) and vue-summercmsio-app (master 05f1cea5ac73ac45e632703a5e406012ac426bd2) from 34-03. Plugin tags follow the framework's v0.6.0 tag (T-34-10). App pointer bumps come last, one commit per app.
|
||||
|
||||
## Notes
|
||||
|
||||
1. sm-user-plugin `TestRegisterCORSPath` (register_test.go:360) fails in a standalone checkout because it walks up the tree looking for an app's `config/http.yaml`. The failure predates this plan and is out of scope (see Deferred items).
|
||||
2. Research F8 was applied to chatbots, chatchannels and chatvideo: sm-chat-plugin v0.0.0 became v0.1.3 (latest tag), with the local replace kept. Each was verified to build and vet with the replace removed (network fetch of v0.1.3). In chatbots, `go mod tidy` also raised sm-user-plugin from v0.1.1 to v0.1.2, because chat v0.1.3 requires it.
|
||||
3. `go mod tidy` removed stale indirect requires (charm.land/fang/v2, charmtone, muesli/mango*, muesli/roff), left over after the framework dropped fang in 53c0a65. No direct require was removed or downgraded. translate, userfriends and chatbots gained kiosk's indirect requires.
|
||||
4. userfriends requires sm-chat-plugin at a pseudo-version, but its Requires() lists only golem15.user, so the manifest raises no pseudo-version warning.
|
||||
5. Other sessions' edits were left alone: in sm-oauthserver-plugin, an uncommitted go.work edit (user replace pointing to ../sm-demo-app/plugins/golem15/user) stays unstaged; in sm-journal-plugin, the untracked go.work.sum stays untouched.
|
||||
6. Timing: Tasks 1-3 ran from 2026-10-29T18:24:35Z to 18:39:52Z. The checkpoint was answered "defer", and the continuation ran Task 5 and wrote this SUMMARY.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] F8 resolution extended from chatbots to chatchannels and chatvideo**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan described the v0.0.0 sm-chat-plugin requirement for chatbots only. chatchannels and chatvideo carry the same requirement, and Task 3's verify rejects a skip for any repository other than chatbots.
|
||||
- **Fix:** The same treatment in all three: require sm-chat-plugin v0.1.3, keep the local replace, and verify the build against the real v0.1.3 with the replace removed.
|
||||
- **Files modified:** ../sm-{chatbots,chatchannels,chatvideo}-plugin/go.mod, go.sum
|
||||
- **Commits:** 0cd78f2, 322629b, 33ff4f5
|
||||
|
||||
**2. [Rule 3 - Blocking] Build byproducts removed after Task 5's app build check**
|
||||
- **Found during:** Task 5 verify
|
||||
- **Issue:** `go build ./...` writes a binary when the pattern matches a single main package. It left `sm-grzybyfunkcjonalne-app/sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app/sm-summercmsio-app` (untracked, created by this run).
|
||||
- **Fix:** Removed both files. No tracked file in any app changed.
|
||||
- **Commit:** none (untracked artifacts)
|
||||
|
||||
## Deferred Issues
|
||||
|
||||
- sm-user-plugin `TestRegisterCORSPath` (register_test.go:360) fails outside an app checkout. It predates this plan and belongs to the plugin's own test setup, not to the manifest rollout.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. plugin.yaml files are generated from each repository's go.mod, plugin.go and README.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. T-34-07 and T-34-08 are mitigated by the generated file and TestPluginManifest. T-34-09 is mitigated by the "defer" answer: no pointer references an unpushed commit. T-34-10 is accepted and handled by the push order above.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 34-06 owns the release ledger: framework v0.6.0 tag, plugin pushes and first or next tags, the three chat rebases, and the 32 pointer bumps listed above.
|
||||
- The registry (34-03) lists a plugin only once its plugin.yaml is in a pushed tag.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- 20/20 plugin-repo commits found as each repository's HEAD with the plan subject (`git -C <repo> log -1`), none tagged.
|
||||
- All five apps build in workspace mode; no app repository modified.
|
||||
|
||||
## Gap closure: SC-1 remaining repos
|
||||
|
||||
34-VERIFICATION.md found four more public plugin repositories on git.golem15.com without plugin.yaml. Jakub chose to roll them out now (2026-10-29). They were cloned next to the framework (`git@git.golem15.com:golem15/sm-<name>-plugin.git`) and given the same treatment as the 20 above: framework require v0.6.0, `go mod tidy`, `summer plugin:manifest` (with `--name` for golem, feedback and quizzes, whose README H1 is the ID), and `manifest_test.go` calling `kiosk.CheckT(t, ".")`. Each got one manifest commit (presentations after a separate `docs: README` commit) with the subject `feat: plugin.yaml manifest for the SummerCMS marketplace`, containing plugin.yaml, manifest_test.go, go.mod and go.sum. None was pushed or tagged.
|
||||
|
||||
| Repository | Branch | Commit | Plugin ID | Name (source) | requires | Framework | Tag now -> planned |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| sm-golem-plugin | master | 89fd90a6d21c78501499c18e4eea5a4853e9519d | golem15.golem | Golem AI (override) | - | v0.6.0 (was v0.4.0) | v0.1.1 -> v0.1.2 |
|
||||
| sm-feedback-plugin | master | dd78bd275ccf533ae3d07d6dfb415f2a1b44b2f7 | golem15.feedback | Feedback (override) | golem15.user v0.1.0 | v0.6.0 (was v0.4.0) | v0.1.1 -> v0.1.2 |
|
||||
| sm-quizzes-plugin | master | 67a83dba321f1c97471bc7ba704c2218c21dc4f8 | golem15.quizzes | Quizzes (override) | golem15.user v0.1.0 | v0.6.0 (was v0.1.9) | v0.1.0 -> v0.1.1 |
|
||||
| sm-presentations-plugin | master | 3eb4de72c5ec43ddd7c56de57202e79f7584a5e8 (after README cfa0c40 (both rebased onto sm-bm-app's unpushed theme commit d205cd7 on 2026-10-30)) | golem15.presentations | Presentations (README) | golem15.user v0.1.0 | v0.6.0 (was v0.5.0) | v0.1.1 -> v0.1.2 |
|
||||
|
||||
- **sm-presentations-plugin:** at first skipped because it had no README.md (kiosk refuses to derive without one, even with `--name` and `--description`). Jakub asked for it to be included, so a README was written from the code (H1 "Presentations", summary, Overview, Features, Usage, Configuration, API reference, CLI commands, Dependencies, Testing) and committed on its own as `docs: README` (cfa0c40). The manifest commit follows, with no name override. Its planned v0.1.2 also releases the 10 commits after v0.1.1.
|
||||
- **Framework fix 87921c2, `fix(kiosk)`:** these repositories are developed inside applications. Their go.mod replaces point at an application mount (`../../../../summercms.go`, and `sm-user-plugin => ../user`). kiosk resolves required plugins with `GOWORK=off`. Next to the framework, `../user` does not exist, so feedback and quizzes derived `golem15.user: ""`, while inside sm-demo-app they derived `v0.1.0`. TestPluginManifest could not pass in both layouts. kiosk now fetches a required module whose local replace directory is missing at the version go.mod requires, so both layouts give `v0.1.0`. The fix has a regression test (TestDeriveMissingReplace, offline file:// proxy), and the 21 existing manifests stayed fresh.
|
||||
- **Local build setup:** each clone has an untracked `go.work` listed in `.git/info/exclude`. It uses `.`, replaces the framework with `../summercms.go`, and, where the plugin requires the user plugin, replaces it with `../sm-demo-app/plugins/golem15/user`. The committed go.mod replaces are unchanged, and tidy ran with them pointed at those paths only temporarily.
|
||||
- **Checks:** for golem, feedback, quizzes and presentations, TestPluginManifest, `go vet ./...`, `go test -short ./...` (green before and after the change), `summer plugin:manifest --check` and anonymous `git ls-remote` over HTTPS all pass. The drift guard was demonstrated on feedback and presentations. `go mod tidy` removed the fang leftovers (note 3) and added framework v0.6.0's indirect requires. No direct require was removed or downgraded.
|
||||
- **Mounted in apps:** sm-demo-app mounts golem, feedback, quizzes and presentations. sm-bm-app mounts quizzes and presentations. fonoteka.go mounts golem and feedback. The eight pointer bumps are in 34-release-log.md. All are fast-forwards: golem, feedback and quizzes sit one commit on top of the app's pointer, and presentations is 13 commits after sm-demo-app's v0.1.1 and 2 after sm-bm-app's d205cd7.
|
||||
409
.planning/phases/34-plugin-marketplace/34-03-PLAN.md
Normal file
409
.planning/phases/34-plugin-marketplace/34-03-PLAN.md
Normal file
@@ -0,0 +1,409 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["34-01"]
|
||||
coupling_justified: ["34-04: same wave; 34-04 adds modules/kiosk/client.go and edits internal/build and cmd/summer in the framework checkout this plan compiles through go.work; only the kiosk package is shared, the change is one new file and each 34-04 commit compiles"]
|
||||
files_modified:
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/go.mod
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/go.sum
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/README.md
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/updates/registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/repository.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/version.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/repository/columns.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/repository/fields.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin/columns.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin/fields.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/git.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/query.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_list.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_form.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_list.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_form.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/console/registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/routes_registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/jobs.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/schedule.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/admin.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/admin_navigation.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/admin_permissions.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/lang.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/lang/en/lang.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/lang/pl/lang.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/links_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/testmain_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry_smoke_test.go
|
||||
- ../sm-summercmsio-app/config/queue.yaml
|
||||
- ../sm-summercmsio-app/config/http.yaml
|
||||
- ../sm-summercmsio-app/deploy/nginx/summercms.io.conf
|
||||
- ../sm-summercmsio-app/deploy/supervisor/summercms-io-changelog.conf
|
||||
- ../sm-summercmsio-app/deploy/changelog.env.example
|
||||
- ../sm-summercmsio-app/scripts/changelog-post.py
|
||||
- ../sm-summercmsio-app/scripts/test_changelog_post.py
|
||||
- ../sm-summercmsio-app/scripts/smoke.sh
|
||||
- ../sm-summercmsio-app/DEPLOY.md
|
||||
- ../sm-summercmsio-app/README.md
|
||||
- ../sm-summercmsio-app/CLAUDE.md
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/i18n/locales/en.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/index.vue
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useRegistry.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/marketplace.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/marketplace-smoke.mjs
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/expect-marketplace.txt
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/registry/plugins.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/registry/plugin-golem15.journal.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/registry/versions-golem15.journal.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/package.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, D-04, D-13, D-14, D-15, D-16]
|
||||
user_setup:
|
||||
- service: summercms-io-registry-token
|
||||
why: "changelog-post.py calls the ingest-now endpoint after a plugin release; without a token scoped `registry` the call answers 403, is tolerated, and the hourly ingest picks the tag up"
|
||||
dashboard_config:
|
||||
- task: "Mint a personal API token with the scope `registry` (Account → API tokens on the summercms.io admin, through the SSH tunnel) and store it in ~/.config/summercms/registry-token with mode 600; the agent never mints, prints or copies tokens"
|
||||
location: "summercms.io admin over the SSH tunnel (DEPLOY.md)"
|
||||
- service: rome-git
|
||||
why: "the ingester shells out to git; production must have it"
|
||||
dashboard_config:
|
||||
- task: "Before the deploy that ships the registry, confirm `command -v git` on rome (DEPLOY.md step added in Task 4)"
|
||||
location: "rome (debian@10.8.3.118 -p 2222)"
|
||||
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A summercms.io administrator curates the listing by adding a repository URL; the ingester fills every fact from the repository and a plugin is published only once one of its vX.Y.Z tags carries a valid plugin.yaml; tags without one are skipped (D-13, D-14)"
|
||||
- "The ingester clones anonymously over HTTPS into a temporary bare partial clone, reads tags, each tag's plugin.yaml files and the latest tag's README, never checks out a tree, never recurses into submodules, caps every blob it reads, and refuses a plugin.yaml whose module differs from the repository path or whose ID another listed repository already owns (D-13, research Pitfall 13)"
|
||||
- "Every vX.Y.Z tag becomes a version row with its framework minimum and plugin minimums; the README is stored for the latest tag only, as markdown and as breakwater-sanitized HTML (D-15)"
|
||||
- "Ingestion runs hourly as a scheduled console command and on demand through POST /api/registry/v1/ingest, which needs a backend principal holding golem15.summercms.registry_ingest through a token scoped `registry`; no forge webhooks (D-13, user decision 3)"
|
||||
- "GET /api/registry/v1/plugins?q=&category=, /plugins/{id} and /plugins/{id}/versions answer without auth with open CORS, Cache-Control and an ETag, encoded with kiosk's wire types so their shape is exactly the one frozen at 34-01 Task 1 (D-16)"
|
||||
- "Category, featured flag, screenshots, icon and sort order are edited in the summercms.io admin and appear in the API; the facts (versions, requirements, module, README) are read-only there (SC-2, decision note)"
|
||||
- "/marketplace lists, searches and filters plugins by category and /marketplace/<vendor>/<name> shows the detail with the install command, README and versions, rendered by the changelog Node SSR app from the registry API (SC-2)"
|
||||
- "`changelog-post.py publish … --repo` refuses a plugin release whose committed plugin.yaml fails `summer plugin:manifest --check`, and after a successful post asks the registry to ingest now, treating a 401/403/404 or network failure as a warning (D-04, D-13, user decision 3)"
|
||||
- "Production config serves jobs (queue.work_in_serve true), nginx routes /marketplace to the Node app and allows POST only on the ingest path, and DEPLOY.md requires git on the server (research F10, Pitfalls 9-10)"
|
||||
artifacts:
|
||||
- path: "../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go"
|
||||
provides: "pull ingester"
|
||||
contains: "breakwater.Render"
|
||||
- path: "../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go"
|
||||
provides: "public registry API and ingest-now endpoint"
|
||||
contains: "kiosk.ListResponse"
|
||||
- path: "../sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go"
|
||||
provides: "registry tables"
|
||||
contains: "golem15_summercms_registry_versions"
|
||||
- path: "../sm-summercmsio-app/plugins/golem15/summercms/registry_smoke_test.go"
|
||||
provides: "TestRegistrySmoke (tracer)"
|
||||
contains: "func TestRegistrySmoke("
|
||||
- path: "../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/index.vue"
|
||||
provides: "marketplace list page"
|
||||
contains: "plugin:install"
|
||||
- path: "../sm-summercmsio-app/scripts/changelog-post.py"
|
||||
provides: "manifest check and ingest-now hooks"
|
||||
contains: "api/registry/v1/ingest"
|
||||
key_links:
|
||||
- from: "../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go"
|
||||
to: "modules/kiosk/manifest.go"
|
||||
via: "each tag's plugin.yaml goes through kiosk.Parse and kiosk.Validate before a version row is written"
|
||||
pattern: "kiosk\\.Parse\\("
|
||||
- from: "../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go"
|
||||
to: "modules/kiosk/registry.go"
|
||||
via: "responses are kiosk.ListResponse, kiosk.DetailResponse, kiosk.VersionsResponse and kiosk.ErrorResponse values"
|
||||
pattern: "kiosk\\.(ListResponse|DetailResponse|VersionsResponse|ErrorResponse)"
|
||||
- from: "../sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useRegistry.ts"
|
||||
to: "/api/registry/v1"
|
||||
via: "SSR fetches through runtimeConfig.registry.apiBase (NUXT_REGISTRY_API_BASE), browser fetches through the same-origin path"
|
||||
pattern: "registry"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
People browse the public Golem15 plugins at summercms.io/marketplace. This plan builds the marketplace itself: the registry that ingests plugin repositories into Postgres, its public read-only API, the admin that curates and decorates the listing, the pages, the deploy configuration and the release-script hooks.
|
||||
|
||||
<objective>
|
||||
In the summercms.io application (`../sm-summercmsio-app`) and its two submodules, the site plugin `golem15.summercms` (`plugins/golem15/summercms`, repository sm-summercmsio-plugin) and `vue-summercmsio-app`:
|
||||
- registry tables, models and a pull ingester (D-13, D-14, D-15);
|
||||
- the public API at /api/registry/v1 using kiosk's wire types (D-16, shape frozen at 34-01 Task 1);
|
||||
- admin curation and presentation fields, the `registry` token scope and the ingest-now endpoint (user decision 3);
|
||||
- hourly scheduled ingestion with jobs enabled in serve;
|
||||
- /marketplace pages in the changelog Node SSR app (research A4), a landing header link;
|
||||
- deploy configs, DEPLOY.md and the changelog-post.py hooks (D-04, D-13).
|
||||
|
||||
Launch listing (user decision 5): the tagged public plugins checked out next to the framework; the curated URLs are recorded in DEPLOY.md and added after the plugins' first plugin.yaml tags exist (user-gated release, ledger in 34-06).
|
||||
|
||||
Output: commits in the site plugin repository, the vue app repository and the app repository (each its own commits). Submodule pointer bumps in sm-summercmsio-app are not committed in this plan: they follow Jakub's push (34-02 Task 4) or move to the release ledger (CLAUDE.md). Nothing is pushed, tagged or deployed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/34-plugin-marketplace/34-CONTEXT.md
|
||||
@.planning/phases/34-plugin-marketplace/34-RESEARCH.md
|
||||
@.planning/phases/34-plugin-marketplace/34-PATTERNS.md
|
||||
@.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
|
||||
@../sm-summercmsio-app/CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 34-01 (names may be adjusted in its SUMMARY): kiosk.Parse, kiosk.Validate, kiosk.FileName, kiosk.ValidVersion, kiosk.CompareVersions; wire types kiosk.RegistryPlugin, RegistryPluginDetail, RegistryScreenshot, RegistryVersion, ListResponse{Data, Meta{Total}}, DetailResponse, VersionsResponse, ErrorResponse{Error{Code, Message}}, IngestRequest{Repository}, IngestResponse; kiosk.RegistryAPIPath = "/api/registry/v1".
|
||||
Framework: breakwater.Render(markdown string) string; cabana.ResolveBackend(app, r, cover...) and cabana.Allows(principal, []string); pact.HasMigrations/HasModels/HasAdminControllers/HasPermissions/HasNavigation/HasTokenScopes/HasJobs/HasSchedule/HasCommands/HasRoutes; pact.ScheduledCommand{Command, Cadence: pact.Every(time.Hour)}; conga.Job[T](fn, opts...) and conga.From(app) for enqueueing; surf middleware "throttle:<bucket>" and "cors:<profile>" with http.cors_profiles.<name> in config; lagoon/attach relations (sm-journal-plugin/models/post.go AttachRelations).
|
||||
Templates: ../sm-faq-plugin (updates/registry.go, migrations, models/registry.go, admin.go, admin_navigation.go, admin_permissions.go, controllers/*/config_*.yaml, routes.go, lang/, testmain_test.go with testcontainers), ../sm-journal-plugin (token_scopes.go, controllers/api/auth.go ~83-106), ../sm-chatvideo-plugin/schedule.go.
|
||||
Site plugin today: plugin.go declares only pact.HasRoutes; Routes fails closed without the public tree (tests use the fsys field); links_test.go nodePaths = ["/changelog"]; go.mod requires the framework v0.4.0 and the app's go.work replaces it with ../summercms.go.
|
||||
App: config/queue.yaml work_in_serve false; nginx denies non-GET outside /_journal/api/v1/ and 404s /backend; changelog Node app on 127.0.0.1:3095 with NUXT_JOURNAL_API_BASE; scripts/changelog-post.py has call(method, path, token, body=None, base=None), cmd_publish, SITE, FRAMEWORK, TOKEN_FILE.
|
||||
Changelog app: changelog/nuxt.config.ts (extends '..' and vue-journal-layer, buildAssetsDir /changelog/_nuxt/, runtimeConfig.journal.apiBase, routeRules swr 60, pages:extend removes the landing index); testing via vue-journal-layer's testing/nuxt-server.mjs startNuxtServer({outputDir, apiBase, port, env}) and smoke-ssr.mjs parseExpectLine.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Three repositories, committed separately and in this order within a task: site plugin (`git -C ../sm-summercmsio-app/plugins/golem15/summercms`), vue app (`git -C ../sm-summercmsio-app/vue-summercmsio-app`), app (`git -C ../sm-summercmsio-app`). Stage explicit paths; never stage a submodule gitlink in the app (the gitlinks stay modified until Jakub pushes; record the pending SHAs in the SUMMARY). Check `git status --short` in each repository first and leave other sessions' edits alone. No co-author tag.
|
||||
- Docker-backed tests: if the socket is denied, run them through `sudo -u golem` (CLAUDE.md).
|
||||
- Resume check: `git -C <repo> log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit subjects (repository) | Resume point |
|
||||
|------|------------------------------|--------------|
|
||||
| 1 | `feat: plugin registry tables, ingester and plugin detail API` (site) | TestRegistrySmoke/detail passes |
|
||||
| 2 | `feat: registry list, search and versions API, hourly ingest` (site); `feat: registry CORS profile and jobs in serve` (app) | TestRegistrySmoke passes |
|
||||
| 3 | `feat: registry admin, token scope and ingest-now endpoint` (site); `feat: manifest check and ingest-now in changelog-post` (app) | test_changelog_post.py passes |
|
||||
| 4 | `feat: marketplace pages` (vue); `feat: marketplace link` (site, links_test); `feat: marketplace deploy config and docs` (app) | marketplace smoke passes |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "a curated repository with a tagged plugin.yaml is served at GET /api/registry/v1/plugins/{id}" — one repository, one tag, one plugin</name>
|
||||
<reversibility rating="one-way">The JSON this endpoint returns is the D-16 contract; its shape was confirmed at 34-01 Task 1 and is encoded only through kiosk's wire types, so this task adds no field of its own.</reversibility>
|
||||
<precondition>.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md records the 34-01 Task 1 answer that froze the /api/registry/v1 shape (the checkpoint:decision for this one-way door); halt if it is absent</precondition>
|
||||
<files>../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/go.mod, ../sm-summercmsio-app/plugins/golem15/summercms/go.sum, ../sm-summercmsio-app/plugins/golem15/summercms/updates/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/models/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/models/repository.go, ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/models/version.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry/git.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry/query.go, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/routes_registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/testmain_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry_smoke_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/README.md</files>
|
||||
<read_first>
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, static.go, smoke_test.go (how tests build the plugin without the public tree)
|
||||
- ../sm-faq-plugin/updates/registry.go, ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go, ../sm-faq-plugin/models/registry.go, ../sm-faq-plugin/models/faq.go, ../sm-faq-plugin/routes.go, ../sm-faq-plugin/controllers/api/faqs.go, ../sm-faq-plugin/testmain_test.go
|
||||
- modules/kiosk/registry.go, modules/kiosk/manifest.go, modules/kiosk/README.md (34-01 output)
|
||||
- modules/breakwater/README.md
|
||||
- internal/build/new.go (~286 runGit: exec pattern)
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md ("Hardened git for the ingester", Pattern 7, Pitfall 13)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-13, D-14, D-15, D-16 (shape from 34-01 Task 1). In the site plugin: raise the framework requirement to the release confirmed at 34-01 Task 1 (`go mod edit -require`, then `go mod tidy`; the app's go.work keeps replacing it). Migration `202610300001_create_golem15_summercms_registry` (raw SQL like faq, with Rollback dropping in reverse order) creates `golem15_summercms_registry_repositories` (id, url text unique, module text, enabled bool default true, tags_digest text, last_ingested_at, last_error text, sort timestamps), `golem15_summercms_registry_plugins` (id, repository_id fk, plugin_id text unique, path text, name, description, module, package, latest_version, latest_framework, readme_markdown, readme_html, category text, featured bool default false, sort_order int default 0, published bool default false, timestamps) and `golem15_summercms_registry_versions` (id, plugin_row_id fk on delete cascade, version, framework, requires jsonb, module, package, commit text, tagged_at, unique (plugin_row_id, version)). Models `Repository`, `Plugin`, `Version` with a models registry like faq. `Repository.module` is derived from an https URL (host plus path without `.git` and trailing slash) when saved; it is what plugin.yaml's module must equal.
|
||||
`registry/git.go`: a runner that executes git with exec.CommandContext, `--` before every URL and path, a minimal environment (PATH plus a temporary HOME) and GIT_TERMINAL_PROMPT=0, GIT_ALLOW_PROTOCOL from a package variable `allowedProtocols` (default "https"; the test sets "https:file"), GIT_CONFIG_NOSYSTEM=1, GIT_CONFIG_GLOBAL=/dev/null; `exec.LookPath("git")` failure is a clear error recorded on the repository.
|
||||
`registry/ingest.go`: `Ingester{DB *gorm.DB}` and `Ingester.Repository(ctx, repositoryID uint) error`: clone `--bare --filter=blob:none` into a temp dir (retry without the filter when the server refuses it; remove the dir always), list tags with for-each-ref (peeled commit, creator date), keep strict vX.Y.Z tags (kiosk.ValidVersion), find `(^|/)plugin.yaml$` with ls-tree per tag, read each with `git show <tag>:<path>` through io.LimitReader (64 KiB), kiosk.Parse plus kiosk.Validate, require module == repository.module, upsert the plugin row (by plugin_id; reject an ID owned by another repository and record the error), insert the version row; for the newest valid tag, read `<dir>/README.md` (cap 512 KiB) into readme_markdown and `breakwater.Render` into readme_html, and set latest_version, latest_framework, name, description, module, package, published = true. A tag without a valid plugin.yaml is skipped and noted in last_error, never fatal (D-14). In this task handle the single-tag path fully; Task 2 adds the change check and the multi-tag/edge rules.
|
||||
`registry/query.go`: `Detail(ctx, db, pluginID) (kiosk.RegistryPluginDetail, bool, error)`. `controllers/api/registry.go`: `Show` handler writing `kiosk.DetailResponse` or 404 `kiosk.ErrorResponse{not_found}`. `routes_registry.go`: `registerRegistryRoutes(r pact.Router, app)` mounting `GET /api/registry/v1/plugins/{id}` (callable from tests without the public tree); plugin.go calls it from Routes and declares HasMigrations and HasModels (update the package doc: the plugin now has registry tables). testmain_test.go starts testcontainers Postgres like faq (flag.Parse, testing.Short skip). `registry_smoke_test.go` `TestRegistrySmoke/detail`: create a source repo with git in t.TempDir (commit a plugin.yaml produced by kiosk.Marshal for module `example.test/acme/sm-acme-plugin`, ID `acme.blog`, plus a README), tag v0.1.0, `git clone --bare` it, insert a repository row with url `file://<bare>` and module `example.test/acme/sm-acme-plugin`, run the Ingester, then GET /api/registry/v1/plugins/acme.blog through an httptest server and assert latest_version v0.1.0, framework, readme_html non-empty and free of `<script`, and GET of an unknown ID answers 404 with code not_found. Document the registry in the site plugin README.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms test -run 'TestRegistrySmoke' -count=1 -v ./... 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", a "--- SKIP: TestRegistrySmoke" line (Docker unavailable: rerun through sudo -u golem), or no "--- PASS: TestRegistrySmoke/detail" line</fails_when>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms test -short ./... 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "golem15_summercms_registry_versions" ../sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go` prints at least two lines (create and drop)
|
||||
- `grep -n "GIT_ALLOW_PROTOCOL\|GIT_TERMINAL_PROMPT=0\|GIT_CONFIG_NOSYSTEM" ../sm-summercmsio-app/plugins/golem15/summercms/registry/git.go` prints three lines
|
||||
- `grep -n "kiosk.DetailResponse" ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go` prints at least one line, and no struct in the site plugin redeclares a json tag `latest_version` (`grep -rn 'json:"latest_version"' ../sm-summercmsio-app/plugins/golem15/summercms` prints nothing)
|
||||
- `grep -n "io.LimitReader" ../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>One tagged plugin repository flows through clone, parse, Postgres and the public detail endpoint.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: The full public API (list, search, category, versions) with CORS and caching, and hourly ingestion that skips unchanged repositories</name>
|
||||
<reversibility rating="one-way">Adds the list and versions endpoints of the frozen v1 contract; query parameters q and category and the envelopes are part of it.</reversibility>
|
||||
<precondition>the 34-01 Task 1 answer in 34-01-SUMMARY.md covers the list and versions shapes; halt if it adjusted them in a way kiosk's wire types do not reflect</precondition>
|
||||
<files>../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry/query.go, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/routes_registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/console/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/schedule.go, ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry_smoke_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/README.md, ../sm-summercmsio-app/config/queue.yaml, ../sm-summercmsio-app/config/http.yaml</files>
|
||||
<read_first>
|
||||
- modules/surf/README.md (named CORS profiles, throttle buckets) and ../sm-faq-plugin/routes.go (bucket constant)
|
||||
- ../sm-chatvideo-plugin/schedule.go and modules/conga/README.md (scheduled commands, work_in_serve)
|
||||
- ../sm-summercmsio-app/config/queue.yaml, ../sm-summercmsio-app/config/http.yaml
|
||||
- the Task 1 files
|
||||
</read_first>
|
||||
<behavior>
|
||||
- GET /api/registry/v1/plugins lists only published plugins ordered featured first, then sort_order, then name, with meta.total equal to the count
|
||||
- q matches id, name or description case-insensitively with LIKE wildcards in q escaped; category filters exactly; q over 100 characters or a category not matching ^[a-z0-9-]{1,40}$ answers 400 invalid_query
|
||||
- GET /plugins/{id}/versions lists every version newest first with framework, requires, commit and tagged_at
|
||||
- every GET carries Access-Control-Allow-Origin * (Origin sent), Cache-Control public max-age=300 and an ETag, and a matching If-None-Match answers 304
|
||||
- a repository with three tags (v0.1.0 without plugin.yaml, v0.2.0 valid, v0.3.0 with an invalid ID) yields one version, latest v0.2.0, and records the skipped tags in last_error
|
||||
- a nested plugin.yaml (sub/plugin.yaml) yields a second plugin with its own package
|
||||
- a plugin.yaml whose module differs from the repository's module is rejected; an ID already owned by another repository is rejected on the newer repository
|
||||
- a second ingest with unchanged ls-remote tags does not clone (tags_digest unchanged); a new tag triggers a clone
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-13..D-16. Add `List(ctx, db, q, category)` and `Versions(ctx, db, pluginID)` to registry/query.go returning kiosk wire types; `List`, `Versions` handlers; mount the group `/api/registry/v1` with `surf.Use("throttle:registry-api", "cors:registry")`, GET only, setting Cache-Control and a strong ETag computed from the response bytes (304 on If-None-Match). Add the `registry` profile under `http.cors_profiles` in ../sm-summercmsio-app/config/http.yaml (origins *, methods GET and HEAD, no credentials) and the matching profile in the test config. Ingest rules: compute a digest of `git ls-remote --tags -- <url>` output first and skip the clone when it equals tags_digest; process every strict tag; nested plugin.yaml files; module and ID rules from the behavior list; ingestion of one repository never aborts the others. Add `IngestAll(ctx)` over enabled repositories. Console command `summercms:registry-ingest [url]` (all enabled repositories, or the one matching url; prints one line per repository with versions added or the error) in console/registry.go with full bonfire docs; `Schedule()` returning `{Command: "summercms:registry-ingest", Cadence: pact.Every(time.Hour)}` in schedule.go, with HasSchedule and HasCommands asserted in plugin.go. Set `work_in_serve: true` in ../sm-summercmsio-app/config/queue.yaml and replace its comment (the site now runs the hourly registry ingest). Write the behavior subtests (`list`, `search`, `versions`, `headers`, `tags`, `nested`, `identity`, `unchanged`) in TestRegistrySmoke first, then implement. Document the API, the command and the schedule in the site plugin README. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms test -run 'TestRegistrySmoke' -count=1 -v ./... 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", a "--- SKIP: TestRegistrySmoke" line, or no "--- PASS: TestRegistrySmoke/unchanged" line</fails_when>
|
||||
<automated>go -C ../sm-summercmsio-app vet ./... && go -C ../sm-summercmsio-app test ./... 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "work_in_serve: true" ../sm-summercmsio-app/config/queue.yaml` prints one line
|
||||
- `grep -n "registry:" ../sm-summercmsio-app/config/http.yaml` prints the CORS profile
|
||||
- `grep -n "pact.Every(time.Hour)" ../sm-summercmsio-app/plugins/golem15/summercms/schedule.go` prints one line
|
||||
- `grep -n "summercms:registry-ingest" ../sm-summercmsio-app/plugins/golem15/summercms/console/registry.go` prints at least one line
|
||||
- every TestRegistrySmoke subtest listed in the behavior block passes
|
||||
</acceptance_criteria>
|
||||
<done>The registry API serves list, search, category, detail and versions publicly and stays fresh through an hourly ingest that only clones when tags change.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Admin curation and presentation fields, the registry token scope and ingest-now, and the release-script hooks</name>
|
||||
<files>../sm-summercmsio-app/plugins/golem15/summercms/admin.go, ../sm-summercmsio-app/plugins/golem15/summercms/admin_navigation.go, ../sm-summercmsio-app/plugins/golem15/summercms/admin_permissions.go, ../sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go, ../sm-summercmsio-app/plugins/golem15/summercms/jobs.go, ../sm-summercmsio-app/plugins/golem15/summercms/lang.go, ../sm-summercmsio-app/plugins/golem15/summercms/lang/en/lang.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/lang/pl/lang.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories.go, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_list.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_form.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins.go, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_list.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_form.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/models/repository/columns.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/models/repository/fields.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin/columns.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin/fields.yaml, ../sm-summercmsio-app/plugins/golem15/summercms/models/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/routes_registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/console/registry.go, ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry_smoke_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/README.md, ../sm-summercmsio-app/scripts/changelog-post.py, ../sm-summercmsio-app/scripts/test_changelog_post.py</files>
|
||||
<read_first>
|
||||
- ../sm-faq-plugin/admin.go, admin_navigation.go, admin_permissions.go, controllers/faqs.go, controllers/faqs/config_*.yaml, models/faq/*.yaml, lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/token_scopes.go, ../sm-journal-plugin/controllers/api/auth.go (~83-106), ../sm-journal-plugin/models/post.go (AttachRelations for icon and screenshots)
|
||||
- modules/conga/README.md (Job, enqueue through conga.From(app))
|
||||
- ../sm-summercmsio-app/scripts/changelog-post.py (cmd_publish ~262-300, call ~204, read_token ~236, is_framework ~103) and scripts/test_changelog_post.py
|
||||
- ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog")
|
||||
</read_first>
|
||||
<behavior>
|
||||
- POST /api/registry/v1/ingest without credentials answers 401; with a token lacking the `registry` scope or the permission answers 403; with a token scoped `registry` for a curated repository answers 202 with kiosk.IngestResponse and enqueues one ingest job; for an uncurated repository answers 404
|
||||
- an https URL saved in the repositories admin form gets its module derived; a non-https URL or one with credentials, a query or a fragment is a validation error
|
||||
- presentation fields (category from a fixed list, featured, sort_order, icon, screenshots with captions) saved in the admin appear in GET /plugins and /plugins/{id} as category, featured, icon_url and screenshots
|
||||
- changelog-post.py: https_remote converts git@host:path.git and ssh://git@host/path.git to https://host/path.git; manifest_dirs finds the root and nested plugin.yaml directories; ingest outcome 202 is success and 401, 403, 404 and a network error are warnings that do not fail publish
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-13, D-14 and user decision 3. Site plugin: permissions `golem15.summercms.registry` (admin screens) and `golem15.summercms.registry_ingest` (endpoint), tab label from lang, roles granted none (superusers pass); navigation main item "Marketplace" with side items Repositories and Plugins; admin controllers `golem15.summercms.repositories` (list url, enabled, last_ingested_at, last_error; form url and enabled only) and `golem15.summercms.plugins` (list plugin_id, name, latest_version, category, featured, published; form: presentation fields editable — category dropdown with content, users, commerce, communication, seo, tools; featured switch; sort_order number; icon attachOne and screenshots attachMany with captions via lagoon/attach — and the facts shown read-only); `Plugin.AttachRelations` for icon and screenshots; query.go fills icon_url and screenshots from the attachments' public URLs. Token scope `registry` in token_scopes.go (Permissions `golem15.summercms.*`, Methods POST). Ingest job `conga.Job[IngestArgs]` in jobs.go (HasJobs) running Ingester.Repository; POST /api/registry/v1/ingest in its own group with `throttle:registry-ingest` and no public CORS, authorized with cabana.ResolveBackend plus cabana.Allows for `golem15.summercms.registry_ingest`, body kiosk.IngestRequest normalized like the admin form, curated and enabled repository required, enqueue through conga.From(app), answer 202 kiosk.IngestResponse. Console `summercms:registry-add <url>...` (same validation as the form) in console/registry.go. Lang strings en and pl. Write the endpoint and admin-save subtests (`ingest_auth`, `curation`, `presentation`) in TestRegistrySmoke first.
|
||||
App script: add `REGISTRY_API` (env SUMMERCMS_REGISTRY_API, default SITE + "/api/registry/v1"), `REGISTRY_TOKEN_FILE` (~/.config/summercms/registry-token, falling back to the journal token), pure helpers `https_remote(url)`, `manifest_dirs(paths)`, `ingest_outcome(status)`; in cmd_publish for a plugin repository (not is_framework()): before posting, require HEAD to be exactly the tag (`git describe --exact-match`) and a clean worktree, list the repository's plugin.yaml files with `git ls-files`, refuse when none exists (hint: summer plugin:manifest) and run `summer plugin:manifest --check <dir>` for each (summer from PATH, else `go -C FRAMEWORK run ./cmd/summer`), refusing on failure; after a successful post call `call("POST", "/ingest", token, body, base=REGISTRY_API)` with the https remote and print a warning instead of failing on 401/403/404 or a network error ("the hourly ingest will pick it up"). The script never prints a token. Add unittest cases for the three helpers in scripts/test_changelog_post.py. Update the site plugin README (admin, scope, endpoint) and the "Posting to the changelog" part of DEPLOY.md is done in Task 4. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms test -run 'TestRegistrySmoke/(ingest_auth|curation|presentation)' -count=1 -v ./... 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestRegistrySmoke/ingest_auth" line</fails_when>
|
||||
<automated>cd ../sm-summercmsio-app && python3 -B scripts/test_changelog_post.py 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit, or a "FAILED" or "Error" line in the summary</fails_when>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms test -short ./... 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'Name: *"registry"' ../sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go` prints one line
|
||||
- `grep -n "golem15.summercms.registry_ingest" ../sm-summercmsio-app/plugins/golem15/summercms/admin_permissions.go ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go` prints at least two lines
|
||||
- `grep -n "def https_remote\|def manifest_dirs\|def ingest_outcome" ../sm-summercmsio-app/scripts/changelog-post.py` prints three lines
|
||||
- `grep -n "plugin:manifest" ../sm-summercmsio-app/scripts/changelog-post.py` prints at least one line
|
||||
- `grep -n "print(.*token" ../sm-summercmsio-app/scripts/changelog-post.py` prints nothing new compared with the file before this task
|
||||
</acceptance_criteria>
|
||||
<done>Jakub curates and decorates the listing in the admin, a plugin release checks its manifest before posting and asks the registry to ingest at once, and a missing scope only delays ingestion to the next hour.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: /marketplace pages in the changelog SSR app, the landing link, deploy configuration and docs</name>
|
||||
<files>../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/index.vue, ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue, ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useRegistry.ts, ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/marketplace.ts, ../sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/changelog/i18n/locales/en.json, ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/marketplace-smoke.mjs, ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/expect-marketplace.txt, ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/registry/plugins.json, ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/registry/plugin-golem15.journal.json, ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/registry/versions-golem15.journal.json, ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue, ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-summercmsio-app/vue-summercmsio-app/README.md, ../sm-summercmsio-app/plugins/golem15/summercms/links_test.go, ../sm-summercmsio-app/deploy/nginx/summercms.io.conf, ../sm-summercmsio-app/deploy/supervisor/summercms-io-changelog.conf, ../sm-summercmsio-app/deploy/changelog.env.example, ../sm-summercmsio-app/scripts/smoke.sh, ../sm-summercmsio-app/DEPLOY.md, ../sm-summercmsio-app/README.md, ../sm-summercmsio-app/CLAUDE.md</files>
|
||||
<read_first>
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts (whole file), changelog/app/pages/changelog/plugins.vue and index.vue, changelog/app/components/ChangelogIndex.vue, changelog/app/utils/streams.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/node_modules/vue-journal-layer/testing/nuxt-server.mjs and smoke-ssr.mjs (startNuxtServer, parseExpectLine, X-Forwarded-For forwarding), and the layer's API client and HTML sanitizer used for post bodies
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue (current prop, changelog links)
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/links_test.go (nodePaths)
|
||||
- ../sm-summercmsio-app/deploy/nginx/summercms.io.conf (the /changelog locations to mirror), deploy/supervisor/summercms-io-changelog.conf, scripts/check-deploy.sh, scripts/smoke.sh
|
||||
- ../sm-summercmsio-app/DEPLOY.md, README.md, CLAUDE.md (cross-repo contracts)
|
||||
</read_first>
|
||||
<action>
|
||||
Pages (layout and visuals are Claude's discretion per CONTEXT; follow the landing look through SiteHeader/SiteFooter and the changelog CSS):
|
||||
- `useRegistry.ts`: server-side requests use `runtimeConfig.registry.apiBase` (`NUXT_REGISTRY_API_BASE`, default `${api}/api/registry/v1` like the journal base) and forward the visitor's X-Forwarded-For; browser requests use the same-origin path `/api/registry/v1`; add a nitro devProxy entry for `/api/registry`. Decode with TypeScript types mirroring kiosk's JSON field names.
|
||||
- `utils/marketplace.ts`: `pluginPath(id)` (`/marketplace/<vendor>/<name>`), `idFromRoute(vendor, name)`, `installCommand(id)` (`summer plugin:install <id>`), validation of vendor/name segments (lower-case letters and digits).
|
||||
- `pages/marketplace/index.vue`: list with a search box bound to `?q=`, category links bound to `?category=`, cards with icon, name, description, latest version and a featured mark; empty state; SEO title and description.
|
||||
- `pages/marketplace/[vendor]/[name].vue`: name, description, install command with a copy button (clipboard with a fallback, as on the landing's terminal card), README HTML passed through the layer's sanitizer before v-html, screenshots, versions table (version, framework minimum, requires, date), repository link; unknown plugin → 404 page.
|
||||
- nuxt.config.ts: runtimeConfig.registry.apiBase, routeRules `/marketplace` and `/marketplace/**` with swr 300; keep buildAssetsDir and the pages:extend hook working for the new pages.
|
||||
- `testing/marketplace-smoke.mjs` plus `expect-marketplace.txt` and three fixture JSON files: start a node:http registry fixture serving the fixtures under /api/registry/v1 (404 envelope otherwise, recording X-Forwarded-For), start the built server with startNuxtServer and env NUXT_REGISTRY_API_BASE, check each line with parseExpectLine (list 200 contains Journal, ?q=journal, ?category=content, detail 200 contains `summer plugin:install golem15.journal`, unknown plugin 404, a README script tag absent), and require that a fixture request carried the visitor address. Script `"test:marketplace": "node changelog/testing/marketplace-smoke.mjs"` in package.json.
|
||||
- SiteHeader.vue: a Marketplace link (plain anchor to /marketplace, `current === 'marketplace'` active) and its landing string in i18n/locales/en.json; add "/marketplace" to nodePaths in links_test.go.
|
||||
App deploy: nginx `location = /marketplace` and `location ^~ /marketplace/` proxied to 127.0.0.1:3095 with GET/HEAD only like /changelog; `location = /api/registry/v1/ingest` proxied to 127.0.0.1:8095 with `limit_except POST { deny all; }`; supervisor env `NUXT_REGISTRY_API_BASE="http://127.0.0.1:8095/api/registry/v1"` and the same in changelog.env.example; scripts/smoke.sh additionally checks that GET /api/registry/v1/plugins answers 200 with a `data` array and that the changelog process answers /marketplace. DEPLOY.md: git required on rome (`command -v git` check), migrate creates the registry tables, `work_in_serve` now true, adding the launch repositories (the tagged sibling plugins: chat, faq, journal, menu, mfa, oauthserver, paymentgateway, paymentgatewaysubscriptions, pgstripe, seo, sitemap, sociallogin, translate, user — https URLs on git.golem15.com) through the admin over the SSH tunnel or `summercms:registry-add`, the registry token (scope `registry`, file ~/.config/summercms/registry-token; Jakub mints it), and the publish flow's new manifest check and ingest-now step. README.md and CLAUDE.md: the /marketplace prefix belongs to the Node app, the registry API and its shape are a contract with released summer binaries.
|
||||
Commit as listed in Execution units (vue, then site links_test, then app).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd ../sm-summercmsio-app/vue-summercmsio-app && pnpm build:changelog >/dev/null 2>&1 && pnpm test:marketplace 2>&1 | tail -8</automated>
|
||||
<fails_when>non-zero exit, or a line reporting a failed expectation, or no line reporting all checks passed</fails_when>
|
||||
<automated>cd ../sm-summercmsio-app/vue-summercmsio-app && pnpm test:changelog 2>&1 | tail -4 && pnpm test 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit or a "not ok" line</fails_when>
|
||||
<automated>cd ../sm-summercmsio-app && scripts/check-deploy.sh 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit or an nginx "emerg" line</fails_when>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms test -short -run 'TestLanding|TestRoutes' ./... 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"/marketplace"' ../sm-summercmsio-app/plugins/golem15/summercms/links_test.go` prints one line
|
||||
- `grep -n "location ^~ /marketplace/\|location = /api/registry/v1/ingest" ../sm-summercmsio-app/deploy/nginx/summercms.io.conf` prints two lines
|
||||
- `grep -n "NUXT_REGISTRY_API_BASE" ../sm-summercmsio-app/deploy/supervisor/summercms-io-changelog.conf` prints one line
|
||||
- `grep -n "command -v git" ../sm-summercmsio-app/DEPLOY.md` prints at least one line, and DEPLOY.md names the fourteen launch repositories
|
||||
- `grep -n "test:marketplace" ../sm-summercmsio-app/vue-summercmsio-app/package.json` prints one line
|
||||
</acceptance_criteria>
|
||||
<done>summercms.io/marketplace renders list, search, category and detail pages from the registry, the landing links to it, and the deploy is described and validated.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| public internet → GET /api/registry/v1 | anonymous, cross-origin readers |
|
||||
| release script (token) → POST /api/registry/v1/ingest | authenticated, scoped |
|
||||
| curated git repository → ingester | repository content controlled by the plugin's maintainers |
|
||||
| ingested README → marketplace HTML | third-party markdown rendered on summercms.io |
|
||||
| summercms.io admin → presentation fields and repository URLs | trusted administrators behind the SSH tunnel |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-34-11 | Elevation of privilege | git invocation with repository URLs | high | mitigate | exec without shell, `--` before URLs, GIT_ALLOW_PROTOCOL=https (file only in tests), no system/global git config, no prompt; https-only URLs validated at curation |
|
||||
| T-34-12 | Denial of service | malicious repository content | high | mitigate | bare partial clone, no checkout/submodules/hooks, io.LimitReader caps (64 KiB plugin.yaml, 512 KiB README), per-command context timeouts, temp dir always removed |
|
||||
| T-34-13 | Tampering (XSS) | README rendered on /marketplace | high | mitigate | breakwater.Render at ingest (raw HTML stripped, http/https/mailto links only, nofollow) and the layer's sanitizer again before v-html; smoke asserts no script tag |
|
||||
| T-34-14 | Spoofing | repository claiming another plugin's ID or module | high | mitigate | admin-curated repositories only; module must equal the repository path; ID owned by the first-listed repository; TestRegistrySmoke/identity |
|
||||
| T-34-15 | Elevation of privilege | POST /api/registry/v1/ingest | high | mitigate | cabana.ResolveBackend plus golem15.summercms.registry_ingest via the `registry` token scope (POST only); unknown repositories 404 (no listing creation); nginx allows POST only on that exact path |
|
||||
| T-34-16 | Denial of service | ingest-now flooding | medium | mitigate | throttle:registry-ingest bucket, one queued job per repository, ls-remote digest check before cloning |
|
||||
| T-34-17 | Information disclosure | open CORS on the registry API | low | mitigate | only public listing data; GET/HEAD only; no credentials; no admin fields (last_error, enabled) in the API |
|
||||
| T-34-18 | Denial of service | public API abuse | medium | mitigate | throttle:registry-api, Cache-Control plus ETag/304, q length cap and category regex, LIKE wildcards escaped |
|
||||
| T-34-19 | Information disclosure | release script token handling | medium | mitigate | token read from a mode-600 file, never printed; the agent never mints or copies tokens; failures report status only |
|
||||
| T-34-20 | Tampering | stale manifest in a published tag | medium | mitigate | changelog-post.py refuses unless HEAD is the tag, the tree is clean and `summer plugin:manifest --check` passes for every plugin.yaml |
|
||||
| T-34-21 | Denial of service | jobs never run in production | medium | mitigate | queue.work_in_serve true; DEPLOY.md checks git and the schedule; smoke checks the API |
|
||||
| T-34-SC | Tampering | npm/pip/cargo installs | high | accept | no package is added (Go modules already required; no new npm dependency) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Site plugin: `go vet ./...`, `go test -short ./...`, and `go test -run TestRegistrySmoke ./...` with Docker.
|
||||
- App: `go vet ./... && go test ./...`, `python3 -B scripts/test_changelog_post.py`, `scripts/check-deploy.sh`.
|
||||
- Vue: `pnpm build:changelog`, `pnpm test:changelog`, `pnpm test:marketplace`, `pnpm test`.
|
||||
- Manual (34-VALIDATION.md): production ingest after deploy and the visual check of /marketplace.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The registry ingests curated repositories hourly and on demand and serves the frozen v1 API.
|
||||
- /marketplace renders list, search, category and detail pages.
|
||||
- Presentation fields are edited in the admin.
|
||||
- The release script checks manifests and triggers ingestion; nothing pushed, tagged or deployed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/34-plugin-marketplace/34-03-SUMMARY.md` when done, including the pending submodule SHAs (site plugin, vue app) for the release ledger.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Tables: `golem15_summercms_registry_repositories`, `golem15_summercms_registry_plugins`, `golem15_summercms_registry_versions` (migration `202610300001_create_golem15_summercms_registry`).
|
||||
- Site plugin Go: models `Repository`, `Plugin`, `Version`; package `registry` with `Ingester`, `Ingester.Repository`, `Ingester.IngestAll` (or `IngestAll`), `List`, `Detail`, `Versions`, `allowedProtocols`; handlers in `controllers/api/registry.go`; `registerRegistryRoutes`; `IngestArgs` job.
|
||||
- Endpoints: `GET /api/registry/v1/plugins`, `GET /api/registry/v1/plugins/{id}`, `GET /api/registry/v1/plugins/{id}/versions`, `POST /api/registry/v1/ingest`.
|
||||
- Admin controllers: `golem15.summercms.repositories`, `golem15.summercms.plugins`; navigation "Marketplace".
|
||||
- Permissions: `golem15.summercms.registry`, `golem15.summercms.registry_ingest`. Token scope: `registry`.
|
||||
- Console commands: `summercms:registry-ingest [url]`, `summercms:registry-add <url>...`; schedule hourly.
|
||||
- Throttle buckets: `registry-api`, `registry-ingest`. CORS profile: `http.cors_profiles.registry`.
|
||||
- Config: `queue.work_in_serve: true`; env `NUXT_REGISTRY_API_BASE`, `SUMMERCMS_REGISTRY_API`; token file `~/.config/summercms/registry-token`.
|
||||
- Script helpers: `https_remote`, `manifest_dirs`, `ingest_outcome`, `REGISTRY_API`, `REGISTRY_TOKEN_FILE`.
|
||||
- Pages: `/marketplace`, `/marketplace/<vendor>/<name>`; composable `useRegistry`; utils `pluginPath`, `idFromRoute`, `installCommand`; script `test:marketplace`.
|
||||
- Tests: `TestRegistrySmoke` (subtests detail, list, search, versions, headers, tags, nested, identity, unchanged, ingest_auth, curation, presentation), marketplace SSR smoke, changelog-post unittest cases.
|
||||
298
.planning/phases/34-plugin-marketplace/34-03-SUMMARY.md
Normal file
298
.planning/phases/34-plugin-marketplace/34-03-SUMMARY.md
Normal file
@@ -0,0 +1,298 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 03
|
||||
subsystem: registry
|
||||
tags: [registry, kiosk, ingester, git, marketplace, nuxt-ssr, cabana-admin, token-scope, river, nginx, changelog-post]
|
||||
|
||||
requires:
|
||||
- phase: 34-plugin-marketplace (34-01)
|
||||
provides: "kiosk.Parse/Validate/Marshal, ValidVersion/CompareVersions, registry v1 wire types (ListResponse, DetailResponse, VersionsResponse, ErrorResponse, IngestRequest/IngestResponse/IngestResult, RegistryError* codes, RegistryAPIPath), summer plugin:manifest --check"
|
||||
provides:
|
||||
- "Site plugin golem15.summercms: registry tables, models Repository/Plugin/Version, pull ingester (registry.Ingester.Repository, IngestAll), public API GET /api/registry/v1/plugins, /plugins/{id}, /plugins/{id}/versions"
|
||||
- "POST /api/registry/v1/ingest (token scope registry, permission golem15.summercms.registry_ingest, unique River job registry.IngestArgs)"
|
||||
- "Marketplace admin: Repositories and Plugins screens, permissions golem15.summercms.registry and registry_ingest"
|
||||
- "Console summercms:registry-ingest [url] [--force] (scheduled hourly) and summercms:registry-add <url>..."
|
||||
- "/marketplace and /marketplace/<vendor>/<name> in the changelog Node SSR app (useRegistry, utils/marketplace.ts), header and footer links"
|
||||
- "changelog-post.py: plugin manifest check before posting, ingest-now after posting (https_remote, manifest_dirs, ingest_outcome)"
|
||||
- "Deploy: nginx /marketplace and ingest locations, NUXT_REGISTRY_API_BASE, queue.work_in_serve true, cors profile registry, upload limit 4 MiB, DEPLOY.md marketplace release"
|
||||
affects: [34-04 install CLI (consumes /api/registry/v1), 34-05 admin Plugins screen (consumes the API), 34-06 tests and release ledger, sm-summercmsio-app deploy]
|
||||
|
||||
actuals:
|
||||
tokens: 59000
|
||||
tasks: 4
|
||||
commits: 10
|
||||
plan_head_before: 69c1fea766bdde7fd38d73967a1dff70d69f21d5
|
||||
plan_head_after: 69c1fea766bdde7fd38d73967a1dff70d69f21d5
|
||||
repo_heads:
|
||||
sm-summercmsio-plugin: { before: ca5941540c456cd850cf3b2f50e1d3c2e3ab6ee5, after: 5853cf5be0bb681a8751a75c65bcaa90a3b19740, commits: 4 }
|
||||
vue-summercmsio-app: { before: b3bfc5da80d020d318e8d6d4b3b60a45c1d84f42, after: 05f1cea5ac73ac45e632703a5e406012ac426bd2, commits: 1 }
|
||||
sm-summercmsio-app: { before: b688a80e6b4f59fda13bf01e7bfabc0ebd6884f6, after: 97c0c08, commits: 5 }
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Hardened git via exec: no shell, -- before URLs, throwaway HOME, GIT_ALLOW_PROTOCOL allow-list, no system/global config, capped reads through readLimited"
|
||||
- "Cheap change detection: digest of ls-remote strict tags + peeled commits, compared with the stored tags_digest before cloning"
|
||||
- "Per-repository pg_advisory_xact_lock around one write transaction"
|
||||
- "River unique job by args (JobArgsWithInsertOpts) for idempotent ingest-now"
|
||||
- "Public JSON with strong ETag from the body bytes, 304 on If-None-Match, Cache-Control public max-age=300"
|
||||
- "Facts on the cabana preview screen (context: preview), presentation fields on the update form, Fillable restricted to presentation"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/{registry,repository,plugin,version}.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/{git,ingest,query}.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/controllers/{repositories,plugins}.go (+ config_list/config_form YAML)
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/models/{repository,plugin}/{columns,fields}.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/console/registry.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/{routes_registry,schedule,jobs,token_scopes,admin,admin_navigation,admin_permissions,lang}.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/lang/{en,pl}/lang.yaml
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/{testmain_test,registry_smoke_test}.go
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/index.vue
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useRegistry.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/marketplace.ts
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/testing/{marketplace-smoke.mjs,expect-marketplace.txt,fixtures/registry/*.json}
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/marketplace.test.ts
|
||||
modified:
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/{plugin.go,go.mod,go.sum,README.md,routes_test.go,links_test.go}
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/{package.json,README.md,nuxt.config.ts,app/components/SiteHeader.vue,app/components/SiteFooter.vue,i18n/locales/en.json}
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/{nuxt.config.ts,i18n/locales/en.json,app/layouts/default.vue,app/error.vue}
|
||||
- ../sm-summercmsio-app/{config/queue.yaml,config/http.yaml,go.mod,deploy/nginx/summercms.io.conf,deploy/supervisor/summercms-io-changelog.conf,deploy/changelog.env.example,scripts/changelog-post.py,scripts/test_changelog_post.py,scripts/smoke.sh,DEPLOY.md,README.md,CLAUDE.md}
|
||||
|
||||
key-decisions:
|
||||
- "Registry API bodies are only kiosk wire types; updated_at is the latest tag's date (falls back to the row's update time); errors carry no-cache so a fresh listing is never hidden behind a cached 404"
|
||||
- "Ingester.Repository returns (Result, error) instead of error, so the console and IngestAll can report versions added, unchanged and notes; Ingester.Force re-reads unchanged repositories (summercms:registry-ingest --force)"
|
||||
- "Identity at ingest: module must equal the repository's module, package must equal module + directory, the first repository listing an ID owns it; a plugin no tag publishes any more is unpublished"
|
||||
- "Ingest-now answers 400 invalid_request for a non-https URL and 401/403 with codes unauthenticated/forbidden (outside kiosk's two public codes; the endpoint is authenticated and only changelog-post.py calls it)"
|
||||
- "Repeated ingest-now requests keep one queued job: IngestArgs implements River InsertOpts with UniqueOpts ByArgs over available/pending/running/retryable/scheduled"
|
||||
- "The registry token scope declares GET, HEAD and POST: the framework requires GET in every scope (the plan said POST only)"
|
||||
- "App go.mod now requires summercms v0.6.0 like the site plugin: the workspace already resolved v0.6.0, so the landing version check failed in scripts/build.sh dev without it"
|
||||
- "Marketplace pages keep only the sanitized README HTML in the payload (no markdown source) and use plain GET forms and anchors, so they work without JavaScript"
|
||||
|
||||
patterns-established:
|
||||
- "Site-plugin registry as the producer of the kiosk v1 contract; the Nuxt pages and the summer CLI consume it"
|
||||
|
||||
requirements-completed: [SC-2, D-04, D-13, D-14, D-15, D-16]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A curated repository with a tagged plugin.yaml is cloned (hardened git, bare partial clone), parsed through kiosk and served at GET /api/registry/v1/plugins/{id} with a breakwater-sanitized README"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "plugins/golem15/summercms/registry_smoke_test.go#TestRegistrySmoke/detail"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "List, search (escaped, capped), category filter and versions newest first, with open CORS, Cache-Control, ETag and 304"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "plugins/golem15/summercms/registry_smoke_test.go#TestRegistrySmoke/list,search,versions,headers"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Every vX.Y.Z tag is a version row; tags without a valid plugin.yaml are skipped and noted; nested plugin.yaml files; module and ID identity rules"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "plugins/golem15/summercms/registry_smoke_test.go#TestRegistrySmoke/tags,nested,identity"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Hourly scheduled ingest that skips the clone when the ls-remote tag digest is unchanged; IngestAll survives a failing repository"
|
||||
requirement: D-15
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "plugins/golem15/summercms/registry_smoke_test.go#TestRegistrySmoke/unchanged"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Ingest-now endpoint: 401 anonymous, 403 wrong scope or missing permission, 202 with one unique queued job, 404 uncurated or disabled, 400 non-https"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "plugins/golem15/summercms/registry_smoke_test.go#TestRegistrySmoke/ingest_auth"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Admin curation (URL rules, derived module, duplicates, registry-add command) and presentation fields (category, featured, sort order, icon, screenshots) appear in the API; facts read-only"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "plugins/golem15/summercms/registry_smoke_test.go#TestRegistrySmoke/curation,presentation"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "changelog-post.py refuses a plugin release that is not exactly the tag, is dirty or has a stale plugin.yaml, and asks the registry to ingest after posting (401/403/404/network are warnings)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "python3 -B scripts/test_changelog_post.py (HttpsRemoteTest, ManifestDirsTest, IngestOutcomeTest, RegistrySettingsTest)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "check_plugin_release on a fixture repo from modules/kiosk/testdata/literal: passes at v0.1.0, refuses a drifted plugin.yaml at v0.1.1"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "/marketplace list, search, category and detail pages rendered by the changelog Node SSR app from the registry API"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: automated_ui
|
||||
ref: "pnpm test:marketplace (changelog/testing/marketplace-smoke.mjs, 30 checks)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "pnpm test (tests/marketplace.test.ts)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "scripts/build.sh dev && scripts/smoke.sh (registry API on the real binary, /marketplace on Node against it)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Layout and visuals of the marketplace pages are Claude's discretion per CONTEXT; the visual check of /marketplace is a manual item in 34-VALIDATION.md"
|
||||
- id: D9
|
||||
description: "Deploy: nginx locations, supervisor env, jobs in serve, CORS profile, DEPLOY.md marketplace release; production ingest after deploy"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/check-deploy.sh"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Production ingest of the launch repositories happens only after the user-gated deploy (nothing was deployed)"
|
||||
|
||||
duration: 51min
|
||||
completed: 2026-10-29
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 34 Plan 03: summercms.io plugin registry and /marketplace Summary
|
||||
|
||||
**The site plugin ingests curated plugin repositories over hardened anonymous git into Postgres and serves them through the frozen kiosk v1 API at /api/registry/v1. It adds an admin for curation and presentation, a `registry`-scoped ingest-now endpoint and an hourly schedule. The changelog Node SSR app renders /marketplace from that API, and changelog-post.py checks plugin manifests before posting and asks the registry to ingest afterwards.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 51 min
|
||||
- **Started:** 2026-10-29T16:17:50Z
|
||||
- **Completed:** 2026-10-29T17:09:20Z
|
||||
- **Tasks:** 4 of 4
|
||||
- **Files modified:** 70 across three repositories (38 site plugin, 20 vue app, 12 app)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Registry tables and ingester.** The three registry tables come from migration `202610300001_create_golem15_summercms_registry`. `registry.Ingester` makes a bare partial clone (`--filter=blob:none`, retried without the filter), lists strict tags with peeled commits and reads each tag's `plugin.yaml` files (64 KiB cap, `kiosk.Parse`/`Validate`). It checks identity: the module must equal the repository's module, the package must match its directory, and the first repository owns an ID. It reads the newest tag's README (512 KiB cap, `breakwater.Render`) and writes everything in one transaction under a per-repository advisory lock.
|
||||
- **Public API.** `GET /plugins`, `/plugins/{id}` and `/plugins/{id}/versions` return only kiosk wire types. Every response has open CORS through the `registry` profile, `throttle:registry-api` (300/min per IP), `Cache-Control: public, max-age=300` and a strong ETag with 304. `q` is escaped and capped at 100 characters, and `category` is checked against `^[a-z0-9-]{1,40}$`.
|
||||
- **Change detection.** An `ls-remote` tag digest skips the clone when the tags have not changed. `IngestAll` keeps going when one repository fails. `summercms:registry-ingest [url] [--force]` runs hourly through `pact.HasSchedule`, and the app now sets `queue.work_in_serve: true` so the worker runs.
|
||||
- **Admin.** A Marketplace main item has two screens. Repositories edits the URL and the enabled switch, with the same rules as `summercms:registry-add`. Plugins shows the facts read-only on the preview screen and lets you edit category, featured, sort order, icon and screenshots with captions. The icon and screenshots reach the API as absolute URLs on `app.url`.
|
||||
- **Ingest now.** `POST /api/registry/v1/ingest` needs a backend principal holding `golem15.summercms.registry_ingest` (a token scoped `registry`). It only queues repositories that are curated and enabled, and keeps one unique River job per repository.
|
||||
- **Marketplace pages.** `/marketplace` is a list with a GET search form and category links. `/marketplace/<vendor>/<name>` shows the install command with a copy button, the README (sanitized again), screenshots and a versions table. The header and footer link to the marketplace, the layout and error page know about it, and the landing prerender ignores `/marketplace`.
|
||||
- **Release script.** `changelog-post.py publish --repo` requires that HEAD is the tag, the worktree is clean, and `summer plugin:manifest --check` passes for every committed `plugin.yaml`. After posting it sends `POST /ingest` with the registry token, and a refusal is only a warning.
|
||||
- **Deploy.** nginx sends `/marketplace` to Node and allows POST only on the ingest path. The supervisor program gets `NUXT_REGISTRY_API_BASE`. `smoke.sh` now checks the registry and the marketplace. DEPLOY.md has a "Plugin marketplace" section with git on rome, the fourteen launch repositories and the registry token.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**sm-summercmsio-plugin** (`plugins/golem15/summercms`, branch master):
|
||||
1. Task 1: `242d641` feat: plugin registry tables, ingester and plugin detail API
|
||||
2. Task 2: `f6a5949` feat: registry list, search and versions API, hourly ingest
|
||||
3. Task 3: `08e04de` feat: registry admin, token scope and ingest-now endpoint
|
||||
4. Task 4: `5853cf5` feat: marketplace link
|
||||
|
||||
**vue-summercmsio-app** (branch master):
|
||||
5. Task 4: `05f1cea` feat: marketplace pages
|
||||
|
||||
**sm-summercmsio-app** (branch master):
|
||||
6. Task 2: `458a4f1` feat: registry CORS profile and jobs in serve
|
||||
7. Task 3: `06593c3` feat: manifest check and ingest-now in changelog-post
|
||||
8. Task 3: `0707fdc` feat: upload limit for the marketplace admin (deviation 3)
|
||||
9. Task 4: `fd8fe3b` feat: marketplace deploy config and docs
|
||||
10. Task 4: `97c0c08` chore: require summercms v0.6.0 (deviation 4)
|
||||
|
||||
**Plan metadata:** the summercms.go docs commit containing this SUMMARY.
|
||||
|
||||
**Pending submodule pointers (release ledger, not committed in the app; gitlinks left modified):**
|
||||
- `plugins/golem15/summercms` → `5853cf5be0bb681a8751a75c65bcaa90a3b19740` (recorded in the app: `ca59415`)
|
||||
- `vue-summercmsio-app` → `05f1cea5ac73ac45e632703a5e406012ac426bd2` (recorded in the app: `b3bfc5d`)
|
||||
|
||||
Nothing was pushed, tagged or deployed.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- Site plugin: the `updates/`, `models/`, `registry/`, `controllers/`, `console/` and `lang/` packages and files listed in key-files; `plugin.go` (Register/Boot keep the app, migrations, models); `routes_registry.go` (API groups and throttle buckets); `schedule.go`, `jobs.go`, `token_scopes.go` and the `admin*.go` files; tests `testmain_test.go` (testcontainers Postgres) and `registry_smoke_test.go` (12 subtests); `routes_test.go` and `links_test.go` adjusted; README "Plugin registry" section.
|
||||
- vue app: the marketplace pages, `useRegistry.ts`, `utils/marketplace.ts`, the SSR smoke and fixtures, `tests/marketplace.test.ts`; config, header, footer, layout, error page and strings.
|
||||
- App: `config/http.yaml` (cors profile, upload limit), `config/queue.yaml`, `go.mod`, the deploy files, `scripts/changelog-post.py` and its tests, `scripts/smoke.sh`, DEPLOY.md, README.md, CLAUDE.md.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. In addition:
|
||||
- `Repository.BeforeSave` derives the module only from an https URL. The admin form, `registry-add` and the ingest endpoint all validate through `models.CheckRepository`. Test fixtures insert `file://` repositories with an explicit module. `registry.SetAllowedProtocolsForTest` (exported, test-only) widens the protocol allow-list to `https:file`.
|
||||
- The plugin rows have no create and no delete in the admin; the ingester alone creates and withdraws them. Removing a repository cascades to its plugins.
|
||||
- Categories: `content`, `users`, `commerce`, `communication`, `seo`, `tools`. They are checked in `FormBeforeUpdate` and are a contract with `utils/marketplace.ts`.
|
||||
- The USER-SETUP content stays in this SUMMARY ("User Setup Required" below) instead of a separate `34-USER-SETUP.md`, so this plan does not collide with sibling plans of the same wave.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Site tests needed config once the plugin has a CORS profile and admin screens**
|
||||
- **Found during:** Tasks 2 and 3
|
||||
- **Issue:** `cors:registry` and the admin made `surf.Assemble(backpack.New(nil), …)` fail to boot in routes_test/links_test (unknown CORS profile, empty admin JWT secret, upload limit). `/backend` is now the admin's, not the site 404.
|
||||
- **Fix:** A `siteApp(t)` helper supplies body limits, the CORS profile and a JWT secret. The route-pattern test allows surf's OPTIONS preflight routes and the admin's own routes. TestPluginIdentity now asserts that the plugin has admin controllers, and the `/backend` expectation became `GET /backend/api/v1/auth/me` 401.
|
||||
- **Commits:** f6a5949, 08e04de
|
||||
|
||||
**2. [Rule 1 - Bug] cabana requires `Rules()` on every editable model**
|
||||
- **Found during:** Task 3 (every Plugins show/update answered an opaque 500)
|
||||
- **Fix:** Added `models.Plugin.Rules()`. The cause was traced with a `go build -overlay` print in cabana's error writer; the framework was not modified.
|
||||
- **Commit:** 08e04de
|
||||
|
||||
**3. [Rule 3 - Blocking] App upload limit too small for the admin's image fields**
|
||||
- **Issue:** cabana refuses to boot when `maxFilesize` plus multipart framing exceeds `http.body_limits.upload_bytes` (1 MiB in production config).
|
||||
- **Fix:** `upload_bytes: 4194304` in config/http.yaml. Without it the binary would not start.
|
||||
- **Commit:** 0707fdc (separate app commit)
|
||||
|
||||
**4. [Rule 3 - Blocking] App go.mod framework requirement**
|
||||
- **Issue:** The site plugin requires v0.6.0, so the workspace resolves v0.6.0, while the app's go.mod said v0.5.0. As a result `scripts/build.sh dev` failed `TestLandingVersion` (the page showed 0.6, go.mod said 0.5).
|
||||
- **Fix:** `go mod edit -require=…summercms@v0.6.0` only. A full `go mod tidy` would also have dropped indirect requirements based on the framework working tree, so it was not committed.
|
||||
- **Commit:** 97c0c08
|
||||
|
||||
**5. [Rule 2 - Missing critical] Extra protections and files beyond the plan's list**
|
||||
- Package path must equal module + directory at ingest. More than 20 plugin.yaml files per tag are not read. A plugin no longer published by any tag is unpublished. The ingest body is capped at 4 KiB, and nginx sets `client_max_body_size 8k` on the ingest path.
|
||||
- The detail page payload carries only sanitized README HTML; `readme_markdown` is dropped.
|
||||
- Extra files: `tests/marketplace.test.ts`, `SiteFooter.vue` (Marketplace link; the header link hides below 480px), the landing `nuxt.config.ts` (prerender/linkChecker ignore `/marketplace`; `nuxt generate` failed without it), the changelog `layouts/default.vue` and `error.vue` (active menu entry, marketplace 404 text).
|
||||
|
||||
**6. [Plan text vs framework] Token scope methods**
|
||||
- The plan said Methods POST. cabana's scope validation requires GET, so the scope declares GET, HEAD and POST.
|
||||
|
||||
**7. [Plan text vs layer] parseExpectLine**
|
||||
- `smoke-ssr.mjs` runs its `main()` when imported, so `marketplace-smoke.mjs` repeats its expectation parser (same grammar) instead of importing it.
|
||||
|
||||
**8. [Process] TDD**
|
||||
- Tasks 2 and 3 wrote their subtests first and saw assertion-level RED: 404s from missing routes, and 405s from the admin/ingest routes. Stubs were added only so the code compiled. `msd_run check tdd-red-evidence` parses TAP/Surefire output, not `go test -v`, so the RED evidence is recorded here rather than machine-checked. One commit per task per repository, following the plan's Execution units table.
|
||||
|
||||
**Total deviations:** 8 (1 Rule 1, 3 Rule 3, 1 Rule 2, 3 plan/process notes). **Impact:** no change to the v1 contract. The extra commits are needed for boot and for the build.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `grep -n "location ^~ /marketplace/\|location = /api/registry/v1/ingest"` (the plan's acceptance command) prints only the second line under GNU grep because of how it parses `^~` inside the alternation. `grep -nF -e … -e …` prints both lines (143 and 162). The file is correct.
|
||||
- `scripts/build.sh dev` regenerates `main.go` and `plugins.gen.go` (34-01's PluginInfo/FrameworkVersion). They were restored to the committed versions, and regenerating them belongs to the release bump.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Before the deploy that ships the registry (nothing was deployed):
|
||||
1. **Release ledger:** push `sm-summercmsio-plugin` (`5853cf5`) and `vue-summercmsio-app` (`05f1cea`), then commit the two gitlink bumps in sm-summercmsio-app. Tag framework `v0.6.0` (34-06) so the release build finds it. Regenerate and commit `main.go`/`plugins.gen.go` with that bump.
|
||||
2. **git on rome:** run `ssh rome 'command -v git && git --version'` (DEPLOY.md "Plugin marketplace", step 2).
|
||||
3. **Reinstall nginx and the changelog supervisor file** from the repository, then run `migrate` (registry and queue tables). `work_in_serve` is now true.
|
||||
4. **Registry token (only you mint it):** `admin:token:create <you> --name registry --scope registry`, stored in `~/.config/summercms/registry-token` (mode 600). Until then the ingest-now call gets 403, which is only a warning, and the hourly run picks the tag up.
|
||||
5. **Curate the fourteen launch repositories** (DEPLOY.md lists them) once their first `plugin.yaml` tags exist (34-02, user-gated), with `summercms:registry-add …` then `summercms:registry-ingest`, or in the admin over the tunnel.
|
||||
6. **Manual checks (34-VALIDATION.md):** run a production ingest after the deploy and look over /marketplace visually.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 34-04 (install CLI) and 34-05 (admin Plugins screen) can call `GET /api/registry/v1/...` locally by running the app (`scripts/build.sh dev`, then `bin/summercms-io serve`) with a curated `file://` or https repository.
|
||||
- The registry answers with exactly the kiosk wire types, so any change to the v1 shape must start in `modules/kiosk/registry.go` and stay additive.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Key files exist: registry/ingest.go (contains `breakwater.Render`, `kiosk.Parse(`, `io.LimitReader`), controllers/api/registry.go (`kiosk.ListResponse`, `kiosk.DetailResponse`), the migration (`golem15_summercms_registry_versions` in create and drop), registry_smoke_test.go (`func TestRegistrySmoke(`), pages/marketplace/index.vue (`plugin:install`), changelog-post.py (`api/registry/v1/ingest`).
|
||||
- Commits found: site 242d641, f6a5949, 08e04de, 5853cf5; vue 05f1cea; app 458a4f1, 06593c3, 0707fdc, fd8fe3b, 97c0c08.
|
||||
- Verification: TestRegistrySmoke 12/12 PASS (Docker); site `go vet` and `go test -short` ok; app `go vet` and `go test` ok; `test_changelog_post.py` OK (36 tests); `check-deploy.sh` ok; `pnpm test:marketplace` ok (30 checks); `pnpm test:changelog` ok (77 checks); `pnpm test` 77 pass; `scripts/build.sh dev` and `scripts/smoke.sh` ok; framework `go vet ./...` and `go test -short ./...` green.
|
||||
- Acceptance greps: every Task 1-4 criterion passes. Two notes: the GIT_* grep prints exactly 3 lines, and the nginx alternation grep is explained under Issues Encountered.
|
||||
309
.planning/phases/34-plugin-marketplace/34-04-PLAN.md
Normal file
309
.planning/phases/34-plugin-marketplace/34-04-PLAN.md
Normal file
@@ -0,0 +1,309 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["34-01"]
|
||||
coupling_justified: ["34-03: same wave; 34-04 adds the new file modules/kiosk/client.go and edits internal/build and cmd/summer, while 34-03 compiles only the kiosk package from this checkout (through its go.work replace); the addition is one new file and every 34-04 commit compiles, so either order gives a correct result"]
|
||||
files_modified:
|
||||
- modules/kiosk/client.go
|
||||
- modules/kiosk/README.md
|
||||
- internal/build/install.go
|
||||
- internal/build/source.go
|
||||
- internal/build/update.go
|
||||
- internal/build/manifest.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/install_smoke_test.go
|
||||
- internal/build/fixtures_test.go
|
||||
- cmd/summer/main.go
|
||||
- docs/plugins/marketplace.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- .planning/research/questions.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-08, D-09, D-10, D-11, D-12, D-18]
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "`summer plugin:install golem15.<name>` asks the registry for the plugin's versions, picks the newest tag whose framework minimum the app's effective framework version satisfies, installs it as a Go module with `go get`, and registers it in summer.yaml; `@vX.Y.Z` pins a version; when only tags needing a newer framework exist it fails and names the framework version to move to, without bumping the framework (D-08, SC-3)"
|
||||
- "Missing required plugins are resolved transitively against the registry, the full list of additions (id@version and mode) is printed and confirmed; `--yes` skips the prompt, a non-interactive run without `--yes` is an error naming `--yes`, and an installed dependency older than its minimum is an error naming `summer plugin:update <id>` (D-09, research F4)"
|
||||
- "`--source` installs the plugin and its dependencies as git submodules under plugins/<vendor>/<name> at the chosen tag, registered like plugin:add with the tag as the require version, plus go.work replace overrides for every local replace in the plugin's go.mod so the app builds (D-09, research F2)"
|
||||
- "`summer plugin:install <git-url>` never sends the plugin's repository to the registry: it clones over https or ssh, reads plugin.yaml at the newest tag whose framework minimum is satisfied, installs a submodule only, and resolves the plugin's required IDs through the registry; `--offline` makes no registry call and then requires those dependencies to be installed already (D-11, D-12)"
|
||||
- "`summer plugin:update [id]` moves module installs to the newest compatible tag with `go get` and checks submodule installs out at the newest compatible tag; without an ID it updates every plugin it can resolve and reports the ones it skipped (D-10)"
|
||||
- "The CLI reads `plugins.registry_url` from the app's config (default https://summercms.io, `SUMMER_PLUGINS__REGISTRY_URL` overrides, an explicitly empty value disables registry calls) and calls the registry through kiosk.Client over fetchguard with a timeout, a size cap and no redirects (D-18)"
|
||||
- "Editing summer.yaml keeps its comments and blank lines (plugin:add and plugin:install both append entries in place; user decision 5)"
|
||||
- "`--source` or a git URL in an app directory that is not a git repository refuses with a message saying a submodule needs a git repository (user decision 5)"
|
||||
- "Registry-provided module paths, repository URLs and versions are validated before any go or git call (strict vX.Y.Z, no leading \"-\", https repository URLs)"
|
||||
artifacts:
|
||||
- path: "modules/kiosk/client.go"
|
||||
provides: "registry HTTP client and plugins.registry_url"
|
||||
contains: "func RegistryURL("
|
||||
- path: "internal/build/install.go"
|
||||
provides: "resolution and module installs"
|
||||
contains: "func PlanInstall("
|
||||
- path: "internal/build/source.go"
|
||||
provides: "submodule and git-URL installs, go.work overrides"
|
||||
contains: "work\", \"edit\""
|
||||
- path: "internal/build/update.go"
|
||||
provides: "plugin:update"
|
||||
contains: "func Update("
|
||||
- path: "internal/build/install_smoke_test.go"
|
||||
provides: "TestInstallSmoke (tracer)"
|
||||
contains: "func TestInstallSmoke("
|
||||
key_links:
|
||||
- from: "cmd/summer/main.go"
|
||||
to: "internal/build/install.go"
|
||||
via: "plugin:install builds InstallOptions and calls build.PlanInstall then build.ApplyInstall"
|
||||
pattern: "build\\.(PlanInstall|ApplyInstall)\\("
|
||||
- from: "internal/build/install.go"
|
||||
to: "modules/kiosk/client.go"
|
||||
via: "versions come from kiosk.Client.Versions and the choice from kiosk.NewestCompatible against build.EffectiveFramework"
|
||||
pattern: "NewestCompatible"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
People add a marketplace plugin to their application with a single command, `summer plugin:install <id>`, and install private plugins from a git URL without contacting summercms.io. This plan builds that command and its companion `summer plugin:update`.
|
||||
|
||||
<objective>
|
||||
Add `summer plugin:install <id[@version]|git-url> [--source] [--yes] [--offline]` and `summer plugin:update [id] [--yes] [--offline]` (D-08..D-12), the registry client and the `plugins.registry_url` setting in kiosk (D-18), comment-preserving summer.yaml edits, and go.work overrides for submodule installs (research F2). Close the `--module` research question as "not in v1" (D-11).
|
||||
|
||||
Purpose: SC-3; the same client and setting serve the admin screen in 34-05.
|
||||
|
||||
Output: commits in this repository (code with its docs; the research-question update as a separate planning-docs commit). Nothing pushed or tagged.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/34-plugin-marketplace/34-CONTEXT.md
|
||||
@.planning/phases/34-plugin-marketplace/34-RESEARCH.md
|
||||
@.planning/phases/34-plugin-marketplace/34-PATTERNS.md
|
||||
@.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
|
||||
@.planning/notes/2026-10-29-plugin-marketplace.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 34-01 (names as adjusted in its SUMMARY): kiosk wire types and RegistryAPIPath; kiosk.Parse, kiosk.Validate, kiosk.ValidVersion, kiosk.CompareVersions, kiosk.Satisfies, kiosk.NewestCompatible, kiosk.ReadIdentity; build.EffectiveFramework(ctx, appDir) (string, error) where a non-nil error with a version is a warning.
|
||||
internal/build: AddPlugin(ctx, startDir, pluginDir) (unchanged signature; v0.0.0 require stays its default), editAppModule(ctx, appDir, pluginDir, modPath), ensureGoWork, runGoWorkUse, findGoWork, applyGoWork, runGo, underRoot, findAppDir/FindAppDir, LoadManifest, SaveManifest (rewrites the file and drops comments), validateModulePath, ValidatePluginID; internal/build/new.go runGit (~286).
|
||||
modules/fetchguard: NewClient(Policy{Mode: TrustedMode, MaxBytes, Timeout}, cfg *compass.Config); Client.Get(ctx, url, header) (*Result, error); WithTransport(ctx, rt) test seam. modules/compass: Open(Options{Dir, Env}) loads config/<file>.yaml plus SUMMER_<FILE>__<KEY> env; Config.Has(path), Config.String(path).
|
||||
modules/bonfire: Interactive(out) bool; Output.Confirm(question, def) (bool, error); the auto-confirming helper in helpers.go returns true off a terminal and must not be used here (research F4); FlagBool(in, name); Input.Argument(name).
|
||||
Verified Go behaviour (research F5): `go get mod@v` works inside a go.work workspace and edits the app module; `go list -m -json` resolves module-cache dirs; a go.work replace overrides a workspace module's own replace of the same path (F2); `git submodule add <url> <path>` adopts an existing clone and `git submodule absorbgitdirs` moves its .git.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Run `git status --short internal/build cmd/summer modules/kiosk docs` before each task; stop if another session has uncommitted edits in these files. Stage explicit paths. No co-author tag.
|
||||
- Resume check: `git log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit subject | Resume point |
|
||||
|------|----------------|--------------|
|
||||
| 1 | `feat(build): summer plugin:install for registry plugins as Go modules` | TestInstallSmoke/module passes |
|
||||
| 2 | `feat(build): plugin:install resolves requirements, pins and prompts` | TestInstallSmoke/deps passes |
|
||||
| 3 | `feat(build): plugin:install --source and git URLs as submodules` | TestInstallSmoke/source passes |
|
||||
| 4 | `feat(build): summer plugin:update`; `docs(34): close the --module question for v1` | TestInstallSmoke/update passes |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "summer plugin:install acme.blog --yes adds a registry plugin to an app as a Go module" — one plugin, no dependencies, module mode only</name>
|
||||
<reversibility rating="costly">`plugins.registry_url` becomes a key in apps' config files and in released binaries; renaming it later needs a fallback read of the old key.</reversibility>
|
||||
<files>modules/kiosk/client.go, modules/kiosk/README.md, internal/build/install.go, internal/build/manifest.go, internal/build/scaffold.go, internal/build/install_smoke_test.go, internal/build/fixtures_test.go, cmd/summer/main.go, docs/plugins/marketplace.md, docs/console/scaffolding.md</files>
|
||||
<read_first>
|
||||
- modules/kiosk/registry.go, modules/kiosk/version.go, internal/build/plugindirs.go (34-01 output)
|
||||
- modules/fetchguard/client.go (NewClient, Get, WithTransport) and modules/fetchguard/README.md (TrustedMode rules)
|
||||
- modules/compass/config.go (Open, Has, String)
|
||||
- internal/build/scaffold.go (AddPlugin, editAppModule, ensureGoWork, runGo, applyGoWork) and internal/build/manifest.go (LoadManifest, SaveManifest)
|
||||
- internal/build/build_test.go (TestMakeAndAddPluginOnHelloCopy: the hello-copy app with an absolute framework replace)
|
||||
- cmd/summer/main.go (addPluginCommand) and cmd/summer/commands_documented_test.go
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (Pattern 4, F3, F5, Pitfalls 4-5, the Validation Architecture row on the file:// GOPROXY)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-08 (single plugin, newest compatible) and D-18. kiosk `client.go`: `DefaultRegistryURL = "https://summercms.io"`, `RegistryURL(cfg *compass.Config) string` (default when the key is absent, the configured value otherwise, "" meaning disabled), `ErrRegistryDisabled`, `ErrNotFound`, `Client` with `NewClient(baseURL string, cfg *compass.Config) (*Client, error)` over `fetchguard.NewClient(fetchguard.Policy{Mode: fetchguard.TrustedMode, MaxBytes: 4 MiB, Timeout: 10s}, cfg)` (base URL must be http or https without user info; no redirects are followed, so it must be the final origin), and methods `List(ctx, q, category string) (ListResponse, error)`, `Plugin(ctx, id string) (RegistryPluginDetail, error)`, `Versions(ctx, id string) ([]RegistryVersion, error)` decoding kiosk's wire types (404 → ErrNotFound, other non-2xx → error with the registry's error code).
|
||||
internal/build `install.go`: `InstallOptions{StartDir, Arg string; Source, Yes, Offline bool; Registry Registry; Interactive bool; Confirm func(summary string) (bool, error); Out io.Writer}` where `Registry` is the interface of the three client methods (tests pass a fake or an httptest-backed client); `InstallPlan{AppDir, Framework string; Steps []InstallStep}`; `InstallStep{ID, Version, Module, Package, Repository, Mode string}`; `PlanInstall(ctx, opts) (*InstallPlan, error)` and `ApplyInstall(ctx, plan *InstallPlan, out io.Writer) error`. In this task: parse `<id>`, fetch versions, filter with kiosk.NewestCompatible against build.EffectiveFramework (print its warning), validate the chosen module (validateModulePath) and version (kiosk.ValidVersion) before exec, apply with one `go get <module>@<version>` run in the app dir with applyGoWork, then append `{id, package}` to summer.yaml. Replace SaveManifest use in AddPlugin with a new comment-preserving `appendManifestPlugins(path string, plugins []Plugin) error` that inserts entries after the last item of the `plugins:` block at the existing indentation (or appends a block), re-parses the result with LoadManifest and restores the original bytes on any error. An ID already in summer.yaml is an error suggesting `summer plugin:update <id>`. Finish with "Installed … ; run summer build" (do not run go mod tidy: research Pitfall 5).
|
||||
cmd/summer: `pluginInstallCommand()` registered after plugin:manifest: Name `plugin:install`, Description, Long (argument forms, modes, framework rule, prompt and --yes, --offline, plugins.registry_url), Examples, Arg `plugin` (required: `vendor.name[@vX.Y.Z]` or a git URL), Flags `source`, `yes`, `offline` (Bare, read with bonfire.FlagBool). It opens the app's config with compass.Open(Options{Dir: <app>/config}), builds the client from kiosk.RegistryURL, sets Interactive from bonfire.Interactive(out) and Confirm from out.Confirm. Prompt rule (research F4): with --yes no prompt; otherwise interactive → Confirm(summary, false); non-interactive → an error naming `--yes`.
|
||||
`fixtures_test.go`: helpers `writeProxyModule(t, proxyDir, modPath, version string, files map[string]string)` building `@v/list`, `.info`, `.mod` and `.zip` with archive/zip (zip entries prefixed `<module>@<version>/`), `fakeRegistry(t, plugins)` serving kiosk JSON through httptest, and `helloCopy(t)` reusing the TestMakeAndAddPluginOnHelloCopy setup. `install_smoke_test.go` `TestInstallSmoke/module` (skipped under -short): registry lists acme.blog with v0.1.0 (framework v0.5.0) and v0.2.0 (framework v9.0.0); the go subprocesses run with GOPROXY=`file://<fixture>,file://<real GOMODCACHE>/cache/download`, GOMODCACHE=t.TempDir(), GOSUMDB=off, GOFLAGS=-modcacherw (offline; record the exact env that worked in the SUMMARY); after PlanInstall plus ApplyInstall with Yes, assert go.mod requires the fixture module at v0.1.0, summer.yaml lists acme.blog and still has a comment planted before the install, and build.App builds the app. A second subtest asserts a pin to v0.2.0 fails naming v9.0.0.
|
||||
Docs in the same commit: client and RegistryURL in modules/kiosk/README.md; "Installing plugins" and the `plugins.registry_url` key in docs/plugins/marketplace.md; a `plugin:install` row in docs/console/scaffolding.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./internal/build -run 'TestInstallSmoke/module' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestInstallSmoke/module" line</fails_when>
|
||||
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'DefaultRegistryURL = "https://summercms.io"' modules/kiosk/client.go` prints one line
|
||||
- `grep -n '"plugin:install"' cmd/summer/main.go` prints one line
|
||||
- `grep -n "bonfire.Interactive" cmd/summer/main.go` prints at least one line, and `grep -n "ConfirmProceed" cmd/summer/main.go internal/build/install.go` shows no call inside the plugin:install code path
|
||||
- `grep -n "appendManifestPlugins" internal/build/scaffold.go internal/build/install.go` prints at least two lines
|
||||
- `grep -n "plugins.registry_url" docs/plugins/marketplace.md` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>A registry plugin installs into an app as a Go module with one command and the app builds.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Requirements, pins, prompts and offline rules for registry installs</name>
|
||||
<files>internal/build/install.go, internal/build/install_smoke_test.go, internal/build/fixtures_test.go, cmd/summer/main.go, docs/plugins/marketplace.md</files>
|
||||
<read_first>
|
||||
- internal/build/install.go and install_smoke_test.go (Task 1)
|
||||
- modules/kiosk/version.go (Satisfies, NewestCompatible)
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (Pattern 4 closure rules)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- installing a plugin that requires golem15.translate v0.1.0 when translate is absent plans two steps, dependency first, each the newest compatible version that also satisfies the minimum, and one go get installs both
|
||||
- a dependency already in summer.yaml whose installed version is below the minimum is an error naming `summer plugin:update golem15.translate`
|
||||
- a dependency cycle in registry data terminates and is reported, it does not loop
|
||||
- `name@v0.1.0` installs exactly that version when compatible; an unknown version is an error listing the available ones
|
||||
- an unknown ID answers "not in the registry" with the registry URL used
|
||||
- with Yes false and Interactive false the plan is printed and PlanInstall's caller gets an error containing "--yes"; with Interactive true and Confirm returning false nothing is changed
|
||||
- with Offline true or an empty plugins.registry_url, an ID install is an error that says so and mentions git URL installs
|
||||
- a registry entry whose module starts with "-" or whose version is not vX.Y.Z is refused before any exec
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-08 and D-09. Extend PlanInstall with the transitive closure over RegistryVersion.Requires (for each missing ID: newest version satisfying both the framework and the minimum; installed IDs: compare the installed version — module version from go.mod, or `git describe` base through kiosk.BaseVersion for local plugins — with the minimum), cycle detection, pins, the not-found and disabled cases, and validation of every registry string before ApplyInstall runs anything. Render the confirmation summary as one line per step (`id@version (module|submodule)`), printed before the prompt. ApplyInstall runs a single `go get` with every module@version so MVS sees the whole set, then appends all summer.yaml entries in dependency order. Write the behavior subtests (`deps`, `installed_old`, `cycle`, `pin`, `unknown`, `prompt`, `offline`, `invalid`) first; they use the fake registry and, where nothing is applied, run under -short. Document the requirement rules and the prompt in docs/plugins/marketplace.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./internal/build -run 'TestInstallSmoke' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestInstallSmoke/deps" or "--- PASS: TestInstallSmoke/prompt" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- every behavior subtest named in the action exists and passes
|
||||
- `grep -n "summer plugin:update" internal/build/install.go` prints at least one line (the installed-too-old hint)
|
||||
- `grep -n -- "--yes" internal/build/install.go cmd/summer/main.go` prints at least one line in an error message
|
||||
</acceptance_criteria>
|
||||
<done>Registry installs bring their missing requirements, refuse incompatible ones with the version to move to, and never proceed without a confirmation or --yes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: --source and git-URL installs as git submodules with go.work overrides</name>
|
||||
<files>internal/build/source.go, internal/build/install.go, internal/build/scaffold.go, internal/build/install_smoke_test.go, internal/build/fixtures_test.go, cmd/summer/main.go, docs/plugins/marketplace.md, docs/architecture/go-modules-and-workspaces.md</files>
|
||||
<read_first>
|
||||
- internal/build/install.go (Tasks 1-2), internal/build/scaffold.go (editAppModule, ensureGoWork, runGoWorkUse)
|
||||
- internal/build/new.go (~286 runGit)
|
||||
- ../sm-demo-app/go.work (hand-written replace overrides for submodule plugins) — read only
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (F2, Pattern 4, "CLI git" code example, Security Domain)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `--source` for an ID clones the registry's repository at the chosen tag into plugins/<vendor>/<name>, adds it as a submodule (`.gitmodules` lists the path, `git submodule status` shows the tag), registers it with require <module>@<tag> plus a local replace and a go.work use, and the app builds although the plugin's go.mod replaces the framework with ../summercms.go (go.work replace override)
|
||||
- dependencies of a `--source` install are installed as submodules too
|
||||
- a git URL install reads plugin.yaml at the newest tag whose framework minimum is satisfied, falls back to the default branch head with a warning when no tag qualifies, never calls the registry for the plugin itself (the fake registry records no request for its ID), and resolves its required IDs through the registry
|
||||
- a git URL install with --offline and a required ID not in summer.yaml fails naming the ID; with it installed it succeeds without any registry request
|
||||
- an app directory that is not a git repository refuses --source and git URLs with a message about submodules
|
||||
- URLs starting with "-", ext:: or file:: forms and file:// (outside tests) are refused; https, ssh:// and scp-like user@host:path are accepted
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-09, D-11, D-12, user decision 5 and research F2. `source.go`: git runner for the CLI that keeps the user's git config (credential helpers, insteadOf), sets `GIT_ALLOW_PROTOCOL` from a package variable `cliGitProtocols` (default "https:ssh"; tests set "https:ssh:file"), sets GIT_TERMINAL_PROMPT=0 when not interactive, and puts `--` before URLs and paths. Git URL detection and validation (`isGitURL`, `validateGitURL`). Target directory: plugins/<vendor>/<name> from the ID in the repository root's plugin.yaml (a repository without one uses its name stripped of `sm-` and `-plugin`); a nested plugin's package directory lives inside it and the module root is what go.work uses. Flow: clone into a temp directory under plugins/, pick the tag (git URL: `git tag` plus `git show <tag>:<path>/plugin.yaml` with kiosk.Parse and the framework rule; ID: the registry's chosen version and repository URL, validated as https), check it out, move it to the target, `git submodule add -- <url> <path>` (adopts the clone) and `git submodule absorbgitdirs -- <path>`, then register through an internal variant of AddPlugin that takes the require version (editAppModule gains a version parameter; plugin:add keeps v0.0.0), and add go.work replace overrides: for every replace in the plugin's go.mod (`go mod edit -json`) whose path the go.work does not already replace, add `go work edit -replace=<path>=<target>` where target is the app's own local replace of that path when present, the workspace directory of that module when it is another installed submodule, otherwise `<path>@<version the app's go.mod requires>`. Non-git app: refuse before cloning. Wire `--source` and git URLs into PlanInstall/ApplyInstall (steps carry Mode submodule) and the CLI. Fixture repositories in tests are created with git init in t.TempDir and addressed with file:// URLs. Write the behavior subtests (`source`, `source_deps`, `giturl`, `giturl_offline`, `nogit`, `urls`) first. Document submodule installs, git URLs and the go.work overrides in docs/plugins/marketplace.md and docs/architecture/go-modules-and-workspaces.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./internal/build -run 'TestInstallSmoke/(source|source_deps|giturl|giturl_offline|nogit|urls)' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestInstallSmoke/source" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "absorbgitdirs" internal/build/source.go` prints one line
|
||||
- `grep -n '"https:ssh"' internal/build/source.go` prints one line
|
||||
- `grep -n "go work edit\|\"work\", \"edit\"" internal/build/source.go` prints at least one line
|
||||
- `go run ./cmd/summer plugin:add --help` output is unchanged in meaning, and TestMakeAndAddPluginOnHelloCopy still passes (plugin:add keeps require v0.0.0)
|
||||
</acceptance_criteria>
|
||||
<done>Plugins install from source or from any git URL the user can reach, as submodules that build inside the app's workspace.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: summer plugin:update [id] for module and submodule installs, and the --module question closed</name>
|
||||
<files>internal/build/update.go, internal/build/install_smoke_test.go, cmd/summer/main.go, docs/plugins/marketplace.md, docs/console/scaffolding.md, .planning/research/questions.md</files>
|
||||
<read_first>
|
||||
- internal/build/install.go and internal/build/source.go (Tasks 1-3)
|
||||
- internal/build/plugindirs.go (34-01: per-plugin source and version)
|
||||
- .planning/research/questions.md (the --module entry)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- a module install at v0.1.0 updates to v0.1.1 when the registry adds it (compatible), and stays when the only newer version needs a newer framework, reporting that version and the framework it needs
|
||||
- a submodule install checks out the newest compatible tag and its go.mod require follows the tag; the framework pin in go.work is refreshed when the app's framework requirement changed
|
||||
- `plugin:update` without an ID updates every resolvable plugin, lists in-app plugins and unlisted plugins as skipped, and exits zero unless a resolvable update failed
|
||||
- the update plan is printed and needs confirmation or --yes exactly like install
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-10 (no-ID semantics are Claude's discretion: skip and report what cannot be resolved; fail only when a resolvable update fails). `update.go`: `UpdateOptions{StartDir, ID string; Yes, Offline, Interactive bool; Registry Registry; Confirm func(string) (bool, error); Out io.Writer}` and `Update(ctx, opts) error`: resolve each summer.yaml plugin's source and version with the 34-01 resolver; module plugins → newest compatible registry version above the installed one → one `go get` for all; submodule plugins → `git -C <dir> fetch --tags`, newest compatible tag (registry for listed IDs, the submodule's own tags and plugin.yaml for git-URL plugins), `git -C <dir> checkout <tag>`, `go mod edit -require=<module>@<tag>`, then refresh go.work overrides; --offline restricts to submodules and their own tags. CLI `pluginUpdateCommand()`: Name `plugin:update`, full docs, optional Arg `plugin`, Flags `yes`, `offline`. Write the behavior subtests (`update`, `update_submodule`, `update_all`, `update_prompt`) first. Docs: "Updating plugins" in docs/plugins/marketplace.md and a `plugin:update` row in docs/console/scaffolding.md (same commit). Then, as a separate planning-docs commit, mark the `--module` question in .planning/research/questions.md answered: not in v1, git URLs install as submodules only (D-11), deferred until submodule-only proves limiting.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./internal/build -run 'TestInstallSmoke/update' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestInstallSmoke/update" line</fails_when>
|
||||
<automated>go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree' -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line</fails_when>
|
||||
<automated>go vet ./... && go test ./internal/build ./cmd/summer ./modules/kiosk 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"plugin:update"' cmd/summer/main.go` prints one line
|
||||
- `grep -n "func Update(ctx context.Context, opts UpdateOptions) error" internal/build/update.go` prints one line
|
||||
- `grep -n -i "not in v1" .planning/research/questions.md` prints a line in the --module entry
|
||||
- docs/console/scaffolding.md names plugin:install and plugin:update
|
||||
</acceptance_criteria>
|
||||
<done>The admin's "update available" badge has a matching command, and the --module question is closed for v1.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| registry JSON → CLI → go/git | module paths, versions and repository URLs from summercms.io (or a configured registry) |
|
||||
| user-supplied git URL → git | private or third-party repositories |
|
||||
| third-party plugin code → app binary | compiled in after confirmation |
|
||||
| CLI → app directory | summer.yaml, go.mod, go.work, .gitmodules, plugins/ |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-34-22 | Elevation of privilege | registry strings passed to go get / git clone | high | mitigate | validateModulePath, kiosk.ValidVersion, https-only repository URLs, exec without shell, `--` before URLs and paths; TestInstallSmoke/invalid and /urls |
|
||||
| T-34-23 | Elevation of privilege | ext:: or file:: transports via a git URL | high | mitigate | GIT_ALLOW_PROTOCOL=https:ssh (file only in tests), URL validator refuses leading "-" and transport prefixes |
|
||||
| T-34-24 | Tampering | malicious plugin code compiled into the app | high | accept | inherent to compiled plugins; mitigated by printing every addition and requiring confirmation or --yes (D-09); sumdb verification stays on for module installs (GONOSUMDB is never set) |
|
||||
| T-34-25 | Tampering | writes outside the app root | medium | mitigate | targets derived from validated IDs under plugins/, underRoot checks before clone, move and registration |
|
||||
| T-34-26 | Denial of service | slow or hostile registry | medium | mitigate | fetchguard client with timeout, 4 MiB cap and no redirects; clear error and --offline for git URLs |
|
||||
| T-34-27 | Spoofing | registry pointing at a different module than it lists | medium | mitigate | the ingester ties module to repository (34-03); the CLI re-reads plugin.yaml from the checkout for submodule installs and refuses an ID mismatch |
|
||||
| T-34-28 | Tampering | summer.yaml damaged by an edit | low | mitigate | appendManifestPlugins re-parses and restores the original bytes on error |
|
||||
| T-34-SC | Tampering | npm/pip/cargo installs | high | accept | the CLI installs Go modules chosen by the user; no package is added to the framework |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -short ./...` green after every commit; `go test ./internal/build -run TestInstallSmoke` (non-short) green.
|
||||
- TestCommandsDocumented, TestDocsTree and `summer docs:build --check` pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- plugin:install covers ID (module), ID --source and git URL; plugin:update covers modules and submodules.
|
||||
- Requirement checks, prompts and --offline behave per D-08..D-12.
|
||||
- No push or tag.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/34-plugin-marketplace/34-04-SUMMARY.md` when done, including the go env that made the offline module-install test work.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Types: `kiosk.Client`, `build.InstallOptions`, `build.InstallPlan`, `build.InstallStep`, `build.Registry`, `build.UpdateOptions`.
|
||||
- Functions: `kiosk.NewClient`, `kiosk.RegistryURL`, `kiosk.Client.List`, `kiosk.Client.Plugin`, `kiosk.Client.Versions`, `build.PlanInstall`, `build.ApplyInstall`, `build.Update`; internal `appendManifestPlugins`, `isGitURL`, `validateGitURL`.
|
||||
- Constants and variables: `kiosk.DefaultRegistryURL`, `kiosk.ErrRegistryDisabled`, `kiosk.ErrNotFound`, `cliGitProtocols`.
|
||||
- CLI: `summer plugin:install <id[@vX.Y.Z]|git-url>` with `--source`, `--yes`, `--offline`; `summer plugin:update [id]` with `--yes`, `--offline`.
|
||||
- Config key: `plugins.registry_url` (file config/plugins.yaml, env `SUMMER_PLUGINS__REGISTRY_URL`; default https://summercms.io; empty disables).
|
||||
- Tests: `TestInstallSmoke` (module, deps, installed_old, cycle, pin, unknown, prompt, offline, invalid, source, source_deps, giturl, giturl_offline, nogit, urls, update, update_submodule, update_all, update_prompt); helpers `writeProxyModule`, `fakeRegistry`, `helloCopy`.
|
||||
- Planning: `.planning/research/questions.md` --module entry answered (not in v1).
|
||||
255
.planning/phases/34-plugin-marketplace/34-04-SUMMARY.md
Normal file
255
.planning/phases/34-plugin-marketplace/34-04-SUMMARY.md
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 04
|
||||
subsystem: plugins
|
||||
tags: [kiosk, fetchguard, plugin-install, plugin-update, go-get, git-submodule, go-work, registry-client]
|
||||
|
||||
requires:
|
||||
- phase: 34-plugin-marketplace (34-01)
|
||||
provides: "kiosk wire types and version rules (RegistryAPIPath, RegistryVersion, ValidVersion, CompareVersions, BaseVersion, Satisfies, NewestCompatible), kiosk.Parse, kiosk.ReadIdentity, build.EffectiveFramework"
|
||||
- phase: 34-plugin-marketplace (34-03)
|
||||
provides: "the registry server at /api/registry/v1 on summercms.io"
|
||||
provides:
|
||||
- "kiosk.Client (List, Plugin, Versions, BaseURL) over fetchguard TrustedMode, 4 MiB, 10 s, no redirects; kiosk.NewClient, kiosk.RegistryURL, kiosk.DefaultRegistryURL, kiosk.ErrRegistryDisabled, kiosk.ErrNotFound; config key plugins.registry_url"
|
||||
- "build.PlanInstall / build.ApplyInstall with InstallOptions, InstallPlan, InstallStep, Registry, ErrCancelled, ModeModule, ModeSubmodule"
|
||||
- "build.Update with UpdateOptions"
|
||||
- "summer plugin:install <id[@vX.Y.Z]|git-url> [--source] [--yes] [--offline]; summer plugin:update [id] [--yes] [--offline]"
|
||||
- "comment-preserving summer.yaml appends (appendManifestPlugins) used by plugin:add and plugin:install"
|
||||
- "go.work replace overrides for submodule plugins (refreshWorkOverrides)"
|
||||
affects: [34-05 admin Plugins screen (kiosk.Client, plugins.registry_url), 34-06 tests and release ledger, every app that installs plugins]
|
||||
|
||||
actuals:
|
||||
tokens: 39000
|
||||
tasks: 4
|
||||
commits: 5
|
||||
plan_head_before: e7eda88ddd8ffb5f5626bc6af4a7c5aa765e1e75
|
||||
plan_head_after: 96a7012ece88c0e0a095b84b7d1b3c4ee31f8898
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Plan, print, confirm, apply: PlanInstall/Update resolve and print everything before any change; --yes or an interactive yes is required (never bonfire.ConfirmProceed)"
|
||||
- "Every registry string is validated before exec: strict vX.Y.Z, plain module paths, package inside module, https repository URLs; git runs with GIT_ALLOW_PROTOCOL and -- before URLs and paths"
|
||||
- "Offline Go module tests: file:// GOPROXY fixture tree built with archive/zip, chained to the developer's module download cache, with a throwaway GOMODCACHE"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/kiosk/client.go
|
||||
- internal/build/install.go
|
||||
- internal/build/source.go
|
||||
- internal/build/update.go
|
||||
- internal/build/install_smoke_test.go
|
||||
- internal/build/fixtures_test.go
|
||||
modified:
|
||||
- internal/build/manifest.go
|
||||
- internal/build/scaffold.go
|
||||
- cmd/summer/main.go
|
||||
- modules/kiosk/README.md
|
||||
- docs/plugins/marketplace.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- .planning/research/questions.md
|
||||
|
||||
key-decisions:
|
||||
- "A workspace module that another workspace module's go.mod replaces gets a versioned go.work replace (M@<required version> => dir): Go refuses a whole-path replace of a workspace module, and any go.work replace of the path resolves the conflict (verified in scratch)"
|
||||
- "go.work overrides are computed over every go.work use module's go.mod, not only the newly installed plugin, so a plugin installed earlier that replaces the new one is covered; replaces the user wrote in go.work are left alone; self-pins (M => M@v) follow the app's go.mod require"
|
||||
- "plugin:update reads each plugin's source and version from go.mod/go.work and git describe (installedSource), offline, instead of the go list resolver, which needs every module's sources downloaded"
|
||||
- "A git URL install with no fitting release tag takes the default branch head with a warning and requires v0.0.0 in go.mod, as plugin:add does"
|
||||
- "plugin:update without an ID applies what resolved, reports the rest under 'Not updated', and fails only when a resolved update could not be carried out"
|
||||
- "--module for git URLs is not in v1 (D-11); the research question is closed"
|
||||
|
||||
patterns-established:
|
||||
- "Commands that change an application's plugins print every change as id@version (mode) and need --yes off a terminal"
|
||||
- "Install and update tests use fakeRegistry (httptest + the real kiosk.Client), writeProxyModule and pluginRepo fixtures, and never touch the network"
|
||||
|
||||
requirements-completed: [SC-3, D-08, D-09, D-10, D-11, D-12, D-18]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summer plugin:install <id> installs the newest compatible registry release as a Go module with go get, appends summer.yaml in place (comments kept) and the app builds; a pin needing a newer framework fails naming it"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/module"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/module_pin_framework,append_manifest"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Transitive requirements, installed-too-old error naming summer plugin:update, cycle report, pins, unknown IDs, prompt and --yes rule, --offline/disabled registry, refusal of invalid registry strings"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/deps,installed_old,cycle,pin,unknown,prompt,offline,invalid"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "--source and git URL installs as git submodules at the chosen tag, registered with the tag as the go.mod require, with go.work overrides so the app builds although the plugin replaces the framework with ../summercms.go; non-git apps refused; URL validation"
|
||||
requirement: D-12
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/source,source_deps,giturl,giturl_offline"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/nogit,urls"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "summer plugin:update for module and submodule installs, all-plugins mode with skipped/failed reporting, go.work pin refresh, prompt rule"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/update,update_submodule,update_all,update_prompt"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "kiosk.Client and plugins.registry_url (default, override, empty disables), fetchguard limits, 404 as ErrNotFound naming the registry"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/install_smoke_test.go#TestInstallSmoke/registry_client,unknown,offline"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "CLI commands and docs: plugin:install and plugin:update documented, kiosk README, marketplace, scaffolding and go-modules pages; --module question closed"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'; go run ./cmd/summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Docs and help wording are a reviewer's call; the checker only proves identifiers, command names, links and snippets"
|
||||
- id: D7
|
||||
description: "Interactive prompt on a real terminal and installs against the live summercms.io registry and real plugin repositories over https/ssh"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Tests run non-interactively against httptest and file:// fixtures; a real terminal prompt, credentials and the deployed registry need a person"
|
||||
|
||||
duration: 34min
|
||||
completed: 2026-10-29
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 34 Plan 04: plugin:install and plugin:update Summary
|
||||
|
||||
**`summer plugin:install` adds registry plugins as Go modules (newest release the app's effective framework allows, with their requirements, after a printed plan and a confirmation or `--yes`), installs from source or any git URL as git submodules with the go.work overrides that make them build, and `summer plugin:update` moves module and submodule installs to their newest compatible release; all registry calls go through the new `kiosk.Client` and the `plugins.registry_url` setting.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 34 min
|
||||
- **Started:** 2026-10-29T17:12:51Z
|
||||
- **Completed:** 2026-10-29T17:46:30Z
|
||||
- **Tasks:** 4 (Task 1 tracer, then three expansion tasks)
|
||||
- **Files modified:** 14 (6 created, 8 modified)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Registry client (D-18).** `kiosk.Client` (`List`, `Plugin`, `Versions`) over a `fetchguard` client in trusted mode with a 4 MiB cap, a 10 s timeout and no redirects. `kiosk.RegistryURL` reads `plugins.registry_url` (default `https://summercms.io`, `SUMMER_PLUGINS__REGISTRY_URL` overrides, empty disables). A 404 wraps `kiosk.ErrNotFound` and names the ID and the registry origin; a redirect is an error asking for the final origin.
|
||||
- **Module installs (D-08).** `build.PlanInstall` parses `id[@vX.Y.Z]`, asks the registry, picks the newest release the effective framework satisfies (or the pin), and fails with the framework version to move to when only newer-framework releases exist. `build.ApplyInstall` runs one `go get -- mod@v ...` and appends summer.yaml in place. It does not run `go mod tidy` (Pitfall 5).
|
||||
- **Requirements, pins and prompts (D-09).** Requirements resolve transitively, dependencies first, each at the newest compatible release at or above its minimum. Plugins sharing a module share one release. An installed requirement below its minimum is an error naming `summer plugin:update <id>`. Cycles are reported. The plan is printed as `id@version (mode)`; `--yes` skips the prompt, a non-interactive run without it is an error naming `--yes`, and a declined prompt changes nothing (`build.ErrCancelled`).
|
||||
- **Submodule installs (D-09, D-11, D-12, research F2).** `--source` clones each registry repository (https only) at the chosen tag. A git URL (https, ssh, `user@host:path`) is cloned with the user's own git setup and never sent to the registry. The command reads `plugin.yaml` at the newest tag whose framework minimum fits, or falls back to the default branch head with a warning. Each plugin is moved to `plugins/<vendor>/<name>`, then `git submodule add` and `absorbgitdirs` run, and the plugin is registered with the tag as its go.mod require (plugin:add keeps `v0.0.0`). The checkout's `plugin.yaml` must match the listed ID and module. `refreshWorkOverrides` adds the go.work replaces the plugins' development-layout `go.mod` replaces need. A non-git app is refused before cloning.
|
||||
- **plugin:update (D-10).** Module installs move with one `go get`. Submodules fetch tags, are checked out at the release tag and require it in go.mod. Unlisted submodules (git URL installs) use their own tags and `plugin.yaml`. Without an ID, every resolvable plugin is updated and the rest are reported. A newer release that needs a newer framework is named.
|
||||
- **summer.yaml edits keep comments** (user decision 5). `appendManifestPlugins` inserts after the last item of the `plugins:` block at its indentation, re-parses, and restores the original bytes on any error. `plugin:add` uses it too.
|
||||
- `--module` research question closed as "not in v1" (D-11).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: plugin:install for registry plugins as Go modules (tracer)**: `9b23e87` (feat). Tracer gate: interactive, `end-of-phase`, automated-only verify, re-run green, expanded.
|
||||
2. **Task 2: requirements, pins, prompts and offline rules**: `a462bf1` (feat)
|
||||
3. **Task 3: --source and git URLs as submodules with go.work overrides**: `8093587` (feat)
|
||||
4. **Task 4: summer plugin:update**: `d024ab4` (feat); **--module question closed**: `96a7012` (docs, planning)
|
||||
|
||||
**Plan metadata:** the docs commit containing this SUMMARY.
|
||||
|
||||
## Offline module-install test environment (as requested by the plan)
|
||||
|
||||
`TestInstallSmoke/module`, `deps` and `update` ran with:
|
||||
|
||||
- `GOPROXY=file://<fixture proxy>,file://<go env GOMODCACHE>/cache/download`
|
||||
- `GOMODCACHE=<t.TempDir()>`
|
||||
- `GOSUMDB=off`, `GOTOOLCHAIN=local`, with `GONOSUMDB`, `GOPRIVATE` and `GONOPROXY` empty
|
||||
- `GOFLAGS=-modcacherw` set in the test env. summer's go runner (`applyGoWorkFile`) drops `GOFLAGS`, and for an app without go.work it sets `GOWORK=off GOFLAGS=-mod=mod`. A test cleanup therefore makes the fresh module cache writable before the temp dir is removed.
|
||||
|
||||
The fixture proxy tree has `@v/list`, `.info`, `.mod` and `.zip` files, with zip entries prefixed `<module>@<version>/`. No network was used. `module` takes about 28 s, mostly because the fresh module cache extracts every dependency before `summer build` links the app. The submodule tests run with `GOPROXY=off`.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/kiosk/client.go`: registry client and `plugins.registry_url`
|
||||
- `internal/build/install.go`: `PlanInstall`, `ApplyInstall`, resolution, validation, prompt rule, `installedVersion`
|
||||
- `internal/build/source.go`: CLI git runner (`cliGitProtocols`), `isGitURL`, `validateGitURL`, `validateRepositoryURL`, clone, tag and `plugin.yaml` selection, `applySubmodule`, `refreshWorkOverrides`
|
||||
- `internal/build/update.go`: `Update`, `installedSource`
|
||||
- `internal/build/manifest.go`: `appendManifestPlugins`, `insertManifestPlugins`
|
||||
- `internal/build/scaffold.go`: `AddPlugin` appends in place; `editAppModule` takes the require version
|
||||
- `internal/build/install_smoke_test.go`, `internal/build/fixtures_test.go`: TestInstallSmoke (module, module_pin_framework, deps, installed_old, cycle, pin, unknown, prompt, offline, invalid, source, source_deps, giturl, giturl_offline, nogit, urls, update, update_submodule, update_all, update_prompt, append_manifest, registry_client) and the helpers writeProxyModule, fakeRegistry, helloCopy, pluginRepo, gitApp
|
||||
- `cmd/summer/main.go`: `plugin:install`, `plugin:update`, `registryFor`
|
||||
- Docs: `modules/kiosk/README.md` (client, Configuration, CLI rows), `docs/plugins/marketplace.md` (Installing plugins, Required plugins, Confirmation, Installing from source, Installing from a git URL, Updating plugins, The registry setting), `docs/console/scaffolding.md` (two rows), `docs/architecture/go-modules-and-workspaces.md` (Plugins installed as submodules)
|
||||
- `.planning/research/questions.md`: the `--module` entry answered
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. In short: versioned go.work replaces for workspace modules, overrides computed across all workspace modules, offline source detection for update, a default-branch-head fallback that requires `v0.0.0`, and lenient update-all semantics (apply what resolved, report the rest).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Workspace modules need a versioned go.work replace**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan's F2 rule replaces a module that is another installed submodule with "the workspace directory of that module". Go rejects that: "workspace module X is replaced at all versions in the go.work file". Without some go.work replace, it reports "conflicting replacements" instead.
|
||||
- **Fix:** `refreshWorkOverrides` writes `replace M <required version> => ./plugins/...` for workspace modules. This was verified in a scratch workspace, where any go.work replace of the path resolves the conflict, even when MVS selects another version.
|
||||
- **Files modified:** internal/build/source.go
|
||||
- **Commit:** 8093587
|
||||
|
||||
**2. [Rule 2 - Missing critical] Overrides cover all workspace modules, not just the new plugin**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** A submodule installed earlier whose go.mod replaces the newly installed module breaks the build the same way.
|
||||
- **Fix:** Every go.work `use` module's go.mod is scanned (except the app's). The function is idempotent, and replaces already in go.work are never touched.
|
||||
- **Commit:** 8093587
|
||||
|
||||
**3. [Rule 2 - Missing critical] Extra URL hardening**
|
||||
- Git URLs carrying a password, and registry repository URLs carrying any user info, are refused, so credentials never land in `.gitmodules`. `file://` registry repositories are accepted only when `cliGitProtocols` includes `file` (the tests). A target directory that already exists is refused while planning (registry `--source`) or before cloning (git URL).
|
||||
- **Commits:** 8093587
|
||||
|
||||
**4. [Rule 3 - Blocking] The docs checker refuses names of commands that do not exist yet**
|
||||
- Tasks 1-3 could not name `summer plugin:update` in docs (`docs:build --check`: "plugin:update is not a summer or application command"). Those sentences were reworded and then restored in Task 4. Because of that, Task 4 also touched `modules/kiosk/README.md` and `docs/architecture/go-modules-and-workspaces.md`, which are not in its file list. The install error message names `summer plugin:update` from Task 2 on, as the acceptance criteria require.
|
||||
- **Commit:** d024ab4
|
||||
|
||||
**5. [Plan adjustment] Source and version detection for update**
|
||||
- The plan says to use "the 34-01 resolver" (go list). `installedSource` reads go.mod, go.work and `git describe` instead, applying the D-07 rules: module version, base tag for a plugin that is its own repository, nothing for an in-app plugin. It needs no downloads, and the installed-version check in plugin:install uses it too.
|
||||
- **Commit:** d024ab4
|
||||
|
||||
**6. [Plan adjustment] Extra exported names**
|
||||
- `build.ErrCancelled`, `build.ModeModule`, `build.ModeSubmodule` and `kiosk.Client.BaseURL` are not in the plan's artifact list. They give the CLI a declined-prompt path and give 34-05 the registry origin. `kiosk.Client.BaseURL` is documented in the kiosk README.
|
||||
|
||||
**7. [Plan adjustment] Update reports newer incompatible releases next to an update**
|
||||
- `acme.translate v0.1.0 -> v0.1.1 (submodule); v0.2.0 needs framework v9.0.0`, so the admin badge and the CLI tell the same story.
|
||||
|
||||
**8. [Process] TDD order**
|
||||
- Task 2: the behavior subtests were written first and failed on their assertions (deps, installed_old, cycle, prompt and invalid) before the implementation. Task 4: the subtests were written first and failed to compile on the missing `Update`/`UpdateOptions`. Task 3: the implementation came before its subtests. As a substitute RED check, `refreshWorkOverrides` was disabled and `source`/`source_deps` were run against it; both failed on the go.work assertions, and the code was then restored. Each task is one commit, as the plan's Execution units table requires, so there are no separate `test(...)` commits.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 8 (1 Rule 1, 2 Rule 2, 1 Rule 3, 3 plan adjustments, 1 process note). **Impact:** no contract change. All planned names exist, `plugin:add` behaviour is unchanged (TestMakeAndAddPluginOnHelloCopy passes), and the overrides follow verified Go behaviour.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `gofmt -l cmd/summer` lists pre-existing alignment differences in `cmd/summer/main.go` (`Args: makeArtifactArgs()` lines), `runtime.go` and `runtime_test.go`, plus `modules/kiosk/manifest_smoke_test.go`. This plan did not introduce them (scope boundary). The new code is gofmt-clean.
|
||||
- A plugin repository that has only nested plugins and no root `plugin.yaml` or `plugin.go` cannot be installed from a git URL; the command reports this. Registry `--source` installs of nested plugins do work: the checkout directory is named after the root plugin, or after the repository name without `sm-` and `-plugin`.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. `plugins.registry_url` defaults to https://summercms.io.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 34-05 (admin Plugins screen) can build its registry client with `kiosk.NewClient(kiosk.RegistryURL(app.Config), app.Config)`, treat `kiosk.ErrRegistryDisabled` as "hide the Marketplace tab", and compute "update available" with the same rule as `plugin:update` (`kiosk.NewestCompatible` above the installed version).
|
||||
- 34-06: TestInstallSmoke is the install/update coverage. The non-short subtests take about 45 s, most of it in `module`.
|
||||
- Real-world check still open (D7): run `summer plugin:install golem15.<name>` against the deployed registry, and run a git URL install over SSH, on a terminal.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Key files exist: modules/kiosk/client.go (`DefaultRegistryURL = "https://summercms.io"`, `func RegistryURL(`), internal/build/install.go (`func PlanInstall(`, `NewestCompatible`), internal/build/source.go (`"work", "edit"`, `absorbgitdirs`, `"https:ssh"`), internal/build/update.go (`func Update(ctx context.Context, opts UpdateOptions) error`) and internal/build/install_smoke_test.go (`func TestInstallSmoke(`).
|
||||
- Commits 9b23e87, a462bf1, 8093587, d024ab4 and 96a7012 are in history; `git rev-list --count e7eda88..HEAD` = 5 before this docs commit.
|
||||
- `go vet ./...`, `go test -short ./...`, `go test ./internal/build ./cmd/summer ./modules/kiosk` (non-short, all TestInstallSmoke subtests), `cd examples/hello && go test ./...`, `go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'` and `summer docs:build --check` all pass.
|
||||
- Acceptance criteria of Tasks 1-4 were re-run and pass. `cmd/summer/main.go` and `internal/build/install.go` do not call `ConfirmProceed`, and `grep -i "not in v1" .planning/research/questions.md` hits the `--module` entry.
|
||||
275
.planning/phases/34-plugin-marketplace/34-05-PLAN.md
Normal file
275
.planning/phases/34-plugin-marketplace/34-05-PLAN.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["34-01", "34-04"]
|
||||
files_modified:
|
||||
- modules/cabana/plugins.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/backend_screens.go
|
||||
- modules/cabana/navigation.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/phase09_contract_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/plugins_smoke_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/views/PluginsView.vue
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/state/useSettings.ts
|
||||
- admin/tests/views/PluginsView.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
- docs/plugins/marketplace.md
|
||||
autonomous: true
|
||||
requirements: [SC-4, D-06, D-07, D-17, D-18, D-19]
|
||||
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The admin has a read-only Plugins screen at /system/plugins, linked from Settings → System, visible to administrators holding the new permission backend.view_plugins; superusers have it without any grant (D-19)"
|
||||
- "The Installed tab lists every activated plugin with the name, description, version and source baked by summer build (ID only for a plugin without plugin.yaml) and, for plugins listed in the registry, whether an update compatible with the built framework version exists, with the matching `summer plugin:update <id>` command (D-06, D-07, D-17, SC-4)"
|
||||
- "The SPA calls only cabana: GET /plugins and GET /plugins/marketplace are swag-annotated cabana endpoints that call the registry server-side with a timeout and a short in-process cache and compute update availability in Go; no CSP or CORS change in the admin (D-17)"
|
||||
- "The Marketplace tab searches the registry by text and category and shows `summer plugin:install <id>` with a copy button; there is no install button (SC-4, decision note)"
|
||||
- "An empty plugins.registry_url hides the Marketplace tab and the update badges while the Installed tab still works; a registry that is down or slow leaves the Installed tab rendering with a registry-unavailable notice (D-18)"
|
||||
- "admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated from the annotations, the contract inventories list the new routes, and modules/boardwalk/dist is rebuilt and passes scripts/check-admin-dist.sh"
|
||||
artifacts:
|
||||
- path: "modules/cabana/plugins.go"
|
||||
provides: "Plugins screen endpoints, registry cache, update computation"
|
||||
contains: "PermissionViewPlugins"
|
||||
- path: "admin/src/views/PluginsView.vue"
|
||||
provides: "Installed and Marketplace tabs"
|
||||
contains: "plugin:install"
|
||||
- path: "modules/cabana/plugins_smoke_test.go"
|
||||
provides: "TestPluginsScreenSmoke (tracer)"
|
||||
contains: "func TestPluginsScreenSmoke("
|
||||
- path: "admin/tests/views/PluginsView.test.ts"
|
||||
provides: "PluginsView smoke spec"
|
||||
contains: "PluginsView"
|
||||
key_links:
|
||||
- from: "modules/cabana/plugins.go"
|
||||
to: "modules/party/info.go"
|
||||
via: "the Installed handler reads app.Lookup[party.Installed]() (falls back to party.Activated IDs for an app built before 34-01)"
|
||||
pattern: "party\\.Installed"
|
||||
- from: "modules/cabana/plugins.go"
|
||||
to: "modules/kiosk/client.go"
|
||||
via: "registry calls through kiosk.Client built from kiosk.RegistryURL(app config), update rule through kiosk.NewestCompatible and kiosk.BaseVersion"
|
||||
pattern: "kiosk\\.(NewClient|RegistryURL|NewestCompatible|BaseVersion)"
|
||||
- from: "admin/src/views/PluginsView.vue"
|
||||
to: "modules/cabana/plugins.go"
|
||||
via: "typed api client calls GET /plugins and GET /plugins/marketplace only"
|
||||
pattern: "/plugins"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
The admin SPA gains a read-only Plugins screen listing installed plugins with versions and available updates and searching the marketplace, which shows the install command with a copy button. This plan builds that screen on top of the metadata summer build bakes (34-01) and the registry client (34-04).
|
||||
|
||||
<objective>
|
||||
Add cabana endpoints GET /plugins and GET /plugins/marketplace with the permission backend.view_plugins (D-17, D-19), a Settings → System entry that opens a custom SPA route, and PluginsView with Installed and Marketplace tabs; regenerate the OpenAPI document and the SPA types, update the contract inventories, rebuild and commit the embedded dist.
|
||||
|
||||
Purpose: SC-4.
|
||||
|
||||
Output: commits in this repository (code with its docs; the rebuilt dist in its own commit). Nothing pushed or tagged.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/34-plugin-marketplace/34-CONTEXT.md
|
||||
@.planning/phases/34-plugin-marketplace/34-RESEARCH.md
|
||||
@.planning/phases/34-plugin-marketplace/34-PATTERNS.md
|
||||
@.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-04-SUMMARY.md
|
||||
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From 34-01: party.Info{ID, Module, Package, Name, Description, Version, Source}, party.Installed{Framework, Plugins} published by the generated main; kiosk.BaseVersion, kiosk.CompareVersions, kiosk.NewestCompatible, kiosk wire types. From 34-04: kiosk.Client (List, Plugin, Versions), kiosk.NewClient(baseURL, cfg), kiosk.RegistryURL(cfg) ("" = disabled), kiosk.ErrNotFound.
|
||||
modules/cabana: routes in http.go inside `r.GroupRaw(api, []string{"backend", "locale.from-principal"}, …)` (~316-339, next to `g.Get("/settings", s.settingsList)`); handler pattern settingsList (~560) with bouncer.User and WriteData/WriteError; permissions in backend_screens.go (PermissionManageUsers = "backend.manage_users", Permissions() with Tab "backend::lang.permissions.tab_system"); SettingsEntry in navigation.go (~23-35) and Registry.Metadata builds the permission-filtered settings list; cabana.Allows(principal, []string); swag blocks in admin_openapi.go (~469-479 AdminSettingsList); TestPhase09ContractInventory (phase09_contract_test.go) and TestPhase09PermissionMatrix/TestPhase09SecurityCoverage (security_coverage_test.go ~55, ~184) list every route and handler.
|
||||
admin: router.ts routes (/settings, /account, then /:vendor/:plugin/:controller); useSettings.ts settingsPath(entry) (~30-39); typed api client admin/src/api/client.ts; reka-ui 2.9.10 (Tabs primitives); copy pattern in admin/src/components/account/ApiTokenCreateDialog.vue (~255-268); tests in admin/tests/views/*.test.ts with admin/tests/helpers.ts.
|
||||
Strings: modules/phrasebook/backend/lang/{en,pl}/lang.yaml (permissions block ~548 en, ~584 pl).
|
||||
Other sessions often edit cabana: check `git status --short modules/cabana admin` and `git log -3 -- modules/cabana` before each task.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Before each task: `git status --short modules/cabana admin modules/phrasebook modules/boardwalk docs/backend`; stop if another session has uncommitted edits in files this task touches. Stage explicit paths. No co-author tag.
|
||||
- Resume check: `git log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit subject | Resume point |
|
||||
|------|----------------|--------------|
|
||||
| 1 | `feat(cabana): read-only Plugins screen with installed plugins` | TestPluginsScreenSmoke/installed and the PluginsView spec pass |
|
||||
| 2 | `feat(cabana): plugin updates and marketplace search on the Plugins screen` | TestPluginsScreenSmoke passes |
|
||||
| 3 | `build(admin): rebuild the embedded admin for the Plugins screen` | check-admin-dist.sh passes |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an administrator with backend.view_plugins opens Settings → Plugins and sees the installed plugins with their versions" — Installed tab only, no registry</name>
|
||||
<files>modules/cabana/plugins.go, modules/cabana/http.go, modules/cabana/backend_screens.go, modules/cabana/navigation.go, modules/cabana/admin_openapi.go, modules/cabana/phase09_contract_test.go, modules/cabana/security_coverage_test.go, modules/cabana/plugins_smoke_test.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/views/PluginsView.vue, admin/src/app/router.ts, admin/src/state/useSettings.ts, admin/tests/views/PluginsView.test.ts, docs/backend/users-and-permissions.md, docs/backend/admin-spa.md, docs/plugins/marketplace.md</files>
|
||||
<read_first>
|
||||
- modules/cabana/http.go (~300-345 routes, ~560 settingsList), modules/cabana/backend_screens.go, modules/cabana/navigation.go (SettingsEntry, Registry.Metadata)
|
||||
- modules/cabana/admin_openapi.go (~469-479) and scripts/check-admin-openapi.sh
|
||||
- modules/cabana/phase09_contract_test.go and modules/cabana/security_coverage_test.go (route and handler lists)
|
||||
- modules/party/info.go (34-01)
|
||||
- admin/src/app/router.ts, admin/src/state/useSettings.ts, admin/src/views/SettingsIndexView.vue, admin/src/views/AccountView.vue (a shell view to copy), admin/tests/views/SettingsFormView.test.ts and admin/tests/helpers.ts
|
||||
- docs/backend/users-and-permissions.md, docs/backend/admin-spa.md
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-06, D-07, D-17 and D-19. cabana: `PermissionViewPlugins = "backend.view_plugins"` declared in backendScreens.Permissions() with Tab tab_system and Label `backend::lang.permissions.view_plugins` (no role grants; superusers pass every check). `SettingsEntry` gains `Path string json:"path,omitempty"` (additive); Registry.Metadata appends cabana's own entry (code `plugins`, category system, label and description from lang, Path `/system/plugins`) only when the principal is allowed backend.view_plugins. `plugins.go`: types `PluginsOverview{Framework string; Registry RegistryState; Plugins []InstalledPlugin}`, `RegistryState{Enabled bool; Status string}` (status `ok`, `disabled`, `unavailable`), `InstalledPlugin{ID, Name, Description, Version, Source, Module, Package string; Listed bool; Latest, LatestFramework string; UpdateAvailable bool; UpdateCommand string}` with lower camel JSON tags matching the existing admin API style; handler `pluginsInstalled` (401 without a backend principal, 403 via WriteError when not allowed backend.view_plugins) reading `app.Lookup[party.Installed]()` and falling back to `app.Lookup[party.Activated]()` IDs for an app built before 34-01; Framework from party.Installed, else bonfire.BuildVersion().Framework. In this task Registry is reported as configured or `disabled` only (kiosk.RegistryURL of the app config) and no registry call is made. Route `g.Get("/plugins", s.pluginsInstalled)` in the backend group. swag block `AdminPluginsInstalled` (@Router /plugins [get], Envelope[PluginsOverview], 401, 403); run `scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts; add the route and handler to the contract inventories. Lang keys en and pl: permission label, settings entry label and description, screen strings (title, tabs, columns, source names, empty state).
|
||||
SPA: route `{ path: '/system/plugins', name: 'plugins', component: PluginsView, meta: { shell: true } }` before the `/:vendor/:plugin/:controller` routes; `settingsPath` returns `entry.path` first when set; `PluginsView.vue` uses reka-ui Tabs with the Installed tab (name or ID, description, version, source) loaded through the typed api client from GET /plugins, and the Marketplace tab present but filled in Task 2. All text through the existing translation helper; render registry text as text, never HTML.
|
||||
Tests first: `TestPluginsScreenSmoke/installed` (an app with party.Installed published: superuser 200 with every plugin and its version; an administrator without the permission 403; with it 200; settings list contains the plugins entry with path only for allowed principals; an app without party.Installed lists Activated IDs) and `admin/tests/views/PluginsView.test.ts` (renders the Installed rows from a mocked /plugins response). Docs in the same commit: permission in docs/backend/users-and-permissions.md, the screen in docs/backend/admin-spa.md and an "Admin Plugins screen" section in docs/plugins/marketplace.md, new identifiers in modules/cabana/README.md. Do not rebuild the dist here (Task 3). Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/cabana -run 'TestPluginsScreenSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase09SecurityCoverage' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestPluginsScreenSmoke/installed" line</fails_when>
|
||||
<automated>scripts/check-admin-openapi.sh --check</automated>
|
||||
<fails_when>non-zero exit (committed OpenAPI or schema.d.ts differs from the generated one)</fails_when>
|
||||
<automated>npm --prefix admin run test -- PluginsView 2>&1 | tail -6 && npm --prefix admin run typecheck 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or "No test files found"</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'PermissionViewPlugins = "backend.view_plugins"' modules/cabana/backend_screens.go modules/cabana/plugins.go` prints one line
|
||||
- `grep -n 'g.Get("/plugins", s.pluginsInstalled)' modules/cabana/http.go` prints one line
|
||||
- `grep -n "@Router /plugins \[get\]" modules/cabana/admin_openapi.go` prints one line; `grep -n '"/plugins"' admin/openapi/admin.json` prints at least one line
|
||||
- `grep -n "/system/plugins" admin/src/app/router.ts` prints one line and `grep -n "entry.path" admin/src/state/useSettings.ts` prints at least one line
|
||||
- `grep -n "view_plugins" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` prints two lines
|
||||
</acceptance_criteria>
|
||||
<done>An allowed administrator reaches the Plugins screen from Settings and sees every installed plugin with its version and source.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Update badges and marketplace search through cabana, degraded when the registry is down or disabled</name>
|
||||
<files>modules/cabana/plugins.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase09_contract_test.go, modules/cabana/security_coverage_test.go, modules/cabana/plugins_smoke_test.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/views/PluginsView.vue, admin/tests/views/PluginsView.test.ts, docs/plugins/marketplace.md</files>
|
||||
<read_first>
|
||||
- modules/cabana/plugins.go (Task 1)
|
||||
- modules/kiosk/client.go, modules/kiosk/version.go (34-01, 34-04)
|
||||
- modules/fetchguard/client.go (WithTransport) for the fake registry in tests
|
||||
- admin/src/components/account/ApiTokenCreateDialog.vue (~255-268 clipboard with fallback)
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (Pattern 5: timeouts, cache, concurrency, update rule)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- an installed module plugin at v0.1.0 with registry versions v0.1.1 (framework satisfied) and v0.2.0 (newer framework) reports Latest v0.1.1, UpdateAvailable true and UpdateCommand `summer plugin:update <id>`
|
||||
- a submodule plugin at "v0.1.1-3-gabc1234" compares by its base v0.1.1 and reports no update when v0.1.1 is the newest compatible version
|
||||
- a plugin the registry does not list has Listed false and no update fields; a local plugin (empty version) gets no registry call
|
||||
- with the registry timing out or answering 500, GET /plugins answers 200 with Registry.Status unavailable and the installed list intact, and GET /plugins/marketplace answers 200 with status unavailable and no results
|
||||
- with plugins.registry_url set to "", Registry.Enabled is false, no request leaves the process, and the SPA hides the Marketplace tab and the badges
|
||||
- GET /plugins/marketplace?q=journal&category=content forwards both parameters, marks installed plugins and plugins whose framework minimum the built framework does not satisfy, and returns InstallCommand `summer plugin:install <id>`; q longer than 100 characters or an invalid category answers 400
|
||||
- a second identical request within the cache window makes no registry request
|
||||
- the copy button writes the command to the clipboard and shows a copied state; when the clipboard API is missing it shows the fallback state
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-17 and D-18. In plugins.go: a registry dependency behind an unexported interface (List, Versions) built lazily from kiosk.NewClient(kiosk.RegistryURL(app config)) with a 4-second per-call timeout; an in-process TTL cache (marketplace queries 5 minutes, per-ID versions 15 minutes, failures cached 1 minute) guarded by a mutex; per-installed-plugin version lookups with at most 4 in flight. Update rule (research Pattern 5): latest = kiosk.NewestCompatible(versions, framework); base = kiosk.BaseVersion(installed version); UpdateAvailable = base != "" and latest is newer by kiosk.CompareVersions; LatestFramework names the framework a newer incompatible version needs. Types `MarketplacePlugin{ID, Name, Description, Category string; Featured bool; IconURL, LatestVersion, LatestFramework string; Installed, Compatible bool; InstallCommand string}`; handler `pluginsMarketplace` with the same auth and permission, q/category validation as the registry's, `g.Get("/plugins/marketplace", s.pluginsMarketplace)`; errors from the registry never surface as non-200 (status in the payload meta and the RegistryState). swag block `AdminPluginsMarketplace`; regenerate with scripts/check-admin-openapi.sh; add the route to the inventories. SPA: Installed rows show an update badge with the copyable update command; the Marketplace tab (hidden when Registry.Enabled is false) has a debounced search box and a category select, result cards with name, description, version, an Installed or Needs framework vX marker and the install command with a copy button using the ApiTokenCreateDialog clipboard pattern; a notice when the registry is unavailable; no install button anywhere. Lang strings en and pl. Write the behavior subtests (`updates`, `describe_base`, `unlisted`, `unavailable`, `disabled`, `marketplace`, `cache`) in TestPluginsScreenSmoke using a fake registry, and the copy and hidden-tab cases in PluginsView.test.ts, first. Document update and marketplace behaviour in docs/plugins/marketplace.md and modules/cabana/README.md. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/cabana -run 'TestPluginsScreenSmoke|TestPhase09' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestPluginsScreenSmoke/updates" or "--- PASS: TestPluginsScreenSmoke/unavailable" line</fails_when>
|
||||
<automated>scripts/check-admin-openapi.sh --check</automated>
|
||||
<fails_when>non-zero exit</fails_when>
|
||||
<automated>npm --prefix admin run test -- PluginsView 2>&1 | tail -6 && npm --prefix admin run typecheck 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or "No test files found"</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'g.Get("/plugins/marketplace", s.pluginsMarketplace)' modules/cabana/http.go` prints one line
|
||||
- `grep -n "kiosk.NewestCompatible\|kiosk.BaseVersion" modules/cabana/plugins.go` prints at least two lines
|
||||
- `grep -n "navigator.clipboard" admin/src/views/PluginsView.vue` prints at least one line
|
||||
- `grep -rn "summercms.io" admin/src` prints nothing (the SPA never calls the registry; D-17)
|
||||
- every behavior subtest named in the action passes
|
||||
</acceptance_criteria>
|
||||
<done>Administrators see which plugins can be updated and search the marketplace from the admin, and nothing breaks when the registry is off or down.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Rebuild and commit the embedded admin, and pass the admin gates</name>
|
||||
<files>modules/boardwalk/dist</files>
|
||||
<read_first>
|
||||
- .planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
- scripts/check-admin-dist.sh
|
||||
- modules/boardwalk/README.md
|
||||
</read_first>
|
||||
<action>
|
||||
Run `npm --prefix admin ci` only if node_modules is missing, then `npm --prefix admin run build`, which writes modules/boardwalk/dist. Stage the whole modules/boardwalk/dist directory by path (it contains only build output) and commit it alone with the subject from Execution units. Run the browser tests for the new view if the admin has a browser suite covering views (`npm --prefix admin run test:browser`); a failing unrelated browser test is recorded, not fixed here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>scripts/check-admin-dist.sh 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit, or output reporting that the committed dist differs from a fresh build</fails_when>
|
||||
<automated>go test ./modules/boardwalk -count=1 2>&1 | tail -3 && npm --prefix admin run test 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git show --stat <the Task 3 commit sha>` lists only paths under modules/boardwalk/dist
|
||||
- scripts/check-admin-dist.sh exits 0 on a clean tree
|
||||
</acceptance_criteria>
|
||||
<done>The embedded admin in the binary carries the Plugins screen and the release gate for admin changes passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser (admin SPA) → cabana /plugins endpoints | authenticated administrators |
|
||||
| cabana → registry | outbound call to an operator-configured URL |
|
||||
| registry JSON → admin UI | third-party text shown to administrators |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-34-29 | Elevation of privilege | GET /plugins, GET /plugins/marketplace | medium | mitigate | backend guard plus cabana.Allows backend.view_plugins (403 otherwise); permission matrix and security coverage inventories include both routes |
|
||||
| T-34-30 | Information disclosure | installed plugin list | low | mitigate | only administrators with the permission see versions and modules; no filesystem paths in party.Info (34-01) |
|
||||
| T-34-31 | Denial of service | registry slow or down | medium | mitigate | 4 s timeout, TTL cache including failure caching, at most 4 concurrent lookups, degraded 200 responses (TestPluginsScreenSmoke/unavailable) |
|
||||
| T-34-32 | Tampering (XSS) | registry names and descriptions in the SPA | medium | mitigate | rendered as text through Vue interpolation, no v-html; README HTML is not shown in the admin |
|
||||
| T-34-33 | Server-side request forgery | plugins.registry_url | low | accept | operator-configured trusted setting (fetchguard TrustedMode by design), no redirects followed, size-capped; administrators cannot change it from the admin |
|
||||
| T-34-SC | Tampering | npm/pip/cargo installs | high | accept | no package is added (reka-ui and the build toolchain are already dependencies) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -short ./...`; `go test ./modules/cabana -run 'TestPluginsScreenSmoke|TestPhase09'`.
|
||||
- `scripts/check-admin-openapi.sh --check`, `npm --prefix admin run test`, `npm --prefix admin run typecheck`, `scripts/check-admin-dist.sh`.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The Plugins screen shows installed plugins with versions and updates and searches the marketplace with copyable install commands.
|
||||
- Registry disabled or down never breaks the Installed tab.
|
||||
- OpenAPI, SPA types and the embedded dist are regenerated and committed; no push or tag.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/34-plugin-marketplace/34-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Constants: `cabana.PermissionViewPlugins` (`backend.view_plugins`).
|
||||
- Types: `cabana.PluginsOverview`, `cabana.RegistryState`, `cabana.InstalledPlugin`, `cabana.MarketplacePlugin`.
|
||||
- Struct fields: `cabana.SettingsEntry.Path` (JSON `path`).
|
||||
- Endpoints (admin API prefix): `GET /plugins`, `GET /plugins/marketplace?q=&category=`; swag operations `AdminPluginsInstalled`, `AdminPluginsMarketplace`.
|
||||
- Handlers: `pluginsInstalled`, `pluginsMarketplace`.
|
||||
- SPA: route `/system/plugins` (name `plugins`), `admin/src/views/PluginsView.vue`, `settingsPath` honours `entry.path`.
|
||||
- Lang keys: `backend::lang.permissions.view_plugins` plus the Plugins screen strings (en, pl).
|
||||
- Tests: `TestPluginsScreenSmoke` (installed, updates, describe_base, unlisted, unavailable, disabled, marketplace, cache), `admin/tests/views/PluginsView.test.ts`.
|
||||
- Generated: admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/boardwalk/dist.
|
||||
- Config keys: reads `plugins.registry_url` (added in 34-04).
|
||||
253
.planning/phases/34-plugin-marketplace/34-05-SUMMARY.md
Normal file
253
.planning/phases/34-plugin-marketplace/34-05-SUMMARY.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 05
|
||||
subsystem: admin
|
||||
tags: [cabana, boardwalk, kiosk, reka-ui, plugins-screen, registry-cache, openapi]
|
||||
|
||||
requires:
|
||||
- phase: 34-plugin-marketplace (34-01)
|
||||
provides: "party.Info, party.Installed, party.Describe; kiosk version rules (BaseVersion, CompareVersions, NewestCompatible, Satisfies) and registry wire types"
|
||||
- phase: 34-plugin-marketplace (34-04)
|
||||
provides: "kiosk.Client (List, Versions), kiosk.NewClient, kiosk.RegistryURL, kiosk.ErrNotFound, plugins.registry_url"
|
||||
provides:
|
||||
- "cabana.PermissionViewPlugins (backend.view_plugins), declared by the admin's own screens with no role grant"
|
||||
- "GET /plugins (cabana.PluginsOverview, cabana.InstalledPlugin, cabana.RegistryState) and GET /plugins/marketplace (cabana.MarketplaceResponse, cabana.MarketplacePlugin, cabana.MarketplaceMeta), swag operations AdminPluginsInstalled and AdminPluginsMarketplace"
|
||||
- "cabana.SettingsEntry.Path (JSON path, omitempty): Settings → System → Plugins links the SPA route /system/plugins"
|
||||
- "Server-side registry cache: 4 s per call, at most 4 concurrent version lookups, searches 5 min, releases 15 min, failures 1 min"
|
||||
- "SPA: /system/plugins route, PluginsView (Installed and Marketplace tabs), settingsPath honours entry.path, settingsEntryForPath for the shell"
|
||||
- "Rebuilt modules/boardwalk/dist"
|
||||
affects: [34-06 tests and release ledger, every app's admin (new Settings → System entry for superusers)]
|
||||
|
||||
actuals:
|
||||
tokens: 29000
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 0418900122a1cba8952ec40a8f8294fc615eb6a8
|
||||
plan_head_after: 678eac4637c26f4d57dfdaa8348fbd41e26f6616
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "A settings entry of the admin's own can open an SPA route (SettingsEntry.Path) instead of an admin controller; plugin entries are unchanged"
|
||||
- "Registry calls from cabana run on context.WithoutCancel(request) plus a timeout, so a closed tab never caches a failure, and fetchguard.WithTransport-style context values still reach the client"
|
||||
- "Degraded 200 answers: a registry failure is a status in the payload (registry.status unavailable), never a non-200"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/plugins.go
|
||||
- modules/cabana/plugins_smoke_test.go
|
||||
- admin/src/views/PluginsView.vue
|
||||
- admin/tests/views/PluginsView.test.ts
|
||||
modified:
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/navigation.go
|
||||
- modules/cabana/backend_screens.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/app/pageTitle.ts
|
||||
- admin/src/state/useSettings.ts
|
||||
- admin/src/components/shell/AppShell.vue
|
||||
- admin/src/components/shell/Breadcrumbs.vue
|
||||
- admin/src/components/shell/PluginRail.vue
|
||||
- admin/tests/fixtures/lang.json
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
- docs/plugins/marketplace.md
|
||||
|
||||
key-decisions:
|
||||
- "The Plugins settings entry is added by Registry.Metadata only when the registry declares backend.view_plugins (the admin's own screens are compiled), no plugin already uses the settings code plugins, and the principal is allowed; a plugin's own plugins setting therefore never breaks"
|
||||
- "The registry catalog is built once by cabana.Activate (service.catalog); a service assembled without Activate (tests) gets a fresh uncached catalog per call, because service is copied by value in existing tests and cannot hold a sync.Once"
|
||||
- "Update rule is plugin:update's: NewestCompatible(versions, framework) above BaseVersion(installed); latestFramework names the minimum of the newest release when it is newer than both the compatible latest and the installed base"
|
||||
- "A 404 for one plugin's versions is an answer (listed false, cached 15 min), not a registry failure"
|
||||
- "Marketplace entries whose ID does not match the plugin ID shape are dropped, and only https icon URLs are passed through, since the ID ends up in a shell command the administrator copies"
|
||||
- "Marketplace categories (for the filter) come from the cached unfiltered registry list and are sent in meta.categories"
|
||||
- "Registry JSON fields in the admin API use lower camel case (updateAvailable, latestFramework, iconUrl, installCommand), as the plan asked"
|
||||
|
||||
patterns-established:
|
||||
- "Admin screens of the framework that are not admin controllers: SPA route plus SettingsEntry.Path, shell helpers settingsEntryForPath"
|
||||
|
||||
requirements-completed: [SC-4, D-06, D-07, D-17, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "GET /plugins lists installed plugins from party.Installed (party.Activated IDs for older builds) for superusers and holders of backend.view_plugins; 401/403 otherwise; Settings → System → Plugins entry with path only for allowed principals"
|
||||
requirement: D-19
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/plugins_smoke_test.go#TestPluginsScreenSmoke/installed"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana/security_coverage_test.go#TestPhase09PermissionMatrix (denied plugins-installed, plugins-marketplace)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Update availability computed in Go with plugin:update's rule; describe versions compare by base tag; unlisted and local plugins get no badge"
|
||||
requirement: D-07
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/plugins_smoke_test.go#TestPluginsScreenSmoke/updates,describe_base,unlisted"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Registry down, slow or disabled never breaks the Installed tab; disabled sends no request; cache windows hold"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/plugins_smoke_test.go#TestPluginsScreenSmoke/unavailable,disabled,cache"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Marketplace search through cabana forwards q and category, validates them like the registry (400 invalid_query), marks installed and incompatible plugins and returns the install command"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/plugins_smoke_test.go#TestPluginsScreenSmoke/marketplace"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "OpenAPI and SPA types regenerated; contract, token-class and conformance inventories include both routes"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/check-admin-openapi.sh --check"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana#TestPhase09ContractInventory, TestTokenRouteGates, TestPhase10ConformanceInventory"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance/GET_/plugins, GET_/plugins/marketplace (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "PluginsView: Installed rows, update badges with copyable update command, Marketplace tab (debounced search, category select, cards with markers and copyable install command, no install button), hidden tab and badges when disabled, unavailable notice, clipboard fallback"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/views/PluginsView.test.ts (9 tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Embedded admin rebuilt and committed; dist gate passes"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/check-admin-dist.sh; go test ./modules/boardwalk"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Visual layout of the Plugins screen (table, badges, marketplace cards) in light and dark themes, and a real search against the deployed registry"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Styling and the live summercms.io registry answer are not covered by jsdom tests; needs a look in a browser on a built app"
|
||||
|
||||
duration: 28min
|
||||
completed: 2026-10-29
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 34 Plan 05: Admin Plugins screen Summary
|
||||
|
||||
**Settings → System → Plugins (backend.view_plugins) lists the installed plugins with versions and sources, marks updates computed server-side with plugin:update's rule, and searches the registry through cabana with copyable `summer plugin:install` / `summer plugin:update` commands, cached and degrading to a notice when the registry is down or disabled.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 28 min of execution (started 2026-10-29T17:54:17Z, completed 2026-10-29T18:21:42Z)
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 33 (4 created, plus the rebuilt dist)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `cabana.PermissionViewPlugins` (`backend.view_plugins`) on the System tab of the permission editor, granted to no role; superusers pass.
|
||||
- `GET /plugins` and `GET /plugins/marketplace` in the backend group, swag-annotated, in every route inventory (permission matrix, contract, token classes, Postgres conformance).
|
||||
- `cabana.SettingsEntry.Path`: the Plugins entry opens `/system/plugins`; the SPA's settings index, settings panel, rail highlight, breadcrumbs and page title handle it.
|
||||
- Registry catalog in cabana: lazy `kiosk.Client` from `plugins.registry_url`, 4 s per call on a detached context, at most 4 version lookups at once, TTL cache with failure caching, 404 treated as "not listed".
|
||||
- PluginsView with reka-ui Tabs: Installed (name or ID, description, version, source, update badge, command copy) and Marketplace (debounced search, category select, cards with Installed / Needs framework markers and the install command). No install button anywhere, all server text rendered as text.
|
||||
- `modules/boardwalk/dist` rebuilt in its own commit; `scripts/check-admin-dist.sh` passes.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: read-only Plugins screen with installed plugins (tracer)**: `c4b1ac4` (feat)
|
||||
2. **Task 2: plugin updates and marketplace search**: `7e8a193` (feat)
|
||||
3. **Task 3: rebuild the embedded admin**: `678eac4` (build)
|
||||
|
||||
**Plan metadata:** the docs commit containing this SUMMARY.
|
||||
|
||||
## Tracer feedback gate
|
||||
|
||||
Task 1's `<verify>` was re-run end to end after its commit (TestPluginsScreenSmoke/installed, the Phase 09 inventories, `check-admin-openapi.sh --check`, the PluginsView spec, typecheck, `go vet ./...`, `go test -short ./...`, and the full Docker cabana suite). All passed before Task 2 expanded the slice.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Two more route inventories**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `TestTokenRouteGates` (token_routes_test.go) and `TestPhase10ConformanceInventory` / `TestPhase10OpenAPIConformance` (openapi_conformance_test.go) also list every admin route and failed on GET /plugins.
|
||||
- **Fix:** Both routes classified `allows_protect` (they check `cabana.Allows`, so a scoped token passes when its scope covers the code); conformance cases for both routes. The conformance admin gets `backend.view_plugins` in its own permissions and the conformance app sets `plugins.registry_url: ""`, so the Postgres test never calls a real registry.
|
||||
- **Commits:** c4b1ac4, 7e8a193
|
||||
|
||||
**2. [Rule 2 - Missing critical] Shell integration of a path-based settings entry**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The settings panel, rail highlight, breadcrumbs and page title only knew singleton forms and controller links; on `/system/plugins` the admin would lose its Settings context.
|
||||
- **Fix:** `settingsEntryForPath` in useSettings.ts, used by AppShell.vue, PluginRail.vue; Breadcrumbs.vue and pageTitle.ts name the screen.
|
||||
- **Files modified:** admin/src/state/useSettings.ts, admin/src/components/shell/{AppShell,Breadcrumbs,PluginRail}.vue, admin/src/app/pageTitle.ts
|
||||
- **Commit:** c4b1ac4
|
||||
|
||||
**3. [Rule 2 - Missing critical] Command and icon hardening**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Registry IDs are third-party text that ends up in a command an administrator pastes into a shell.
|
||||
- **Fix:** Marketplace entries (and update lookups) only for IDs matching `^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`; only absolute https icon URLs are passed through, rendered with `referrerpolicy="no-referrer"`.
|
||||
- **Commit:** 7e8a193
|
||||
|
||||
**4. [Rule 3 - Blocking] sync.Once in service breaks go vet**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Existing tests copy `service` by value (copylocks).
|
||||
- **Fix:** `cabana.Activate` builds the catalog (`service.catalog`); without it a fresh uncached catalog is used per call.
|
||||
- **Commit:** 7e8a193
|
||||
|
||||
**5. [Plan adjustment] Names and files beyond the plan's lists**
|
||||
- Extra exported names: `cabana.MarketplaceResponse`, `cabana.MarketplaceMeta` (the marketplace answer with registry state, total and categories, following the `FieldTypesResponse` pattern), `cabana.AdminPluginsMarketplace` is as planned.
|
||||
- Extra files: `admin/src/api/types.ts` (type aliases), `admin/tests/fixtures/lang.json` (hand-kept Polish fixture), the shell files above, token_routes_test.go and openapi_conformance_test.go.
|
||||
- No `view_plugins_comment` permission-editor comment: the acceptance criterion expects exactly one `view_plugins` line per lang file, so the comment line used by the other two admin permissions was left out.
|
||||
|
||||
**6. [Process] TDD order**
|
||||
- Task 2's Go behavior subtests were written first and failed to compile on the missing `MarketplaceResponse`, `pluginsMarketplace` and `pluginCatalog` (RED), then the implementation made all eight pass. The SPA specs for Task 2 were written after the view and passed on the first run. One commit per task, as the Execution units table requires.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 (2 Rule 2, 2 Rule 3, 1 plan adjustment, 1 process note). **Impact:** no contract change outside the plan; existing settings entries and plugins are untouched (`path` is omitted from their JSON).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- One `go test -short ./...` run failed in `internal/build` on a missing Go build-cache file (`open ~/.cache/go-build/...: no such file or directory`), most likely another session trimming the shared cache; the immediate rerun passed with no change.
|
||||
- `gofmt -l modules/cabana` lists pre-existing test files (account_tokens_test.go, field_trigger_smoke_test.go, token_postgres_smoke_test.go, token_routes_test.go, token_scopes_test.go); the new code is gofmt-clean. Left untouched (scope boundary).
|
||||
- `npm --prefix admin run test:browser` passes (2 files); it has no view coverage, so it does not exercise the new screen.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. Grant `backend.view_plugins` to a role or administrator to give non-superusers the screen.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 34-06 can add unit coverage on top of TestPluginsScreenSmoke and PluginsView.test.ts.
|
||||
- Open for human review (coverage D8): the screen's look in both themes and a live search against the deployed summercms.io registry.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: modules/cabana/plugins.go, modules/cabana/plugins_smoke_test.go, admin/src/views/PluginsView.vue, admin/tests/views/PluginsView.test.ts.
|
||||
- Commits c4b1ac4, 7e8a193, 678eac4 are in history; `git rev-list --count 0418900..HEAD` = 3 before this docs commit; `git show --stat 678eac4` lists only modules/boardwalk/dist paths.
|
||||
- `go vet ./...`, `go test -short ./...`, `go test ./modules/cabana` (Docker), `go test ./modules/boardwalk`, `summer docs:build --check`, `go test ./cmd/summer -run 'TestDocsTree|TestCommandsDocumented'`, `npm --prefix admin run test` (1614 tests), `typecheck`, `test:browser`, `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` pass.
|
||||
- Acceptance greps of Tasks 1-3 re-run: permission constant, both routes, swag router, `/system/plugins`, `entry.path`, two `view_plugins` lines, `kiosk.NewestCompatible`/`kiosk.BaseVersion`, `navigator.clipboard`, no `summercms.io` in admin/src.
|
||||
329
.planning/phases/34-plugin-marketplace/34-06-PLAN.md
Normal file
329
.planning/phases/34-plugin-marketplace/34-06-PLAN.md
Normal file
@@ -0,0 +1,329 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["34-01", "34-02", "34-03", "34-04", "34-05"]
|
||||
files_modified:
|
||||
- scripts/check-phase34.sh
|
||||
- modules/kiosk/manifest_test.go
|
||||
- modules/kiosk/identity_test.go
|
||||
- modules/kiosk/derive_test.go
|
||||
- modules/kiosk/version_test.go
|
||||
- modules/kiosk/client_test.go
|
||||
- modules/kiosk/example_test.go
|
||||
- modules/party/info_test.go
|
||||
- internal/build/plugindirs_test.go
|
||||
- internal/build/install_test.go
|
||||
- internal/build/manifest_append_test.go
|
||||
- cmd/summer/plugin_commands_test.go
|
||||
- modules/cabana/plugins_test.go
|
||||
- admin/tests/views/PluginsView.test.ts
|
||||
- admin/tests/state/useSettings.test.ts
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/query_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry_api_test.go
|
||||
- ../sm-summercmsio-app/scripts/test_changelog_post.py
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/marketplace.test.ts
|
||||
- .planning/phases/34-plugin-marketplace/34-release-log.md
|
||||
- .planning/phases/34-plugin-marketplace/34-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [SC-5, SC-1, SC-2, SC-3, SC-4, D-02, D-03, D-04, D-05, D-07, D-08, D-09, D-10, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19]
|
||||
|
||||
estimate:
|
||||
tokens: 180000
|
||||
raw_tokens: 180000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scripts/check-phase34.sh runs every Phase 34 check across the framework, the plugin repositories and the summercms.io repositories by stage, fails closed on a missing repository or a missing required test, and its self-test detects every planted failure (SC-5, Wave 0 of 34-VALIDATION.md)"
|
||||
- "The new framework code has unit tests: plugin.yaml derivation for literal, same-package, cross-package and other-module constants and their error messages, README extraction and overrides, check/stale detection, validation, version rules including git describe bases and pseudo-version minimums, the registry client, party.Describe, plugin directory and version resolution, effective framework version, install resolution, prompts and offline rules, summer.yaml comment preservation, and the cabana Plugins endpoints (SC-5; D-02..D-05, D-07..D-10, D-12, D-17..D-19)"
|
||||
- "The summercms.io code has unit tests: ingest rules (tags, nested manifests, module and ID identity, digest skip, caps), API shapes, query validation and caching headers, the ingest endpoint's auth, the release script's new helpers and refusals, and the marketplace page utilities (SC-5; D-13..D-16)"
|
||||
- "Unit tests never write into a sibling repository or the developer's module cache: fixtures live in t.TempDir() and module downloads use a temporary GOMODCACHE"
|
||||
- "34-release-log.md lists, in release order, what v0.6.0 of the framework carries, every plugin, site plugin, vue app and app commit waiting for push or tag, the pending submodule pointer bumps, the app regenerations of main.go and plugins.gen.go, the summercms.io deploy steps and the changelog posts; nothing is pushed, tagged or deployed by this plan (memory: tag only at phase completion, on the default branch)"
|
||||
- "34-VALIDATION.md's per-task verification map is filled for every task of plans 34-01..34-06 and its Wave 0 items are checked"
|
||||
artifacts:
|
||||
- path: "scripts/check-phase34.sh"
|
||||
provides: "phase gate (tracer)"
|
||||
contains: "--self-test"
|
||||
- path: "modules/kiosk/identity_test.go"
|
||||
provides: "ID reader unit tests"
|
||||
contains: "func TestReadIdentity"
|
||||
- path: "internal/build/install_test.go"
|
||||
provides: "resolution unit tests"
|
||||
contains: "func TestResolveInstall"
|
||||
- path: "modules/cabana/plugins_test.go"
|
||||
provides: "Plugins endpoint unit tests"
|
||||
contains: "func TestPluginsEndpoints"
|
||||
- path: ".planning/phases/34-plugin-marketplace/34-release-log.md"
|
||||
provides: "release ledger"
|
||||
contains: "v0.6.0"
|
||||
key_links:
|
||||
- from: "scripts/check-phase34.sh"
|
||||
to: "every Phase 34 test"
|
||||
via: "stages run the tests by required name and fail when a name is missing from the go test -json output"
|
||||
pattern: "TestManifestSmoke|TestInstallSmoke|TestPluginsScreenSmoke|TestRegistrySmoke"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
People browse the marketplace, install plugins with one command and see them in the admin. This last plan proves the phase with unit tests across every repository it touched (project rule: unit tests are the last plan) and writes down the user-gated release.
|
||||
|
||||
<objective>
|
||||
- `scripts/check-phase34.sh` (Wave 0): the phase gate over all repositories, by stage.
|
||||
- Unit tests for kiosk, party, internal/build, cmd/summer, cabana, the admin view, the summercms.io site plugin, the release script and the marketplace utilities (SC-5).
|
||||
- `34-release-log.md`: the release order and every pending push, tag, pointer bump, regeneration, deploy step and changelog post for Jakub.
|
||||
- `34-VALIDATION.md`: per-task map and Wave 0 filled.
|
||||
|
||||
Output: test commits in each repository (framework, site plugin, app, vue app), the gate script commit, and one planning-docs commit. Nothing pushed, tagged or deployed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/34-plugin-marketplace/34-RESEARCH.md
|
||||
@.planning/phases/34-plugin-marketplace/34-VALIDATION.md
|
||||
@.planning/phases/34-plugin-marketplace/34-01-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-02-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-03-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-04-SUMMARY.md
|
||||
@.planning/phases/34-plugin-marketplace/34-05-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
Smoke tests already in place (plans 01-05): TestManifestSmoke and TestPluginInfoSmoke and TestAddPluginConstID (framework), TestPluginManifest (every plugin repository), TestRegistrySmoke (site plugin, Docker), marketplace SSR smoke (`pnpm test:marketplace`), TestInstallSmoke (framework, non-short subtests apply installs), TestPluginsScreenSmoke and admin/tests/views/PluginsView.test.ts.
|
||||
Gate template: scripts/check-phase32.sh (stage engine: usage, detect_go, go_named required-name checks over go test -json, run_* stages, run_self_test planting, marker and additive stages via scripts/apisurface against a tag, missing-repository failure).
|
||||
Framework tag base: v0.5.3 (newest tag before this phase). Consuming application names the framework must not contain: grzyby, fonoteka, plytarium, summercmsio, sm-demo-app, sm-bm-app (summercms.io, the framework's own site and default registry, is allowed).
|
||||
Docker-backed tests skip under -short; run them through `sudo -u golem` if the socket is denied.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Execution units and resume points
|
||||
|
||||
- Each task commits per repository, staging explicit paths; test-only commits use `test:` subjects. Check `git status --short` in each repository first and leave other sessions' edits alone. No co-author tag.
|
||||
- Resume check: `git -C <repo> log --format=%s | grep -Fx '<subject>'`.
|
||||
|
||||
| Task | Commit subjects (repository) | Resume point |
|
||||
|------|------------------------------|--------------|
|
||||
| 1 | `test: phase 34 gate script` (framework) | `--self-test` passes |
|
||||
| 2 | `test(kiosk): manifest, identity, version and client unit tests`; `test(build): plugin dirs, install resolution and summer.yaml edits`; `test(summer): plugin command prompts` (framework) | --go stage passes |
|
||||
| 3 | `test(cabana): Plugins screen endpoints`; `test(admin): PluginsView and settings paths` (framework) | --admin stage passes |
|
||||
| 4 | `test: registry ingest, queries and API` (site); `test: changelog-post manifest and ingest helpers` (app); `test: marketplace utilities` (vue) | --site stage passes |
|
||||
| 5 | `docs(34): release ledger and validation map` (framework, planning docs) | ledger committed |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "one command runs every Phase 34 check across all repositories" — the gate script over the existing smoke tests</name>
|
||||
<files>scripts/check-phase34.sh</files>
|
||||
<read_first>
|
||||
- scripts/check-phase32.sh (whole file: stage engine, go_named, self-test planting, marker, additive, missing-repository failure)
|
||||
- scripts/apisurface/main.go
|
||||
- .planning/phases/34-plugin-marketplace/34-VALIDATION.md and the five plan SUMMARYs (test names and commands that worked)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/check-phase34.sh` from check-phase32.sh's engine, labelled Phase 34, with stages (each a flag; `--all` runs them in this order; no argument prints usage listing every flag and exits 2):
|
||||
- `--self-test`: plants a failing go test name, a missing required test name, a marker hit in a scratch repository, an additive removal and a missing repository, and requires each to be detected.
|
||||
- `--go`: `go vet ./...`, `go test -short ./...`, plus required names TestManifestSmoke, TestPluginInfoSmoke, TestAddPluginConstID, TestPluginsScreenSmoke and the unit tests Task 2 and 3 add (the list is a variable GO_TESTS extended by later tasks); `cd examples/hello && go test ./...`.
|
||||
- `--docker`: Docker host only (NOT RUN otherwise, which fails under --all unless PHASE34_ALLOW_NO_DOCKER=1): `go test ./...` in the framework including TestInstallSmoke's non-short subtests, and the site plugin's TestRegistrySmoke.
|
||||
- `--plugins`: for each of the 20 sibling sm-*-plugin repositories: `go vet ./...`, `go test -short ./...`, TestPluginManifest by required name, and `summer plugin:manifest --check` (built from this checkout into a temp dir) for each plugin.yaml directory; sm-chatbots-plugin may be listed in PHASE34_PENDING_MANIFESTS when 34-02 recorded the F8 skip.
|
||||
- `--site`: in ../sm-summercmsio-app: site plugin `go vet` and `go test -short`, app `go vet ./... && go test ./...`, `python3 -B scripts/test_changelog_post.py`, `scripts/check-deploy.sh` when nginx is available, and in vue-summercmsio-app `pnpm test`, `pnpm build:changelog`, `pnpm test:changelog`, `pnpm test:marketplace`.
|
||||
- `--admin`: `npm --prefix admin run test`, `npm --prefix admin run typecheck`, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`.
|
||||
- `--docs`: TestDocsTree, TestCommandsDocumented and `go run ./cmd/summer docs:build --check`.
|
||||
- `--marker`: the lines this phase added since v0.5.3 to non-test Go files, READMEs and docs under modules/, cmd/, internal/ and docs/ name no consuming application (word list in interfaces).
|
||||
- `--additive`: apisurface of every framework package changed since v0.5.3 shows no removed or changed exported element (kiosk is new; party, cabana and fetchguard-users only gain elements).
|
||||
A stage whose repository or directory is missing fails with `missing <path>`. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase34.sh --self-test</automated>
|
||||
<fails_when>non-zero exit, or a planted case reported as undetected</fails_when>
|
||||
<automated>bash scripts/check-phase34.sh --go --docs --marker --additive</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL", "missing " or marker hit line</fails_when>
|
||||
<automated>bash scripts/check-phase34.sh --plugins --admin</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash scripts/check-phase34.sh; echo $?` prints a usage naming --self-test, --go, --docker, --plugins, --site, --admin, --docs, --marker, --additive, --all and exits 2
|
||||
- `grep -c "missing " scripts/check-phase34.sh` is at least 1
|
||||
- `grep -n "TestPluginManifest" scripts/check-phase34.sh` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>The phase has one gate that exercises every repository through the smoke tests plans 01-05 left behind.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Unit tests for kiosk, party, internal/build and the plugin commands</name>
|
||||
<files>modules/kiosk/manifest_test.go, modules/kiosk/identity_test.go, modules/kiosk/derive_test.go, modules/kiosk/version_test.go, modules/kiosk/client_test.go, modules/kiosk/example_test.go, modules/party/info_test.go, internal/build/plugindirs_test.go, internal/build/install_test.go, internal/build/manifest_append_test.go, cmd/summer/plugin_commands_test.go, scripts/check-phase34.sh</files>
|
||||
<read_first>
|
||||
- modules/kiosk/*.go and their smoke tests; modules/party/info.go; internal/build/{plugindirs,install,source,update,manifest,scaffold}.go; cmd/summer/main.go (plugin:* commands)
|
||||
- .planning/phases/34-plugin-marketplace/34-RESEARCH.md (Validation Architecture rows for SC-1, SC-3, D-06/07)
|
||||
- cmd/summer/main_test.go (how commands are run with bonfire.Call or a test root)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestReadIdentity: table of fixtures (literal, same-file const, const in another file, chained const, cross-package same module, named import alias, other module via replace, Requires nil/empty/mixed literals and consts) and refusals (function call, var, concatenation, missing ID method, two ID methods returning different values) with the function named in each error
|
||||
- TestDerive: README H1/summary extraction (inline code, links and emphasis flattened to text, multi-line paragraph collapsed), missing README or H1 errors, H1 == ID needs --name, override set/keep/clear, framework missing or v0.0.0 errors, soft dependency empty minimum, same-module dependency empty minimum, pseudo-version warning
|
||||
- TestManifestFormat: Marshal is byte-stable and ordered, requires sorted, Parse round-trips, unknown field and newer schema refused, Validate table (ID, module, package, versions)
|
||||
- TestCheck: missing, stale (first differing line in the message), fresh; CheckT calls Fatalf exactly once on stale and never on fresh (fake TB)
|
||||
- TestVersions: ValidVersion, CompareVersions, BaseVersion, Satisfies (including pseudo-version minimums), NewestCompatible edge cases (empty list, all incompatible, unsorted input)
|
||||
- TestRegistryClient: List/Plugin/Versions decode, query escaping, 404 → ErrNotFound, 500 with error envelope → error carrying the code, oversized body refused, redirect not followed, disabled URL → ErrRegistryDisabled
|
||||
- TestDescribe (party): Describe publishes Installed; nil app error; a second Describe replaces the first
|
||||
- TestPluginDirs and TestEffectiveFramework: go list JSON fixtures parsed into module/submodule/local sources; describe only for a dir that is its own repository top level; no dirty suffix; framework replaced in go.mod vs go.work vs not replaced; git missing falls back with a warning
|
||||
- TestResolveInstall: newest compatible, pin, pin incompatible names the framework, no compatible version names the minimum framework, transitive closure order, dependency minimum above newest compatible errors, installed-too-old hint, cycle, offline and disabled refusals, invalid registry strings refused
|
||||
- TestAppendManifestPlugins: comments and blank lines preserved, indentation matched, missing plugins block appended, invalid result restores the original bytes
|
||||
- TestPluginCommands (cmd/summer): plugin:install without --yes off a terminal errors naming --yes; --offline ID install errors; plugin:manifest --check exit status and output on missing and stale files; --check with --name refused
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests listed in the behavior block in the files named (unit level: fixtures in t.TempDir(), fake registries through httptest or fetchguard.WithTransport, go list output as JSON fixtures where running go is not the point; tests that need git or go skip when the binary is missing). Add a runnable `Example` for kiosk.Marshal and kiosk.NewestCompatible in example_test.go with Output comments (docs examples stay runnable). Fix any defect the tests expose in the production code in the same commit as the test that found it, and note it in the SUMMARY. Add the new test names to GO_TESTS in scripts/check-phase34.sh. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short ./modules/kiosk ./modules/party ./internal/build ./cmd/summer -count=1 2>&1 | tail -6</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
<automated>go test -short ./modules/kiosk ./internal/build -run 'TestReadIdentity|TestDerive|TestManifestFormat|TestCheck|TestVersions|TestRegistryClient|TestPluginDirs|TestEffectiveFramework|TestResolveInstall|TestAppendManifestPlugins' -count=1 -v 2>&1 | grep -c '^--- PASS'</automated>
|
||||
<fails_when>a count below 10, or non-zero exit</fails_when>
|
||||
<automated>bash scripts/check-phase34.sh --go</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" or "missing" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- every test function named in the behavior block exists (`grep -rn "func TestReadIdentity\|func TestResolveInstall\|func TestAppendManifestPlugins\|func TestPluginCommands" modules internal cmd` prints at least four lines)
|
||||
- `go test -cover ./modules/kiosk` reports coverage of at least 70%
|
||||
- `git -C ../sm-mfa-plugin status --short` shows no change made by the tests
|
||||
</acceptance_criteria>
|
||||
<done>The framework side of the marketplace is covered by fast unit tests that pin every rule the decisions set.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Unit tests for the cabana Plugins endpoints and the admin view</name>
|
||||
<files>modules/cabana/plugins_test.go, admin/tests/views/PluginsView.test.ts, admin/tests/state/useSettings.test.ts, scripts/check-phase34.sh</files>
|
||||
<read_first>
|
||||
- modules/cabana/plugins.go and plugins_smoke_test.go (34-05)
|
||||
- admin/src/views/PluginsView.vue, admin/src/state/useSettings.ts, admin/tests/helpers.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestPluginsEndpoints: unauthenticated 401, no permission 403, permission 200, superuser 200; settings entry present only when allowed and carrying path /system/plugins; update rule table (exact tag, describe base, local, unlisted, newer incompatible only, registry error); cache hit and expiry (injected clock); concurrency bound of 4 (fake registry counting in-flight calls); marketplace query validation and installed/compatible flags; disabled registry makes no call
|
||||
- PluginsView: both tabs render; Marketplace tab hidden when registry is disabled; search debounce sends one request; category select filters; copy writes the exact command and shows the copied state; clipboard missing shows the fallback; registry unavailable notice; update badge with the update command; names fall back to IDs
|
||||
- useSettings.settingsPath prefers entry.path, then controller, then the singleton form
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests in the behavior block; inject the clock and the registry through the unexported seams 34-05 created (add a minimal seam if missing, without changing exported API). Add the Go test name to GO_TESTS and the vitest files to the --admin stage. Commit as listed in Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short ./modules/cabana -run 'TestPluginsEndpoints|TestPluginsScreenSmoke' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestPluginsEndpoints" line</fails_when>
|
||||
<automated>npm --prefix admin run test -- PluginsView useSettings 2>&1 | tail -6</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or "No test files found"</fails_when>
|
||||
<automated>bash scripts/check-phase34.sh --admin</automated>
|
||||
<fails_when>non-zero exit or a "FAIL" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestPluginsEndpoints" modules/cabana/plugins_test.go` prints one line
|
||||
- `grep -c "it(\|test(" admin/tests/views/PluginsView.test.ts` is at least 9
|
||||
- `bash scripts/check-phase34.sh --additive` passes after this task's commit (any seam added for the tests is unexported)
|
||||
</acceptance_criteria>
|
||||
<done>The admin side of the marketplace is covered by Go and vitest unit tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Unit tests for the summercms.io registry, the release script and the marketplace utilities</name>
|
||||
<files>../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry/query_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/registry_api_test.go, ../sm-summercmsio-app/scripts/test_changelog_post.py, ../sm-summercmsio-app/vue-summercmsio-app/tests/marketplace.test.ts, scripts/check-phase34.sh</files>
|
||||
<read_first>
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/*.go, controllers/api/registry.go, registry_smoke_test.go, testmain_test.go (34-03)
|
||||
- ../sm-summercmsio-app/scripts/changelog-post.py and scripts/test_changelog_post.py
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/marketplace.ts and tests/changelog.test.ts (node --test style)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- ingest (unit, no Docker where possible): repository URL normalization and module derivation (https only; credentials, query, fragment refused), strict tag filter, plugin.yaml path filter, tags digest stability, blob caps enforced, module mismatch and ID ownership decisions, skipped-tag notes in last_error, git missing error, allowedProtocols default https
|
||||
- queries and API (Docker where they need Postgres): LIKE escaping of % and _, ordering, category regex, ETag stable for equal bodies and 304 on match, 404 and 400 envelopes, versions newest first, icon_url and screenshots URLs, no admin-only fields in any response
|
||||
- ingest endpoint: 401, 403 (no scope, wrong permission), 404 uncurated, 202 queued once for two rapid calls on the same repository
|
||||
- changelog-post.py: refusal when HEAD is not the tag, when the tree is dirty, when no plugin.yaml is tracked, when the manifest check fails (check runner stubbed); framework releases skip both; ingest call skipped on dry runs and its 401/403/404/network outcomes are warnings
|
||||
- marketplace utilities: pluginPath and idFromRoute round-trip, invalid segments refused, installCommand exact text
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests in the behavior block (site plugin tests reuse testmain_test.go's container and skip under -short where they need Postgres; Python tests stub subprocess and network calls the way the existing tests stub `call`; the vue test imports the utilities with node --test like the existing tests). Fix defects found in the same repository's commit and record them in the SUMMARY. Wire the new tests into the --site stage by required name. Commit per repository as listed in Execution units; do not commit submodule gitlinks in sm-summercmsio-app.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-summercmsio-app/plugins/golem15/summercms test -count=1 ./... 2>&1 | tail -6</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or "--- SKIP" for the registry tests on a Docker host</fails_when>
|
||||
<automated>cd ../sm-summercmsio-app && python3 -B scripts/test_changelog_post.py 2>&1 | tail -4 && cd vue-summercmsio-app && pnpm test 2>&1 | tail -4</automated>
|
||||
<fails_when>non-zero exit, a "FAILED" line, or a "not ok" line</fails_when>
|
||||
<automated>bash scripts/check-phase34.sh --site</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "missing" line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func Test" ../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest_test.go` prints at least three lines
|
||||
- `grep -c "def test_" ../sm-summercmsio-app/scripts/test_changelog_post.py` grew by at least six compared with 34-03's version
|
||||
- `grep -n "installCommand" ../sm-summercmsio-app/vue-summercmsio-app/tests/marketplace.test.ts` prints at least one line
|
||||
</acceptance_criteria>
|
||||
<done>The registry, its API, the release hooks and the marketplace utilities are unit-tested in their own repositories.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Release ledger for Jakub and the filled validation map</name>
|
||||
<files>.planning/phases/34-plugin-marketplace/34-release-log.md, .planning/phases/34-plugin-marketplace/34-VALIDATION.md</files>
|
||||
<read_first>
|
||||
- the five plan SUMMARYs (commit SHAs, pending pointer bumps, skips, test names)
|
||||
- .planning/phases/32-seo-foundation/32-06-release-log.md (ledger format)
|
||||
- ../sm-summercmsio-app/DEPLOY.md (deploy and changelog posting) and CLAUDE.md (changelog post rules)
|
||||
- .planning/phases/34-plugin-marketplace/34-VALIDATION.md
|
||||
</read_first>
|
||||
<action>
|
||||
Write `34-release-log.md` with sections: "Planned contents" (framework v0.6.0: the commits of 34-01, 34-04, 34-05, 34-06 by subject and SHA; each plugin repository's new commit and its proposed next tag, a minor or patch bump chosen from its own log since its last tag; sm-summercmsio-plugin and vue-summercmsio-app commits); "Release order" (push framework master, tag v0.6.0 on master, changelog post; for each plugin in dependency order: verify `summer plugin:manifest --check`, tag, push, changelog post through `changelog-post.py publish … --repo`, which now checks the manifest and triggers ingestion; then sm-summercmsio-plugin and vue-summercmsio-app pushes; then each app: framework `go get` to v0.6.0, plugin pointer bumps as separate commits after each plugin push, `summer build` to regenerate main.go and plugins.gen.go committed separately (research F10), release build); "summercms.io deploy" (git on rome, migrate, work_in_serve, nginx and supervisor configs, `check-deploy.sh`, add the fourteen launch repositories, mint the `registry` token, first ingest, curl the API, visual check of /marketplace); "Rules" (never move a tag: proxy and sumdb cache it forever, research Pitfall 7; tags only on the default branch; every tag gets its changelog post the same day); and an empty "Executed steps" table. Fill 34-VALIDATION.md's per-task verification map with one row per task of plans 34-01..34-06 (task ID written as 34-0N-TM, for example 34-03-T2; plan, wave, requirement or decision, threat ref, test type, automated command, file exists, status) and tick the Wave 0 items; leave `nyquist_compliant` and `status` to /msd-validate-phase. Commit both as one planning-docs commit with the subject from Execution units.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "^| 34-0" .planning/phases/34-plugin-marketplace/34-VALIDATION.md</automated>
|
||||
<fails_when>a count below 26 (one row per task of the six plans: 5+5+4+4+3+5), or non-zero exit</fails_when>
|
||||
<automated>grep -n "v0.6.0\|Release order\|Executed steps\|never move" .planning/phases/34-plugin-marketplace/34-release-log.md</automated>
|
||||
<fails_when>non-zero exit, or fewer than four matching lines</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 34-release-log.md names every repository listed in the SUMMARYs with a pending action, and the fourteen launch repositories
|
||||
- 34-VALIDATION.md has no remaining "_filled by the planner/executor" placeholder row
|
||||
- no tag or push was made (`git tag --points-at HEAD` prints nothing)
|
||||
</acceptance_criteria>
|
||||
<done>Jakub has an exact, ordered release list, and the validation map reflects every task's automated check.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| gate script → sibling repositories | runs tests and read-only checks in other repositories |
|
||||
| tests → developer environment | module cache, sibling checkouts, Docker |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-34-34 | Tampering | tests writing into sibling repositories or the module cache | medium | mitigate | fixtures in t.TempDir(), temporary GOMODCACHE for downloads, acceptance check that sibling worktrees stay clean |
|
||||
| T-34-35 | Repudiation | a gate that passes because a test silently did not run | medium | mitigate | required test names checked in go test -json output; Docker stages report NOT RUN and fail under --all unless explicitly allowed; self-test plants each failure |
|
||||
| T-34-36 | Information disclosure | release ledger | low | mitigate | the ledger names repositories, SHAs and steps only; no tokens, hosts' secrets or token file contents |
|
||||
| T-34-SC | Tampering | npm/pip/cargo installs | high | accept | no package is added |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase34.sh --all` passes on a Docker host (or with PHASE34_ALLOW_NO_DOCKER=1 and the Docker stage run separately through sudo -u golem).
|
||||
- Every repository touched by the phase has its tests green.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Unit tests cover the rules of D-02..D-19 in every repository (SC-5).
|
||||
- The gate script and the release ledger exist; VALIDATION's map is filled.
|
||||
- No push, tag or deploy.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/34-plugin-marketplace/34-06-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Script: `scripts/check-phase34.sh` with `--self-test`, `--go`, `--docker`, `--plugins`, `--site`, `--admin`, `--docs`, `--marker`, `--additive`, `--all`; env `PHASE34_ALLOW_NO_DOCKER`, `PHASE34_PENDING_MANIFESTS`.
|
||||
- Tests: `TestReadIdentity`, `TestDerive`, `TestManifestFormat`, `TestCheck`, `TestVersions`, `TestRegistryClient`, `TestDescribe`, `TestPluginDirs`, `TestEffectiveFramework`, `TestResolveInstall`, `TestAppendManifestPlugins`, `TestPluginCommands`, `TestPluginsEndpoints`, PluginsView and useSettings vitest specs, site plugin registry unit tests, changelog-post unittest cases, `tests/marketplace.test.ts`; runnable examples for `kiosk.Marshal` and `kiosk.NewestCompatible`.
|
||||
- Files: `.planning/phases/34-plugin-marketplace/34-release-log.md`; filled `34-VALIDATION.md` map.
|
||||
- Config keys, CLI commands, exported types: none new.
|
||||
202
.planning/phases/34-plugin-marketplace/34-06-SUMMARY.md
Normal file
202
.planning/phases/34-plugin-marketplace/34-06-SUMMARY.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
plan: 06
|
||||
subsystem: testing
|
||||
tags: [phase-gate, unit-tests, kiosk, install, cabana, admin, registry, changelog-post, marketplace, release-ledger]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 34-plugin-marketplace (34-01..34-05)
|
||||
provides: "kiosk, party.Describe, plugin:manifest/install/update, cabana Plugins endpoints and PluginsView, the summercms.io registry, release script hooks and /marketplace pages, the smoke tests and the 34-02 push blockers and pending pointer bumps"
|
||||
provides:
|
||||
- "scripts/check-phase34.sh: --self-test, --go, --docker, --plugins, --site, --admin, --docs, --marker, --additive, --all (PHASE34_ALLOW_NO_DOCKER, PHASE34_PENDING_MANIFESTS, PHASE34_PLUGIN_SKIP)"
|
||||
- "Unit tests: TestReadIdentity, TestDerive, TestManifestFormat, TestCheck, TestVersions, TestRegistryClient, ExampleMarshal, ExampleNewestCompatible, TestDescribe, TestPluginDirs (kiosk and build), TestEffectiveFramework, TestResolveInstall, TestAppendManifestPlugins, TestPluginCommands, TestPluginsEndpoints, PluginsView and useSettings specs"
|
||||
- "summercms.io unit tests: registry ingest/query/git-runner/URL rules, TestRegistryAPIShapes and TestRegistryIngestEndpoint (Postgres), 14 new changelog-post cases, marketplace utility cases"
|
||||
- "34-release-log.md: release order, plugin tag table, pointer bumps, deploy, v0.6.0 tag message draft"
|
||||
- "34-VALIDATION.md: 26-row per-task map, Wave 0 ticked"
|
||||
affects: [phase 34 verification, the v0.6.0 release, every plugin tag of the release]
|
||||
|
||||
actuals:
|
||||
tokens: 52900 # chars/4 over the diffs of this plan's commits in all four repositories
|
||||
tasks: 5
|
||||
commits: 7 # framework, measured: git rev-list --count b5f1ed2..HEAD before the docs commit
|
||||
plan_head_before: b5f1ed202a94759aecdd36babe80c180cad960ff
|
||||
plan_head_after: dfc6127
|
||||
repo_heads:
|
||||
sm-summercmsio-plugin: { before: 5853cf5, after: c52e721, commits: 1 }
|
||||
sm-summercmsio-app: { before: 97c0c08, after: 352314a, commits: 1 }
|
||||
vue-summercmsio-app: { before: 05f1cea, after: 8267c55, commits: 1 }
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Phase gate per stage with required test names checked in go test -json, TAP and unittest output; a Docker stage that is NOT RUN fails --all unless explicitly allowed"
|
||||
- "In-memory registries (build.Registry, cabana pluginRegistry) and an injected clock for cache and concurrency tests"
|
||||
- "Contract tests that read the sibling Go source (categories, query limit) from the TypeScript side"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase34.sh
|
||||
- modules/kiosk/manifest_test.go
|
||||
- modules/kiosk/identity_test.go
|
||||
- modules/kiosk/derive_test.go
|
||||
- modules/kiosk/version_test.go
|
||||
- modules/kiosk/client_test.go
|
||||
- modules/kiosk/example_test.go
|
||||
- modules/party/info_test.go
|
||||
- internal/build/plugindirs_test.go
|
||||
- internal/build/install_test.go
|
||||
- internal/build/manifest_append_test.go
|
||||
- cmd/summer/plugin_commands_test.go
|
||||
- modules/cabana/plugins_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/ingest_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry/query_test.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/summercms/registry_api_test.go
|
||||
- .planning/phases/34-plugin-marketplace/34-release-log.md
|
||||
modified:
|
||||
- internal/build/plugindirs.go
|
||||
- internal/build/manifest.go
|
||||
- modules/cabana/plugins.go
|
||||
- admin/tests/views/PluginsView.test.ts
|
||||
- admin/tests/state/useSettings.test.ts
|
||||
- ../sm-summercmsio-app/scripts/changelog-post.py
|
||||
- ../sm-summercmsio-app/scripts/test_changelog_post.py
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/tests/marketplace.test.ts
|
||||
- .planning/phases/34-plugin-marketplace/34-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "Plugin tags of the release (Jakub, via the coordinator): every plugin with a 34-02 plugin.yaml commit plus sm-summercmsio-plugin gets the next patch tag (v0.1.0 when untagged); no /changelog/plugins posts for them, the framework v0.6.0 post and tag message list the bumps"
|
||||
- "The v0.6.0 tag message is drafted in 34-release-log.md (longer than usual, user-facing, with a 'Plugins released alongside' section)"
|
||||
- "Tests pin documented behaviour where the plan text differed: Parse ignores unknown fields (forward compatibility, its doc comment), party.Describe publishes once per app (backpack refuses a duplicate), ReadIdentity prefers plugin.go's ID() over another type's (34-01 decision)"
|
||||
- "sm-user-plugin's pre-existing TestRegisterCORSPath failure is skipped in --plugins through PHASE34_PLUGIN_SKIP (default) and printed on every run"
|
||||
|
||||
patterns-established:
|
||||
- "Each phase gate names its required tests per repository and fails closed on a missing repository"
|
||||
|
||||
requirements-completed: [SC-5, SC-1, SC-2, SC-3, SC-4, D-02, D-03, D-04, D-05, D-07, D-08, D-09, D-10, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19]
|
||||
|
||||
duration: ~40min
|
||||
completed: 2026-10-29
|
||||
---
|
||||
|
||||
# Phase 34 Plan 06: Phase gate, unit tests and release ledger Summary
|
||||
|
||||
**One gate script now runs every Phase 34 check across the framework, the 20 plugin repositories and the summercms.io repositories. Unit tests cover kiosk, install resolution, the Plugins endpoints and screen, the registry and the release script, and they found and fixed four defects. 34-release-log.md gives Jakub the exact v0.6.0 release: tag order, one new tag per plugin, the pointer bumps, the deploy and a drafted tag message. Nothing was pushed or tagged.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 40 min (2026-10-29T20:03:29Z to about 20:45Z)
|
||||
- **Tasks:** 5/5
|
||||
- **Commits:** 7 framework code/test commits, 1 in each of sm-summercmsio-plugin, sm-summercmsio-app and vue-summercmsio-app, plus the planning-docs commit(s)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Gate (Task 1, tracer):** `scripts/check-phase34.sh` reuses check-phase32's engine and adds TAP (node --test) and unittest detectors. The self-test plants each failure (failing, skipped and missing tests, a marker hit, an additive removal, a missing repository, a Docker daemon that does not answer) and requires that each one is detected. The `--plugins` stage builds `summer` from this checkout into a temporary directory and runs `plugin:manifest --check` for all 21 plugin packages.
|
||||
- **Framework unit tests (Task 2):** kiosk coverage is 90.2%. TestResolveInstall runs against an in-memory registry and needs no Go build. TestPluginDirs decodes `go list` JSON fixtures. TestPluginCommands drives the CLI through `bonfire.Execute` and checks the exit status. The runnable examples cover Marshal and NewestCompatible. Every fixture lives in `t.TempDir()`, and `git -C ../sm-mfa-plugin status` is unchanged.
|
||||
- **Admin side (Task 3):** TestPluginsEndpoints covers access, the update-rule table, cache hit and expiry on an injected clock, failure caching, at most 4 lookups in flight, the call timeout, marketplace validation and a disabled registry. The PluginsView spec grew to 15 tests and the useSettings spec to 12.
|
||||
- **summercms.io (Task 4):** registry unit tests run without Docker: strict and peeled tags, digest equality between ls-remote and the clone, nested manifests, identity and duplicate refusals, blob and manifest caps, the https-only git runner and the URL rules. Postgres tests cover the API shapes and the ingest endpoint. The changelog-post tests went from 36 to 50. The marketplace tests went from 7 to 13 and include a contract check against the Go categories and the query limit.
|
||||
- **Ledger (Task 5):** 34-release-log.md follows the coordinator's two instructions: every plugin gets a new tag with no plugin changelog posts, and the v0.6.0 tag message is drafted with a "Plugins released alongside" section. 34-VALIDATION.md has 26 task rows and Wave 0 is ticked.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**summercms.go (master):**
|
||||
1. Task 1: `b93a6de` test: phase 34 gate script
|
||||
2. Task 2: `9109e5b` test(kiosk): manifest, identity, version and client unit tests
|
||||
3. Task 2: `7de774b` test(build): plugin dirs, install resolution and summer.yaml edits
|
||||
4. Task 2: `a231309` test(summer): plugin command prompts
|
||||
5. Task 3: `df2ba57` test(cabana): Plugins screen endpoints
|
||||
6. Task 3: `bad273b` test(admin): PluginsView and settings paths
|
||||
7. Task 4: `dfc6127` test: phase 34 gate requires the summercms.io unit tests
|
||||
8. Task 5: `docs(34): release ledger and validation map` (planning docs)
|
||||
|
||||
**sm-summercmsio-plugin (master):** `c52e721` test: registry ingest, queries and API
|
||||
**sm-summercmsio-app (master):** `352314a` test: changelog-post manifest and ingest helpers (the gitlinks of the two submodules stay modified and uncommitted, as instructed)
|
||||
**vue-summercmsio-app (master):** `8267c55` test: marketplace utilities
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash scripts/check-phase34.sh --self-test`: passes (34 planted cases).
|
||||
- `--go --docs --marker --additive`: pass. The marker stage finds no consuming application named since v0.5.3. In the additive stage cabana gains 43 lines and party 15, kiosk is new, and nothing is removed.
|
||||
- `--plugins --admin`: pass. 20 repositories and 21 manifests are fresh; vitest reports 1614 tests before Task 3; typecheck, openapi and dist all pass.
|
||||
- `--admin --additive` after Task 3: pass.
|
||||
- `--site`: pass. The site plugin's vet, short tests and required tests; the app's vet and test; the unittest suite; check-deploy; and pnpm test, build:changelog, test:changelog and test:marketplace.
|
||||
- `--docker`: pass. The framework's full `go test ./...` (Docker), the non-short TestInstallSmoke, and in the site plugin TestRegistrySmoke, TestRegistryAPIShapes and TestRegistryIngestEndpoint (Postgres).
|
||||
- Framework `go vet ./...` and `go test -short ./...` are green, as is `cd examples/hello && go test ./...`.
|
||||
- No tag and no push: `git tag --points-at HEAD` is empty in every repository touched.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] An in-app plugin package was reported as a submodule**
|
||||
- **Found during:** Task 2 (TestPluginDirs)
|
||||
- **Issue:** A plugin package of the application's own module (no go.mod of its own) resolved its module root to the application directory. That directory is the top of the application's git repository, so `summer build` baked `Source: submodule` with the application's `git describe` as the plugin version.
|
||||
- **Fix:** `pluginVersion` treats the application directory as in-app (`party.SourceLocal`).
|
||||
- **Files:** internal/build/plugindirs.go
|
||||
- **Commit:** 7de774b
|
||||
|
||||
**2. [Rule 1 - Bug] summer.yaml items written as `- id:` broke the in-place append**
|
||||
- **Found during:** Task 2 (TestAppendManifestPlugins)
|
||||
- **Issue:** The inserted items always used `- id:`, so with wider spacing after the dash the `module:` key landed at the wrong column. The edit then failed to parse back and was refused, so plugin:add and plugin:install could not append.
|
||||
- **Fix:** The spacing after the dash is kept, as are a dash alone on its line and the existing indentation.
|
||||
- **Files:** internal/build/manifest.go
|
||||
- **Commit:** 7de774b
|
||||
|
||||
**3. [Rule 1 - Bug] Marketplace categories included categories of dropped entries**
|
||||
- **Found during:** Task 3 (TestPluginsEndpoints)
|
||||
- **Issue:** A registry entry whose ID is outside the plugin ID shape is not offered on the screen, but its category still appeared in the category select.
|
||||
- **Fix:** `categoriesOf` counts only entries with a valid ID.
|
||||
- **Files:** modules/cabana/plugins.go
|
||||
- **Commit:** df2ba57
|
||||
|
||||
**4. [Rule 1 - Bug] changelog-post.py left the notes and token files open**
|
||||
- **Found during:** Task 4. The unittest run reported ResourceWarnings.
|
||||
- **Fix:** Both files are read with `with open(...)`.
|
||||
- **Files:** ../sm-summercmsio-app/scripts/changelog-post.py
|
||||
- **Commit:** 352314a
|
||||
|
||||
**5. [Rule 3 - Blocking] A pre-existing sm-user-plugin test fails standalone**
|
||||
- **Found during:** Task 1 (--plugins)
|
||||
- **Issue:** `TestRegisterCORSPath` looks for an application's config/http.yaml. This was noted in 34-02 and is out of scope.
|
||||
- **Fix:** `PHASE34_PLUGIN_SKIP` defaults to `sm-user-plugin:TestRegisterCORSPath`. The skip applies only to that repository's `go test -short` and is printed on every run; `PHASE34_PLUGIN_SKIP=` disables it.
|
||||
- **Commit:** b93a6de
|
||||
|
||||
**6. [Rule 2 - Missing critical] Extra gate commit and extra tests**
|
||||
- `dfc6127` wires the summercms.io tests into the gate. The plan's Task 4 lists commits only in the summercms.io repositories, but the gate script lives in the framework.
|
||||
- Additional tests beyond the behavior lists: TestPackagePath, TestPluginDirs and TestLastElem (kiosk), TestAskToProceed, TestGitRunner, TestIngestHelpers and TestQueryRules.
|
||||
|
||||
### Plan text vs code (tests pin the documented behaviour)
|
||||
|
||||
- **Parse:** The plan said "unknown field refused", but `kiosk.Parse` documents that unknown fields are ignored so older summer binaries can read newer schema-1 files. Only a newer schema is refused, and the tests pin that.
|
||||
- **party.Describe:** The plan said "a second Describe replaces the first". In fact `backpack.Publish` refuses a duplicate, so a second Describe returns an error and the first value stays published. The generated main calls Describe once.
|
||||
- **ReadIdentity:** The plan said "two ID methods returning different values" should be refused. 34-01 deliberately prefers plugin.go's `ID()`, along with the `Requires()` on the same receiver, so an `ID()` on a model in the same package is ignored. The test pins that.
|
||||
- **changelog-post "dry runs":** `publish` has no dry-run mode. The tests instead pin that a refused manifest check stops before the post, that a failed post never asks for an ingest, and that a framework release skips both.
|
||||
|
||||
### Process
|
||||
|
||||
- TDD: the unit tests were written against existing code. RED was seen where they exposed defects 1-3 (TestPluginDirs failed on the in-app source, TestAppendManifestPlugins on the four-space layout, TestPluginsEndpoints on the categories), and each fix went into the commit with the test that found it. The other tests passed on their first or second run, after test-side fixes.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `gofmt -l modules/kiosk` still lists the pre-existing `manifest_smoke_test.go` (noted in 34-04). It was left alone.
|
||||
- Repositories with uncommitted state from other sessions were left as found: summercms.go `.planning/STATE.md`, `state.json`, `G15OFFICE.md` and `.msd/`; the sm-summercmsio-app gitlinks; the sm-oauthserver-plugin go.work; the sm-journal-plugin go.work.sum.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan. The release itself is Jakub's, step by step, in 34-release-log.md.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `/msd-verify-work` and `/msd-validate-phase` can run `bash scripts/check-phase34.sh --all` on this Docker host.
|
||||
- The release waits for Jakub: framework v0.6.0, 20 plugin tags, sm-summercmsio-plugin v0.1.0, three rebases, 32 pointer bumps and the deploy.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: scripts/check-phase34.sh, the seven kiosk/party test files, the four build/cmd test files, modules/cabana/plugins_test.go, the three site plugin test files, 34-release-log.md, the filled 34-VALIDATION.md (26 rows, no placeholder row).
|
||||
- Commits found: b93a6de, 9109e5b, 7de774b, a231309, df2ba57, bad273b, dfc6127 (framework); c52e721 (sm-summercmsio-plugin); 352314a (sm-summercmsio-app); 8267c55 (vue-summercmsio-app).
|
||||
- Every gate stage passed on this host: self-test, go, docs, marker, additive, plugins, admin, site, docker.
|
||||
- No tag points at HEAD in any repository touched; nothing was pushed.
|
||||
124
.planning/phases/34-plugin-marketplace/34-CONTEXT.md
Normal file
124
.planning/phases/34-plugin-marketplace/34-CONTEXT.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Phase 34: Plugin marketplace - Context
|
||||
|
||||
**Gathered:** 2026-10-29
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
People browse the public Golem15 plugins at summercms.io/marketplace and add one to an application with `summer plugin:install <id>`; the same command installs private plugins from a git URL. Each public `sm-*-plugin` repo carries a generated `plugin.yaml`; summercms.io ingests manifests, READMEs and tags into a registry with a public read-only API; the admin SPA gains a read-only Plugins screen (Installed + Marketplace tabs). v1 has no paid plugins, no developer registration, no themes and no installs from the admin panel (all settled in `.planning/notes/2026-10-29-plugin-marketplace.md`).
|
||||
|
||||
Discussion added one capability to the roadmap text: `summer plugin:update`, so the admin's "update available" badge has a matching command (D-10).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Manifest: plugin.yaml is generated, never hand-edited
|
||||
- **D-01:** `plugin.yaml` is generated by a new command `summer plugin:manifest` run in a plugin repo. `plugin.go` stays as it is (no change to `party.Plugin`, no breaking change to core plugins).
|
||||
- **D-02:** Every field is derived from the repo: `id` and `requires` from `plugin.go` (AST: the literals returned by `ID()` / `Requires()`), module path from `go.mod`, minimum framework version and minimum required-plugin versions from `go.mod`'s `require` lines, `name` and `description` from the README's H1 and its one-sentence summary (the standard module README structure). A plugin whose `ID()`/`Requires()` are not literal returns is an error that names the function.
|
||||
- **D-03:** Optional `--name="…"` / `--description="…"` override the README-derived text. Overrides persist in `plugin.yaml` and survive regeneration until changed or cleared (exact storage is planner's choice, e.g. an `overrides:` block).
|
||||
- **D-04:** `summer plugin:manifest --check` fails when the committed `plugin.yaml` is stale. It runs in each plugin repo's tests (a pact/test helper or `go test`-runnable check) and in the tag/changelog step (`scripts/changelog-post.py publish … --repo`). `plugin.yaml` is committed. — **Reversibility:** costly — the format is read by the registry ingester, `plugin:install` for git URLs, and every public plugin repo; changing it means regenerating all repos and migrating the ingester.
|
||||
- **D-05:** Version constraints are Go-style minimums: `framework: v0.4.0`, `requires: {golem15.translate: v0.2.0}`. No semver ranges (Go's minimal version selection; a major bump changes the module path).
|
||||
|
||||
### Runtime metadata for the Installed tab
|
||||
- **D-06:** `summer build` reads each plugin's `plugin.yaml` and bakes name and description into `plugins.gen.go`. Plugins without a `plugin.yaml` show only their ID.
|
||||
- **D-07:** Version: module installs use the version from `go.mod`/build info; submodule or local-`replace` plugins get `git describe --tags` run in the plugin directory at build time (e.g. `v0.3.1-4-gabc123`), also baked into `plugins.gen.go`.
|
||||
|
||||
### Install resolution
|
||||
- **D-08:** `summer plugin:install golem15.journal` picks the newest tag whose minimum framework version the app's `go.mod` already satisfies; `golem15.journal@v0.3.0` pins. If only tags needing a newer framework exist, it fails and names the framework version to move to (it does not bump the framework silently).
|
||||
- **D-09:** Missing required plugins are resolved transitively, the full list of additions is printed, and the user confirms; `--yes` skips the prompt and non-interactive runs require `--yes`. Dependencies install in the same mode as the requested plugin (module, or submodule under `--source`).
|
||||
- **D-10:** `summer plugin:update [id]` is in scope: module installs move to the newest compatible tag (`go get`), submodule installs are checked out at the newest compatible tag. Without an ID it updates all plugins it can resolve.
|
||||
- **D-11:** Git-URL installs are submodule-only in v1; no `--module`. Close the open question in `.planning/research/questions.md` as "not in v1". — **Reversibility:** reversible — adding `--module` later is additive.
|
||||
- **D-12:** A git-URL plugin's own repo is never sent to summercms.io, but its required plugin IDs (e.g. `golem15.translate`) are resolved through the registry like any ID install. `--offline` disables registry calls and then requires the dependencies to be installed already.
|
||||
- Both modes finish with the same registration `plugin:add` does (`summer.yaml`, `go.mod`, `go.work` where applicable), and module-installed plugins must work with the build steps that currently inspect plugin directories (`internal/build/leaf.go`, `internal/build/registry.go`) — resolve their directory from the module cache.
|
||||
|
||||
### Registry ingestion and API (summercms.io, `golem15.summercms` site plugin)
|
||||
- **D-13:** Pull ingester: given a repo URL it clones anonymously, reads tags, each tag's `plugin.yaml`, and the README. It runs as a River periodic job (around hourly) and on an authenticated "ingest now" endpoint that `changelog-post.py publish --repo` calls. No forge webhooks.
|
||||
- **D-14:** The listing is admin-curated: a summercms.io admin adds a repo URL; the ingester fills the facts. A plugin is published only once it has a tag with a valid `plugin.yaml`; tags without one are skipped.
|
||||
- **D-15:** Every `vX.Y.Z` tag is stored as a version row with that tag's framework and plugin minimums (needed by D-08). README is stored for the latest tag only.
|
||||
- **D-16:** Public read-only JSON API, no auth, open CORS, cache headers: `GET /api/registry/v1/plugins?q=&category=` (list/search), `GET /api/registry/v1/plugins/{id}` (detail incl. README as markdown and rendered HTML), `GET /api/registry/v1/plugins/{id}/versions`. Consumed by the Nuxt marketplace pages, the CLI and cabana. — **Reversibility:** one-way — once released `summer` binaries call `/api/registry/v1`, its shape cannot change without a v2 path.
|
||||
- Presentation fields (category, featured, screenshots, icon, sort order) are edited in the summercms.io admin (from the decision note).
|
||||
|
||||
### Admin Plugins screen
|
||||
- **D-17:** The SPA talks only to its own backend: cabana gets swag-annotated admin endpoints that call the registry with a timeout and short cache, and compute "update available" in Go against installed versions. No CSP/CORS change in the admin.
|
||||
- **D-18:** Config key `plugins.registry_url` (default `https://summercms.io`); empty disables registry calls, which hides the Marketplace tab and update badges while Installed still works. The CLI reads the same setting (allows a staging or self-hosted registry). Exact key name is planner's choice if it clashes with existing config.
|
||||
- **D-19:** New permission (a `system.plugins.view`-style key in cabana's permission set), granted to superusers by default.
|
||||
|
||||
### Claude's Discretion
|
||||
- Marketplace page layout and visuals on `vue-summercmsio-app` (list, search, category, detail); `/msd-ui-phase 34` can produce a UI contract if wanted.
|
||||
- Storage of D-03 overrides inside `plugin.yaml`.
|
||||
- Registry DB schema, ingester error handling, cache durations, periodic interval.
|
||||
- Whether older tags (before `plugin.yaml` existed) get backfilled; default per D-14 is to skip them.
|
||||
- Exact CLI output/prompt wording and the semantics of `plugin:update` with no ID when some plugins can't be resolved.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Marketplace decisions
|
||||
- `.planning/notes/2026-10-29-plugin-marketplace.md` — v1 scope, listing data split, install-mode table, read-only admin screen, out-of-scope list
|
||||
- `.planning/research/questions.md` — "Is `--module` for git-URL installs worth supporting?" (answered by D-11: not in v1)
|
||||
- `.planning/ROADMAP.md` § Phase 34 — goal, repos, success criteria
|
||||
|
||||
### Plugins and repos
|
||||
- `.planning/notes/core-plugins-own-repos.md` — `sm-*-plugin` repos, module path = repo path, submodules at `plugins/<vendor>/<name>`, commit plugin first then pointer bump
|
||||
- `.planning/notes/why-go-not-scala.md` and `.planning/research/FEATURES.md` (anti-features) — no runtime plugin loading
|
||||
- `.planning/research/STACK.md` — decided dependency set (YAML via `goccy/go-yaml`; any new dependency, e.g. a markdown renderer or git library, needs a note)
|
||||
|
||||
### Release flow
|
||||
- `../sm-summercmsio-app/DEPLOY.md` § "Posting to the changelog" and `../sm-summercmsio-app/scripts/changelog-post.py` — plugin tag step that D-04 and D-13 hook into
|
||||
- `.planning/notes/2026-10-07-embedded-admin-release-gate.md` — rebuild and commit `modules/boardwalk/dist` when `admin/` changes
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `cmd/summer/main.go` `addPluginCommand()` and `internal/build.AddPlugin` — the registration step both install modes end with.
|
||||
- `internal/build/manifest.go` — `Manifest`, `LoadManifest`/`SaveManifest`, `ValidatePluginID`, `validateModulePath`: reuse for `summer.yaml` edits and ID validation in `plugin.yaml`.
|
||||
- `internal/build/build.go` `generatePluginsGen` — where D-06/D-07 metadata gets baked in.
|
||||
- `modules/party` `Activated` — the installed list cabana's Installed tab reads.
|
||||
- `conga` (River periodic jobs) — the ingester schedule on summercms.io.
|
||||
- `scripts/check-admin-openapi.sh` — swag → `admin/openapi/admin.json` → `admin/src/api/schema.d.ts` for the new cabana endpoints.
|
||||
|
||||
### Established Patterns
|
||||
- `party.Plugin` is only `ID()`, `Requires()`, `Register`, `Boot`; plugin metadata does not exist at runtime today, hence D-06.
|
||||
- `plugin:add` assumes a local directory + `replace`; module installs (no `replace`) are new and must satisfy `leaf.go`/`registry.go` directory inspection.
|
||||
- Commands are `bonfire.Command` with Long text, Examples and Args/Flags; docs checker (`summer docs:build --check`) verifies command names in docs.
|
||||
- New admin screens follow the cabana admin API + boardwalk SPA split; the dist is committed.
|
||||
|
||||
### Integration Points
|
||||
- `../sm-summercmsio-app/plugins/golem15/summercms` — currently routes/static only (docs, `/new`, `/update`, site tree); gains models, migrations, admin (presentation fields, repo list), registry API routes and the ingester job.
|
||||
- `../sm-summercmsio-app/vue-summercmsio-app` — marketplace pages consuming D-16.
|
||||
- Every public `../sm-*-plugin` repo — commit generated `plugin.yaml` plus the `--check` test (plugin commits first, app pointer bumps separately).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The user wanted hand-editable text despite generation, hence `--name` / `--description` overrides on `plugin:manifest`.
|
||||
- Modelled on octobercms.com/marketplace for browsing; install is CLI only.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- `--module` for git-URL (private) installs. Revisit if submodule-only proves limiting.
|
||||
- Already out of scope per the decision note: paid plugins, licensing, developer accounts, third-party submissions, themes, installing from the admin panel.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- 17 todos matched on keywords only (e.g. gate scripts naming apps, OIDC parity, MFA findings, menu plugin port). None concern the marketplace; none folded.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 34-plugin-marketplace*
|
||||
81
.planning/phases/34-plugin-marketplace/34-DISCUSSION-LOG.md
Normal file
81
.planning/phases/34-plugin-marketplace/34-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Phase 34: Plugin marketplace - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-29
|
||||
**Phase:** 34-plugin-marketplace
|
||||
**Areas discussed:** plugin.yaml vs plugin.go, Install resolution, Registry ingestion & API, Admin ↔ registry link
|
||||
|
||||
---
|
||||
|
||||
## plugin.yaml vs plugin.go
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Both stay, build checks | plugin.go unchanged; build/validator fail on mismatch | |
|
||||
| plugin.go reads plugin.yaml | embed and derive ID()/Requires(); edits every plugin.go | |
|
||||
| Generate plugin.yaml | command writes it from plugin.go plus extra fields | ✓ |
|
||||
|
||||
**Field sources:** derive everything from the repo (plugin.go AST, go.mod, README H1 + summary). **Notes:** user asked for optional `--name` / `--description` overrides for hand-edited text.
|
||||
|
||||
**When generated:** `summer plugin:manifest` + `--check` before tagging (selected) vs generated only at ingestion.
|
||||
|
||||
| Runtime meta | Selected |
|
||||
|---|---|
|
||||
| Baked into plugins.gen.go by summer build | ✓ |
|
||||
| Optional pact Details() capability | |
|
||||
|
||||
| Local version | Selected |
|
||||
|---|---|
|
||||
| git describe at build | ✓ |
|
||||
| Show "local" | |
|
||||
| version field in plugin.yaml | |
|
||||
|
||||
| Constraint format | Selected |
|
||||
|---|---|
|
||||
| Go-style minimum | ✓ |
|
||||
| Semver ranges | |
|
||||
|
||||
---
|
||||
|
||||
## Install resolution
|
||||
|
||||
| Question | Options | Selected |
|
||||
|---|---|---|
|
||||
| Version pick | Newest compatible + @ pins / Always latest tag | Newest compatible + @ pins |
|
||||
| Missing deps | Install transitively after listing / Fail with command | Install transitively after listing |
|
||||
| Update command | plugin:update in scope / Defer | plugin:update in scope |
|
||||
| `--module` for git URLs | Let research decide / No, submodule only / Yes with diagnostics | No, submodule only |
|
||||
| Git-URL plugin's public deps | Registry for IDs only / Never contact, fail / From go.mod alone | Registry for IDs only |
|
||||
|
||||
---
|
||||
|
||||
## Registry ingestion & API
|
||||
|
||||
| Question | Options | Selected |
|
||||
|---|---|---|
|
||||
| Trigger | Pull job + changelog step / Forge webhook / changelog-post.py push only | Pull job + changelog step |
|
||||
| Listing | Admin-curated repo list / Auto-discover sm-*-plugin | Admin-curated repo list |
|
||||
| Versions | Every tag + its plugin.yaml / Latest only | Every tag + its plugin.yaml |
|
||||
| API shape | Small read-only JSON / Static index.json | Small read-only JSON |
|
||||
|
||||
---
|
||||
|
||||
## Admin ↔ registry link
|
||||
|
||||
| Question | Options | Selected |
|
||||
|---|---|---|
|
||||
| Path | Server-side via cabana / Browser calls summercms.io | Server-side via cabana |
|
||||
| Opt-out | Config key, on by default / Always on | Config key, on by default |
|
||||
| Permission | New permission, superuser default / Any admin | New permission, superuser default |
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Marketplace page layout; override storage in plugin.yaml; registry schema, cache durations, ingest interval; backfilling older tags; CLI wording.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- `--module` for git-URL installs.
|
||||
349
.planning/phases/34-plugin-marketplace/34-PATTERNS.md
Normal file
349
.planning/phases/34-plugin-marketplace/34-PATTERNS.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Phase 34: Plugin marketplace - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-29
|
||||
**Files analyzed:** 24 (grouped by plan)
|
||||
**Analogs found:** 22 / 24
|
||||
|
||||
Paths are relative to `summercms.go/` unless prefixed with `../` (sibling repos under the meta repo). All analogs are git-tracked sources (no `.msd/` mirrors).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Plan | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|---|
|
||||
| `modules/<beach-name>/manifest.go` (plugin.yaml types, Parse/Validate/Marshal) | 01 | model/utility | transform | `internal/build/manifest.go` (`Manifest`, `LoadManifest`/`SaveManifest`, `ValidatePluginID`) | exact |
|
||||
| `modules/<beach-name>/resolve_ast.go` (const-resolving ID/Requires) | 01 | utility | transform (AST) | `internal/build/scaffold.go:360-392` `pluginIDFromGo` | exact (extend) |
|
||||
| `modules/<beach-name>/derive.go` (go.mod via `go mod edit -json`, README via goldmark) | 01 | utility | file-I/O | `internal/build/scaffold.go` `runGo`/`applyGoWork` (l.632); goldmark use in `modules/breakwater` / `internal/docsite` | role-match |
|
||||
| `modules/<beach-name>/check.go` (`Check`, `CheckT(t, dir)` test helper) | 01 | test helper | file-I/O | `../sm-faq-plugin/commands_documented_test.go` (bonfire.CheckDocs helper used by plugin tests) | role-match |
|
||||
| `modules/<beach-name>/client.go` (registry HTTP client) | 01/04/05 | service | request-response | `modules/fetchguard/client.go:40` `NewClient` | role-match |
|
||||
| `modules/<beach-name>/version.go` (strict vX.Y.Z compare) | 01 | utility | transform | none | no analog |
|
||||
| `modules/<beach-name>/README.md` + root `README.md` row | 01 | docs | - | any `modules/*/README.md` (e.g. `modules/fetchguard/README.md`) | exact |
|
||||
| `internal/build/scaffold.go` (`pluginIDFromGo` -> shared resolver; `AddPlugin` require-version option) | 01/04 | utility | file-I/O | itself (l.144, l.360, l.394-406) | modify |
|
||||
| `internal/build/build.go` `generatePluginsGen`/`generateMain` (PluginInfo, FrameworkVersion) | 01 | codegen | transform | itself l.145-163, l.125 | modify |
|
||||
| `internal/build/plugindirs.go` (go list -json, git describe) | 01 | utility | file-I/O (exec) | `internal/build/new.go:286` `runGit`; `scaffold.go:452` `runGoWorkUse` | role-match |
|
||||
| `modules/party` Info type + Describe/lookup | 01 | provider | request-response (registry in app) | `party.Activate` (called from generated main, build.go:125) | role-match |
|
||||
| `cmd/summer/main.go` `plugin:manifest` command | 01 | controller (CLI) | file-I/O | `cmd/summer/main.go:381-415` `addPluginCommand` | exact |
|
||||
| `../sm-*-plugin/plugin.yaml` + `manifest_test.go` per repo | 02 | config + test | file-I/O | `../sm-faq-plugin/commands_documented_test.go` | role-match |
|
||||
| `../sm-summercmsio-app/plugins/golem15/summercms/updates/*.go` (registry tables) | 03 | migration | CRUD | `../sm-faq-plugin/updates/registry.go`, `202610110001_create_golem15_faq_faqs.go` | exact |
|
||||
| `.../summercms/models/*` + admin controllers YAML | 03 | model/controller | CRUD | `../sm-faq-plugin/models/`, `controllers/faqs/`, `admin.go`, `admin_navigation.go` | exact |
|
||||
| `.../summercms/admin_permissions.go` | 03 | config | - | `../sm-faq-plugin/admin_permissions.go` | exact |
|
||||
| `.../summercms/token_scopes.go` (`registry` scope) | 03 | config | - | `../sm-journal-plugin/token_scopes.go` | exact |
|
||||
| `.../summercms/controllers/api/registry.go` (public GET + ingest POST) | 03 | controller | request-response | `../sm-faq-plugin/routes.go` + `controllers/api`; auth: `../sm-journal-plugin/controllers/api/auth.go:83-106` | exact |
|
||||
| `.../summercms/console/registry_ingest.go` + `schedule.go` | 03 | command/job | batch (scheduled) | `../sm-chatvideo-plugin/schedule.go` | exact |
|
||||
| `.../summercms/plugin.go` (gains capabilities) | 03 | provider | - | itself (routes-only today) + `../sm-faq-plugin/plugin.go` | modify |
|
||||
| `../sm-summercmsio-app/scripts/changelog-post.py` (`--check` + ingest-now) | 03 | script | request-response | itself `cmd_publish` l.262-300 | modify |
|
||||
| `../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/*.vue` | 03 | component | request-response (SSR) | `.../changelog/app/pages/changelog/plugins.vue`, `index.vue` | role-match |
|
||||
| `cmd/summer/main.go` `plugin:install`, `plugin:update` + `internal/build/install.go` | 04 | controller (CLI) + service | file-I/O + request-response | `addPluginCommand` + `build.AddPlugin` | role-match |
|
||||
| `modules/cabana/plugins.go`, `http.go` routes, `admin_openapi.go`, `backend_screens.go` permission | 05 | controller | request-response | `modules/cabana/http.go:560` `settingsList`; `admin_openapi.go:469-479`; `backend_screens.go:16-25,80-87` | exact |
|
||||
| `admin/src/views/PluginsView.vue`, `router.ts`, `useSettings.ts` | 05 | component | request-response | `admin/src/app/router.ts:70-74`; `useSettings.ts:30-39`; `ApiTokenCreateDialog.vue:255-268` | role-match |
|
||||
| Unit tests (plan 06) | 06 | test | - | `internal/build/*_test.go`, `modules/cabana/phase09_contract_test.go` | role-match |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Plan 01 — const-resolving reader (`resolve_ast.go`)
|
||||
|
||||
**Analog:** `internal/build/scaffold.go` lines 360-392 (current, literal-only; to be replaced by a call into the shared resolver):
|
||||
```go
|
||||
func pluginIDFromGo(path string) (string, error) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
...
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
fn, ok := n.(*ast.FuncDecl)
|
||||
if !ok || fn.Name == nil || fn.Name.Name != "ID" || fn.Recv == nil || fn.Body == nil {
|
||||
return true
|
||||
}
|
||||
for _, stmt := range fn.Body.List {
|
||||
ret, ok := stmt.(*ast.ReturnStmt)
|
||||
if !ok || len(ret.Results) != 1 { continue }
|
||||
lit, ok := ret.Results[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING { continue }
|
||||
...
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("build: %s does not return a plugin id", path)
|
||||
}
|
||||
```
|
||||
Extend per RESEARCH Pattern 2: switch on `*ast.BasicLit` / `*ast.Ident` (package-level const in any non-test `.go` file of the dir) / `*ast.SelectorExpr` (same-module dir or `go list -m -json` Dir). Locked: same-package and cross-package constants, never execute code. Error must name the function (`ID()`/`Requires()`). Test fixtures: `sm-mfa-plugin` (same-file const), `sm-paymentgateway-plugin/plugin.go:47` (cross-pkg), `sm-pgstripe-plugin/subscriptions/plugin.go:83-85` (cross-module Requires).
|
||||
|
||||
### Plan 01 — `summer plugin:manifest` command
|
||||
|
||||
**Analog:** `cmd/summer/main.go` lines 381-415:
|
||||
```go
|
||||
func addPluginCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "plugin:add",
|
||||
Description: "Register a local plugin module in the app manifest and workspace",
|
||||
Long: `Adds a plugin that already exists on disk ...`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "plugin:add plugins/acme/blog", Description: "Register the plugin in plugins/acme/blog"},
|
||||
},
|
||||
Args: []bonfire.Arg{{Name: "dir", Description: "Local plugin directory", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
pluginDir, ok := in.Argument("dir")
|
||||
...
|
||||
dir, err := os.Getwd()
|
||||
if err := build.AddPlugin(ctx, dir, pluginDir); err != nil { return err }
|
||||
out.Success("Added " + pluginDir + " to summer.yaml, go.mod and go.work; run summer build")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
Every new command (`plugin:manifest`, `plugin:install`, `plugin:update`) needs Description, Long, Examples and described Args/Flags (`TestCommandsDocumented`), and a mention in `docs/console/scaffolding.md` (docs checker). Thin Run, logic in `internal/build` or the new module.
|
||||
|
||||
### Plan 01 — metadata baking in `generatePluginsGen`
|
||||
|
||||
**Analog:** `internal/build/build.go` lines 145-163:
|
||||
```go
|
||||
func generatePluginsGen(m Manifest) ([]byte, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
|
||||
...
|
||||
b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n")
|
||||
b.WriteString("var PluginIDs = []string{\n")
|
||||
for _, p := range m.Plugins {
|
||||
fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID))
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
return formatSource([]byte(b.String()))
|
||||
}
|
||||
```
|
||||
Add `var PluginInfo = []party.Info{...}` and `const FrameworkVersion` the same way (strconv.Quote every string, then `formatSource`; output written through `writeIfChanged`). Generated main (line 125, `plugins, err := party.Activate(app, PluginIDs)`) gains one `party.Describe(...)` line before it. The signature must take resolved metadata (a new param or struct) so failure of `go list` degrades to ID-only and never fails the build.
|
||||
|
||||
### Plan 01 — exec helpers (`plugindirs.go`, `derive.go`)
|
||||
|
||||
**Analogs:** `internal/build/new.go:286` `func runGit(ctx, dir, args...) ([]byte, error)`; `internal/build/scaffold.go:452` `runGoWorkUse`, `:632` `applyGoWork(cmd *exec.Cmd, dir string)`, and `editAppModule` (l.394-406):
|
||||
```go
|
||||
if err := runGo(ctx, appDir, "mod", "edit", "-require="+modPath+"@v0.0.0"); err != nil { return err }
|
||||
...
|
||||
if err := runGo(ctx, appDir, "mod", "edit", "-replace="+modPath+"="+rel); err != nil { return err }
|
||||
return ensureToolchain(filepath.Join(appDir, "go.mod"))
|
||||
```
|
||||
Use `go list -e -json=...` with `applyGoWork(cmd, appDir)`; `go mod edit -json` for go.mod parsing (do not extend `parseGoMod`). `editAppModule` needs a version parameter for `--source` installs (plan 04) while `plugin:add` keeps `v0.0.0`.
|
||||
|
||||
### Plan 01 — registry client (`client.go`)
|
||||
|
||||
**Analog:** `modules/fetchguard/client.go:40` `func NewClient(policy Policy, cfg *compass.Config) (*Client, error)`; use `Policy{Mode: fetchguard.TrustedMode, MaxBytes: ...}`; tests use its `WithTransport` seam. No redirects, so `plugins.registry_url` must be the final origin.
|
||||
|
||||
### Plan 02 — per-repo `plugin.yaml` + check test
|
||||
|
||||
**Analog:** `../sm-faq-plugin/commands_documented_test.go`:
|
||||
```go
|
||||
package faq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
func TestCommandsDocumented(t *testing.T) {
|
||||
commands := (&Plugin{}).Commands()
|
||||
...
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Logf("%s", problem)
|
||||
}
|
||||
}
|
||||
```
|
||||
New `manifest_test.go`: `func TestPluginManifest(t *testing.T) { <module>.CheckT(t, ".") }` (fails, not logs). For `sm-pgstripe-plugin` add one for `subscriptions/` too (plugin.yaml per plugin package dir). Repos with H1 == ID need `--name` (F7). Commit in plugin repo first, pointer bump in apps separately.
|
||||
|
||||
### Plan 03 — summercms.io site plugin
|
||||
|
||||
**Migrations** — `../sm-faq-plugin/updates/registry.go` (Register/All verbatim) and `202610110001_create_golem15_faq_faqs.go`:
|
||||
```go
|
||||
var createFAQs = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610110001_create_golem15_faq_faqs",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE golem15_faq_faqs ( id SERIAL PRIMARY KEY, ... created_at TIMESTAMPTZ NULL, updated_at TIMESTAMPTZ NULL )`,
|
||||
`CREATE INDEX ... ON golem15_faq_faqs (slug, locale_id)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS golem15_faq_faqs`).Error },
|
||||
},
|
||||
}
|
||||
func init() { Register(createFAQs...) }
|
||||
```
|
||||
Tables `golem15_summercms_registry_plugins`, `golem15_summercms_registry_versions`, IDs `2026103000NN_create_golem15_summercms_registry_*`.
|
||||
|
||||
**Permissions** — `../sm-faq-plugin/admin_permissions.go`:
|
||||
```go
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{
|
||||
Code: controllers.PermissionFAQs,
|
||||
Tab: "golem15.faq::lang.plugin.name",
|
||||
Label: "golem15.faq::lang.permissions.faqs",
|
||||
Roles: []string{"developer", "publisher"},
|
||||
}}
|
||||
}
|
||||
var _ pact.HasPermissions = (*Plugin)(nil)
|
||||
```
|
||||
Codes `golem15.summercms.registry` (admin) and `golem15.summercms.registry_ingest`.
|
||||
|
||||
**Token scope** — `../sm-journal-plugin/token_scopes.go`:
|
||||
```go
|
||||
var _ pact.HasTokenScopes = (*Plugin)(nil)
|
||||
func (p *Plugin) TokenScopes() []pact.TokenScope {
|
||||
return []pact.TokenScope{{
|
||||
Name: "journal", Label: "golem15.journal::lang.scopes.journal",
|
||||
Description: "golem15.journal::lang.scopes.journal_description",
|
||||
Permissions: []string{"golem15.journal.*"}, Methods: all,
|
||||
}, ...}
|
||||
}
|
||||
```
|
||||
New scope `Name: "registry"`, `Permissions: []string{"golem15.summercms.*"}`, `Methods: []string{"POST"}` (locked decision).
|
||||
|
||||
**Ingest auth** — `../sm-journal-plugin/controllers/api/auth.go` lines 83-106:
|
||||
```go
|
||||
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
|
||||
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
|
||||
if err != nil || pr == nil { resolveError(w, err); return r, nil, false }
|
||||
return out, pr, true
|
||||
}
|
||||
func requireAccessPosts(w http.ResponseWriter, pr *bouncer.Principal) bool {
|
||||
if !cabana.Allows(pr, []string{permAccessPosts}) {
|
||||
writeAPIError(w, http.StatusForbidden, errInsufficientPerms); return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**Public routes** — `../sm-faq-plugin/routes.go`:
|
||||
```go
|
||||
const BucketPublicAPI = "faq-public-api"
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_faq/api/v1", surf.Use("throttle:"+BucketPublicAPI), func(g pact.Router) {
|
||||
g.Get("/faqs/{slug}", apicontrollers.Show(p.app))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Use `r.Group("/api/registry/v1", surf.Use("throttle:registry-api", "cors:registry"), ...)`, plus `Cache-Control`; ingest POST in a separate group without public CORS. Add `http.cors_profiles.registry` in `../sm-summercmsio-app/config/http.yaml`.
|
||||
|
||||
**Schedule** — `../sm-chatvideo-plugin/schedule.go`:
|
||||
```go
|
||||
func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
return []pact.ScheduledCommand{
|
||||
{Command: console.ReconcileSessionsName, Cadence: pact.Every(5 * time.Minute)},
|
||||
}
|
||||
}
|
||||
```
|
||||
-> `{Command: console.RegistryIngestName, Cadence: pact.Every(time.Hour)}`; set `queue.work_in_serve: true` in `config/queue.yaml` (F10).
|
||||
|
||||
**Plugin shell** — `../sm-summercmsio-app/plugins/golem15/summercms/plugin.go` currently declares only `pact.HasRoutes`; `Register`/`Boot` return nil and the package doc says "declares no admin controllers, so ... no /backend route exists" — update doc comment, add `var _ pact.HasMigrations/HasModels/HasAdminControllers/HasPermissions/HasTokenScopes/HasSchedule/HasCommands = (*Plugin)(nil)` like faq. Add `/marketplace` to `nodePaths` in `links_test.go:45`.
|
||||
|
||||
**changelog-post.py** — `cmd_publish` l.262-300 already validates tag on origin, then GETs/POSTs via `call(method, path, token)`. Insert (a) before posting, when `kind == "plugins"`: run `summer plugin:manifest --check` in `--repo`; (b) after a successful post: `call("POST", <ingest-now URL>, token)` — note `call` targets the Journal API base, so add a sibling helper for `/api/registry/v1/ingest`. Extend `test_changelog_post.py`.
|
||||
|
||||
**Marketplace pages** — `../sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/changelog/plugins.vue`:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
</script>
|
||||
<template>
|
||||
<ChangelogIndex stream="plugins" />
|
||||
</template>
|
||||
```
|
||||
Same Node SSR app; new `pages/marketplace/index.vue` and `[id].vue` with a component like `ChangelogIndex`; runtimeConfig registry base mirrors `NUXT_JOURNAL_API_BASE` (supervisor conf); nginx `/marketplace` -> :3095 as for `/changelog`.
|
||||
|
||||
### Plan 04 — `plugin:install` / `plugin:update`
|
||||
|
||||
**Analog:** `addPluginCommand` (above) + `build.AddPlugin` (`internal/build/scaffold.go:144`). Prompt: do NOT use `bonfire.ConfirmProceed` (`modules/bonfire/helpers.go:144-149` returns true off a terminal). Use:
|
||||
```go
|
||||
if !yes {
|
||||
if !bonfire.Interactive(out) { return fmt.Errorf("plugin:install: non-interactive run needs --yes") }
|
||||
ok, err := out.Confirm(question, false)
|
||||
...
|
||||
}
|
||||
```
|
||||
Validate registry-provided module paths with `validateModulePath` (`internal/build/manifest.go:270-292`) and versions with `^v\d+\.\d+\.\d+$` before `go get`. go.work overrides via `go work edit -replace=` (F2), invoked like `runGoWorkUse`.
|
||||
|
||||
### Plan 05 — cabana Plugins endpoints
|
||||
|
||||
**Handler analog:** `modules/cabana/http.go:560-568`:
|
||||
```go
|
||||
func (s *service) settingsList(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
...
|
||||
WriteData(w, http.StatusOK, settings, map[string]any{"locale": ...})
|
||||
}
|
||||
```
|
||||
Add permission check for the new key (403 via `WriteError`). Routes go into the `r.GroupRaw(api, []string{"backend", "locale.from-principal"}, ...)` block at `http.go:316-339`: `g.Get("/plugins", s.pluginsInstalled)`, `g.Get("/plugins/marketplace", s.pluginsMarketplace)`.
|
||||
|
||||
**Permission analog:** `modules/cabana/backend_screens.go:16-25, 80-87`:
|
||||
```go
|
||||
PermissionManageUsers = "backend.manage_users"
|
||||
...
|
||||
func (*backendScreens) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{Code: PermissionManageUsers, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_users"},
|
||||
...
|
||||
```
|
||||
Add `PermissionViewPlugins = "backend.view_plugins"` (no Roles; superusers pass all checks). Strings in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`.
|
||||
|
||||
**swag analog:** `modules/cabana/admin_openapi.go:469-479`:
|
||||
```go
|
||||
// AdminSettingsList documents GET /settings.
|
||||
//
|
||||
// @Summary List admin settings
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} Envelope[[]SettingsEntry]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /settings [get]
|
||||
```
|
||||
Then `scripts/check-admin-openapi.sh`; update `phase09_contract_test.go` / `security_coverage_test.go` inventories. Check for in-flight cabana edits from other sessions first.
|
||||
|
||||
### Plan 05 — boardwalk view
|
||||
|
||||
- Router (`admin/src/app/router.ts:70-74`): `{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } }` — add `{ path: '/system/plugins', name: 'plugins', component: PluginsView, meta: { shell: true } }` before the `/:vendor/:plugin/:controller` routes.
|
||||
- Settings link (`admin/src/state/useSettings.ts:30-39`): `settingsPath(entry)` returns `controllerPath(entry.controller)` or `/settings/<code>`; add a leading `if (entry.path) return entry.path` with an additive `Path` field on `cabana.SettingsEntry` (`modules/cabana/navigation.go:23-35`).
|
||||
- Copy button (`admin/src/components/account/ApiTokenCreateDialog.vue:255-268`):
|
||||
```ts
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) { throw new Error('clipboard unavailable') }
|
||||
await navigator.clipboard.writeText(value)
|
||||
markCopied()
|
||||
} catch {
|
||||
copyFailed.value = true
|
||||
...
|
||||
}
|
||||
```
|
||||
- Tabs via `reka-ui`; data via typed `api` client only. Rebuild and commit `modules/boardwalk/dist`, run `scripts/check-admin-dist.sh`.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### CLI command shape
|
||||
**Source:** `cmd/summer/main.go:381-415`. **Apply to:** plugin:manifest, plugin:install, plugin:update. Full docs fields; Run delegates to a package function; `out.Success(...)` at the end.
|
||||
|
||||
### Shelling out to go/git
|
||||
**Source:** `internal/build/new.go:286` `runGit`, `scaffold.go` `runGo` + `applyGoWork` (l.632). **Apply to:** plugindirs, derive, install, ingester (ingester adds hardened env: `GIT_TERMINAL_PROMPT=0`, `GIT_ALLOW_PROTOCOL=https`, args after `--`).
|
||||
|
||||
### YAML
|
||||
`goccy/go-yaml` only (as `internal/build/manifest.go`); struct field order = output order.
|
||||
|
||||
### Plugin capability assertions
|
||||
`var _ pact.HasX = (*Plugin)(nil)` next to each capability method (faq, journal files above).
|
||||
|
||||
### Error envelopes
|
||||
cabana: `WriteError(w, status, code, msg)` / `WriteData(w, status, data, meta)`. Site plugin public API: `{"data":..., "meta":...}` / `{"error":{...}}` per RESEARCH registry shape.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `modules/<beach-name>/version.go` | utility | transform | No semver comparer in tree; write a strict `vX.Y.Z` comparer (~20 lines), strip `-N-gHASH` from git describe |
|
||||
| ingester core (`classes/ingest/*.go` on site plugin) | service | batch (git clone/ls-tree) | No git-cloning service exists; follow RESEARCH "Hardened git for the ingester" and use `breakwater.Render` for README HTML |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/{cmd,internal/build,modules/{cabana,bonfire,fetchguard,party},admin/src}`, `../sm-faq-plugin`, `../sm-journal-plugin`, `../sm-chatvideo-plugin`, `../sm-summercmsio-app/{plugins/golem15/summercms,scripts,vue-summercmsio-app/changelog}`
|
||||
**Files scanned:** ~35
|
||||
**Pattern extraction date:** 2026-10-29
|
||||
708
.planning/phases/34-plugin-marketplace/34-RESEARCH.md
Normal file
708
.planning/phases/34-plugin-marketplace/34-RESEARCH.md
Normal file
@@ -0,0 +1,708 @@
|
||||
# Phase 34: Plugin marketplace - Research
|
||||
|
||||
**Researched:** 2026-10-29
|
||||
**Domain:** Go module/workspace mechanics for compiled plugins, git-based registry ingestion, a public JSON registry API, cabana/boardwalk admin screen, Nuxt SSR pages
|
||||
**Confidence:** HIGH for everything grounded in the code and the local probes below; MEDIUM for proposed shapes (registry JSON, DB schema, module name), which are design recommendations, not facts.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Manifest: plugin.yaml is generated, never hand-edited
|
||||
- **D-01:** `plugin.yaml` is generated by a new command `summer plugin:manifest` run in a plugin repo. `plugin.go` stays as it is (no change to `party.Plugin`, no breaking change to core plugins).
|
||||
- **D-02:** Every field is derived from the repo: `id` and `requires` from `plugin.go` (AST: the literals returned by `ID()` / `Requires()`), module path from `go.mod`, minimum framework version and minimum required-plugin versions from `go.mod`'s `require` lines, `name` and `description` from the README's H1 and its one-sentence summary (the standard module README structure). A plugin whose `ID()`/`Requires()` are not literal returns is an error that names the function.
|
||||
- **D-03:** Optional `--name="…"` / `--description="…"` override the README-derived text. Overrides persist in `plugin.yaml` and survive regeneration until changed or cleared (exact storage is planner's choice, e.g. an `overrides:` block).
|
||||
- **D-04:** `summer plugin:manifest --check` fails when the committed `plugin.yaml` is stale. It runs in each plugin repo's tests (a pact/test helper or `go test`-runnable check) and in the tag/changelog step (`scripts/changelog-post.py publish … --repo`). `plugin.yaml` is committed. — **Reversibility:** costly — the format is read by the registry ingester, `plugin:install` for git URLs, and every public plugin repo; changing it means regenerating all repos and migrating the ingester.
|
||||
- **D-05:** Version constraints are Go-style minimums: `framework: v0.4.0`, `requires: {golem15.translate: v0.2.0}`. No semver ranges (Go's minimal version selection; a major bump changes the module path).
|
||||
|
||||
#### Runtime metadata for the Installed tab
|
||||
- **D-06:** `summer build` reads each plugin's `plugin.yaml` and bakes name and description into `plugins.gen.go`. Plugins without a `plugin.yaml` show only their ID.
|
||||
- **D-07:** Version: module installs use the version from `go.mod`/build info; submodule or local-`replace` plugins get `git describe --tags` run in the plugin directory at build time (e.g. `v0.3.1-4-gabc123`), also baked into `plugins.gen.go`.
|
||||
|
||||
#### Install resolution
|
||||
- **D-08:** `summer plugin:install golem15.journal` picks the newest tag whose minimum framework version the app's `go.mod` already satisfies; `golem15.journal@v0.3.0` pins. If only tags needing a newer framework exist, it fails and names the framework version to move to (it does not bump the framework silently).
|
||||
- **D-09:** Missing required plugins are resolved transitively, the full list of additions is printed, and the user confirms; `--yes` skips the prompt and non-interactive runs require `--yes`. Dependencies install in the same mode as the requested plugin (module, or submodule under `--source`).
|
||||
- **D-10:** `summer plugin:update [id]` is in scope: module installs move to the newest compatible tag (`go get`), submodule installs are checked out at the newest compatible tag. Without an ID it updates all plugins it can resolve.
|
||||
- **D-11:** Git-URL installs are submodule-only in v1; no `--module`. Close the open question in `.planning/research/questions.md` as "not in v1". — **Reversibility:** reversible — adding `--module` later is additive.
|
||||
- **D-12:** A git-URL plugin's own repo is never sent to summercms.io, but its required plugin IDs (e.g. `golem15.translate`) are resolved through the registry like any ID install. `--offline` disables registry calls and then requires the dependencies to be installed already.
|
||||
- Both modes finish with the same registration `plugin:add` does (`summer.yaml`, `go.mod`, `go.work` where applicable), and module-installed plugins must work with the build steps that currently inspect plugin directories (`internal/build/leaf.go`, `internal/build/registry.go`) — resolve their directory from the module cache.
|
||||
|
||||
#### Registry ingestion and API (summercms.io, `golem15.summercms` site plugin)
|
||||
- **D-13:** Pull ingester: given a repo URL it clones anonymously, reads tags, each tag's `plugin.yaml`, and the README. It runs as a River periodic job (around hourly) and on an authenticated "ingest now" endpoint that `changelog-post.py publish --repo` calls. No forge webhooks.
|
||||
- **D-14:** The listing is admin-curated: a summercms.io admin adds a repo URL; the ingester fills the facts. A plugin is published only once it has a tag with a valid `plugin.yaml`; tags without one are skipped.
|
||||
- **D-15:** Every `vX.Y.Z` tag is stored as a version row with that tag's framework and plugin minimums (needed by D-08). README is stored for the latest tag only.
|
||||
- **D-16:** Public read-only JSON API, no auth, open CORS, cache headers: `GET /api/registry/v1/plugins?q=&category=` (list/search), `GET /api/registry/v1/plugins/{id}` (detail incl. README as markdown and rendered HTML), `GET /api/registry/v1/plugins/{id}/versions`. Consumed by the Nuxt marketplace pages, the CLI and cabana. — **Reversibility:** one-way — once released `summer` binaries call `/api/registry/v1`, its shape cannot change without a v2 path.
|
||||
- Presentation fields (category, featured, screenshots, icon, sort order) are edited in the summercms.io admin (from the decision note).
|
||||
|
||||
#### Admin Plugins screen
|
||||
- **D-17:** The SPA talks only to its own backend: cabana gets swag-annotated admin endpoints that call the registry with a timeout and short cache, and compute "update available" in Go against installed versions. No CSP/CORS change in the admin.
|
||||
- **D-18:** Config key `plugins.registry_url` (default `https://summercms.io`); empty disables registry calls, which hides the Marketplace tab and update badges while Installed still works. The CLI reads the same setting (allows a staging or self-hosted registry). Exact key name is planner's choice if it clashes with existing config.
|
||||
- **D-19:** New permission (a `system.plugins.view`-style key in cabana's permission set), granted to superusers by default.
|
||||
|
||||
### Claude's Discretion
|
||||
- Marketplace page layout and visuals on `vue-summercmsio-app` (list, search, category, detail); `/msd-ui-phase 34` can produce a UI contract if wanted.
|
||||
- Storage of D-03 overrides inside `plugin.yaml`.
|
||||
- Registry DB schema, ingester error handling, cache durations, periodic interval.
|
||||
- Whether older tags (before `plugin.yaml` existed) get backfilled; default per D-14 is to skip them.
|
||||
- Exact CLI output/prompt wording and the semantics of `plugin:update` with no ID when some plugins can't be resolved.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- `--module` for git-URL (private) installs. Revisit if submodule-only proves limiting.
|
||||
- Already out of scope per the decision note: paid plugins, licensing, developer accounts, third-party submissions, themes, installing from the admin panel.
|
||||
- Reviewed Todos (not folded): 17 todos matched on keywords only (e.g. gate scripts naming apps, OIDC parity, MFA findings, menu plugin port). None concern the marketplace; none folded.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
The roadmap lists "Requirements: TBD" and no requirement IDs were provided. The five success criteria are the acceptance surface; this table maps them so the planner can treat them as SC-1..SC-5.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | Every public `sm-*-plugin` repo carries a framework-validated `plugin.yaml`; anonymous fetch works | §Manifest generation, §Finding F1 (13 plugins return a const, not a literal), §Plugin repo survey, anonymous `git ls-remote` probe |
|
||||
| SC-2 | summercms.io ingests manifest/README/versions on tag, serves the registry API, renders /marketplace; presentation fields in admin | §Ingester, §Registry API shape, §summercms.io layout, §Marketplace pages |
|
||||
| SC-3 | `plugin:install <id>` (module; `--source` submodule) and `plugin:install <git-url>` (submodule), with requirement checks and `plugin:add`-style registration | §Install resolution, §Findings F2–F5 (go.work replace, effective framework version, `ConfirmProceed`) |
|
||||
| SC-4 | Admin Plugins screen: Installed (versions, updates) + Marketplace (search, copy command) | §Admin screen, §Runtime metadata (D-06/D-07) |
|
||||
| SC-5 | Unit tests in the last plan | §Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
From `summercms.go/CLAUDE.md`, `../CLAUDE.md` and `~/.claude/CLAUDE.md` (same authority as locked decisions):
|
||||
|
||||
- **No new dependency** without a research doc or a phase/decision note naming it (`.planning/research/STACK.md` is the decided set). This research recommends **zero new Go or npm dependencies** (see Standard Stack).
|
||||
- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- Standard library first.
|
||||
- Framework code never names a consuming application (READMEs, docs, examples use `acme`/`blog`). The registry default URL `https://summercms.io` is the framework's own site, not a consuming app, but **code comments and docs must not name `sm-summercmsio-app`**.
|
||||
- A new framework module gets a beach-themed name, a README in the standard structure (H1, one-sentence summary, import line, `**Name:**`, Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing) and a row in the root `README.md` table.
|
||||
- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages **in the same commit**. Every identifier in docs must exist (`summer docs:build --check`, `go test ./cmd/summer -run TestDocsTree`). New commands must pass `TestCommandsDocumented` (description, long help, examples, described args/flags).
|
||||
- Core plugins (user, blog/journal, pages, payment, …) are used by many apps: **no breaking changes** unless asked. Plugin contracts (routes, payloads, tables, migration IDs, config keys) stay stable.
|
||||
- Plugin repos: commit and push the plugin first, then the app's submodule pointer bump as a separate commit. Never stage files inside a submodule from the app repo.
|
||||
- Lean planning: few, large plans; present plan count + one-line scopes and wait for confirmation; **unit tests are the last plan**; earlier plans may carry smoke tests.
|
||||
- State updates through `msd_run query state.*` handlers; g15office sync comment when the phase advances.
|
||||
- No co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path (other sessions work concurrently, often in cabana — check for in-flight edits before touching it).
|
||||
- Don't push, tag or deploy without the user's go-ahead. Release tags only at phase completion, on the default branch (memory). Every pushed framework tag gets a /changelog post; plugin tags get /changelog/plugins posts.
|
||||
- Admin changes: rebuild `modules/boardwalk/dist`, commit it, pass `scripts/check-admin-dist.sh` before any tag (`.planning/notes/2026-10-07-embedded-admin-release-gate.md`).
|
||||
- Keep `go vet ./...` and `go test ./...` green at every commit. Docker-backed tests skip under `-short`; a `TestMain` starting containers must call `flag.Parse()` and check `testing.Short()`.
|
||||
|
||||
## Summary
|
||||
|
||||
The phase spans four repo groups, and most of its risk is in the Go module and workspace mechanics, not in the UI. Probes against the real repos and Go 1.27.2 turned up five facts the planner must design around. They are not visible from CONTEXT.md:
|
||||
|
||||
1. **13 of 22 plugins return a constant, not a literal, from `ID()`**, and two (`sm-pgstripe-plugin/subscriptions`, `sm-paymentgateway-plugin`) use cross-package constants. The existing `pluginIDFromGo` already fails on them (`plugin:add` cannot register `golem15.mfa` today; reproduced). D-02's "literal returns" needs a narrow, static constant resolver. Otherwise most public plugins cannot get a `plugin.yaml` without editing `plugin.go`.
|
||||
2. **A `--source` (submodule) install breaks the app's build**: every public plugin's committed `go.mod` carries `replace git.golem15.com/golem15/summercms => ../summercms.go`, and workspace modules' replaces apply. Reproduced; fixed by a `go.work`-level `replace` (verified).
|
||||
3. **Apps created by `summer new` require the framework at `v0.0.0`** with a local replace, so "the version the app's go.mod satisfies" (D-08) must be the *effective* framework version: the newest tag reachable in the replaced checkout.
|
||||
4. `bonfire.ConfirmProceed` returns **true** when not interactive, the opposite of D-09; `plugin:install` must not use it.
|
||||
5. Module installs work out of the box: `go get` succeeds in workspace mode, non-main-module replaces are ignored, and proxy.golang.org already serves the public plugins. `go list -json` resolves every plugin's directory (workspace, replace or module cache) in one call, so `leaf.go`/`registry.go` need no change and the D-06/D-07 metadata step has one resolver.
|
||||
|
||||
On summercms.io, the site plugin today is routes-only. It gains models, migrations, admin, a scheduled ingest command, a public API with a CORS profile, and an authenticated ingest endpoint. Production nginx denies non-GET outside `/_journal/api/v1/` and 404s `/backend`, and `queue.work_in_serve` is `false`, so the deploy configs change too. The marketplace pages fit the existing changelog Node SSR app (same prefix-routing pattern) better than the static `nuxt generate` landing.
|
||||
|
||||
**Primary recommendation:** put the plugin.yaml spec (parse, validate, generate, check, const-resolving ID/Requires reader) and the registry HTTP client in one new public framework module. The CLI, the build, cabana, plugin repo tests and the summercms.io ingester then share one implementation. Use `go list -json`, `go mod edit -json`, `go get`, `go work edit` and `git` through `os/exec`, as the framework already does, and add no new dependencies.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| `plugin.yaml` generation/check | Dev CLI (`summer`, framework public module) | Plugin repo `go test` | Derived from plugin source; must run inside plugin repos and the publish script |
|
||||
| Metadata baking (D-06/D-07) | Build tool (`internal/build`) | Runtime (`party`/cabana read it) | Only build time sees plugin dirs and git; runtime only reads baked values |
|
||||
| Registry data (plugins, versions, presentation) | API/Backend (summercms.io site plugin, Postgres) | — | Single source of truth for CLI, cabana, Nuxt |
|
||||
| Ingestion (clone, parse tags) | Backend job (conga scheduled command) | Backend HTTP (ingest-now endpoint) | Long-running, network-bound, must not run in browser or CLI |
|
||||
| Install/update resolution | Dev CLI | Registry API (read) | Mutates app files and go.mod/go.work/submodules: local only |
|
||||
| "Update available" computation | API/Backend (cabana, Go) | — | D-17: SPA talks only to its own backend |
|
||||
| Plugins screen UI | Browser (boardwalk SPA) | — | Read-only view + clipboard |
|
||||
| Marketplace pages | Frontend Server (Nuxt SSR, changelog Node app) | API (registry) | SEO-indexable, fresh data without rebuilding the embedded landing |
|
||||
| README → HTML | API/Backend (ingester, `breakwater.Render`) | — | Sanitize once at write time; Nuxt renders stored HTML |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
No new dependency is needed. Everything below is already in the framework `go.mod`, in the decided set, or a standard tool already shelled out to.
|
||||
|
||||
### Core (existing, reused)
|
||||
| Component | Version | Purpose | Source of truth |
|
||||
|-----------|---------|---------|-----------------|
|
||||
| Go toolchain | 1.27 (`go 1.27.0` / `toolchain go1.27.0` lines; local go1.27.2) | `go list -json`, `go mod edit -json`, `go get`, `go work edit` via `os/exec` | `[VERIFIED: internal/build/scaffold.go:57-59]` `goVersionLine = "go 1.27.0"`, `toolchainLine = "toolchain go1.27.0"` |
|
||||
| `go/ast`, `go/parser`, `go/token` | stdlib | Const-resolving `ID()`/`Requires()` reader | already used in `internal/build/scaffold.go`, `registry.go`, `leaf.go` |
|
||||
| `github.com/goccy/go-yaml` | v1.19.2 | Parse and marshal `plugin.yaml` (struct field order = output order) | `[VERIFIED: go.mod]` |
|
||||
| `github.com/yuin/goldmark` | v1.8.6 | README AST walk: H1 text and first paragraph for name/description | `[VERIFIED: go.mod]`; already used by `internal/docsite`, `modules/breakwater` |
|
||||
| `modules/breakwater` (`breakwater.Render`) | in-tree | README markdown → sanitized HTML on summercms.io (GFM, raw HTML stripped, `http/https/mailto` links only, nofollow) | `[VERIFIED: modules/breakwater/breakwater.go:90]` |
|
||||
| `modules/fetchguard` (`fetchguard.NewClient`, `TrustedMode`) | in-tree | Registry HTTP client with size cap, timeout, no redirects; `WithTransport` test seam | `[VERIFIED: modules/fetchguard/client.go:40, policy.go:23-30]` |
|
||||
| `modules/compass` | in-tree | CLI reads `plugins.registry_url` from the app's `config/` + `SUMMER_` env | `[VERIFIED: modules/compass/README.md]` |
|
||||
| `modules/conga` + `pact.HasSchedule` | in-tree (River v0.47.0) | Hourly ingest as a scheduled console command | `[VERIFIED: modules/pact/capabilities.go:166-176]` |
|
||||
| `git` CLI | 2.56.0 locally; server: unverified | Ingester clone/tag reads; submodule installs; `git describe` | shelled out already in `internal/build/new.go:286` (`runGit`) |
|
||||
| swag | v1.16.6 via `go run` | cabana annotations → `admin/openapi/admin.json` → `schema.d.ts` | `[VERIFIED: scripts/check-admin-openapi.sh]` |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff / why not |
|
||||
|------------|-----------|--------------------|
|
||||
| `go mod edit -json` for go.mod parsing | `golang.org/x/mod/modfile` | Not a dependency today (`go mod why -m golang.org/x/mod`: "main module does not need module golang.org/x/mod"); would need a decision note. `go mod edit -json` gives `Module.Path` and `Require[].Path/Version` (verified) with no dependency. |
|
||||
| Strict `vX.Y.Z` comparer (≈20 lines) | `golang.org/x/mod/semver` | Registry versions are strict `vX.Y.Z` (D-15), so a strict comparer is enough. Never feed `git describe` output (`v0.3.1-4-gabc`) into a semver comparer: semver reads it as a pre-release that sorts **before** `v0.3.1`. |
|
||||
| shell `git` in the ingester | `go-git` | New large dependency + decision note. Shell git is the framework's existing pattern; harden it with env (see Security). |
|
||||
| Forge HTTP API (raw files, tags) | — | Ties ingestion to one forge; D-13 locks "clones anonymously". |
|
||||
| Third Nuxt app for /marketplace | Extend the changelog SSR app | A third app means another supervisor program, port and nginx block. Reusing the changelog app adds one nginx prefix and reuses its fetch/swr/purge setup. Planner's discretion. |
|
||||
|
||||
**Installation:** none (no packages added).
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
This phase installs **no external packages** (Go or npm). The audit is not applicable.
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Key Findings (verified in this session)
|
||||
|
||||
### F1. Most plugins return a constant from `ID()`; the current reader fails on them
|
||||
`[VERIFIED: internal/build/scaffold.go:360-392]` `pluginIDFromGo` accepts only `*ast.BasicLit` of kind `token.STRING` in a `return` inside `func (…) ID()`. Probe (copy of that function run against real files):
|
||||
```
|
||||
sm-mfa-plugin/plugin.go => build: sm-mfa-plugin/plugin.go does not return a plugin id
|
||||
sm-journal-plugin/plugin.go => golem15.journal <nil>
|
||||
```
|
||||
Survey of `ID()`/`Requires()` in all sibling plugins:
|
||||
- Literal ID: faq, journal, menu, seo, sitemap, translate, user, summercms site plugin.
|
||||
- `return PluginID` with a same-file `const PluginID = "golem15.<name>"` (`[VERIFIED: grep of plugin.go files]`): chatbots, chatchannels, chat, chatvideo, forum, herald, mfa, oauthserver, paymentgatewaysubscriptions, pgstripe, sociallogin, userfriends.
|
||||
- Cross-package: `sm-paymentgateway-plugin/plugin.go:47` `const PluginID = payments.PluginID` (→ `payments/service.go:23` `const PluginID = "golem15.paymentgateway"`); `sm-pgstripe-plugin/subscriptions/plugin.go:83-85` `return []string{pgstripe.PluginID, pgsub.PluginID}` (one same-module package, one **other module**).
|
||||
- `Requires()` are literal string slices or `nil` everywhere else.
|
||||
|
||||
**Consequence:** D-02's strict "literal returns" would make 13 plugins fail `plugin:manifest`, and `plugin:add`/git-URL install (which "registers like plugin:add") already fails for them.
|
||||
**Recommendation (needs a quick user OK, since D-02 is locked):** a static resolver that still never runs code. It accepts (a) a string literal, (b) an identifier naming a package-level `const` whose value resolves recursively, in any non-test `.go` file of the same package directory, (c) `pkg.Ident` where `pkg`'s import path is in the same module (directory derived from module path) or in a required module (directory from `go list -m -json <module>` `.Dir`). Anything else (function call, var, concatenation with non-consts) is the D-02 error naming the function. Use the same resolver for `pluginIDFromGo` so `plugin:add` and `make:*` start working for const-ID plugins (a fix, not a behaviour break). The alternative, rewriting 13 `plugin.go` files to literal returns, touches core plugins and duplicates the exported `PluginID` constants.
|
||||
|
||||
### F2. `--source` (submodule) installs break the build unless go.work overrides plugin replaces
|
||||
All public plugin repos commit `replace git.golem15.com/golem15/summercms => ../summercms.go` (`[VERIFIED: grep "replace" sm-*-plugin/go.mod]`, e.g. `sm-menu-plugin/go.mod:176`). Several also replace sibling plugins (`sm-pgstripe-plugin/go.mod:184-186`, `sm-chatbots-plugin/go.mod:150-152`). In workspace mode these replaces apply. Probe: app + `go.work use ./plugins/golem15/menu` (clone of v0.1.1):
|
||||
```
|
||||
plugins/golem15/menu/plugin.go:17:2: git.golem15.com/golem15/summercms@v0.4.0 (replaced by ./plugins/golem15/summercms.go): reading plugins/golem15/summercms.go/go.mod: ... no such file or directory
|
||||
```
|
||||
After `go work edit -replace=git.golem15.com/golem15/summercms=git.golem15.com/golem15/summercms@v0.4.0`, `go build` succeeded (rc=0). A go.work replace overrides module replaces of the same path. The demo app already does this by hand (`sm-demo-app/go.work`: `replace git.golem15.com/golem15/summercms => ../summercms.go` plus three plugin replaces with the comment "pgstripe, pgsub, presentations, quizzes and feedback replace these three modules with paths that only exist in their own development layouts").
|
||||
**Rule for the installer:** after adding a submodule plugin to go.work, read its go.mod replaces (`go mod edit -json` → `Replace[]`). For every replaced module path `M` that the workspace does not already replace in go.work, add a go.work replace: to the app's own local replace target when the app's go.mod replaces `M` (framework dev layout), to the workspace dir of `M` when `M` is another installed submodule plugin, otherwise to `M@<version the app's go.mod requires>`. Whenever the app's framework requirement changes (`plugin:update`, update script), this pin must be refreshed. Document it as a pitfall.
|
||||
|
||||
### F3. The "app framework version" must be the effective one
|
||||
`[VERIFIED: internal/build/stubs/new.tmpl:7,9]` `require {{.Framework}} v0.0.0` and `replace {{.Framework}} => {{.Replace}}`. A `summer new` app satisfies no `framework: vX` minimum by its go.mod line. Other apps require real tags (`sm-demo-app/go.mod:23` `git.golem15.com/golem15/summercms v0.5.1`) but may still replace locally.
|
||||
**Algorithm:** `effectiveFramework(app)`. If the app's go.mod (or go.work) replaces the framework with a local directory, run `git -C <dir> describe --tags --abbrev=0 --match 'v[0-9]*'`. That is the newest tag reachable from the checkout's HEAD, so HEAD has at least its features. If that fails (no git, no tags), fall back to the go.mod require version and warn. Otherwise use the go.mod `require` version. Bake the same value into `plugins.gen.go` so cabana compares updates against what was actually built. At runtime `bonfire.BuildVersion().Framework` reports the go.mod require (`v0.0.0` for such apps) `[VERIFIED: modules/bonfire/version.go:31-51]`.
|
||||
|
||||
### F4. `ConfirmProceed` auto-confirms off a terminal
|
||||
`[VERIFIED: modules/bonfire/helpers.go:144-149]`: "Off a terminal, with -n or -q and in bonfire.Call it returns true without asking". D-09 requires the opposite. Use `bonfire.Interactive(out)` (`helpers.go:58`): interactive means `out.Confirm(question, false)`; non-interactive without `--yes` returns an error with a hint naming `--yes`.
|
||||
|
||||
### F5. Module installs and directory resolution work with stock Go
|
||||
- `go get git.golem15.com/golem15/sm-menu-plugin@v0.1.1` inside a go.work workspace succeeded and edited the cwd module's go.mod (`sm-menu-plugin v0.1.1 // indirect`, `summercms v0.4.0 // indirect`). The plugin's own `replace … => ../summercms.go` was ignored, as expected for a non-main module.
|
||||
- `GOPROXY=https://proxy.golang.org,direct` (local default) and `https://proxy.golang.org/git.golem15.com/golem15/sm-menu-plugin/@v/list` returned `v0.1.0 v0.1.1`. The public proxy and sum.golang.org already serve these modules, so users need no GOPRIVATE setup for public plugins.
|
||||
- `go list -e -json=ImportPath,Dir,Module <import paths…>` in `sm-demo-app` (0.37 s for 3 plugins) returned `Dir` and `Module{Path,Version,Replace,Main,Dir}` for a workspace module (`Main: true`), a go.work-replaced module (`Version: v0.1.13`, `Replace.Dir`) and the nested package `…/sm-pgstripe-plugin/subscriptions` (`Dir` = the subdirectory, `Module.Dir` = repo root). `go list -m -json` of a cached module gives `Dir: /home/jin/go/pkg/mod/git.golem15.com/golem15/sm-menu-plugin@v0.1.1`.
|
||||
- `[VERIFIED: internal/build/leaf.go:22-49]` `checkModelsLeaf` skips plugins whose dir is not found via `localModuleDir`/`defaultPluginDir` or is outside the app root. Module-cache plugins are skipped silently, which is correct: the leaf rule is a plugin-author check. **No change is needed in leaf.go.** `registry.go` (`refreshRegistry`) runs only from `make:*` on local plugins; it must never write into the module cache (read-only files).
|
||||
- **Conclusion:** use one helper, `resolvePluginDirs(appDir, manifest)` → `go list -e -json` over all `summer.yaml` import paths (with `applyGoWork`). The metadata step (D-06/D-07) uses it. The helper must tolerate failures (warn, ID-only metadata) so `summer build` never fails because of metadata.
|
||||
|
||||
### F6. summer.yaml `module:` is an import path, and one repo can hold two plugins
|
||||
`[VERIFIED: sm-demo-app/summer.yaml]` `- id: golem15.pgstripesubscriptions` / `module: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions` (no `subscriptions/go.mod`). The `sm-pgstripe-plugin` repo ships two plugins. **plugin.yaml must be per plugin package directory**, not per repo: `subscriptions/plugin.yaml` next to `subscriptions/plugin.go`. Each file carries the go.mod module path (what `go get` takes) and the package import path (what `summer.yaml` lists). The ingester finds them with `git ls-tree -r --name-only <tag>` filtered to `(^|/)plugin.yaml$`. Requires within the same module carry no separate minimum, because they ship in the same release.
|
||||
|
||||
### F7. README H1 is the plugin ID in 13 repos
|
||||
`[VERIFIED: head of each README.md]` H1 is `# golem15.<name>` in faq, journal, menu, mfa, oauthserver, paymentgateway, paymentgatewaysubscriptions, pgstripe, seo, sitemap, sociallogin, translate, user (and `pgstripe/subscriptions/README.md`). It is a human name in chatbots ("Chat bots"), chatchannels, chat, chatvideo, forum, herald, userfriends ("Friends"). The second paragraph is one sentence in all of them.
|
||||
**Recommendation:** when H1 equals the ID, `plugin:manifest` fails with a hint to pass `--name="…"` (persisted per D-03), rather than publishing "golem15.journal" as a display name or inventing a title-case transform. Rollout then needs one `--name` per such repo.
|
||||
|
||||
### F8. Go.mod requirements that cannot be installed as modules
|
||||
`sm-chatbots-plugin/go.mod:6` requires `git.golem15.com/golem15/sm-chat-plugin v0.0.0` (with a local replace). A consumer `go get` would fail on `v0.0.0`. `sm-userfriends-plugin/go.mod:6` uses a pseudo-version (fetchable). `plugin:manifest` should report a plugin requirement at `v0.0.0` as an error ("not a released version"), and the ingester should reject such tags as invalid (D-14). Pseudo-versions: allow, with a warning.
|
||||
|
||||
### F9. Untagged and private repos
|
||||
Anonymous `git ls-remote https://git.golem15.com/golem15/<repo>.git` (this session) succeeded for every sibling plugin repo plus quizzes, presentations, golem and feedback. `sm-summercmsio-plugin` is private, which is fine: it is a site plugin, not listed. Tag counts on origin: chatbots 0, chatchannels 0, chatvideo 0, forum 0, herald 0, userfriends 0, seo 1, quizzes 1, others 2–6. Untagged repos are not published until tagged (D-14). The go-get meta is served: `<meta name="go-import" content="git.golem15.com/golem15/sm-user-plugin git https://git.golem15.com/golem15/sm-user-plugin.git">`.
|
||||
|
||||
### F10. summercms.io deploy constraints
|
||||
- `[VERIFIED: sm-summercmsio-app/deploy/nginx/summercms.io.conf]` `location ^~ /backend { return 404; }`, and `limit_except GET HEAD { deny all; }` on `/`, `/changelog`, `/changelog/`. POST is allowed only under `location ^~ /_journal/api/v1/`. The ingest-now endpoint needs its own nginx location; the summercms.io admin (presentation fields) is reached through the existing SSH tunnel, like `changelog-post.py backfill`.
|
||||
- `[VERIFIED: sm-summercmsio-app/config/queue.yaml]` `work_in_serve: false` ("The site has no jobs, so serve does not start a worker"). The hourly scheduled ingest never runs unless this becomes `true`, or a separate `queue:work` supervisor program is added.
|
||||
- `[VERIFIED: sm-summercmsio-app/deploy/supervisor/summercms-io-changelog.conf]` changelog Node SSR on `NITRO_PORT="3095"`, `NUXT_JOURNAL_API_BASE="http://127.0.0.1:8095/_journal/api/v1"`.
|
||||
- `[VERIFIED: sm-summercmsio-app/plugins/golem15/summercms/links_test.go:45]` `var nodePaths = []string{"/changelog"}`. A landing link to `/marketplace` must be added there, or `TestLandingLinks` will try to resolve it through the binary.
|
||||
- Release builds fail on drift of committed `main.go`/`plugins.gen.go` (`sm-summercmsio-app/scripts/build.sh:160`, `sm-demo-app/scripts/build.sh:82`). The D-06/D-07 changes regenerate both files in every app, so each app needs one regeneration commit after upgrading.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
plugin repo (sm-x-plugin) summercms.io (Go binary + Postgres) Nuxt SSR (changelog app, :3095)
|
||||
───────────────────────── ───────────────────────────────────── ───────────────────────────────
|
||||
plugin.go, go.mod, README.md admin (SSH tunnel): add repo URL, presentation ─┐
|
||||
│ summer plugin:manifest │ │
|
||||
▼ ▼ │
|
||||
plugin.yaml (committed) ── tag vX.Y.Z ──► [ingest] hourly scheduled cmd ◄── POST /api/registry/v1/ingest (g15_ token, scope registry)
|
||||
│ go test: --check │ git ls-remote (changed?) → bare partial clone ▲
|
||||
│ │ per vX.Y.Z tag: ls-tree → plugin.yaml(s) → validate │ changelog-post.py publish --repo
|
||||
│ │ latest tag: README → breakwater.Render │ (after plugin:manifest --check)
|
||||
│ ▼
|
||||
│ registry tables (plugins, versions, presentation, attachments)
|
||||
│ │
|
||||
│ ▼
|
||||
│ GET /api/registry/v1/plugins[?q,category] | /{id} | /{id}/versions (CORS *, Cache-Control)
|
||||
│ │ │ │
|
||||
│ ▼ ▼ ▼
|
||||
│ summer CLI (dev machine) cabana (app backend) /marketplace pages (SSR, swr)
|
||||
│ plugin:install / update GET /plugins (installed + updates)
|
||||
│ │ GET /plugins/marketplace?q=
|
||||
│ ▼ │
|
||||
│ ┌──── ID ──► resolve newest compatible ───┐ ▼
|
||||
│ │ (effective framework, deps) │ boardwalk SPA: Plugins screen
|
||||
│ ▼ ▼ (Installed | Marketplace, copy command)
|
||||
│ module: go get mod@v --source / git URL: clone → submodule add
|
||||
│ │ → go.work use + go.work replace overrides
|
||||
│ └──────────────► summer.yaml + go.mod (+ go.work) ◄─┘
|
||||
│ │
|
||||
└────────── module cache / submodule ─────┤ summer build: go list -json → plugin dirs
|
||||
│ read plugin.yaml, git describe → plugins.gen.go PluginInfo
|
||||
▼
|
||||
app binary: party publishes PluginInfo → cabana Installed tab
|
||||
```
|
||||
|
||||
### Recommended Project Structure (new/changed files)
|
||||
|
||||
```
|
||||
summercms.go/
|
||||
├── modules/<new-module>/ # NEW public module (name: beach-themed, e.g. "kiosk" — planner's choice)
|
||||
│ ├── manifest.go # plugin.yaml types, Parse, Validate, Marshal (goccy), schema version
|
||||
│ ├── derive.go # Derive(dir): go.mod via `go mod edit -json`, README via goldmark, ID/Requires resolver
|
||||
│ ├── resolve_ast.go # const-resolving ID()/Requires() reader (F1)
|
||||
│ ├── check.go # Check(dir) -> stale diff; CheckT(t, dir) helper for plugin repo tests
|
||||
│ ├── client.go # registry client over fetchguard (List, Get, Versions), JSON types
|
||||
│ ├── version.go # strict vX.Y.Z compare, git-describe base-tag parsing
|
||||
│ └── README.md
|
||||
├── internal/build/
|
||||
│ ├── build.go # generatePluginsGen + generateMain: PluginInfo, effective framework version
|
||||
│ ├── plugindirs.go # NEW: go list -json resolver (F5), git describe (D-07)
|
||||
│ ├── install.go # NEW: InstallModule, InstallSource, InstallGitURL, Update; go.work overrides (F2)
|
||||
│ ├── scaffold.go # AddPlugin gains a require-version option; pluginIDFromGo -> shared resolver
|
||||
│ └── manifest.go # unchanged API (SaveManifest drops summer.yaml comments — see Pitfalls)
|
||||
├── cmd/summer/main.go # plugin:manifest, plugin:install, plugin:update commands
|
||||
├── modules/party/ # Info type + publish/lookup of baked metadata (additive)
|
||||
├── modules/cabana/ # plugins.go (handlers), admin_openapi.go annotations, permission + settings entry
|
||||
├── modules/phrasebook/backend/lang/{en,pl}/lang.yaml # strings for screen + permission
|
||||
├── admin/src/views/PluginsView.vue, admin/src/app/router.ts, admin/src/state/useSettings.ts
|
||||
├── admin/openapi/admin.json, admin/src/api/schema.d.ts (generated), modules/boardwalk/dist (rebuilt)
|
||||
└── docs/… # console/scaffolding.md, architecture/go-modules-and-workspaces.md, a new plugins/marketplace page
|
||||
|
||||
sm-summercmsio-app/
|
||||
├── plugins/golem15/summercms/ # site plugin (submodule sm-summercmsio-plugin)
|
||||
│ ├── models/, updates/, controllers/ (admin YAML), console/ (registry:ingest), classes/ingest/, controllers/api/registry.go
|
||||
│ └── token_scopes.go, admin_permissions.go, lang/
|
||||
├── config/queue.yaml (work_in_serve), config/http.yaml (cors_profiles.registry), config/plugins.yaml (optional)
|
||||
├── deploy/nginx/summercms.io.conf (/marketplace → :3095, POST ingest), deploy/supervisor (if separate worker)
|
||||
├── scripts/changelog-post.py (+ test_changelog_post.py)
|
||||
└── vue-summercmsio-app/changelog/app/pages/marketplace/… (+ nuxt.config routeRules, runtimeConfig.registry)
|
||||
```
|
||||
|
||||
### Pattern 1: plugin.yaml format (recommendation; D-04 makes it costly to change)
|
||||
Include a schema version from day one, so the ingester and old `summer` binaries can branch later. Keep the generated text and the overrides separate.
|
||||
|
||||
```yaml
|
||||
# Generated by summer plugin:manifest. Do not edit; rerun the command.
|
||||
schema: 1
|
||||
id: golem15.journal
|
||||
module: git.golem15.com/golem15/sm-journal-plugin
|
||||
package: git.golem15.com/golem15/sm-journal-plugin # import path summer.yaml lists; differs for nested plugins (…/subscriptions)
|
||||
name: Journal
|
||||
description: The compiled journal plugin for SummerCMS applications: translatable posts and categories, tags, a settings singleton, and the tables a host migrate creates for a blog.
|
||||
framework: v0.5.1 # go.mod require of git.golem15.com/golem15/summercms
|
||||
requires:
|
||||
golem15.translate: v0.1.0 # Requires() IDs, minimum from go.mod require of that plugin's module
|
||||
overrides: # D-03: persisted --name/--description; absent when unused
|
||||
name: Journal
|
||||
```
|
||||
- `[ASSUMED]` field names (`schema`, `package`, `overrides`) are recommendations; the D-02/D-05 fields (`id`, module, `name`, `description`, `framework`, `requires`) come from CONTEXT.
|
||||
- Values in the example: `framework: v0.5.1` and `golem15.translate: v0.1.0` are quoted from `[VERIFIED: sm-journal-plugin/go.mod:6,8]` (`git.golem15.com/golem15/sm-translate-plugin v0.1.0`, `git.golem15.com/golem15/summercms v0.5.1`). The ID and Requires come from `[VERIFIED: sm-journal-plugin/plugin.go:44,46]` (`return "golem15.journal"`, `return []string{"golem15.translate"}`). The description is the README's second paragraph. Note that `name: Journal` is a `--name` override, because that README's H1 is `# golem15.journal` (F7).
|
||||
- Emit with `goccy/go-yaml` `Marshal` of an ordered struct (deterministic) plus the header comment. `--check` regenerates and compares bytes.
|
||||
- A requirement ID whose module is not in go.mod (soft dependency) gets an empty minimum (`""` = any). The ID→module mapping comes from resolving each required module's directory (`go list -m -json`) and reading its plugin ID with the F1 resolver.
|
||||
|
||||
### Pattern 2: Const-resolving ID reader (F1)
|
||||
```go
|
||||
// Source: pattern derived from internal/build/scaffold.go:360-392 (pluginIDFromGo), extended.
|
||||
// resolveString evaluates expr to a string constant without running code.
|
||||
func (r *resolver) resolveString(pkgDir string, file *ast.File, expr ast.Expr) (string, error) {
|
||||
switch e := expr.(type) {
|
||||
case *ast.BasicLit: // "golem15.journal"
|
||||
if e.Kind != token.STRING { return "", errNotConst }
|
||||
return strconv.Unquote(e.Value)
|
||||
case *ast.Ident: // PluginID -> package-level const in pkgDir
|
||||
return r.packageConst(pkgDir, e.Name)
|
||||
case *ast.SelectorExpr: // payments.PluginID, pgsub.PluginID
|
||||
imp := importPathFor(file, e.X) // from file.Imports, honouring named imports
|
||||
dir, err := r.dirForImport(imp) // same module: module root + suffix; else `go list -m -json` Dir
|
||||
if err != nil { return "", err }
|
||||
return r.packageConst(dir, e.Sel.Name)
|
||||
}
|
||||
return "", errNotConst // D-02 error names the function: "plugin.go: ID() must return a string literal or constant"
|
||||
}
|
||||
```
|
||||
`Requires()` accepts `nil`, or a `*ast.CompositeLit` of `[]string` whose elements each go through `resolveString`.
|
||||
|
||||
### Pattern 3: Plugin directory resolution (F5) and metadata baking (D-06/D-07)
|
||||
```go
|
||||
// go list -e -json=ImportPath,Dir,Module,Error <every summer.yaml module> in appDir, with applyGoWork(cmd, appDir).
|
||||
type listedPkg struct {
|
||||
ImportPath string
|
||||
Dir string
|
||||
Module *struct {
|
||||
Path, Version, Dir string
|
||||
Main bool
|
||||
Replace *struct{ Path, Dir, Version string }
|
||||
}
|
||||
}
|
||||
```
|
||||
Per plugin:
|
||||
- `plugin.yaml` is read from `Dir`; it is optional.
|
||||
- Version: if `Module.Main` (workspace module) or `Module.Replace` has a local `Dir`, run `git -C <Module.Dir> describe --tags --match 'v[0-9]*'` only when `git -C <Module.Dir> rev-parse --show-toplevel` equals `Module.Dir` (its own repo or submodule). Otherwise it is an in-app plugin with no version. Without `--dirty` (see Pitfall 6). Else (module cache): `Module.Version`.
|
||||
- Source: `module` | `submodule` | `local`.
|
||||
|
||||
Generated additions to `plugins.gen.go` (and one line in `generateMain`). The type lives in `party`; adding it is additive:
|
||||
```go
|
||||
// PluginInfo is the plugin metadata summer build read from plugin.yaml and git.
|
||||
var PluginInfo = []party.Info{
|
||||
{ID: "golem15.journal", Module: "git.golem15.com/golem15/sm-journal-plugin", Name: "…", Description: "…", Version: "v0.2.5", Source: "module"},
|
||||
}
|
||||
// FrameworkVersion is the effective framework version the build used (F3).
|
||||
const FrameworkVersion = "v0.5.3"
|
||||
```
|
||||
`[VERIFIED: internal/build/build.go:125]` the generated main calls `party.Activate(app, PluginIDs)`; add a publish of the info (e.g. `party.Describe(app, PluginInfo, FrameworkVersion)`) before it. `[ASSUMED]` names `party.Info`/`party.Describe`.
|
||||
|
||||
### Pattern 4: Install resolution (D-08/D-09/D-12)
|
||||
```
|
||||
install(arg, flags):
|
||||
app := FindAppDir; m := LoadManifest; fw := effectiveFramework(app) // F3
|
||||
if arg is git URL (https://, ssh://, scp-like user@host:path; never starting with "-"):
|
||||
tmp := git clone -- <url> plugins/.summer-install-<rand> // GIT_ALLOW_PROTOCOL=https:ssh
|
||||
pick tag: newest vX.Y.Z whose `git show <tag>:<path>/plugin.yaml` framework <= fw; else default-branch HEAD with a warning
|
||||
checkout tag; read plugin.yaml(s); validate; target := plugins/<vendor>/<name>; rename tmp -> target
|
||||
deps := plugin.yaml requires (resolved via registry unless --offline; --offline: must already be in summer.yaml)
|
||||
else (ID[@vX.Y.Z]):
|
||||
if registry disabled or --offline: error with hint
|
||||
versions := GET /plugins/{id}/versions; choose pinned or newest with framework <= fw
|
||||
none compatible: error "golem15.x vA.B.C needs framework >= vF; update the framework (current vE)"
|
||||
deps := chosen.requires
|
||||
closure: for each dep not in summer.yaml -> choose newest compatible with framework AND >= dep minimum;
|
||||
dep already installed -> check installed version >= minimum, else error naming `summer plugin:update <dep>`
|
||||
print additions (id@version, mode); if !--yes: Interactive? Confirm : error "non-interactive run needs --yes" (F4)
|
||||
apply:
|
||||
module mode: one `go get mod1@v1 mod2@v2 …` (applyGoWork; MVS sees the full set), then append summer.yaml entries
|
||||
source mode: per plugin: git clone/checkout tag -> `git submodule add <url> <path>` (adopts the clone) ->
|
||||
`git submodule absorbgitdirs` -> AddPlugin-equivalent with require version = tag -> go.work replace overrides (F2)
|
||||
finish: print "run summer build"
|
||||
```
|
||||
- `git submodule add` adopts an existing clone ("Adding existing repo at 'plugins/golem15/menu' to the index"). `git submodule absorbgitdirs` then moved `.git` into `.git/modules/…`, and `git submodule status` shows `86564746… plugins/golem15/menu (v0.1.1)` (verified in scratch).
|
||||
- Not a git repo (`summer new --no-git`): `git submodule add` fails. Use a plain clone and a warning, or error with a hint; planner's choice.
|
||||
- `AddPlugin` today writes `-require=<mod>@v0.0.0` and a go.mod `replace` (`[VERIFIED: internal/build/scaffold.go:394-406]`). For registry `--source` installs, pass the real tag as the require version, so a `GOWORK=off` release build (`sm-demo-app/scripts/build.sh` uses `GOWORK=off … go build`) and pin checks match. Keep `plugin:add`'s `v0.0.0` default unchanged.
|
||||
- `plugin:update [id]`: modules use `go get mod@newest-compatible`; submodules use `git -C dir fetch --tags`, `git checkout <tag>` and `go mod edit -require=mod@tag`. Without an ID: every summer.yaml plugin that resolves (registry ID, or a git-URL submodule via its own remote's tags). Report the skipped ones and exit 0 unless one that resolved failed (discretion).
|
||||
|
||||
### Pattern 5: cabana Plugins endpoints (D-17/D-19)
|
||||
- Routes inside the existing `r.GroupRaw(api, []string{"backend", "locale.from-principal"}, …)` block (`[VERIFIED: modules/cabana/http.go:316-339]`): `g.Get("/plugins", s.pluginsInstalled)` and `g.Get("/plugins/marketplace", s.pluginsMarketplace)`. Both are two-segment literal paths, so they do not collide with `/{vendor}/{plugin}/{controller}` (three segments) or `/settings/{code}`.
|
||||
- Permission: existing framework permissions use the `backend.*` namespace: `[VERIFIED: modules/cabana/backend_screens.go:18,21]` `PermissionManageUsers = "backend.manage_users"`, `PermissionManageRoles = "backend.manage_roles"`, declared with `Tab: "backend::lang.permissions.tab_system"` (`backend_screens.go:84-85`), no `Roles`. Recommend `backend.view_plugins` (D-19 allows the exact key) declared in `backendScreens.Permissions()`. Superusers pass every check (`modules/cabana/permissions_test.go:31` superuser case allows `a.b.c`), so "granted to superusers by default" needs no code.
|
||||
- Settings link: `pact.SettingsItem` link entries only target an admin **controller** (`[VERIFIED: modules/pact/capabilities.go:881-898]`), and the SPA maps `entry.controller` to a list route (`admin/src/state/useSettings.ts:34-39`). A custom view needs an additive field, e.g. `Path string` on `cabana.SettingsEntry` (`modules/cabana/navigation.go:23-35`) set by cabana's own entry, with the SPA's `settingsPath` preferring `entry.path`. Do not add it to `pact` unless plugins need it.
|
||||
- Registry calls: `fetchguard.NewClient(fetchguard.Policy{Mode: fetchguard.TrustedMode, MaxBytes: …}, app.Config)` (no redirects, so `plugins.registry_url` must be the final origin). Timeout of about 3–5 s, in-process TTL cache (list/search about 5 min, per-ID versions about 15 min), concurrency limit of about 4 for per-installed-plugin version lookups. Registry errors give HTTP 200 with `registry: {status: "unavailable"}` so the Installed tab still renders.
|
||||
- Update rule: `latest := newest version with framework <= FrameworkVersion`; compare it with the installed base tag (strip `-N-gHASH` from `git describe`); `update_available = latest > base`.
|
||||
- swag annotations in `admin_openapi.go`, then `scripts/check-admin-openapi.sh`; update `TestPhase09PermissionMatrix`/`TestPhase09ContractInventory` (`modules/cabana/phase09_contract_test.go`, `security_coverage_test.go`), which fail when routes and annotations diverge.
|
||||
|
||||
### Pattern 6: boardwalk view
|
||||
- New route in `admin/src/app/router.ts`, e.g. `{ path: '/system/plugins', name: 'plugins', component: PluginsView, meta: { shell: true } }`, placed before the `/:vendor/:plugin/:controller` patterns. A two-segment path cannot collide with them anyway.
|
||||
- Tabs: `reka-ui` is already a dependency (`admin/package.json` `"reka-ui": "2.9.10"`), so use its Tabs primitives. The copy button uses the existing pattern in `admin/src/components/account/ApiTokenCreateDialog.vue:260-263` (`navigator.clipboard.writeText`, with a fallback when `navigator.clipboard` is missing).
|
||||
- Data through the typed `api` client (`admin/src/api/client.ts`) only. Render registry text as text; do not render README HTML in the admin in v1.
|
||||
- Strings in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`. Then `npm --prefix admin run build`, commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`.
|
||||
|
||||
### Pattern 7: summercms.io registry (site plugin `golem15.summercms`)
|
||||
- Follow `sm-faq-plugin` as the template: `updates/registry.go` `Register`/`All`, raw-SQL `CREATE TABLE` migrations with IDs like `202610110001_create_golem15_faq_faqs`, `pact.HasMigrations`/`HasModels`/`HasAdminControllers`/`HasPermissions`/`HasNavigation`, embedded admin YAML (`[VERIFIED: sm-faq-plugin/updates/*.go, admin*.go]`). Tables: `golem15_summercms_registry_plugins`, `golem15_summercms_registry_versions` (schema is discretion, see below).
|
||||
- Ingest auth mirrors the Journal API: `cabana.ResolveBackend(app, r, "golem15.summercms.*")` + `cabana.Allows(pr, []string{"golem15.summercms.registry_ingest"})` (`[VERIFIED: sm-journal-plugin/controllers/api/auth.go:83-104]`), plus a `pact.HasTokenScopes` scope (`sm-journal-plugin/token_scopes.go` pattern) such as `registry`, with `Permissions: []string{"golem15.summercms.*"}` and `Methods: ["POST"]`.
|
||||
- Scheduled ingest: a console command `summercms:registry-ingest [id]` + `Schedule() []pact.ScheduledCommand{{Command: "summercms:registry-ingest", Cadence: pact.Every(time.Hour)}}` (README example shape `[VERIFIED: modules/conga/README.md:119-125]`). Requires `queue.work_in_serve: true` (F10).
|
||||
- Public API: `r.Group("/api/registry/v1", surf.Use("throttle:<bucket>", "cors:registry"), …)` with a `http.cors_profiles.registry` profile (`allowed_origins: ['*']`, GET/HEAD, no credentials) per `[VERIFIED: modules/surf/README.md:31]` named CORS profiles. Set `Cache-Control: public, max-age=300` (discretion) and an ETag. The site plugin's `GET /` catch-all is less specific, so `/api/registry/v1/…` wins.
|
||||
|
||||
### Proposed registry API shape (D-16 is one-way: decide carefully)
|
||||
`[ASSUMED]`: this is a design proposal for user/planner sign-off, not an existing contract.
|
||||
```jsonc
|
||||
// GET /api/registry/v1/plugins?q=journal&category=content
|
||||
{ "data": [ { "id": "golem15.journal", "name": "Journal", "description": "…", "module": "git.golem15.com/golem15/sm-journal-plugin",
|
||||
"package": "git.golem15.com/golem15/sm-journal-plugin", "repository": "https://git.golem15.com/golem15/sm-journal-plugin.git",
|
||||
"category": "content", "featured": true, "icon_url": "https://summercms.io/storage/uploads/…",
|
||||
"latest_version": "v0.2.5", "latest_framework": "v0.5.1", "updated_at": "2026-10-29T10:00:00Z" } ],
|
||||
"meta": { "total": 1 } }
|
||||
// GET /api/registry/v1/plugins/{id} -> { "data": { …list fields, "readme_markdown": "…", "readme_html": "…", "screenshots": [{"url","caption"}] } }
|
||||
// GET /api/registry/v1/plugins/{id}/versions -> { "data": [ { "version": "v0.2.5", "framework": "v0.5.1",
|
||||
// "requires": { "golem15.translate": "v0.1.0" }, "module": "…", "package": "…", "tagged_at": "…" } ] } // newest first
|
||||
```
|
||||
Forward-compatibility rules: always use an envelope object (`data`, `meta`); clients ignore unknown fields; only additive changes within v1; 404 → `{"error": {...}}` for unknown IDs. Per-version `module`/`package` let a later major version change the module path (`/v2`).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Running plugin code to read its ID** (`go run` a probe that blank-imports the plugin): it executes init() of untrusted code and contradicts D-02's static intent.
|
||||
- **Writing into the module cache** (e.g. `refreshRegistry` on a cached plugin): the files are read-only and shared.
|
||||
- **`bonfire.ConfirmProceed` for the install prompt** (F4).
|
||||
- **Comparing `git describe` strings with semver**: a pre-release sorts below its base tag.
|
||||
- **Calling the registry from the SPA directly**: violates D-17 and CSP.
|
||||
- **Prerendering /marketplace into the embedded landing**: new plugins would need a binary release to appear.
|
||||
- **Calling `go get` with registry-provided strings without validation**: validate the module path (`validateModulePath`, which rejects a leading `-`, `[VERIFIED: internal/build/manifest.go:270-292]`) and the version (`^v\d+\.\d+\.\d+$`) first.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| go.mod parsing (requires, replaces) | Extending the line scanner `parseGoMod` (`scaffold.go:494-535`) to handle `require (` blocks | `go mod edit -json` | Verified structured output; handles every go.mod syntax form |
|
||||
| Locating plugin source dirs | Guessing `plugins/<vendor>/<name>` or `$GOMODCACHE/<path>@<ver>` (case-encoding of uppercase letters!) | `go list -e -json` / `go list -m -json` | Handles workspace, replace, nested packages, module-cache path escaping |
|
||||
| Adding go.work entries | Text edits of go.work | `go work use` (already `runGoWorkUse`), `go work edit -replace=` | Verified override behaviour |
|
||||
| Markdown → HTML for READMEs | goldmark + a custom policy | `breakwater.Render` | Existing decided policy (GFM, safe, nofollow) |
|
||||
| Outbound HTTP to the registry | `http.DefaultClient` | `fetchguard.Client` (TrustedMode) | Size caps, timeouts, no redirects, test transport seam |
|
||||
| Submodule plumbing | Hand-writing `.gitmodules` | `git submodule add` + `absorbgitdirs` | Verified adoption of an existing clone |
|
||||
| YAML writing | `fmt` with manual quoting (descriptions contain `:` and backticks) | `goccy/go-yaml` `Marshal` | Correct quoting; deterministic field order |
|
||||
| Rate limiting/CORS on the public API | Custom middleware | `throttle:` + `cors:<profile>` surf middleware | Built-in and tested |
|
||||
|
||||
**Key insight:** every hard part (module resolution, MVS, workspace overrides, quoting, sanitizing) already exists either in the Go toolchain or in a framework module. The phase is integration work. Custom parsers in this area break on the first unusual go.mod or README.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: const IDs (F1)
|
||||
**What goes wrong:** `plugin:manifest` and git-URL installs fail for 13 public plugins.
|
||||
**How to avoid:** the static const resolver, shared with `pluginIDFromGo`.
|
||||
**Warning signs:** "does not return a plugin id" on `sm-mfa-plugin`.
|
||||
|
||||
### Pitfall 2: submodule installs inherit dev replaces (F2)
|
||||
**What goes wrong:** `go build` fails with "replaced by ./plugins/golem15/summercms.go … no such file".
|
||||
**How to avoid:** go.work replace overrides for every local replace in an installed submodule's go.mod; refresh the framework pin when the framework requirement moves.
|
||||
|
||||
### Pitfall 3: `v0.0.0` framework requirement in `summer new` apps (F3)
|
||||
**What goes wrong:** every install reports "needs a newer framework", or the version check silently passes against a meaningless number.
|
||||
**How to avoid:** `effectiveFramework` via `git describe --abbrev=0` of the replaced checkout. Bake it for cabana.
|
||||
|
||||
### Pitfall 4: summer.yaml comments are lost
|
||||
`[VERIFIED: internal/build/manifest.go:146-181]` `SaveManifest` rewrites the file from `module`, `binary`, `plugins`, `build.tags`. `sm-demo-app/summer.yaml` and `sm-bm-app/summer.yaml` carry comments ("# summercms_mcp links the MCP endpoint …") that `plugin:add`/`plugin:install` would delete. Either append plugin entries with a comment-preserving edit (goccy AST, or text insertion after the last `plugins:` item), or document it. Prefer preserving: the CLI is meant for real apps.
|
||||
|
||||
### Pitfall 5: `go get` marks new plugins `// indirect`
|
||||
Until `plugins.gen.go` imports them, the go.mod entry is `// indirect` (verified). It is harmless for builds. `plugin:install` could run `summer build` (regenerates imports) and then `go mod tidy`, or just tell the user to run `summer build`. Do not run `go mod tidy` before plugins.gen.go imports the module: tidy would remove it.
|
||||
|
||||
### Pitfall 6: `git describe` churn in committed `plugins.gen.go`
|
||||
Apps commit `plugins.gen.go`, and release builds refuse drift (F10). Baking `git describe` output changes the file whenever a plugin submodule moves. That is acceptable because submodule bumps are commits that should include the regenerated file. Never use `--dirty`: an uncommitted change inside a submodule would make `summer build` rewrite a tracked file and break release builds. Give an in-app plugin (not its own repo) an empty version, not the app's describe.
|
||||
|
||||
### Pitfall 7: proxy/sumdb immutability
|
||||
proxy.golang.org caches every fetched version forever. A moved or retagged plugin tag gives users a checksum mismatch. Plugin tags must never be moved. Add this to the release docs.
|
||||
|
||||
### Pitfall 8: framework-version inflation from the `--check` test helper
|
||||
If plugin repos import a new framework helper in `go test`, their go.mod must require the framework release that contains it. By D-02 that becomes their `framework:` minimum (Go's MVS would enforce it anyway). See Open Question 2.
|
||||
|
||||
### Pitfall 9: summercms.io jobs never run
|
||||
`queue.work_in_serve: false` (F10). Without flipping it, or adding a `queue:work` supervisor program, the hourly ingest does nothing in production while every local test passes.
|
||||
|
||||
### Pitfall 10: nginx blocks the ingest POST and the admin
|
||||
Add `location = /api/registry/v1/ingest { limit_except POST { deny all; } proxy_pass http://127.0.0.1:8095; … }`. Keep the presentation admin behind the SSH tunnel. `scripts/check-deploy.sh` validates nginx/supervisor syntax.
|
||||
|
||||
### Pitfall 11: CORS preflight
|
||||
nginx `limit_except GET HEAD` denies OPTIONS. Simple GETs without custom headers need no preflight; keep the registry API GET-only and header-free for browsers.
|
||||
|
||||
### Pitfall 12: concurrent sessions in cabana and internal/build
|
||||
Phases 20 and 32.1 are in flight. Check `git status`/`git log -- modules/cabana` before editing, and stage explicit paths.
|
||||
|
||||
### Pitfall 13: ingestion trusts plugin.yaml identity
|
||||
A repo could claim another plugin's ID or a module path that is not its own. At ingest: (a) ID unique across listed repos (first-listed wins; conflict is an error on the newer listing); (b) `module` must equal the repo URL's host+path without `.git` (core-plugins rule: module path = repo path), so `go get <module>` fetches the same code the listing shows.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Hardened git for the ingester (stdlib exec)
|
||||
```go
|
||||
// Source: env semantics from https://git-scm.com/docs/git (GIT_ALLOW_PROTOCOL, GIT_TERMINAL_PROMPT,
|
||||
// GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL), pattern of internal/build/new.go runGit.
|
||||
func gitCmd(ctx context.Context, dir string, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(minimalEnv(), // PATH, HOME=<tmp>, no inherited GIT_*
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_ALLOW_PROTOCOL=https", // ingester: https only (tests may add "file")
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
// clone: git clone --bare --filter=blob:none --no-tags? (keep tags) -- <url> <tmp> (fallback without --filter if the server refuses)
|
||||
// tags: git for-each-ref --format=%(refname:short)%09%(objectname)%09%(creatordate:iso-strict) refs/tags
|
||||
// files: git ls-tree -r --name-only <tag> ; git show <tag>:<path>/plugin.yaml ; git show <latest>:<path>/README.md
|
||||
// cheap change check before cloning: git ls-remote --tags -- <url>
|
||||
```
|
||||
Never check out the tree, never `--recurse-submodules`, cap blob sizes read (`io.LimitReader`), and remove the temp dir. Always put `--` before URLs and paths.
|
||||
|
||||
### CLI git (user's own credentials, private SSH allowed)
|
||||
Same pattern but keep the user's global config (credential helpers, `insteadOf`), set `GIT_TERMINAL_PROMPT=0` only when not interactive, and `GIT_ALLOW_PROTOCOL=https:ssh` (test seam for `file`).
|
||||
|
||||
### Effective framework version (F3)
|
||||
```go
|
||||
// app go.mod: go mod edit -json -> Require[] (summercms version), Replace[] (local dir?)
|
||||
if dir := localFrameworkReplace(app); dir != "" {
|
||||
out, err := exec.CommandContext(ctx, "git", "-C", dir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*").Output()
|
||||
if err == nil { return strings.TrimSpace(string(out)), nil }
|
||||
}
|
||||
return requireVersion(app, "git.golem15.com/golem15/summercms"), nil
|
||||
```
|
||||
|
||||
### changelog-post.py hook (D-04, D-13)
|
||||
- Before posting a plugin release: require that `git -C REPO describe --exact-match HEAD` equals the tag and the worktree is clean, then run `summer plugin:manifest --check <dir>`, using `summer` from PATH or else `go -C FRAMEWORK run ./cmd/summer plugin:manifest --check <abs REPO dir>` (so the command needs an optional `[dir]` argument). Do the same for nested plugin dirs (any `plugin.yaml` in the repo).
|
||||
- After a successful publish: `POST {SITE}/api/registry/v1/ingest` with `{"repository": <origin https URL>}` and the same token. A 403 (token lacks the new scope) or a network failure prints a warning ("the hourly ingest will pick it up") and does not fail the publish. Unit-test the new pure helpers in `scripts/test_changelog_post.py` (stdlib unittest; `call` is replaced with a failing stub there).
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Plugins only as local dirs + `replace` (`plugin:add`) | Plugins as plain Go module requires, resolved by `go list` | This phase | Build steps must stop assuming `plugins/<vendor>/<name>` exists |
|
||||
| `go get` refused in workspace mode (old Go) | `go get` works in workspace mode, edits cwd module | Verified on Go 1.27.2 | No `GOWORK=off` dance needed for module installs |
|
||||
| README H1 = plugin ID (13 repos) | Human name via `--name` override | This phase | One-time `--name` per repo during rollout |
|
||||
|
||||
**Deprecated/outdated:** the `.planning/notes/core-plugins-own-repos.md` statement that plugin go.mod replaces the framework with `../../../../summercms.go` is outdated. The repos use `../summercms.go` (sibling layout) `[VERIFIED: sm-*-plugin/go.mod]`.
|
||||
|
||||
## Plan Decomposition (recommendation for the planner)
|
||||
|
||||
Repo/dependency order, tracer-first, few large plans, tests last (project rule):
|
||||
|
||||
1. **Manifest foundation + build metadata (summercms.go):** the new public module (plugin.yaml types, const-resolving reader, derive from go.mod/README, marshal, check, test helper), `summer plugin:manifest [--check] [--name] [--description] [dir]`, `pluginIDFromGo` switched to the resolver, `go list` plugin-dir resolver, D-06/D-07 baking + effective framework version, `party.Info`. Module README, root table row, docs. Smoke: generate plugin.yaml for journal (literal), mfa (const) and pgstripe/subscriptions (cross-module const).
|
||||
2. **Plugin repo rollout (each public `sm-*-plugin`):** generate and commit `plugin.yaml` (`--name` where H1 = ID), add the check test, fix `v0.0.0` plugin requirements where they block (F8), app pointer bumps as separate commits. Tags remain **user-gated**, and the framework tag that contains the helper must exist before plugin tags whose go.mod requires it (Open Question 2). This plan is mostly mechanical; it could be merged into plan 1 if the user prefers fewer plans.
|
||||
3. **Registry on summercms.io (sm-summercmsio-app, site plugin, vue app):** models/migrations, admin (repos + presentation, attachments), ingester service + scheduled command + ingest endpoint + token scope, public API (CORS profile, cache), config (`work_in_serve`, CORS profile), nginx/supervisor, `/marketplace` pages in the changelog SSR app, landing header link + `nodePaths`, changelog-post.py hooks. Smoke: ingest a local fixture repo, `curl` the API.
|
||||
4. **Install/update CLI (summercms.go):** registry client usage, `plugin:install` (ID module / `--source` / git URL), `plugin:update`, `plugins.registry_url` config read, `--yes`/`--offline`, go.work overrides (F2), summer.yaml comment preservation, docs. Smoke against an `httptest` registry.
|
||||
5. **Admin Plugins screen (summercms.go):** cabana endpoints + permission + settings link + swag/openapi regen + contract tests update + PluginsView (tabs, copy) + lang + dist rebuild + gate.
|
||||
6. **Unit tests (all repos):** see the Validation Architecture.
|
||||
|
||||
Plans 4 and 5 both depend only on plan 1 and the API shape (fixtures), not on plan 3 being deployed. They can follow plan 1 in either order; plan 5 touches cabana, where other sessions are active.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Extending D-02 to resolve package-level and cross-package string constants is acceptable to the user | F1, Pattern 2 | If refused, 13 core plugins need `plugin.go` edits (literal returns) |
|
||||
| A2 | plugin.yaml field names `schema`, `package`, `overrides` and one file per plugin package dir | Pattern 1, F6 | Format is costly to change after rollout (D-04) |
|
||||
| A3 | Registry JSON shape in "Proposed registry API shape" | Pattern 7 | One-way after release (D-16) |
|
||||
| A4 | Marketplace pages live in the existing changelog Node SSR app, URL `/marketplace/<vendor>/<name>` | Architecture, Alternatives | A separate app/URL scheme changes nginx and supervisor work |
|
||||
| A5 | `git` is installed on the production server (rome) for the ingester | Environment | Ingest fails in production; needs install or a go-git decision note |
|
||||
| A6 | Permission key `backend.view_plugins` and SPA path `/system/plugins` | Pattern 5/6 | Cosmetic; D-19 left the exact key open |
|
||||
| A7 | Ingest endpoint uses a new token scope (`registry`); the publish token may lack it, so ingest-now is best-effort | Pattern 7, changelog hook | User must mint a token with the new scope for instant ingestion (never done by the agent) |
|
||||
| A8 | `party.Info` / `party.Describe` names and placement in `party` | Pattern 3 | Naming only |
|
||||
| A9 | Which repos are "public plugins" to list (quizzes, presentations, golem, feedback exist publicly but have no sibling checkout; six chat-family repos are untagged) | F9 | Wrong listing scope |
|
||||
| A10 | Proposed new module name (e.g. `kiosk`) | Structure | Naming only |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All six were answered by the user during /msd-plan-phase 34 on 2026-10-29.
|
||||
|
||||
1. **Constant IDs vs D-02's "literal returns" (A1).**
|
||||
- What we know: 13/22 plugins return `PluginID`; two use cross-package constants; `plugin:add` already fails on them.
|
||||
- **RESOLVED:** follow constants (same-package and cross-package); `plugin:add` uses the same resolver. No `plugin.go` edits.
|
||||
2. **How the plugin repos' `--check` runs in `go test` without inflating framework minimums.**
|
||||
- Options: (a) import a framework helper. Each plugin's go.mod then requires the release containing it, which becomes its `framework:` minimum (likely the phase's release tag), and plugin tags wait for that framework tag. (b) The test shells out to `summer` from PATH or `go run git.golem15.com/golem15/summercms/cmd/summer@<pinned>` (the framework go.mod has no replace, so `go run pkg@version` works) and skips under `-short` or when unavailable. No go.mod change, but weaker and network-bound.
|
||||
- **RESOLVED:** (a) framework test helper; plugins require the phase's framework release and their tags wait for it.
|
||||
3. **Which repos are listed at launch (A9)**, and whether untagged repos get a first tag in this phase (tags are user-gated).
|
||||
- **RESOLVED:** only tagged public plugins checked out next to the framework; no new tags in this phase.
|
||||
4. **Ingest token scope (A7)**: new `registry` scope (user mints a token), or accept the existing `journal`-scoped token for the ingest endpoint (lower friction, scope semantics blur).
|
||||
- **RESOLVED:** new `registry` scope; the user mints the token, ingest-now is best-effort until then.
|
||||
5. **`summer.yaml` comment preservation**: implement now (recommended) or document the loss.
|
||||
- **RESOLVED:** preserve comments.
|
||||
6. **plugin:install in a non-git app directory** (`summer new --no-git`) with `--source`: clone without a submodule, or refuse.
|
||||
- **RESOLVED:** refuse with a clear message.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go toolchain | everything Go; `go list/get/mod edit/work edit` | ✓ | go1.27.2 | — |
|
||||
| git | submodule installs, `git describe`, ingester, tests with fixture repos | ✓ (local) | 2.56.0 | — |
|
||||
| git on rome (production) | ingester | unknown | — | install git, or a go-git decision note (A5) |
|
||||
| Network to git.golem15.com, proxy.golang.org | install integration checks, rollout | ✓ | anonymous HTTPS verified | `GOPROXY=file://` fixture for tests |
|
||||
| Docker | testcontainers Postgres (registry DB tests, cabana tests) | ✓ (`docker ps` ok in this session) | client 29.9.1 | `sudo -u golem` if the socket is denied; `-short` skips |
|
||||
| Node / npm | admin SPA build/tests | ✓ | node v24.22.0, npm 12.2.1 | — |
|
||||
| pnpm | vue-summercmsio-app | ✓ | 12.14.0 | — |
|
||||
| Python 3 | changelog-post.py tests | ✓ | 3.14.8 | — |
|
||||
| swag | openapi gate | ✓ via `go run …@v1.16.6` (network) | v1.16.6 | — |
|
||||
|
||||
**Missing dependencies with no fallback:** none locally. Production `git` is unverified (A5): plan a deploy-time check (`command -v git` in DEPLOY.md / smoke) and fail ingestion with a clear error if `exec.LookPath("git")` fails.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testcontainers-go v0.44.0 for Postgres); Vitest 3.2.7 (+ browser mode) for admin; `node --test` for vue-summercmsio-app; stdlib `unittest` for scripts |
|
||||
| Config file | none for Go; `admin/vitest.config.ts`, `admin/vitest.browser.config.ts` |
|
||||
| Quick run command | `go test -short ./internal/build ./cmd/summer ./modules/<new-module> ./modules/party ./modules/cabana` |
|
||||
| Full suite command | `go vet ./... && go test ./... && npm --prefix admin run test && npm --prefix admin run typecheck && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && summer docs:build --check` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | Derive: literal, same-pkg const, cross-pkg const (same module + other module), non-const → error naming function | unit (temp dirs, `go mod edit -json`; fixture module tree) | `go test -short ./modules/<new> -run TestDerive` | ❌ Wave 0 |
|
||||
| SC-1 | README H1/summary extraction; H1 == ID → `--name` hint; overrides persist across regeneration | unit | `go test -short ./modules/<new> -run TestReadme` | ❌ |
|
||||
| SC-1 | `--check` detects stale file; byte-stable output | unit | `go test -short ./modules/<new> -run TestCheck` | ❌ |
|
||||
| SC-1 | Validate rejects bad ID, bad module, `v0.0.0` plugin requirement, unknown schema | unit | `go test -short ./modules/<new> -run TestValidate` | ❌ |
|
||||
| SC-1 | `plugin:add` registers a const-ID plugin (regression for F1) | unit (temp app copy, like `TestMakeAndAddPluginOnHelloCopy`) | `go test -short ./internal/build -run TestAddPluginConstID` | ❌ |
|
||||
| SC-1 | Each plugin repo's own check | plugin repo test | `go -C ../sm-<name>-plugin test -run TestPluginManifest ./...` | ❌ per repo |
|
||||
| D-06/07 | plugins.gen.go includes PluginInfo/FrameworkVersion; go list resolution for workspace/replace/module-cache/nested; git describe only when own repo; no `--dirty` | unit (golden strings) + fixture git repo | `go test -short ./internal/build -run 'TestGeneratePluginInfo|TestPluginDirs|TestDescribe'` | ❌ |
|
||||
| SC-2 | Ingester: fixture bare repo (git init, tags with/without plugin.yaml, nested plugin) via `file://` (test-only protocol) → version rows, latest README, skips invalid tags, module/repo mismatch rejected, ID conflict rejected | integration (Docker Postgres) | `go -C ../sm-summercmsio-app/plugins/golem15/summercms test -run TestIngest ./...` | ❌ |
|
||||
| SC-2 | Registry API: list/search/category/detail/versions shapes, CORS header, Cache-Control, 404 envelope | integration (httptest + Postgres) | `… test -run TestRegistryAPI ./...` | ❌ |
|
||||
| SC-2 | Ingest endpoint: 401 without token, 403 wrong scope, 200 with scope | integration | `… test -run TestIngestEndpoint ./...` | ❌ |
|
||||
| SC-2 | changelog-post.py: ingest call + check invocation helpers, 403 tolerated | unit | `python3 -B scripts/test_changelog_post.py` | ✅ (extend) |
|
||||
| SC-2 | Marketplace pages render from fixture API | Node SSR smoke | `pnpm --dir vue-summercmsio-app test` (+ new marketplace fixture check) | ❌ |
|
||||
| SC-2 | Landing `/marketplace` link is a node path | unit (gated build) | `SUMMERCMS_REQUIRE_BUILD=1 go -C plugins/golem15/summercms test -run TestLandingLinks ./...` | ✅ (extend `nodePaths`) |
|
||||
| SC-3 | Resolution: newest compatible, pin, no compatible → names framework, transitive closure, installed-too-old dep, cycle guard | unit (pure, fake registry) | `go test -short ./internal/build -run TestResolveInstall` | ❌ |
|
||||
| SC-3 | Effective framework version: require vs local replace + describe | unit (fixture git repo) | `go test -short ./internal/build -run TestEffectiveFramework` | ❌ |
|
||||
| SC-3 | Module install end-to-end: `GOPROXY=file://` fixture proxy (stdlib `archive/zip` builds `@v/list,.info,.mod,.zip`), with `GOPROXY=file://<dir> GOSUMDB=off GOFLAGS=-modcacherw GOMODCACHE=<t.TempDir()>` set only in the test env `[ASSUMED: exact env set to confirm while writing the test]` | integration (no network) | `go test ./internal/build -run TestInstallModule` | ❌ |
|
||||
| SC-3 | `--source`/git-URL install: fixture repo via `file://`, submodule added, go.work use + replace override, `go build` passes | integration | `go test ./internal/build -run TestInstallSource` | ❌ |
|
||||
| SC-3 | Non-interactive without `--yes` errors; `--offline` with missing deps errors | unit (bonfire.Call root) | `go test -short ./cmd/summer -run TestPluginInstallPrompts` | ❌ |
|
||||
| SC-3 | Commands documented | existing gate | `go test ./cmd/summer -run 'TestCommandsDocumented|TestDocsTree'` | ✅ |
|
||||
| SC-4 | Installed endpoint: metadata, versions, update flag (describe base vs registry), registry down → still 200 | unit (fake registry via `fetchguard.WithTransport`) | `go test -short ./modules/cabana -run TestPluginsEndpoints` | ❌ |
|
||||
| SC-4 | Permission matrix + contract inventory include new routes | existing tests | `go test -short ./modules/cabana -run 'TestPhase09PermissionMatrix|TestPhase09ContractInventory'` | ✅ (update) |
|
||||
| SC-4 | PluginsView: tabs, search, copy button (clipboard stub), hidden Marketplace when registry disabled | vitest | `npm --prefix admin run test -- PluginsView` | ❌ |
|
||||
| SC-4 | OpenAPI and dist gates | scripts | `scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh` | ✅ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go vet ./... && go test -short <touched packages>` (+ `npm --prefix admin run test` for SPA tasks)
|
||||
- **Per wave merge:** `go test ./...` (Docker) in summercms.go; site plugin `go test ./...`; admin gates
|
||||
- **Phase gate:** full suite green in all touched repos, plus a `scripts/check-phase34.sh` (convention: `scripts/check-phaseN.sh`) before `/msd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/<new>/…_test.go`: derive/readme/check/validate, client (fetchguard transport fake)
|
||||
- [ ] `internal/build/install_test.go`, `plugindirs_test.go`: fixture helpers to create a git repo with tags and a `file://` GOPROXY tree (stdlib only)
|
||||
- [ ] site plugin `TestMain` with testcontainers (must call `flag.Parse()` and honour `testing.Short()`)
|
||||
- [ ] `admin/tests/…PluginsView` spec
|
||||
- [ ] `scripts/check-phase34.sh`
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is not set in `.planning/config.json`, so it is enabled by default.
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (ingest endpoint) | `cabana.ResolveBackend` (backend JWT or `g15_` personal token) |
|
||||
| V3 Session Management | no new sessions | existing admin cookie session for cabana endpoints |
|
||||
| V4 Access Control | yes | `backend.view_plugins` (or chosen key) on cabana endpoints; `golem15.summercms.registry_ingest` + token scope on ingest; admin-only repo curation |
|
||||
| V5 Input Validation | yes | plugin.yaml schema validation (ID regex, `validateModulePath`, strict `vX.Y.Z`), git URL validation (scheme allow-list, no leading `-`), query params (`q` length cap, category slug regex), registry JSON validated by the CLI before any exec |
|
||||
| V6 Cryptography | no new crypto | sumdb verification of module installs stays on (do not set GONOSUMDB for public plugins) |
|
||||
| V10 Malicious code | partially | installing a plugin compiles third-party code into the binary. This is inherent and out of scope, but the CLI prints exactly what it will add and requires confirmation (D-09) |
|
||||
| V12 Files and Resources | yes | ingester: temp dirs, bare clones, no checkout/hooks/submodules, size caps; CLI never writes outside the app root (`underRoot`) |
|
||||
| V14 Configuration | yes | `plugins.registry_url` operator-trusted (TrustedMode), https by default, no redirects |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Option/argument injection via repo URL or module/version strings into `git`/`go` | Tampering/Elevation | exec without shell, `--` before positional args, validators rejecting leading `-`, strict version regex |
|
||||
| `ext::`/`file::` transport abuse via an attacker-supplied git URL | Elevation | `GIT_ALLOW_PROTOCOL=https:ssh` (CLI) / `https` (ingester) `[CITED: git-scm.com/docs/git]` |
|
||||
| Malicious repo content during ingest (huge blobs, symlinks, hooks) | DoS/Tampering | bare partial clone, read only via `git show`/`ls-tree`, `io.LimitReader`, timeouts, no checkout |
|
||||
| README XSS on the marketplace | Tampering | `breakwater.Render` at ingest (strips raw HTML, http/https/mailto only, nofollow); Nuxt renders only stored sanitized HTML |
|
||||
| Registry poisoning (repo claims another ID or another module) | Spoofing | admin-curated listing; ID uniqueness; module path must equal repo path (Pitfall 13) |
|
||||
| Registry outage or slowness breaking the admin or CLI | DoS | cabana timeouts + cache + degraded 200; CLI clear error, `--offline` for git-URL installs |
|
||||
| Ingest endpoint abuse (forced clones) | DoS | token + permission, throttle bucket, per-repo single-flight, `ls-remote` change check before cloning |
|
||||
| Supply chain: moved tags | Tampering | sumdb catches mismatch; never move tags (Pitfall 7); submodule installs pin the commit in the parent repo |
|
||||
| Open CORS leaking private data | Information disclosure | registry API serves only public data, no credentials, GET only |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Codebase files read this session: `internal/build/{manifest,build,leaf,registry,scaffold,artifact}.go`, `cmd/summer/main.go`, `cmd/summer/commands_documented_test.go`, `modules/bonfire/{version,helpers}.go`, `modules/party/registry.go`, `modules/cabana/{backend_screens,navigation,http,admin_openapi}.go`, `modules/pact/capabilities.go`, `modules/fetchguard/{client,policy}.go`, `modules/breakwater/breakwater.go`, `modules/seaglass/seaglass.go`, `modules/compass/README.md`, `modules/conga/README.md`, `admin/src/app/router.ts`, `admin/src/views/{AccountView,SettingsIndexView}.vue`, `admin/src/api/client.ts`, `admin/package.json`, `scripts/check-admin-{openapi,dist}.sh`, `internal/build/stubs/new.tmpl`
|
||||
- Sibling repos: all `sm-*-plugin/{plugin.go,go.mod,README.md}`, `sm-faq-plugin` structure, `sm-journal-plugin/{routes.go,controllers/api/auth.go,token_scopes.go}`, `sm-pgstripe-plugin/subscriptions`, `sm-demo-app/{summer.yaml,go.work,scripts/build.sh}`, `sm-summercmsio-app/{CLAUDE.md,summer.yaml,config/queue.yaml,deploy/nginx,deploy/supervisor,scripts/changelog-post.py,scripts/build.sh}`, site plugin `plugin.go`/`static.go`/`links_test.go`, `vue-summercmsio-app/{package.json,nuxt.config.ts,changelog/nuxt.config.ts}`
|
||||
- Probes this session: `pluginIDFromGo` on mfa/journal; `go get` in workspace; `go list -json` in sm-demo-app; `go list -m -json` module cache Dir; proxy.golang.org `@v/list`; `go.work` replace override; `git submodule add` adoption + `absorbgitdirs`; anonymous `git ls-remote` for 23 repos; go-get meta tag; `go mod edit -json`; `go mod why -m golang.org/x/mod`
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- https://git-scm.com/docs/git: environment variables `GIT_ALLOW_PROTOCOL`, `GIT_TERMINAL_PROMPT`, `GIT_CONFIG_NOSYSTEM`, `GIT_CONFIG_GLOBAL` (fetched this session)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None used for decisions. The proposed API shape, schema and names are marked `[ASSUMED]`.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. All components exist in-tree; no additions.
|
||||
- Go module/workspace mechanics (F2, F3, F5): HIGH. Reproduced locally on Go 1.27.2.
|
||||
- Plugin repo survey (F1, F6–F9): HIGH. Read every repo this session.
|
||||
- Architecture/API shape/schema: MEDIUM. These are design recommendations needing sign-off (A2, A3).
|
||||
- Pitfalls: HIGH for the code-grounded ones; MEDIUM for production (rome git, A5).
|
||||
|
||||
**Research date:** 2026-10-29
|
||||
**Valid until:** 2026-11-28 (stable toolchain; re-check plugin repo tags and the go.mod replace layouts before rollout)
|
||||
157
.planning/phases/34-plugin-marketplace/34-REVIEW-FIX.md
Normal file
157
.planning/phases/34-plugin-marketplace/34-REVIEW-FIX.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
fixed_at: 2026-10-29T22:45:00Z
|
||||
review_path: .planning/phases/34-plugin-marketplace/34-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 13
|
||||
fixed: 13
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 34: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-29T22:45:00Z
|
||||
**Source review:** .planning/phases/34-plugin-marketplace/34-REVIEW.md
|
||||
**Iteration:** 1
|
||||
**Scope:** Critical + Warning (CR-01, CR-02, WR-01..WR-11). Info findings IN-01..IN-09 were not in scope.
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 13
|
||||
- Fixed: 13 (CR-01, CR-02 and WR-10 change version or compatibility logic: fixed, requires human verification)
|
||||
- Skipped: 0
|
||||
|
||||
Repositories:
|
||||
- framework: `summercms.go` (branch master)
|
||||
- site plugin: `sm-summercmsio-app/plugins/golem15/summercms` (repo `sm-summercmsio-plugin`, branch master)
|
||||
- app: `sm-summercmsio-app` (branch master; only `scripts/changelog-post.py` and its test were staged, the two submodule gitlinks were left uncommitted)
|
||||
|
||||
Nothing was pushed, tagged or rebased.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: `plugin:update` downgrades module installs pinned at a pseudo-version
|
||||
|
||||
**Status:** fixed: requires human verification (version ordering logic)
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 6055148
|
||||
**Files modified:** `modules/kiosk/version.go`, `modules/kiosk/version_test.go`, `modules/kiosk/README.md`, `internal/build/update.go`, `internal/build/install.go`, `internal/build/install_test.go`, `modules/cabana/plugins.go`, `modules/cabana/plugins_test.go`, `modules/cabana/README.md`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** New exported `kiosk.Newer(release, installed)` and `kiosk.Comparable(installed)`. A release, a `git describe` string (base tag) and a Go pseudo-version are compared in Go's version order. A pseudo-version counts as newer than the tag it builds on, so `v0.4.1-0.<ts>-<hash>` gets `v0.4.1` and later and never `v0.4.0`. `installedSource` keeps the go.mod version instead of blanking a pseudo-version. `plugin:update` uses `kiosk.Newer` in `pick` and refuses a module whose version `kiosk.Comparable` rejects (`vX.0.0-<ts>-<hash>`, a pre-release); the note names `go get <module>@<tag>`. The requirement checks (`installedVersion`, `checkRequires`) still see `""` for a pseudo-version, as before. The cabana badge (`applyUpdate`) uses the same `kiosk.Newer` rule. Regression tests: `TestUpdatePseudoVersion` (fails without the fix), `TestVersions/Newer`, and new cases in the cabana "update rule" table.
|
||||
|
||||
### CR-02: `plugin:install` silently moves already-installed plugins that share a Go module
|
||||
|
||||
**Status:** fixed: requires human verification (resolution logic)
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 9b72439
|
||||
**Files modified:** `internal/build/install.go`, `internal/build/source.go`, `internal/build/install_test.go`, `internal/build/install_smoke_test.go`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** `PlanInstall` records every module in summer.yaml (`installedModules`, from `installedSource`). In `choose`, a release in such a module is eligible only at the version the application has. The step is marked `existing`, so `ApplyInstall` runs no `go get` and no clone for it and only appends the summer.yaml entry. This holds with `--source` too, whichever way the module is installed. A submodule checkout must contain the package. When no release matches, or the version is not a release (pseudo-version, untagged checkout, local directory), the install fails with a `plugin:update <installed id>` hint. A git URL whose repository is already a submodule (matched against `.gitmodules` before cloning), or whose module is already installed, is refused. Tests: `TestInstallSharedModule` (unit) and `TestInstallSmoke/source_shared_module` (fixture repos: no second submodule, no clone, git URL refused before cloning).
|
||||
|
||||
### WR-01: Generated `main.go`/`plugins.gen.go` require framework >= v0.6.0 with no guard
|
||||
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 07673e4
|
||||
**Files modified:** `internal/build/build.go`, `internal/build/plugindirs.go`, `internal/build/plugindirs_test.go`, `docs/architecture/go-modules-and-workspaces.md`
|
||||
**Applied fix:** I chose a static check over comparing versions. A local checkout reports the previous tag until it is tagged, so a version compare can be wrong. `resolveBuildMeta` resolves the framework's `party` package with the same `go list` the metadata step uses (it honours go.mod replaces and go.work) and parses it for `func Describe` and `type Info`. If either is missing, `buildMeta.Legacy` is set: `main.go` is written without the `party.Describe` call, `plugins.gen.go` byte-for-byte as before v0.6.0 (imports and `PluginIDs` only), and a warning names v0.6.0. If the package cannot be resolved, the current sources are generated. I verified this by hand: `examples/hello` with go.mod and the plugins' go.mod replacing the framework with a `git archive v0.5.3` export builds with the new `summer build`. Test: `TestLegacyFramework` (old party package → legacy sources and the exact old plugins.gen.go; new party package → Describe; failing go list → current sources).
|
||||
|
||||
### WR-02: Install and update are not atomic; a failure leaves the app half-modified
|
||||
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 9977e31
|
||||
**Files modified:** `internal/build/rollback.go` (new), `internal/build/install.go`, `internal/build/source.go`, `internal/build/update.go`, `internal/build/install_smoke_test.go`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** `snapshotApp` records go.mod, go.sum, go.work, go.work.sum, summer.yaml and .gitmodules, plus the index entry of .gitmodules, before `ApplyInstall` and `updater.apply` change anything. On failure, `undo` restores those files. It also removes each submodule added in the run (index entry via `update-index --force-remove`, the `submodule.<path>` config section, the absorbed `.git/modules/<path>` and the checkout), and checks moved submodules out at their previous branch or commit. `applySubmodule` registers the target for cleanup right after the rename. Anything the rollback cannot undo is appended to the error with the command that finishes the job. Test: `TestInstallSmoke/rollback`. It covers install (a submodule added, then a failing `go get`: files, git status and `.git/modules` restored, and the next install succeeds) and update (a submodule moved to v0.1.1, then a failing `go get`: back at v0.1.0, go.mod restored).
|
||||
|
||||
### WR-03: Credentials in a user-supplied https git URL end up in `.gitmodules` and output
|
||||
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 2f3eba0
|
||||
**Files modified:** `internal/build/source.go`, `internal/build/install_smoke_test.go`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** `validateGitURL` refuses any user info in an `https://` URL; the error points to a credential helper. ssh and scp-like users are still accepted. A password is still refused in any URL. The refusal happens before the "Cloning" line is printed. Test: new refusals in `TestInstallSmoke/urls` (`https://ghp_token@...`, `https://oauth2@...`, `ssh://git:secret@...`).
|
||||
|
||||
### WR-04: changelog-post sends the journal token to the registry endpoint
|
||||
|
||||
**Repo:** sm-summercmsio-app
|
||||
**Commit:** 72bce11
|
||||
**Files modified:** `scripts/changelog-post.py`, `scripts/test_changelog_post.py`
|
||||
**Applied fix:** The journal-token fallback is removed. `registry_credential()` reads only `~/.config/summercms/registry-token`. Without that token, `request_ingest()` sends no request and warns that the hourly ingest will pick the tag up. The module docstring now says the journal token never goes to the registry. Tests: `test_without_a_registry_token_nothing_is_sent` (missing and empty file), plus the updated accepted and file-mode tests.
|
||||
**Follow-up (not done here):** the user asked that only the script and its test be staged, so these app docs were left unchanged and still describe the old fallback: `README.md` (line 66, "else the journal token"), `DEPLOY.md` (lines 129 and 404) and `CLAUDE.md` (line 78, phrasing is still accurate). They need a docs commit in sm-summercmsio-app.
|
||||
|
||||
### WR-05: The `registry` token scope covers all of `golem15.summercms.*`, not just ingest
|
||||
|
||||
**Repo:** sm-summercmsio-plugin (site plugin submodule)
|
||||
**Commit:** e82d627
|
||||
**Files modified:** `token_scopes.go`, `controllers/api/registry.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `registry_api_test.go`, `registry_smoke_test.go`, `README.md`
|
||||
**Applied fix:** The scope's permissions are now `controllers.PermissionRegistryIngest` only, and the ingest endpoint's `ingestCover` is that permission. Scopes are resolved by name on every request, so existing `registry` tokens narrow without being minted again. An administrator without the ingest permission is no longer offered the scope (the mint answers 422); the tests were adapted to that. Regression: a superuser's registry token gets 403 on `GET`/`POST /backend/api/v1/golem15/summercms/repositories`. A token whose owner later loses the permission still gets 403 on ingest. The scope description strings say the token allows nothing else.
|
||||
|
||||
### WR-06: The admin Plugins screen's search cache grows without bound
|
||||
|
||||
**Repo:** summercms.go
|
||||
**Commit:** 7b19303
|
||||
**Files modified:** `modules/cabana/plugins.go`, `modules/cabana/plugins_test.go`, `modules/cabana/README.md`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** `storeList` caps `pluginCatalog.lists` at `marketplaceCacheSize` (256). When a new key arrives and the cache is full, it drops the expired searches first, then the entries that expire soonest. Test: "search cache is bounded" (306 distinct searches leave 256, the oldest is gone, the newest is still a hit, an expired cache shrinks to the new entry).
|
||||
|
||||
### WR-07: Disabling a repository does not withdraw its plugins from the public registry
|
||||
|
||||
**Repo:** sm-summercmsio-plugin
|
||||
**Commit:** 8a2f0fa
|
||||
**Files modified:** `registry/query.go`, `registry_api_test.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `README.md`
|
||||
**Applied fix:** A shared `public()` scope (published, and `repository_id IN (enabled repositories)`, as a subquery so joined columns stay unambiguous) is applied in `List`, `Versions` and `Detail`. Disabling a repository removes its plugins from the list, the detail and the versions at once. Enabling it again brings them back with their presentation intact. The `enabled_comment` strings (en and pl) and the README describe this kill switch. Test: `TestRegistryAPIShapes/disabled_repository_withdraws_its_plugins` (Docker).
|
||||
|
||||
### WR-08: A transient ingest failure leaves a stale `last_error` forever
|
||||
|
||||
**Repo:** sm-summercmsio-plugin
|
||||
**Commit:** a50c47c
|
||||
**Files modified:** `registry/ingest.go`, `registry_smoke_test.go`, `README.md`
|
||||
**Applied fix:** `recordError` now also clears `tags_digest`. The next run therefore reads the repository in full and `store` replaces `last_error` with that read's notes, or an empty string. Clearing the error on the unchanged path would also have wiped the skipped-tag notes; this way they survive without a new column. Test: an extension of `TestRegistrySmoke/unchanged`. An unreachable URL sets the error and clears the digest; restoring the URL triggers a full read that clears the error; the run after that is unchanged again.
|
||||
|
||||
### WR-09: The hourly `IngestAll` runs inside one 300 s job and starves later repositories
|
||||
|
||||
**Repo:** sm-summercmsio-plugin
|
||||
**Commit:** 0b3fbf4
|
||||
**Files modified:** `console/registry.go`, `schedule.go`, `registry_smoke_test.go`, `README.md`
|
||||
**Applied fix:** `summercms:registry-ingest --queue` queues one `registry.IngestArgs` job per enabled repository, or for the one named, and prints `queued` for each. The job is unique per repository while it waits or runs, so repeats do not double up. A disabled repository and `--queue --force` are refused. The hourly schedule now runs the command with `--queue`, so each repository has its own job deadline and retries, and ingest-now jobs wait for at most one repository. Run by hand without the flag, the command still ingests directly as before. Test: `TestRegistrySmoke/scheduled_queue` (schedule args, 2 jobs for 2 enabled repositories, still 2 after repeats, refusals).
|
||||
|
||||
### WR-10: Plugins screen and `plugin:install` disagree on compatibility for unknown or dev framework versions
|
||||
|
||||
**Status:** fixed: requires human verification (compatibility semantics)
|
||||
**Repo:** summercms.go
|
||||
**Commits:** 754ab54, 75a0dea (smoke test expectation)
|
||||
**Files modified:** `modules/cabana/plugins.go`, `modules/cabana/plugins_test.go`, `modules/cabana/plugins_smoke_test.go`, `modules/cabana/README.md`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** The JSON shape is unchanged, so the SPA and the OpenAPI schema stay as they are (`check-admin-openapi.sh --check` passes). When the built framework version is not a release (devel, empty, a hash), `Compatible` is true, so no marker is shown, and the Installed tab gets no `LatestFramework` note. When the newest release is too new, `olderCompatible` looks up the plugin's releases (cached, four at a time). It marks the plugin compatible if `kiosk.NewestCompatible` finds an older release that fits, which is the one `plugin:install` would pick. The 75a0dea commit updates the existing Plugins screen smoke test, whose fixture has exactly that case. Test: "marketplace compatibility".
|
||||
|
||||
### WR-11: `plugin:add` now fails on summer.yaml files with an inline `plugins:` list (regression)
|
||||
|
||||
**Repo:** summercms.go
|
||||
**Commit:** e6b3aa2
|
||||
**Files modified:** `internal/build/manifest.go`, `internal/build/scaffold.go`, `internal/build/install.go`, `internal/build/manifest_append_test.go`, `internal/build/install_smoke_test.go`, `docs/console/scaffolding.md`, `docs/plugins/marketplace.md`
|
||||
**Applied fix:** `insertManifestPlugins` finds the end of an inline flow list, on one line or several, skipping quotes and comments. It replaces those lines with a block list of the existing items plus the new ones and keeps every other line and comment. A list it cannot edit in place (a tagged `!!seq [...]`, say) falls back to the old full `SaveManifest` rewrite. `appendManifestPlugins` then reports `rewrote`, and `plugin:add` and `plugin:install` print a note that comments were not kept. The edit is still parsed back and verified, and the original bytes are restored on mismatch. Tests: new inline cases in `TestAppendManifestPlugins` (single line with a trailing comment, multi-line with comments, fallback rewrite). The old "inline refused" expectations were replaced.
|
||||
|
||||
## Verification
|
||||
|
||||
All checks ran in the main checkouts; `workflow.use_worktrees` is false, so no isolated worktree was created.
|
||||
|
||||
summercms.go:
|
||||
- `go vet ./...`: pass
|
||||
- `go test -short ./...`: pass (the first run failed on `TestPluginsScreenSmoke/marketplace`, whose expectation predated WR-10; 75a0dea fixed it, then the suite passed)
|
||||
- `go test ./internal/build` without `-short` (fixture repos, module proxy): pass
|
||||
- `cd examples/hello && go test ./...`: pass
|
||||
- `go run ./cmd/summer docs:build --check`: no problems found
|
||||
- `scripts/check-admin-openapi.sh --check`: pass. `admin/` was not changed, so neither `npm --prefix admin run test` nor a dist rebuild was needed; the gate's admin stage still ran vitest (1622 passed), typecheck and `check-admin-dist.sh` (dist matches a fresh build)
|
||||
- Manual WR-01 check: hello against a `git archive v0.5.3` framework export builds with the new `summer build` (legacy sources plus the warning)
|
||||
|
||||
Site plugin (sm-summercmsio-plugin):
|
||||
- `go vet ./... && go test -short ./...`: pass
|
||||
- `go test ./...` with Docker (testcontainers Postgres): pass
|
||||
|
||||
sm-summercmsio-app:
|
||||
- `go vet ./... && go test ./...`: pass
|
||||
- `python3 -m pytest -q scripts/test_changelog_post.py`: 51 passed. `python3 -B -m unittest discover -s scripts -p 'test_*.py'`: 51 OK. A `scripts/__pycache__` left by pytest was removed.
|
||||
|
||||
`scripts/check-phase34.sh --all` with Docker available: **Phase 34 gate passed**. Stages self-test, go, plugins (20 repositories), site (including pnpm test, build:changelog, test:changelog and test:marketplace), admin, docs, marker, additive (cabana +43 lines, party +15, kiosk new) and docker (framework `go test ./...`, `TestInstallSmoke`, and the site plugin's `TestRegistrySmoke`, `TestRegistryAPIShapes` and `TestRegistryIngestEndpoint`) all PASS.
|
||||
|
||||
Not committed, as instructed: the app's two submodule gitlinks (`plugins/golem15/summercms` now points at the new site-plugin commits; `vue-summercmsio-app`). Other sessions' edits (`.planning/STATE.md`, `state.json`, `G15OFFICE.md`, `.msd/`, other phases' `.gitkeep`) were not touched.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-29T22:45:00Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
328
.planning/phases/34-plugin-marketplace/34-REVIEW.md
Normal file
328
.planning/phases/34-plugin-marketplace/34-REVIEW.md
Normal file
@@ -0,0 +1,328 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
reviewed: 2026-10-29T12:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 81
|
||||
files_reviewed_list:
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/pageTitle.ts
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/components/shell/AppShell.vue
|
||||
- admin/src/components/shell/Breadcrumbs.vue
|
||||
- admin/src/components/shell/PluginRail.vue
|
||||
- admin/src/state/useSettings.ts
|
||||
- admin/src/views/PluginsView.vue
|
||||
- cmd/summer/main.go
|
||||
- examples/hello/main.go
|
||||
- examples/hello/plugins.gen.go
|
||||
- internal/build/build.go
|
||||
- internal/build/install.go
|
||||
- internal/build/manifest.go
|
||||
- internal/build/new.go
|
||||
- internal/build/plugindirs.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/source.go
|
||||
- internal/build/update.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/backend_screens.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/navigation.go
|
||||
- modules/cabana/plugins.go
|
||||
- modules/kiosk/check.go
|
||||
- modules/kiosk/client.go
|
||||
- modules/kiosk/derive.go
|
||||
- modules/kiosk/identity.go
|
||||
- modules/kiosk/kiosk.go
|
||||
- modules/kiosk/manifest.go
|
||||
- modules/kiosk/registry.go
|
||||
- modules/kiosk/version.go
|
||||
- modules/party/info.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- scripts/check-phase34.sh
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/admin.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/admin_navigation.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/admin_permissions.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/console/registry.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_form.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_list.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_form.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_list.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/go.mod
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/jobs.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/lang.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/plugin.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/plugin/columns.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/plugin/fields.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/registry.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/repository.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/repository/columns.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/repository/fields.yaml
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/models/version.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/plugin.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/registry/git.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/registry/query.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/routes_registry.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/schedule.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go
|
||||
- sm-summercmsio-app/plugins/golem15/summercms/updates/registry.go
|
||||
- sm-summercmsio-app/vue-summercmsio-app/app/components/SiteFooter.vue
|
||||
- sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useRegistry.ts
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/app/error.vue
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/app/layouts/default.vue
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/index.vue
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/marketplace.ts
|
||||
- sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts
|
||||
- sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts
|
||||
- sm-summercmsio-app/scripts/changelog-post.py
|
||||
- sm-summercmsio-app/config/http.yaml
|
||||
- sm-summercmsio-app/config/queue.yaml
|
||||
- sm-summercmsio-app/deploy/nginx/summercms.io.conf
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 11
|
||||
info: 9
|
||||
total: 22
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 34: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-29T12:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 81
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Scope: the kiosk module (plugin.yaml, static identity reader, registry client and version rules), `summer plugin:manifest/install/update`, `summer build` metadata generation (`party.Describe`, `PluginInfo`), the cabana Plugins screen and SPA, the summercms.io registry (ingester, public API, ingest-now, admin), the Nuxt `/marketplace` pages and the deploy/config changes. Diff bases were framework `824e3d5..HEAD`, site plugin `ca59415..HEAD` and vue app `b3bfc5d..HEAD`.
|
||||
|
||||
The hardening is mostly sound. Every git and go exec uses an argv with no shell and puts `--` before URLs and paths. Registry strings go through strict version, module path and https checks before they reach exec, and plugin IDs are regex-checked before they become paths or shell commands. The ingester runs git with a throwaway HOME, no system or global config and an https-only `GIT_ALLOW_PROTOCOL`, and caps every read. The README HTML is sanitized twice before `v-html`. Admin requests to the registry are bounded, cached and permission-gated. The registry uses parameterized queries with escaped LIKE wildcards. No SQL injection, command injection, path traversal or XSS was found.
|
||||
|
||||
The main problems are in the version logic of the install and update CLI:
|
||||
|
||||
- `plugin:update` can downgrade a plugin installed at a pseudo-version (CR-01).
|
||||
- `plugin:install` can silently move an already-installed plugin that shares a Go module with the plugin being added (CR-02).
|
||||
|
||||
Other findings:
|
||||
|
||||
- **Compatibility:** generated code breaks apps pinned to an older framework (WR-01).
|
||||
- **Robustness:** partial installs are not rolled back (WR-02); an unbounded admin cache (WR-06); the hourly ingest can be starved by the job timeout (WR-09).
|
||||
- **Credential handling:** tokens in URLs end up in `.gitmodules` (WR-03); the journal token is sent to the registry endpoint (WR-04).
|
||||
- **Least privilege:** the `registry` token scope is broader than ingest needs (WR-05).
|
||||
- **Registry moderation:** disabling a repository does not withdraw its plugins (WR-07); a stale `last_error` sticks after a transient failure (WR-08).
|
||||
|
||||
No structural (fallow) findings were provided for this run.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: `plugin:update` downgrades module installs pinned at a pseudo-version
|
||||
|
||||
**File:** `internal/build/update.go:118-123`, `internal/build/update.go:286-301`, `internal/build/update.go:311-342`
|
||||
**Issue:** `installedSource` throws away any go.mod require that is not a strict `vX.Y.Z` (`if !kiosk.ValidVersion(src.Version) { src.Version = "" }`). `pick` then treats `""` as `v0.0.0`, so every registry release counts as "newer". Take an app that requires a plugin at a commit after its last tag, for example `M v0.4.1-0.20261001120000-abcdef123456` (commonly from `go get M@main`). `plugin:update` picks `v0.4.0` and runs `go get M@v0.4.0`, which is a **downgrade** that drops the commits the app depends on. The plan line prints `default branch head -> v0.4.0 (module)`, which does not read as a downgrade, and with `--yes` (the documented non-interactive mode) it runs unseen. The cabana Plugins screen gives the same install no update badge (`BaseVersion` of a pseudo-version is `""`), so the comment "the rule is summer plugin:update's" (`modules/cabana/plugins.go:325-329`) is also false.
|
||||
**Fix:** Keep the pseudo-version and compare against the release it builds on. Never offer a release at or below that base:
|
||||
```go
|
||||
// installedSource, module branch
|
||||
if src.Dir == "" {
|
||||
src.Kind = ModeModule
|
||||
src.Pseudo = src.Version // keep for reporting
|
||||
if !kiosk.ValidVersion(src.Version) {
|
||||
src.Version = pseudoFloor(src.Version) // vX.Y.(Z-1) for vX.Y.Z-0.<ts>-<hash>, "" when unknown
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// planModule: refuse to guess when the installed version is unknown
|
||||
if src.Version == "" && src.Pseudo != "" {
|
||||
u.note(p.ID, "installed at "+src.Pseudo+", a commit, not a release; pin a release with plugin:update after `go get "+src.Module+"@<tag>`")
|
||||
return
|
||||
}
|
||||
```
|
||||
Add a regression test in `install_smoke_test.go#update` with a pseudo-version require.
|
||||
|
||||
### CR-02: `plugin:install` silently moves already-installed plugins that share a Go module
|
||||
|
||||
**File:** `internal/build/install.go:395-406`, `internal/build/install.go:571-612`, `internal/build/install.go:310-341`
|
||||
**Issue:** `moduleVersion` holds only modules chosen *in this plan*. If the plugin being installed (or one of its requirements) lives in a module the app already requires for another plugin, `choose` still picks the newest compatible release of that module. One example is a second plugin package inside `sm-pgstripe-plugin`, where the app has the main plugin at v0.2.0. `ApplyInstall` then runs `go get M@v0.3.0`, which upgrades the plugin the app already has. The plan prints only the new plugin (`acme.sub@v0.3.0 (module)`). This contradicts the guarantee the code states twice ("plugin:install does not move plugins the application already has", `install.go:337`; "plugin:install never moves the framework"). It can also break the existing plugin's own requirements, which are never re-checked. In `--source` mode it gets worse: the same repository is cloned and added a second time as a submodule under another directory, so go.work ends up with one module path in two `use` directories and the workspace stops loading.
|
||||
**Fix:** Seed `moduleVersion` from the app's go.mod and go.work before resolving, and pin plugins of an already-required module to the installed version (or fail with a hint to run `plugin:update`):
|
||||
```go
|
||||
// PlanInstall, after EffectiveFramework
|
||||
for _, p := range m.Plugins {
|
||||
if src, err := installedSource(ctx, appDir, p.Module); err == nil && src.Module != "" {
|
||||
if src.Kind == ModeSubmodule {
|
||||
in.installedDirs[src.Module] = src.Dir // reuse, never clone again
|
||||
}
|
||||
in.moduleVersion[src.Module] = src.Version // "" means: refuse, cannot pin
|
||||
}
|
||||
}
|
||||
// choose(): when want, ok := in.moduleVersion[v.Module]; ok && want == "" -> error naming plugin:update
|
||||
```
|
||||
For a submodule module that is already installed, register the new package from the existing checkout instead of calling `applySubmodule`.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Generated `main.go`/`plugins.gen.go` require framework >= v0.6.0 with no guard (breaks apps on older framework)
|
||||
|
||||
**File:** `internal/build/build.go:131`, `internal/build/build.go:159-188`
|
||||
**Issue:** `summer build` now always emits `party.Describe(app, PluginInfo, FrameworkVersion)` and `[]party.Info{...}`. An application whose go.mod requires the framework at v0.5.x (no local replace) and that is built with a `summer` installed from this tree fails to compile (`undefined: party.Describe`, `undefined: party.Info`). Before, the same build succeeded. `resolveBuildMeta` already computes the effective framework but never uses it to gate the new generated code. That is a breaking change for consumer apps that bump the CLI before the framework, against the project's no-breaking-changes rule.
|
||||
**Fix:** Generate the Describe call and the `PluginInfo` declarations only when the resolved framework package provides them. Check for the identifier statically, for example by running `go list -f '{{.Dir}}' git.golem15.com/golem15/summercms/modules/party` and scanning `info.go`. Do not compare version tags: `git describe --abbrev=0` reports v0.5.3 for an untagged v0.6.0 checkout. Otherwise fail early with a hint to bump the framework.
|
||||
|
||||
### WR-02: Install and update are not atomic; a failure leaves the app half-modified
|
||||
|
||||
**File:** `internal/build/source.go:422-451`, `internal/build/install.go:588-615`, `internal/build/update.go:488-509`
|
||||
**Issue:**
|
||||
- In `applySubmodule`, once `os.Rename(dir, target)` succeeds the checkout is no longer in `plan.temps`. A failure in `git submodule add`, `absorbgitdirs`, `editAppModule` or `ensureGoWork` leaves `plugins/<vendor>/<name>` behind, and go.mod may already be edited.
|
||||
- In `ApplyInstall`, submodules are added before `go get`. A failing `go get` leaves the added submodules without summer.yaml entries.
|
||||
- The next `plugin:install` then refuses ("already exists; remove it or register it with plugin:add").
|
||||
- `updater.apply` checks submodules out and edits go.mod one by one. A failure mid-loop leaves some plugins moved and others not, and `refreshWorkOverrides` is skipped.
|
||||
|
||||
**Fix:** Snapshot go.mod, go.sum, go.work and summer.yaml before applying, and on error restore them. Also remove moved checkouts and run `git submodule deinit -f`/`git rm --cached` for submodules added in this run, or at least print the exact cleanup commands. In `applySubmodule`, register the target directory for cleanup right after the rename.
|
||||
|
||||
### WR-03: Credentials in a user-supplied https git URL end up in `.gitmodules` and output
|
||||
|
||||
**File:** `internal/build/source.go:117-119`, `internal/build/source.go:233`, `internal/build/source.go:430`
|
||||
**Issue:** `validateGitURL` only rejects a URL with a *password*. GitHub, GitLab and Gitea all accept a token as the user part (`https://ghp_xxx@github.com/acme/plugin.git`). Such a URL passes, is printed (`Cloning %s`) and is written by `git submodule add` into `.gitmodules`, which is committed and pushed with the application.
|
||||
**Fix:** For user-supplied URLs, refuse `u.User != nil` on `https://` (keep it for `ssh://git@...` and scp-like forms, where the user is not a secret):
|
||||
```go
|
||||
if scheme == "https" && u.User != nil {
|
||||
return fmt.Errorf("git URL must not carry user info; use a git credential helper")
|
||||
}
|
||||
```
|
||||
|
||||
### WR-04: changelog-post sends the journal token to the registry endpoint
|
||||
|
||||
**File:** `sm-summercmsio-app/scripts/changelog-post.py` (`registry_credential`, `request_ingest`, `cmd_publish`)
|
||||
**Issue:** Without `~/.config/summercms/registry-token`, `request_ingest(token)` posts the **journal** write token to `SUMMERCMS_REGISTRY_API` (overridable by env), even though the docstring says this "the registry answers with 403". The credential goes to an endpoint that is known to reject it, and if `SUMMERCMS_REGISTRY_API` points anywhere else (staging or a typo), the journal token leaks to that host. This goes against the project rule to never copy the journal token anywhere.
|
||||
**Fix:** Drop the fallback: when no registry token exists, skip the ingest request with a warning ("no registry token; the hourly ingest will pick it up"):
|
||||
```python
|
||||
cred = read_secret_file(REGISTRY_TOKEN_FILE)
|
||||
if not cred:
|
||||
print("changelog-post: warning: no registry token; the hourly ingest will pick it up", file=sys.stderr)
|
||||
return
|
||||
```
|
||||
|
||||
### WR-05: The `registry` token scope covers all of `golem15.summercms.*`, not just ingest
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go:12-19`, `sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go:30`
|
||||
**Issue:** The release script's token scope allows `GET/HEAD/POST` on every `golem15.summercms.*` permission within the owner's rights (`methodFilterScopes` in cabana matches by pattern). The owner is normally a superuser, so the token can POST to the admin API controllers guarded by `golem15.summercms.registry`, for example to create curated repositories. That is a supply-chain lever over the public marketplace, and more than the one ingest call the token exists for. nginx refusing `/backend` limits this to tunnel access, but it is still broader than least privilege.
|
||||
**Fix:** Scope it to the ingest permission only, and cover the same code in `Ingest`:
|
||||
```go
|
||||
Permissions: []string{"golem15.summercms.registry_ingest"},
|
||||
// controllers/api/registry.go
|
||||
const ingestCover = "golem15.summercms.registry_ingest"
|
||||
```
|
||||
|
||||
### WR-06: The admin Plugins screen's search cache grows without bound
|
||||
|
||||
**File:** `modules/cabana/plugins.go:228-253`
|
||||
**Issue:** `pluginCatalog.lists` is keyed by every distinct `q` (up to 100 runes) and `category`. Expired entries are never deleted, only overwritten on the same key. Each entry keeps a full `kiosk.ListResponse` (up to 4 MiB), and failures are cached too. Any principal with `backend.view_plugins` can grow process memory without limit by searching distinct strings, and so can a debounced search box used over a long session.
|
||||
**Fix:** Bound the map. Drop expired entries on insert and cap the size (for example 256 keys, oldest out), or cache only the unfiltered list and filter locally.
|
||||
|
||||
### WR-07: Disabling a repository does not withdraw its plugins from the public registry
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/query.go:45`, `:85`, `:133-134`; `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:116-118`
|
||||
**Issue:** `List`, `Versions` and `Detail` filter only on `plugins.published`. When an administrator disables a repository (for example because it was compromised), `Repository()` returns early, so `published` never changes. The plugins stay listed and installable through `summer plugin:install`. The admin has no other unpublish control (`published` is preview-only and plugin delete is forbidden), so the only kill switch is deleting the repository, which also loses category, featured flag, icon and screenshots. The lang string documents this ("their plugins stay as they are"), but it leaves the marketplace with no way to pull a bad plugin quickly.
|
||||
**Fix:** Join on the repository in the public queries:
|
||||
```go
|
||||
query := db.WithContext(ctx).Preload("Repository").
|
||||
Joins("JOIN golem15_summercms_registry_repositories r ON r.id = golem15_summercms_registry_plugins.repository_id AND r.enabled").
|
||||
Where("golem15_summercms_registry_plugins.published = ?", true)
|
||||
```
|
||||
Apply the same join in `Versions` and `Detail`. Alternatively, unpublish the repository's plugins in `FormBeforeUpdate` when `enabled` turns false.
|
||||
|
||||
### WR-08: A transient ingest failure leaves a stale `last_error` forever
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:136-141`
|
||||
**Issue:** `recordError` writes `last_error` but leaves `tags_digest` alone. On the next successful run the digest still matches, so the "unchanged" path updates only `last_ingested_at`/`updated_at`. The old error (for example a network timeout) stays in the admin's Repositories list until the tags change, and administrators see a failure that no longer exists.
|
||||
**Fix:** Clear the error on the unchanged path:
|
||||
```go
|
||||
Updates(map[string]any{"last_ingested_at": now, "updated_at": now, "last_error": ""})
|
||||
```
|
||||
If notes from the last full read should survive, store them in a separate column.
|
||||
|
||||
### WR-09: The hourly `IngestAll` runs inside one 300 s job and starves later repositories
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:165-182`, `sm-summercmsio-app/plugins/golem15/summercms/schedule.go:20-24`, `sm-summercmsio-app/config/queue.yaml` (`job_timeout: 300`)
|
||||
**Issue:** The scheduled command runs as a conga job with a 300 s deadline. `IngestAll` processes repositories sequentially in id order. Each one can take up to 30 s (ls-remote), plus 180 s (clone, retried once on failure: up to 360 s), plus 30 s per tag operation. One slow or hanging repository uses up the deadline, so every later repository gets `ctx.Err()` and is reported failed. This repeats every hour in the same order, so the tail of the list is never ingested. `queues.default: 1` also blocks ingest-now jobs behind it.
|
||||
**Fix:** Enqueue one `IngestArgs` job per enabled repository from the scheduled command (each gets its own deadline and retry), or give `IngestAll` per-repository budgets and rotate the start index.
|
||||
|
||||
### WR-10: Plugins screen and `plugin:install` disagree on compatibility for unknown or dev framework versions
|
||||
|
||||
**File:** `modules/cabana/plugins.go:435-447`, `modules/cabana/plugins.go:487-495`
|
||||
**Issue:** `frameworkRelease` returns the raw string when it is not a release, for example `""` from an app built by `summer new`, `(devel)` from `bonfire.BuildVersion`, or a pseudo-version. `kiosk.Satisfies` is then false for every plugin with a framework minimum. The marketplace marks *every* plugin "needs framework vX" and `Latest` stays empty, while `plugin:install` (which uses `EffectiveFramework`) would install them fine. `Compatible` also checks only the newest release, so a plugin with an older compatible release is still shown as incompatible even though `plugin:install` would pick that release.
|
||||
**Fix:** Return a tri-state (unknown, compatible, incompatible) and omit the marker when the framework is unknown. Compute `Compatible` from `NewestCompatible` over the versions (cached), or rename the field `latestCompatible`.
|
||||
|
||||
### WR-11: `plugin:add` now fails on summer.yaml files with an inline `plugins:` list (regression)
|
||||
|
||||
**File:** `internal/build/scaffold.go:207`, `internal/build/manifest.go:255-260`
|
||||
**Issue:** `AddPlugin` switched from `SaveManifest` (full rewrite, any YAML shape) to `appendManifestPlugins`, which refuses a flow-style list (`plugins: [{id: a, module: b}]`) with "write it as a block list". A summer.yaml that `plugin:add` used to handle now errors, which breaks an existing CLI command's behavior.
|
||||
**Fix:** When `insertManifestPlugins` reports an inline list, fall back to the previous `SaveManifest(manifestPath, m)` with a notice that comments were not preserved.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Ingest-now accepts a cookie/JWT admin session without a CSRF guard
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go:126-149`
|
||||
**Issue:** The endpoint decodes any body as JSON regardless of `Content-Type`, so a cross-site `text/plain` form can produce a valid body if a session credential is ambient. The impact is limited to queuing an ingest of an already-curated repository (and nginx refuses `/backend` sessions), but requiring `Content-Type: application/json` costs nothing.
|
||||
**Fix:** Reject requests whose `Content-Type` is not `application/json`.
|
||||
|
||||
### IN-02: The registry client accepts plain `http://` registries
|
||||
|
||||
**File:** `modules/kiosk/client.go:74-76`
|
||||
**Issue:** The registry answer decides which module path `go get` fetches. Over `http://`, an on-path attacker can substitute any module and code runs at build time. The default is https, but nothing warns when an operator sets `http://`.
|
||||
**Fix:** Allow `http://` only for loopback hosts, or print a warning in `registryFor`/`newPluginCatalog`.
|
||||
|
||||
### IN-03: The nested `kiosk.Validate` after `kiosk.Parse` is redundant
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:275-278`
|
||||
**Issue:** `Parse` already calls `Validate`. The second call adds nothing.
|
||||
**Fix:** Remove it.
|
||||
|
||||
### IN-04: Plugin ID ownership is first-come and never released
|
||||
|
||||
**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:439-451`
|
||||
**Issue:** A repository that once published an ID keeps it after it stops publishing it (the row is only unpublished). Any other curated repository that publishes the ID later is refused until an administrator deletes the original repository. A curated repository can also squat an unrelated vendor's ID, because IDs are not tied to the module or vendor.
|
||||
**Fix:** Release the ID when the owning row is unpublished, and consider requiring the ID vendor to match an allow-list per repository.
|
||||
|
||||
### IN-05: The Nitro SWR route cache on `/marketplace` is keyed by query string
|
||||
|
||||
**File:** `sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts` (`routeRules['/marketplace']`)
|
||||
**Issue:** Every distinct `?q=` becomes a cache entry for 300 s. With in-memory cache storage, random queries grow the Node process's memory, limited only by the per-IP API throttle on cache misses.
|
||||
**Fix:** Cache only `/marketplace` without a query (and the detail pages), or configure a bounded cache storage.
|
||||
|
||||
### IN-06: The detail page trusts the registry for the repository link's scheme
|
||||
|
||||
**File:** `sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue:135`
|
||||
**Issue:** `:href="plugin.repository.replace(/\.git$/, '')"` does no scheme check. The server canonicalizes URLs to https today, but `Repository.BeforeSave` keeps non-https URLs unchanged (as the tests rely on), so a `javascript:` value written directly to the database would become a clickable link.
|
||||
**Fix:** Render the link only when `plugin.repository.startsWith('https://')`.
|
||||
|
||||
### IN-07: The marketplace icon is loaded from third-party URLs in the admin
|
||||
|
||||
**File:** `admin/src/views/PluginsView.vue:371-378`
|
||||
**Issue:** Each Marketplace tab view makes the administrator's browser fetch registry-provided icon URLs (any https host), and every Plugins screen view sends the installed plugin IDs to summercms.io by default. `referrerpolicy="no-referrer"` helps, but the default registry is opt-out.
|
||||
**Fix:** Document the outbound calls in the admin-spa docs, or proxy icons through cabana.
|
||||
|
||||
### IN-08: `GET /plugins` and `GET /plugins/marketplace` use different envelopes
|
||||
|
||||
**File:** `modules/cabana/plugins.go:395`, `modules/cabana/plugins.go:421-456`
|
||||
**Issue:** The installed list is wrapped (`WriteData` → `{data: ...}`), while the marketplace answers `MarketplaceResponse` raw via `writeJSON`. It is documented in OpenAPI, but inconsistent for SPA clients.
|
||||
**Fix:** Pick one shape for new admin endpoints.
|
||||
|
||||
### IN-09: The committed `examples/hello/plugins.gen.go` bakes `FrameworkVersion = "v0.5.3"` on a v0.6.0 tree
|
||||
|
||||
**File:** `examples/hello/plugins.gen.go:29`
|
||||
**Issue:** `EffectiveFramework` uses `git describe --abbrev=0`, so the generated constant names the previous tag until v0.6.0 is tagged and the file is regenerated. It shows up as drift in the committed example. Related: the site plugin's `go.mod` requires `summercms v0.6.0`, which does not exist as a tag yet; a standalone `go mod download` fails until it is tagged.
|
||||
**Fix:** Regenerate the example after tagging v0.6.0 (add it to the release ledger), and tag the framework before pushing the site plugin's go.mod bump.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-29T12:00:00Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
48
.planning/phases/34-plugin-marketplace/34-UAT.md
Normal file
48
.planning/phases/34-plugin-marketplace/34-UAT.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 34-plugin-marketplace
|
||||
source: [34-VERIFICATION.md]
|
||||
started: 2026-10-30T11:40:44.406Z
|
||||
updated: 2026-10-30T11:40:44.406Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: After the user-gated deploy, open https://summercms.io/marketplace: list, a search, a category link and a plugin detail page, in light and dark themes
|
||||
expected: |
|
||||
Plugins from the registry with install command, README and versions; search and category filter work; layout matches the summercms.io look
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. After the user-gated deploy, open https://summercms.io/marketplace: list, a search, a category link and a plugin detail page, in light and dark themes
|
||||
expected: Plugins from the registry with install command, README and versions; search and category filter work; layout matches the summercms.io look
|
||||
result: [pending]
|
||||
|
||||
### 2. Production ingest on rome: registry-add the launch URLs, run summercms:registry-ingest, curl /api/registry/v1/plugins (CORS, 401 on POST ingest, 403 on POST plugins)
|
||||
expected: Each repository ingested from its new plugin.yaml tag; API answers as in the release log step 7
|
||||
result: [pending]
|
||||
|
||||
### 3. Admin Settings -> System -> Plugins in an application built against the live registry, light and dark themes: Installed tab (versions, sources, update badge with copy) and Marketplace tab (search, category, install command with copy, no install button)
|
||||
expected: Screen renders, copy puts the exact summer command on the clipboard, registry-down notice when the registry is unreachable
|
||||
result: [pending]
|
||||
|
||||
### 4. Run `summer plugin:install golem15.faq` on a scratch application on a real terminal, then `--source`, then a private plugin by ssh/https git URL, then `summer plugin:update`
|
||||
expected: Plan printed as id@version (mode), interactive confirmation prompt, module/submodule installed and registered in summer.yaml/go.mod/go.work; git URL never contacts the registry for its own repo
|
||||
result: [pending]
|
||||
|
||||
### 5. Review the version/compatibility semantics of the code-review fixes CR-01 (kiosk.Newer/Comparable, pseudo-version installs never downgraded), CR-02 (plugins sharing an installed module pinned at its version) and WR-10 (marketplace compatible marker matches plugin:install for dev/unknown framework versions)
|
||||
expected: Rules match the intended policy; regression tests TestUpdatePseudoVersion, TestInstallSharedModule and cabana 'marketplace compatibility' pass (they do)
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 5
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
106
.planning/phases/34-plugin-marketplace/34-VALIDATION.md
Normal file
106
.planning/phases/34-plugin-marketplace/34-VALIDATION.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
phase: "34"
|
||||
slug: "plugin-marketplace"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: true
|
||||
created: "2026-10-29"
|
||||
---
|
||||
|
||||
# Phase 34 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | go test (+ testcontainers Postgres); Vitest for admin; node --test for vue-summercmsio-app; unittest for scripts |
|
||||
| **Config file** | none for Go; admin/vitest.config.ts |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./internal/build ./cmd/summer ./modules/party ./modules/cabana` |
|
||||
| **Full suite command** | `go test ./... && npm --prefix admin run test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && summer docs:build --check` |
|
||||
| **Estimated runtime** | ~180 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick run command
|
||||
- **After every plan wave:** Run the full suite command (per repo touched)
|
||||
- **Before `/msd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 34-01-T1 | 34-01 | 1 | SC-1, D-16 | - | Format, API shape, names and v0.6.0 confirmed before code | decision | n/a (answered as-planned) | n/a | ✅ green |
|
||||
| 34-01-T2 | 34-01 | 1 | D-01, D-04 | T-34-01 | plugin.yaml generated from sources, never run | unit | `go test -short ./modules/kiosk -run 'TestManifestSmoke\|TestManifestFormat\|TestCheck'` | ✅ | ✅ green |
|
||||
| 34-01-T3 | 34-01 | 1 | D-02, D-03, D-05 | T-34-02 | Const IDs read statically; non-const refused naming ID()/Requires() | unit | `go test -short ./modules/kiosk -run 'TestReadIdentity\|TestDerive\|TestPluginManifest'`; `./internal/build -run TestAddPluginConstID` | ✅ | ✅ green |
|
||||
| 34-01-T4 | 34-01 | 1 | D-16 | T-34-04 | Strict vX.Y.Z rules; unknown JSON fields ignored | unit | `go test -short ./modules/kiosk -run 'TestVersions\|TestManifestSmoke/registry'` | ✅ | ✅ green |
|
||||
| 34-01-T5 | 34-01 | 1 | SC-4, D-06, D-07 | T-34-03, T-34-05, T-34-06 | No filesystem path in PluginInfo; metadata failure never breaks a build | unit | `go test -short ./internal/build -run 'TestPluginInfoSmoke\|TestPluginDirs\|TestEffectiveFramework'`; `./modules/party -run TestDescribe` | ✅ | ✅ green |
|
||||
| 34-02-T1 | 34-02 | 4 | SC-1, D-01, D-04 | T-34-07 | Drift guard fails go test on a stale plugin.yaml | integration | `bash scripts/check-phase34.sh --plugins` (sm-journal-plugin TestPluginManifest) | ✅ | ✅ green |
|
||||
| 34-02-T2 | 34-02 | 4 | SC-1, D-01..D-05 | T-34-07, T-34-08 | 13 tagged repos: generated manifest, CheckT guard | integration | `bash scripts/check-phase34.sh --plugins` | ✅ | ✅ green |
|
||||
| 34-02-T3 | 34-02 | 4 | SC-1, D-05 | T-34-08 | 6 untagged repos; no v0.0.0 plugin requirement | integration | `bash scripts/check-phase34.sh --plugins` | ✅ | ✅ green |
|
||||
| 34-02-T4 | 34-02 | 4 | - | T-34-10 | Push/bump decision (answered defer) | decision | n/a (deferred to 34-release-log.md) | n/a | ✅ green |
|
||||
| 34-02-T5 | 34-02 | 4 | - | T-34-09 | No pointer references an unpushed commit | manual | n/a (ledger: 34-release-log.md step 5) | n/a | ✅ green |
|
||||
| 34-03-T1 | 34-03 | 2 | SC-2, D-13 | T-34-11, T-34-12, T-34-13 | Hardened anonymous https git, capped reads, sanitized README | integration | `go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -run 'TestRegistrySmoke\|TestIngestTags\|TestIngestManifests\|TestGitRunner'` | ✅ | ✅ green |
|
||||
| 34-03-T2 | 34-03 | 2 | D-14, D-15, D-16 | T-34-16, T-34-17, T-34-18 | Escaped search, capped query, ETag/304, digest skip | integration | `bash scripts/check-phase34.sh --site` and `--docker` (TestRegistryAPIShapes, TestQueryRules) | ✅ | ✅ green |
|
||||
| 34-03-T3 | 34-03 | 2 | D-04, D-13 | T-34-14, T-34-15, T-34-19, T-34-20 | Ingest-now needs scope + permission, never creates a listing; release script refuses dirty/stale trees | integration | `go -C …/summercms test -run TestRegistryIngestEndpoint`; `python3 -B scripts/test_changelog_post.py` | ✅ | ✅ green |
|
||||
| 34-03-T4 | 34-03 | 2 | SC-2 | T-34-21 | /marketplace renders registry text as text | automated_ui | `pnpm test && pnpm test:marketplace` (vue-summercmsio-app); `scripts/check-deploy.sh` | ✅ | ✅ green |
|
||||
| 34-04-T1 | 34-04 | 2 | SC-3, D-08 | T-34-22 | Install picks newest compatible release; no go mod tidy | integration | `go test ./internal/build -run TestInstallSmoke/module` (`--docker` stage) | ✅ | ✅ green |
|
||||
| 34-04-T2 | 34-04 | 2 | D-09, D-18 | T-34-23, T-34-24, T-34-25 | Registry strings validated before exec; --yes off a terminal; offline makes no call | unit | `go test -short ./internal/build -run 'TestResolveInstall\|TestAskToProceed'`; `./cmd/summer -run TestPluginCommands` | ✅ | ✅ green |
|
||||
| 34-04-T3 | 34-04 | 2 | D-11, D-12 | T-34-26, T-34-27 | Git URLs validated, no credentials in .gitmodules, protocol allow-list | integration | `go test ./internal/build -run 'TestInstallSmoke/(source\|giturl\|urls\|nogit)'` | ✅ | ✅ green |
|
||||
| 34-04-T4 | 34-04 | 2 | D-10 | T-34-28 | Update never moves the framework; comments in summer.yaml kept | integration | `go test ./internal/build -run 'TestInstallSmoke/update\|TestAppendManifestPlugins'` | ✅ | ✅ green |
|
||||
| 34-05-T1 | 34-05 | 3 | D-19, D-06 | T-34-29, T-34-30 | 401/403 without backend.view_plugins; entry only for allowed principals | unit | `go test -short ./modules/cabana -run 'TestPluginsScreenSmoke\|TestPluginsEndpoints'` | ✅ | ✅ green |
|
||||
| 34-05-T2 | 34-05 | 3 | SC-4, D-07, D-17, D-18 | T-34-31, T-34-32 | Cached, bounded registry calls; IDs and icons sanitized; degraded 200 | unit | `go test -short ./modules/cabana -run TestPluginsEndpoints`; `npm --prefix admin run test -- PluginsView useSettings` | ✅ | ✅ green |
|
||||
| 34-05-T3 | 34-05 | 3 | D-17 | T-34-33 | Committed dist matches a fresh build | other | `bash scripts/check-phase34.sh --admin` (check-admin-openapi --check, check-admin-dist) | ✅ | ✅ green |
|
||||
| 34-06-T1 | 34-06 | 5 | SC-5 | T-34-35 | Gate fails on a skipped, missing or failing test and on a missing repository | other | `bash scripts/check-phase34.sh --self-test` | ✅ | ✅ green |
|
||||
| 34-06-T2 | 34-06 | 5 | SC-5, D-02..D-05, D-07..D-10, D-12, D-18 | T-34-34 | Fixtures in t.TempDir(), no writes to sibling repos | unit | `bash scripts/check-phase34.sh --go` | ✅ | ✅ green |
|
||||
| 34-06-T3 | 34-06 | 5 | SC-5, D-17, D-19 | - | Unexported seams only (additive) | unit | `bash scripts/check-phase34.sh --admin --additive` | ✅ | ✅ green |
|
||||
| 34-06-T4 | 34-06 | 5 | SC-5, D-13..D-16 | T-34-34 | Release script and registry tests never touch the network | unit | `bash scripts/check-phase34.sh --site` | ✅ | ✅ green |
|
||||
| 34-06-T5 | 34-06 | 5 | - | T-34-36 | Ledger names repos, SHAs and steps only; no secrets | manual | `grep -n 'v0.6.0\|Release order\|Executed steps\|never move' 34-release-log.md` | ✅ | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [x] manifest module tests: derive / README / check / validate (`modules/kiosk/*_test.go`)
|
||||
- [x] `internal/build` fixture helpers: git repo with tags, `file://` GOPROXY tree (stdlib only) (`internal/build/fixtures_test.go`, 34-04)
|
||||
- [x] site plugin `TestMain` with testcontainers (`flag.Parse()` + `testing.Short()`) (`testmain_test.go`, 34-03)
|
||||
- [x] admin PluginsView vitest spec (`admin/tests/views/PluginsView.test.ts`)
|
||||
- [x] `scripts/check-phase34.sh`
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Production ingest on summercms.io (git present on server, hourly job runs) | SC-2 | Needs the deployed host | Deploy, add a repo in the admin, wait for/trigger ingest, `curl /api/registry/v1/plugins` |
|
||||
| Marketplace pages look right | SC-2 | Visual | Open /marketplace list, search, category and a detail page |
|
||||
|
||||
*If none: "All phase behaviors have automated verification."*
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
193
.planning/phases/34-plugin-marketplace/34-VERIFICATION.md
Normal file
193
.planning/phases/34-plugin-marketplace/34-VERIFICATION.md
Normal file
@@ -0,0 +1,193 @@
|
||||
---
|
||||
phase: 34-plugin-marketplace
|
||||
verified: 2026-10-30T11:38:52Z
|
||||
status: human_needed
|
||||
score: 52/52 must-haves verified
|
||||
covered_files: [".planning/phases/34-plugin-marketplace/34-01-PLAN.md", ".planning/phases/34-plugin-marketplace/34-01-SUMMARY.md", ".planning/phases/34-plugin-marketplace/34-02-PLAN.md", ".planning/phases/34-plugin-marketplace/34-02-SUMMARY.md", ".planning/phases/34-plugin-marketplace/34-03-PLAN.md", ".planning/phases/34-plugin-marketplace/34-03-SUMMARY.md", ".planning/phases/34-plugin-marketplace/34-04-PLAN.md", ".planning/phases/34-plugin-marketplace/34-04-SUMMARY.md", ".planning/phases/34-plugin-marketplace/34-05-PLAN.md", ".planning/phases/34-plugin-marketplace/34-05-SUMMARY.md", ".planning/phases/34-plugin-marketplace/34-06-PLAN.md", ".planning/phases/34-plugin-marketplace/34-06-SUMMARY.md", "README.md", "admin/src/api/schema.d.ts", "admin/src/api/types.ts", "admin/src/app/pageTitle.ts", "admin/src/app/router.ts", "admin/src/components/shell/AppShell.vue", "admin/src/components/shell/Breadcrumbs.vue", "admin/src/components/shell/PluginRail.vue", "admin/src/state/useSettings.ts", "admin/src/views/PluginsView.vue", "cmd/summer/main.go", "cmd/summer/plugin_commands_test.go", "docs/architecture/go-modules-and-workspaces.md", "docs/architecture/introduction.md", "docs/backend/admin-spa.md", "docs/backend/users-and-permissions.md", "docs/console/scaffolding.md", "docs/plugins/marketplace.md", "internal/build/build.go", "internal/build/build_test.go", "internal/build/fixtures_test.go", "internal/build/install.go", "internal/build/install_smoke_test.go", "internal/build/install_test.go", "internal/build/manifest.go", "internal/build/manifest_append_test.go", "internal/build/new.go", "internal/build/plugindirs.go", "internal/build/plugindirs_test.go", "internal/build/plugininfo_smoke_test.go", "internal/build/rollback.go", "internal/build/scaffold.go", "internal/build/source.go", "internal/build/testdata/new-no-plugin/plugins.gen.go.golden", "internal/build/testdata/new/main.go.golden", "internal/build/testdata/new/plugins.gen.go.golden", "internal/build/update.go", "modules/cabana/README.md", "modules/cabana/admin_openapi.go", "modules/cabana/backend_screens.go", "modules/cabana/http.go", "modules/cabana/navigation.go", "modules/cabana/openapi_conformance_test.go", "modules/cabana/plugins.go", "modules/cabana/plugins_smoke_test.go", "modules/cabana/plugins_test.go", "modules/cabana/security_coverage_test.go", "modules/cabana/token_routes_test.go", "modules/kiosk/README.md", "modules/kiosk/check.go", "modules/kiosk/checkt_test.go", "modules/kiosk/client.go", "modules/kiosk/client_test.go", "modules/kiosk/derive.go", "modules/kiosk/derive_test.go", "modules/kiosk/example_test.go", "modules/kiosk/identity.go", "modules/kiosk/identity_test.go", "modules/kiosk/kiosk.go", "modules/kiosk/manifest.go", "modules/kiosk/manifest_smoke_test.go", "modules/kiosk/manifest_test.go", "modules/kiosk/registry.go", "modules/kiosk/testdata/consts/README.md", "modules/kiosk/testdata/consts/go.mod", "modules/kiosk/testdata/consts/ids/ids.go", "modules/kiosk/testdata/consts/nested/README.md", "modules/kiosk/testdata/consts/nested/plugin.go", "modules/kiosk/testdata/consts/plugin.go", "modules/kiosk/testdata/consts/vendor.go", "modules/kiosk/testdata/framework/go.mod", "modules/kiosk/testdata/literal/README.md", "modules/kiosk/testdata/literal/go.mod", "modules/kiosk/testdata/literal/plugin.go", "modules/kiosk/testdata/literal/plugin.yaml", "modules/kiosk/testdata/other/README.md", "modules/kiosk/testdata/other/go.mod", "modules/kiosk/testdata/other/plugin.go", "modules/kiosk/version.go", "modules/kiosk/version_test.go", "modules/party/README.md", "modules/party/info.go", "modules/party/info_test.go", "modules/phrasebook/backend/lang/en/lang.yaml", "modules/phrasebook/backend/lang/pl/lang.yaml", "scripts/check-phase34.sh"]
|
||||
covered_digest: "v2:sha256:ea9e014d5303a11e95afc159507675fe76c296ac7e68c5e2fa5dcf14070988b6"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: gaps_found
|
||||
previous_score: 51/52
|
||||
gaps_closed:
|
||||
- "34-06: 34-release-log.md lists every plugin commit waiting for push or tag and the pending submodule pointer bumps. sm-presentations-plugin README and manifest rebased onto sm-bm-app's theme commit d205cd7 (cfa0c40, 3eb4de7). d205cd7, 5bcbaaf (v0.1.1, sm-demo-app pointer) and 30a47ac are ancestors of 3eb4de7, so both bumps are fast-forwards. Ledger rows 24, 206 and 207 and 34-02-SUMMARY name 3eb4de7. Plugin tree clean, vet/short tests/manifest --check pass, gate --plugins PASS over 24 repositories"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verification:
|
||||
- test: "After the user-gated deploy, open https://summercms.io/marketplace: list, a search, a category link and a plugin detail page, in light and dark themes"
|
||||
expected: "Plugins from the registry with install command, README and versions; search and category filter work; layout matches the summercms.io look"
|
||||
why_human: "Visual check against the live deployed site; the code is covered by pnpm test:marketplace and the Node SSR smoke against the real binary"
|
||||
- test: "Production ingest on rome: registry-add the launch URLs, run summercms:registry-ingest, curl /api/registry/v1/plugins (CORS, 401 on POST ingest, 403 on POST plugins)"
|
||||
expected: "Each repository ingested from its new plugin.yaml tag; API answers as in the release log step 7"
|
||||
why_human: "Needs pushed tags, git on the server and the deployed binary"
|
||||
- test: "Admin Settings -> System -> Plugins in an application built against the live registry, light and dark themes: Installed tab (versions, sources, update badge with copy) and Marketplace tab (search, category, install command with copy, no install button)"
|
||||
expected: "Screen renders, copy puts the exact summer command on the clipboard, registry-down notice when the registry is unreachable"
|
||||
why_human: "Visual and clipboard behaviour in a real browser; vitest covers the component"
|
||||
- test: "Run `summer plugin:install golem15.faq` on a scratch application on a real terminal, then `--source`, then a private plugin by ssh/https git URL, then `summer plugin:update`"
|
||||
expected: "Plan printed as id@version (mode), interactive confirmation prompt, module/submodule installed and registered in summer.yaml/go.mod/go.work; git URL never contacts the registry for its own repo"
|
||||
why_human: "Real terminal prompt, user git credentials and the live registry; tests use httptest and file:// fixtures"
|
||||
- test: "Review the version/compatibility semantics of the code-review fixes CR-01 (kiosk.Newer/Comparable, pseudo-version installs never downgraded), CR-02 (plugins sharing an installed module pinned at its version) and WR-10 (marketplace compatible marker matches plugin:install for dev/unknown framework versions)"
|
||||
expected: "Rules match the intended policy; regression tests TestUpdatePseudoVersion, TestInstallSharedModule and cabana 'marketplace compatibility' pass (they do)"
|
||||
why_human: "Flagged by 34-REVIEW-FIX.md as logic changes needing a human decision on semantics"
|
||||
---
|
||||
|
||||
# Phase 34: Plugin marketplace Verification Report
|
||||
|
||||
**Phase Goal:** People browse the public Golem15 plugins at summercms.io/marketplace and add one to their application with a single command, `summer plugin:install <id>`. The same command installs private plugins straight from a git URL without contacting summercms.io. The admin SPA gains a read-only Plugins screen listing installed plugins and searching the marketplace, which shows the install command with a copy button. v1 has no paid plugins, no developer registration and no installs from the admin panel.
|
||||
**Verified:** 2026-10-30T11:38:52Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes, after gap closure (previous: gaps_found, 51/52, verified 2026-10-30T11:31:46Z)
|
||||
|
||||
## Re-verification summary
|
||||
|
||||
| Item | Previous | Now | Evidence |
|
||||
|------|----------|-----|----------|
|
||||
| 34-06 release ledger (sm-bm-app presentations row) | ✗ PARTIAL | ✓ VERIFIED | Plugin history rebased onto d205cd7; ancestry, clean tree, tests, manifest check and gate all pass; ledger rows name 3eb4de7 (details below) |
|
||||
| SC-1 (24 public plugin repos with plugin.yaml) | ✓ | ✓ (regression check) | Gate `--plugins`: `phase34 plugins passed (24 repositories)`, 25 `manifest: ... fresh` lines |
|
||||
| Other 50 items | ✓ | ✓ (regression check) | No framework code changed since the previous run: the only commit after it is c739a7d (planning docs). `git diff ba39bb1..HEAD` outside `.planning` is `scripts/check-phase34.sh` (65e0cdd, presentations added to `PLUGIN_REPOS`), exercised by the gate run |
|
||||
|
||||
### Gap closure evidence (presentations)
|
||||
|
||||
| Check | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| History | `git log --format='%h %p %s'` in `../sm-presentations-plugin` | 3eb4de7 (manifest) parent cfa0c40 (README) parent d205cd7 (sztab theme) parent 30a47ac = `origin/master`. Linear, 3 ahead, 0 behind |
|
||||
| Same commit as the sm-bm-app mount | `git rev-parse d205cd7` in both | `d205cd718563b390a0dcf678e41119fa0ca90976` in both; sm-bm-app HEAD 9929195 gitlink = d205cd7, mount worktree at d205cd7 |
|
||||
| (a) Fast-forward from sm-bm-app | `git merge-base --is-ancestor d205cd7 3eb4de7` | true (2 commits) |
|
||||
| (a) Fast-forward from sm-demo-app | `git merge-base --is-ancestor 5bcbaaf 3eb4de7` | true; 5bcbaaf = tag v0.1.1, sm-demo-app gitlink, `v0.1.1..3eb4de7` = 13 commits, matching ledger row 24 |
|
||||
| The rebase changed only the base | `git diff --stat d205cd7 3eb4de7` | README.md, go.mod, go.sum, manifest_test.go, plugin.yaml (same five files as before). d205cd7 touches only `controllers/presentations_test.go`, `lang/{en,pl}/lang.yaml`, `models/presentations/fields.yaml`, none of the manifest inputs |
|
||||
| (b) Clean tree | `git status --porcelain` | empty |
|
||||
| (b) Vet and short tests | `go vet ./... && go test -short -count=1 ./...` | exit 0, 7 packages ok |
|
||||
| (b) Manifest | `summer plugin:manifest --check .` (scratchpad binary) | `plugin.yaml is up to date`, exit 0; `golem15.presentations`, framework v0.6.0, requires `golem15.user: v0.1.0` |
|
||||
| No tag yet | `git tag --points-at HEAD` | none (tagging is in the user-gated release) |
|
||||
| (c) Stale SHAs | `grep -rn 'b6247aa\|bb81f4d' .planning/phases/34-plugin-marketplace/` | only this report's history (now removed). None in the release log or SUMMARYs |
|
||||
| (c) Ledger rows | `34-release-log.md` rows 24, 206, 207; 34-02-SUMMARY table | row 24: 3eb4de7, 13 commits, v0.1.1 -> v0.1.2; sm-demo-app 5bcbaaf -> v0.1.2 (3eb4de7); sm-bm-app d205cd7 (committed in 9929195) -> v0.1.2 (3eb4de7); note dated 2026-10-30 to push the plugin master; SUMMARY row names 3eb4de7 after cfa0c40. All agree with git |
|
||||
| (d) Gate | `bash scripts/check-phase34.sh --plugins` | `phase34 plugins passed (24 repositories)`, exit 0, 25 fresh manifests (sm-user-plugin TestRegisterCORSPath skip, pre-existing) |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Roadmap Success Criteria
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| SC-1 | Every public sm-*-plugin repo carries a validated plugin.yaml and fetches anonymously | ✓ VERIFIED | Anonymous forge API (`/orgs/golem15/repos`, `/repos/search?q=sm-` and `?q=plugin`) lists 24 public golem15 `sm-*-plugin` repos; all 24 are in `PLUGIN_REPOS` and have a sibling checkout. The four gap repos each have a tracked `plugin.yaml` + `manifest_test.go` (`kiosk.CheckT(t, ".")`), `require .../summercms v0.6.0`, a clean tree and no tag at HEAD; golem, feedback and quizzes are 1 commit ahead of origin, presentations 3 (sztab theme d205cd7, README cfa0c40, manifest 3eb4de7). `check-phase34.sh --plugins` re-run this time: `phase34 plugins passed (24 repositories)`, 25 `manifest: ... fresh` lines (pgstripe nested). `git ls-remote https://git.golem15.com/golem15/<repo>.git` without credentials returned refs for all four in the previous run. See the note on `jakub/sm-bm-plugin` below |
|
||||
| SC-2 | summercms.io ingests manifest/README/versions on tag, serves registry API, renders /marketplace list/search/category/detail; presentation fields edited in admin | ✓ VERIFIED (code) | Unchanged since a9237c8 (no site or vue commit in either gap closure). Live deploy is a human item |
|
||||
| SC-3 | `plugin:install golem15.<name>` (module; `--source` submodule) and `plugin:install <git-url>` (submodule) register like plugin:add and check requirements | ✓ VERIFIED | Unchanged; `internal/build` and `cmd/summer` untouched since a9237c8; short suite green in the previous run, no code change since |
|
||||
| SC-4 | Admin Plugins screen: installed plugins with versions and updates; Marketplace tab searches registry, install command with copy | ✓ VERIFIED | Unchanged; cabana and admin untouched since a9237c8 |
|
||||
| SC-5 | New code has unit tests in the last plan | ✓ VERIFIED | Plus `TestDeriveMissingReplace` for 87921c2 |
|
||||
|
||||
**Note on `jakub/sm-bm-plugin`.** The anonymous search also returns `jakub/sm-bm-plugin` (public, a Go plugin with `go.mod` and `plugin.go`, no `plugin.yaml`). It sits in a personal namespace, uses vendor `jz` and is mounted only in `sm-bm-app` (`plugins/jz/bm`). The phase goal covers "the public Golem15 plugins", and the previous verifications used the same golem15-organisation scope, so it is recorded here as information, not a gap. If it is not meant to be public, making it private settles the question.
|
||||
|
||||
### Plan must-haves (summary)
|
||||
|
||||
| Plan | Truths | Status | Notes |
|
||||
|------|--------|--------|-------|
|
||||
| 34-01 kiosk, plugin:manifest, PluginInfo | 10 | 10 ✓ | 87921c2 changes `kiosk/identity.go` (see below); README and docs updated in the same commit; docs checker passes |
|
||||
| 34-02 plugin.yaml rollout | 7 | 7 ✓ | 25 manifests in 24 repos, all fresh (gate `--plugins`, re-run) |
|
||||
| 34-03 registry and /marketplace | 9 | 9 ✓ | No change |
|
||||
| 34-04 plugin:install / plugin:update | 9 | 9 ✓ | No change |
|
||||
| 34-05 admin Plugins screen | 6 | 6 ✓ | No change |
|
||||
| 34-06 tests, gate, ledger | 6 | 6 ✓ | Gate `PLUGIN_REPOS` has 24 entries and passes; release log rows 21-24 list the four new tags (golem v0.1.2, feedback v0.1.2, quizzes v0.1.1, presentations v0.1.2 on 3eb4de7) and eight pointer bumps; all eight current pointers match the committed gitlinks and every target is a descendant of them. One prose sentence below the pointer table still carries the old counts (warning below) |
|
||||
|
||||
**Score:** 52/52 truths verified (5 roadmap SCs + 47 plan truths; 0 present-but-behavior-unverified)
|
||||
|
||||
### 87921c2: fetch a required module whose replace directory is missing
|
||||
|
||||
`resolver.list` (`modules/kiosk/identity.go`) now marks a `go list -m -e -json` entry as `fallback` when it has a `Dir`, no `Error` and the directory does not exist, and clears `Dir`/`GoMod` so `download` fetches it at the go.mod-required version. `minimums` downloads fallbacks in their own `go mod download` batch, after the framework-requiring candidates, and after the download skips a fallback whose go.mod does not require the framework.
|
||||
|
||||
- **Correctness:** `TestDeriveMissingReplace` (file:// proxy, offline) PASS: with `../user` absent, `acme.user` gets `v0.1.2`; with a version the proxy lacks, the minimum is `""` and there is one "not matched" warning.
|
||||
- **Real layout:** in `sm-feedback-plugin`, with a temporary go.work that replaces only the framework (so go.mod's `replace sm-user-plugin => ../user` points at a directory that does not exist), `summer plugin:manifest --check .` prints `plugin.yaml is up to date` (`golem15.user: v0.1.0`). The manifest is the same in both layouts, as the commit claims.
|
||||
- **No regression:** an existing replace directory, or a module-cache module, keeps its `Dir`, so the path is unchanged. A fallback at `v0.0.0` or without a version is dropped as before (the old code read a missing go.mod and skipped it). Gate `--plugins` reports all manifests fresh; `go test -short ./...` is green.
|
||||
- **Info:** all fallbacks share one download, so one unfetchable fallback version drops the minimums of the other fallbacks too, with a warning. The commit message says "one unfetchable version does not hold up the others", which is true only of fallbacks against candidates. A standalone checkout whose sibling replace is missing now reaches the module proxy (network) in `TestPluginManifest`.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `modules/kiosk/{manifest,identity,check,registry,version,client}.go` | ✓ VERIFIED | identity.go +40/-9 (87921c2), substantive |
|
||||
| `modules/kiosk/derive_test.go` | ✓ VERIFIED | `TestDeriveMissingReplace`, `writeProxyModule` |
|
||||
| `../sm-{golem,feedback,quizzes,presentations}-plugin/{plugin.yaml,manifest_test.go}` | ✓ VERIFIED | Tracked, generated header, `framework: v0.6.0`; feedback/quizzes/presentations `requires: golem15.user: v0.1.0`; golem no requires; overrides block where `--name` was used |
|
||||
| `../sm-presentations-plugin/README.md` | ✓ VERIFIED | cfa0c40 (rebased), H1 "Presentations" + summary paragraph that the manifest's name and description come from |
|
||||
| `scripts/check-phase34.sh` | ✓ VERIFIED | `PLUGIN_REPOS` 24 entries |
|
||||
| `34-release-log.md` | ✓ VERIFIED | Rows 21-24, eight pointer rows and the 2026-10-30 presentations note match git; one stale prose sentence (warning) |
|
||||
| All other artifacts | ✓ VERIFIED | Unchanged since the previous run |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| sm-*-plugin/manifest_test.go | kiosk/check.go | `kiosk.CheckT(t, ".")` | WIRED (24/24 repos, 25 packages) |
|
||||
| kiosk `minimums` | kiosk `download` | fallback batch then `requiresFramework` | WIRED |
|
||||
| check-phase34.sh `--plugins` | 24 sibling repos | `PLUGIN_REPOS`, `summer plugin:manifest --check` | WIRED |
|
||||
| sm-bm-app / sm-demo-app presentations gitlinks | sm-presentations-plugin 3eb4de7 | fast-forward (ancestry) | WIRED (d205cd7 and 5bcbaaf both ancestors) |
|
||||
| All other links | | | WIRED (unchanged) |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| go vet + short suite (framework) | `go vet ./... && go test -short -count=1 ./...` | exit 0 in the previous run; no framework code changed since | ✓ PASS |
|
||||
| New kiosk test | `go test -short ./modules/kiosk -run TestDeriveMissingReplace -v` | PASS (previous run) | ✓ PASS |
|
||||
| Fallback layout | `GOWORK=<framework-only go.work> summer plugin:manifest --check .` in sm-feedback-plugin | "plugin.yaml is up to date" | ✓ PASS |
|
||||
| Anonymous fetch (4 new) | `git -c credential.helper= ls-remote https://git.golem15.com/golem15/<repo>.git` | refs returned | ✓ PASS |
|
||||
| Forge scope | anonymous `/api/v1/orgs/golem15/repos`, `/repos/search?q=sm-`, `?q=plugin` | 24 public golem15 sm-*-plugin repos, all in PLUGIN_REPOS | ✓ PASS |
|
||||
| Presentations plugin (this run) | `go vet ./... && go test -short -count=1 ./...`; `summer plugin:manifest --check .` | exit 0; up to date | ✓ PASS |
|
||||
| Ledger pointers vs gitlinks (this run) | `git ls-tree HEAD plugins/golem15/presentations` in sm-bm-app and sm-demo-app; `merge-base --is-ancestor` | d205cd7 and 5bcbaaf, as in the ledger; both ancestors of 3eb4de7 | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| `scripts/check-phase34.sh --plugins` | `bash scripts/check-phase34.sh --plugins` (this run) | `phase34 plugins passed (24 repositories)`, exit 0, 25 fresh manifests (sm-user-plugin TestRegisterCORSPath skip note, pre-existing) | PASS |
|
||||
| `scripts/check-phase34.sh --go --docs` | same (previous run) | `PASS go`, `PASS docs`, exit 0 | PASS |
|
||||
| `scripts/check-phase34.sh --self-test --marker --additive` | same (previous run) | all PASS, exit 0 | PASS |
|
||||
|
||||
Only `--plugins` was re-run: since the previous verification the only commit is c739a7d (planning docs) plus the plugin-repo rebase, which `--plugins` covers. The site, admin and docker stages passed in earlier runs and nothing they cover has changed.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| ID | Plans | Status | Evidence |
|
||||
|----|-------|--------|----------|
|
||||
| SC-1 | 01, 02, 06 | ✓ SATISFIED | 24/24 public golem15 plugin repos |
|
||||
| SC-2..SC-5 | | ✓ SATISFIED | unchanged |
|
||||
| D-01..D-19 | | ✓ SATISFIED | unchanged; D-05 (Go-style minimums) extended by 87921c2 |
|
||||
|
||||
No orphaned IDs.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| 87921c2 files, the four new plugin files, c739a7d | - | TBD/FIXME/XXX | - | none found |
|
||||
| `34-release-log.md` | 209 | stale counts: "presentations 12 commits after sm-demo-app's v0.1.1 and 2 after sm-bm-app's 30a47ac" | ⚠️ Warning | Now 13 after v0.1.1 (row 24 already says 13) and 2 after sm-bm-app's d205cd7 (3 after 30a47ac). The fast-forward claim itself is true; the table rows two lines above and the closing note are correct, so following the ledger does the right thing. The previous gap asked for this sentence to be corrected and c739a7d did not touch it |
|
||||
| `34-02-SUMMARY.md` | 260 | same stale sentence ("12 commits after sm-demo-app's v0.1.1 and 2 after sm-bm-app's 30a47ac") | ⚠️ Warning | Same correction |
|
||||
| `34-release-log.md` | 241 | "the three of the SC-1 gap closure" followed by four URLs | ℹ️ Info | Wording from 211be60; should say four |
|
||||
| `modules/kiosk/identity.go` | minimums | fallbacks share one download batch | ℹ️ Info | one unfetchable fallback hides the others' minimums (with a warning) |
|
||||
| `../sm-oauthserver-plugin/go.work` | - | tracked file modified since 2026-10-23 | ℹ️ Info | pre-existing, outside the phase |
|
||||
| `../sm-demo-app/plugins/golem15/oauthserver` | - | worktree at b6a9586, committed gitlink 8bbec72 | ℹ️ Info | both are ancestors of d600e2c, so the bump is still a fast-forward |
|
||||
| `../sm-bm-app/plugins/golem15/presentations` | - | mount master ahead 1 of origin (d205cd7) | ℹ️ Info | expected until `../sm-presentations-plugin` master is pushed, as the ledger note says |
|
||||
|
||||
### Advisory (New Scope, Unevidenced)
|
||||
|
||||
None.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
1. **Live /marketplace:** after the deploy, list, search, category and detail pages in light and dark themes.
|
||||
2. **Production ingest:** registry-add the launch URLs, ingest, curl the API (release log step 7).
|
||||
3. **Admin Plugins screen:** Installed and Marketplace tabs against the live registry, light and dark themes, copy to clipboard.
|
||||
4. **Real-terminal installs:** `summer plugin:install golem15.faq`, `--source`, a private git URL over ssh/https, and `summer plugin:update` with the interactive prompt.
|
||||
5. **Fix semantics:** CR-01, CR-02 and WR-10 version and compatibility rules (34-REVIEW-FIX.md flags them); their regression tests pass.
|
||||
|
||||
These are user-gated by design (deploy, push, tags, a real browser and terminal), so the status is human_needed rather than passed.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps remain. The release-ledger regression is closed. In `../sm-presentations-plugin` the README and manifest commits now sit on top of d205cd7, the same commit sm-bm-app points at. Both app pointers (sm-bm-app d205cd7, sm-demo-app 5bcbaaf = v0.1.1) are ancestors of 3eb4de7, so both bumps are fast-forwards and v0.1.2 carries the sztab theme. The plugin tree is clean, its tests, vet and manifest check pass, and the phase gate passes over all 24 repositories. The ledger rows and 34-02-SUMMARY name the new SHAs, and no b6247aa/bb81f4d reference is left in the phase docs.
|
||||
|
||||
One warning remains. The fast-forward sentence below the pointer table (release log line 209, repeated in 34-02-SUMMARY line 260) still says "12 commits after sm-demo-app's v0.1.1 and 2 after sm-bm-app's 30a47ac". It should say 13, and 2 after sm-bm-app's d205cd7. This is a two-number edit. The sentence's claim is still true and the rows are correct, so it does not block.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-30T11:38:52Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
362
.planning/phases/34-plugin-marketplace/34-release-log.md
Normal file
362
.planning/phases/34-plugin-marketplace/34-release-log.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Phase 34 release log
|
||||
|
||||
The user-gated release of the plugin marketplace: framework `v0.6.0`, a new tag in every public plugin repository, the summercms.io site plugin and pages, the application pointer bumps and the summercms.io deploy. Nothing in this file has been pushed, tagged or deployed; plan 34-06 only wrote it. Jakub runs the steps in order and records each one under "Executed steps".
|
||||
|
||||
Decisions this ledger follows:
|
||||
|
||||
- **Plugin tags (Jakub, 2026-10-29):** every plugin that got a `plugin.yaml` commit in this phase (the 20 `sm-*-plugin` repositories of 34-02 and the 4 of its SC-1 gap closure: golem, feedback, quizzes, presentations) and `sm-summercmsio-plugin` get a new release tag: the next patch after the newest existing tag, `v0.1.0` for a repository without one.
|
||||
- **Changelog (Jakub, 2026-10-29):** only the framework `v0.6.0` tag gets a post on https://summercms.io/changelog. The plugin tags get **no** `/changelog/plugins` posts; the framework post and the tag message list the plugin bumps instead.
|
||||
- **App pointer bumps:** deferred in 34-02 ("defer"); they move here and target the tagged plugin commits.
|
||||
- Memory rules: tag only at phase completion after full verification, tags only on the default branch, every tag contains the earlier tags.
|
||||
|
||||
## Planned contents
|
||||
|
||||
### Framework `v0.6.0` (summercms.go, master)
|
||||
|
||||
Newest tag before the phase: `v0.5.3`. master is 32 commits ahead of origin/master at the time of writing (planning docs of other sessions included). Code commits of the phase:
|
||||
|
||||
| Plan | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 34-01 | ec6a195 | feat(kiosk): plugin.yaml manifest and summer plugin:manifest |
|
||||
| 34-01 | 51ec2f9 | feat(kiosk): resolve constant plugin IDs, overrides and the CheckT helper |
|
||||
| 34-01 | 4916ee3 | feat(kiosk): registry v1 wire types and version rules |
|
||||
| 34-01 | e8d3d72 | feat(build): bake plugin metadata and the framework version into plugins.gen.go |
|
||||
| 34-04 | 9b23e87 | feat(build): summer plugin:install for registry plugins as Go modules |
|
||||
| 34-04 | a462bf1 | feat(build): plugin:install resolves requirements, pins and prompts |
|
||||
| 34-04 | 8093587 | feat(build): plugin:install --source and git URLs as submodules |
|
||||
| 34-04 | d024ab4 | feat(build): summer plugin:update |
|
||||
| 34-05 | c4b1ac4 | feat(cabana): read-only Plugins screen with installed plugins |
|
||||
| 34-05 | 7e8a193 | feat(cabana): plugin updates and marketplace search on the Plugins screen |
|
||||
| 34-05 | 678eac4 | build(admin): rebuild the embedded admin for the Plugins screen |
|
||||
| 34-06 | b93a6de | test: phase 34 gate script |
|
||||
| 34-06 | 9109e5b | test(kiosk): manifest, identity, version and client unit tests |
|
||||
| 34-06 | 7de774b | test(build): plugin dirs, install resolution and summer.yaml edits (two fixes) |
|
||||
| 34-06 | a231309 | test(summer): plugin command prompts |
|
||||
| 34-06 | df2ba57 | test(cabana): Plugins screen endpoints (one fix) |
|
||||
| 34-06 | bad273b | test(admin): PluginsView and settings paths |
|
||||
| 34-06 | dfc6127 | test: phase 34 gate requires the summercms.io unit tests |
|
||||
| 34 gap | 87921c2 | fix(kiosk): fetch a required plugin module whose replace directory is missing |
|
||||
|
||||
The admin dist was rebuilt in 678eac4 and 34-06 changed no `admin/src` file (only `admin/tests`), so no rebuild is needed before the tag; `scripts/check-admin-dist.sh` passes.
|
||||
|
||||
### Plugin repositories: current tag -> new tag
|
||||
|
||||
Each repository's new commit is the 34-02 manifest commit (`feat: plugin.yaml manifest for the SummerCMS marketplace`); every `plugin.yaml` says `framework: v0.6.0` and every `go.mod` requires `git.golem15.com/golem15/summercms v0.6.0` (with the development `replace => ../summercms.go`, which consumers ignore). Patch bumps throughout: the change is a manifest file, a test and the framework minimum.
|
||||
|
||||
| # | Repository | Branch | Commit to tag | Commits since the last tag | Current tag | New tag | Push blocker |
|
||||
|---|------------|--------|---------------|----------------------------|-------------|---------|--------------|
|
||||
| 1 | sm-translate-plugin | main | 7e64224 | 1 | v0.1.2 | **v0.1.3** | - |
|
||||
| 2 | sm-user-plugin | master | 97f83cc | 1 | v0.1.18 | **v0.1.19** | - |
|
||||
| 3 | sm-chat-plugin | main | rebased b952128 (new SHA) | 1 (+12 on origin) | v0.1.3 | **v0.1.4** | 1 ahead, 12 behind: rebase first |
|
||||
| 4 | sm-faq-plugin | main | 3bf54cd | 1 | v0.1.1 | **v0.1.2** | - |
|
||||
| 5 | sm-menu-plugin | main | 98df4c7 | 1 | v0.1.1 | **v0.1.2** | - |
|
||||
| 6 | sm-seo-plugin | main | 55f5e5d | 22 (21 review fixes + manifest) | v0.1.0 | **v0.1.1** | - |
|
||||
| 7 | sm-sitemap-plugin | main | 049f3ee | 1 | v0.1.1 | **v0.1.2** | - |
|
||||
| 8 | sm-mfa-plugin | master | 544017c | 1 | v0.1.2 | **v0.1.3** | - |
|
||||
| 9 | sm-oauthserver-plugin | master | d600e2c | 1 | v0.1.5 | **v0.1.6** | - (an uncommitted go.work edit of another session stays unstaged) |
|
||||
| 10 | sm-sociallogin-plugin | master | 47b3bb2 | 1 | v0.1.5 | **v0.1.6** | - |
|
||||
| 11 | sm-paymentgateway-plugin | main | 300bc09 | 1 | v0.1.1 | **v0.1.2** | - |
|
||||
| 12 | sm-paymentgatewaysubscriptions-plugin | main | c81e385 | 1 | v0.1.2 | **v0.1.3** | - |
|
||||
| 13 | sm-pgstripe-plugin (root and `subscriptions/`) | main | d61508f | 1 | v0.1.3 | **v0.1.4** | - |
|
||||
| 14 | sm-journal-plugin | main | 5b38a3b | 4 (3 API fixes + manifest) | v0.2.5 | **v0.2.6** | - |
|
||||
| 15 | sm-chatvideo-plugin | main | 33ff4f5 | 8 | none | **v0.1.0** | - |
|
||||
| 16 | sm-forum-plugin | main | 36bd875 | 7 | none | **v0.1.0** | - |
|
||||
| 17 | sm-herald-plugin | main | db8a1bb | 7 | none | **v0.1.0** | - |
|
||||
| 18 | sm-userfriends-plugin | main | 2f29b26 | 6 | none | **v0.1.0** | - |
|
||||
| 19 | sm-chatchannels-plugin | main | rebased 322629b (new SHA) | 10 | none | **v0.1.0** | 1 ahead, 1 behind (origin a30f04a touches go.mod): rebase first |
|
||||
| 20 | sm-chatbots-plugin | main | rebased 0cd78f2 (new SHA) | 7 | none | **v0.1.0** | 1 ahead, 1 behind (origin d0b9fb1 drops sibling replaces): rebase first; builds only once v0.6.0 is on the remote |
|
||||
| 21 | sm-golem-plugin | master | 89fd90a | 1 | v0.1.1 | **v0.1.2** | - |
|
||||
| 22 | sm-feedback-plugin | master | dd78bd2 | 1 | v0.1.1 | **v0.1.2** | - |
|
||||
| 23 | sm-quizzes-plugin | master | 67a83db | 1 | v0.1.0 | **v0.1.1** | - |
|
||||
| 24 | sm-presentations-plugin | master | 3eb4de7 | 13 (10 unreleased feature/test commits, the sztab theme d205cd7 from sm-bm-app's mount, README, manifest) | v0.1.1 | **v0.1.2** | - |
|
||||
|
||||
Rows 21-24 are the SC-1 gap closure (34-02-SUMMARY, "Gap closure: SC-1 remaining repos"); fresh clones, 0 behind origin/master (golem, feedback and quizzes 1 ahead; presentations 3 ahead: the sztab theme d205cd7 from sm-bm-app's mount, `docs: README` cfa0c40, then the manifest commit 3eb4de7). Their go.mod replaces point at an application mount (`../../../../summercms.go`, `../user`); next to the framework they build through an untracked, git-excluded `go.work` (framework `../summercms.go`, user `../sm-demo-app/plugins/golem15/user`), so the `GOWORK=off go build` in step 3 does not apply to them: use `go build ./...`. Their manifests need framework commit 87921c2 (in v0.6.0). sm-presentations-plugin had no README.md, so a README was added first (cfa0c40 `docs: README`, H1 "Presentations"); its manifest needs no name override. Its v0.1.2 also carries the 10 commits since v0.1.1 (deck image, revision and deck tools over MCP, signed one-time image uploads), which sm-bm-app already runs.
|
||||
|
||||
Requirements inside the set (from the manifests): journal and faq need translate; chat, mfa, oauthserver, sociallogin, paymentgateway, forum and userfriends need user; chatvideo needs chat; chatchannels and chatbots need chat and user; paymentgatewaysubscriptions needs paymentgateway and user; pgstripe needs paymentgateway, and its `subscriptions/` plugin needs paymentgatewaysubscriptions; feedback, quizzes and presentations need user; golem needs none.
|
||||
|
||||
### summercms.io repositories
|
||||
|
||||
| Repository | Branch | Commits | Tag | Push |
|
||||
|------------|--------|---------|-----|------|
|
||||
| sm-summercmsio-plugin (`sm-summercmsio-app/plugins/golem15/summercms`) | master | 34-03: 242d641, f6a5949, 08e04de, 5853cf5; 34-06: c52e721 `test: registry ingest, queries and API` (5 ahead of origin) | none -> **v0.1.0** on c52e721 | yes |
|
||||
| vue-summercmsio-app | master | 34-03: 05f1cea `feat: marketplace pages`; 34-06: 8267c55 `test: marketplace utilities` (2 ahead) | none (consumed as a submodule) | yes |
|
||||
| sm-summercmsio-app | master | 34-03: 458a4f1, 06593c3, 0707fdc, fd8fe3b, 97c0c08; 34-06: 352314a `test: changelog-post manifest and ingest helpers` (6 ahead) | none | yes, after its gitlink commits |
|
||||
|
||||
## Release order
|
||||
|
||||
Run every step only after the previous one is pushed and verified. No `--force` anywhere; never move a tag.
|
||||
|
||||
### 1. Gate
|
||||
|
||||
```sh
|
||||
cd summercms.go
|
||||
bash scripts/check-phase34.sh --all # Docker host (sudo -u golem when the socket is denied)
|
||||
```
|
||||
|
||||
Expected: `Phase 34 gate passed`. The `--plugins` stage skips sm-user-plugin's pre-existing `TestRegisterCORSPath` (it needs an application around it) and prints that skip.
|
||||
|
||||
### 2. Framework v0.6.0
|
||||
|
||||
```sh
|
||||
cd summercms.go
|
||||
git fetch origin && git status # master fast-forwards over origin/master
|
||||
go vet ./... && go test -short ./...
|
||||
git tag -a v0.6.0 -F <tag message below>
|
||||
git push origin master
|
||||
git push origin v0.6.0
|
||||
git ls-remote --tags origin v0.6.0
|
||||
```
|
||||
|
||||
Then the changelog post (the only post of this release), from `../sm-summercmsio-app`:
|
||||
|
||||
```sh
|
||||
scripts/changelog-post.py source v0.6.0 # material for the notes
|
||||
scripts/changelog-post.py publish v0.6.0 notes.md --excerpt "Plugin marketplace: plugin.yaml manifests, summer plugin:install and plugin:update, and a Plugins screen in the admin."
|
||||
```
|
||||
|
||||
The notes reuse the tag message below (`## Highlights` / `## Added` / `## Changed`, no H1, no planning IDs), including the "Plugins released alongside" section.
|
||||
|
||||
### 3. Plugins, in dependency order
|
||||
|
||||
For each repository in the table order (translate, user, chat, faq, menu, seo, sitemap, mfa, oauthserver, sociallogin, paymentgateway, paymentgatewaysubscriptions, pgstripe, journal, chatvideo, forum, herald, userfriends, then chatchannels and chatbots, then golem, feedback, quizzes and presentations):
|
||||
|
||||
```sh
|
||||
cd ../sm-<name>-plugin
|
||||
git fetch origin && git status # ahead only, except chat/chatchannels/chatbots
|
||||
GOWORK=off go build ./... && go vet ./... && go test -short ./... # TestPluginManifest included
|
||||
summer plugin:manifest --check . # and `--check subscriptions` in sm-pgstripe-plugin
|
||||
git tag -a <new tag> -m "<new tag>: plugin.yaml for the SummerCMS marketplace; requires SummerCMS v0.6.0"
|
||||
git push origin <branch>
|
||||
git push origin <new tag>
|
||||
```
|
||||
|
||||
No `changelog-post.py publish --repo` for the plugins (no plugin posts this release). That script normally runs the manifest check and asks the registry to ingest; here the manifest check is the explicit command above, and the ingest happens in step 6.
|
||||
|
||||
The three rebases (34-02 push blockers), each before its tag:
|
||||
|
||||
- **sm-chat-plugin:** `git rebase origin/main`; resolve go.mod/go.sum (keep origin's 12 commits, require summercms v0.6.0); `summer plugin:manifest .` to regenerate plugin.yaml against the rebased sources and amend it into the manifest commit; build, vet, test; tag `v0.1.4` on the rebased commit.
|
||||
- **sm-chatchannels-plugin** (after chat is tagged): `git rebase origin/main` over a30f04a (optional HTTP API switch, touches go.mod); require sm-chat-plugin `v0.1.4` if the rebased code needs it, else keep v0.1.3; regenerate plugin.yaml; build, vet, test; tag `v0.1.0`.
|
||||
- **sm-chatbots-plugin** (after chat is tagged): `git rebase origin/main` over d0b9fb1 (drops sibling replaces); with no `../summercms.go` replace the build and `TestPluginManifest` resolve framework v0.6.0 from the remote, so step 2 must be done; regenerate plugin.yaml; build, vet, test; tag `v0.1.0`.
|
||||
|
||||
Their new SHAs replace b952128, 322629b and 0cd78f2 in the pointer bumps below.
|
||||
|
||||
### 4. summercms.io site plugin and pages
|
||||
|
||||
```sh
|
||||
cd ../sm-summercmsio-app/plugins/golem15/summercms
|
||||
go vet ./... && go test ./... # Docker: TestRegistrySmoke, TestRegistryAPIShapes, TestRegistryIngestEndpoint
|
||||
git tag -a v0.1.0 -m "v0.1.0: plugin registry and marketplace API"
|
||||
git push origin master && git push origin v0.1.0
|
||||
cd ../../../vue-summercmsio-app
|
||||
pnpm test && pnpm build:changelog && pnpm test:changelog && pnpm test:marketplace
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### 5. Applications
|
||||
|
||||
Per application, in separate commits (research F10: the regenerated `main.go` and `plugins.gen.go` never ride in a pointer-bump commit):
|
||||
|
||||
1. `go get git.golem15.com/golem15/summercms@v0.6.0` (where go.mod pins a framework version; apps that `replace` it with `../summercms.go` still get the require raised) -> commit `chore: require SummerCMS v0.6.0`.
|
||||
2. Plugin pointer bumps to the tagged commits below, `git -C plugins/golem15/<name> checkout <tag>` then stage only the gitlinks -> commit `chore: bump plugin pointers for plugin.yaml`.
|
||||
3. `summer build` (regenerates `main.go` and `plugins.gen.go` with `PluginInfo` and `FrameworkVersion v0.6.0`) -> commit `chore: regenerate main.go and plugins.gen.go for SummerCMS v0.6.0`.
|
||||
4. Release build per the application's DEPLOY.md, then push.
|
||||
|
||||
Pending gitlinks (from 34-02 and 34-03; "tag" is the tagged commit):
|
||||
|
||||
| Application | Path | Current | Target |
|
||||
|-------------|------|---------|--------|
|
||||
| sm-demo-app | plugins/golem15/faq | b3cc978 | v0.1.2 (3bf54cd) |
|
||||
| sm-demo-app | plugins/golem15/journal | f5a0394 | v0.2.6 (5b38a3b) |
|
||||
| sm-demo-app | plugins/golem15/menu | 8656474 | v0.1.2 (98df4c7) |
|
||||
| sm-demo-app | plugins/golem15/mfa | 463c750 | v0.1.3 (544017c) |
|
||||
| sm-demo-app | plugins/golem15/oauthserver | 8bbec72 | v0.1.6 (d600e2c) |
|
||||
| sm-demo-app | plugins/golem15/paymentgateway | e0badb5 | v0.1.2 (300bc09) |
|
||||
| sm-demo-app | plugins/golem15/paymentgatewaysubscriptions | 1ec96df | v0.1.3 (c81e385) |
|
||||
| sm-demo-app | plugins/golem15/pgstripe | 6d07d98 | v0.1.4 (d61508f) |
|
||||
| sm-demo-app | plugins/golem15/seo | b19cd06 | v0.1.1 (55f5e5d) |
|
||||
| sm-demo-app | plugins/golem15/sitemap | 96c790d | v0.1.2 (049f3ee) |
|
||||
| sm-demo-app | plugins/golem15/sociallogin | ecfb8a7 | v0.1.6 (47b3bb2) |
|
||||
| sm-demo-app | plugins/golem15/translate | 54cf93b | v0.1.3 (7e64224) |
|
||||
| sm-demo-app | plugins/golem15/user | 07196a4 | v0.1.19 (97f83cc) |
|
||||
| sm-grzybyfunkcjonalne-app | plugins/golem15/faq | b3cc978 | v0.1.2 (3bf54cd) |
|
||||
| sm-grzybyfunkcjonalne-app | plugins/golem15/journal | ffa2c8f | v0.2.6 (5b38a3b) |
|
||||
| sm-grzybyfunkcjonalne-app | plugins/golem15/menu | 8656474 | v0.1.2 (98df4c7) |
|
||||
| sm-grzybyfunkcjonalne-app | plugins/golem15/sitemap | 96c790d | v0.1.2 (049f3ee) |
|
||||
| sm-grzybyfunkcjonalne-app | plugins/golem15/translate | 54cf93b | v0.1.3 (7e64224) |
|
||||
| sm-grzybyfunkcjonalne-app | plugins/golem15/user | bfcfea4 | v0.1.19 (97f83cc) |
|
||||
| sm-bm-app | plugins/golem15/chat | 51c9e17 (= origin/main) | v0.1.4 (rebased chat) |
|
||||
| sm-bm-app | plugins/golem15/chatbots | d0b9fb1 (= origin/main) | v0.1.0 (rebased chatbots) |
|
||||
| sm-bm-app | plugins/golem15/chatchannels | a30f04a (= origin/main) | v0.1.0 (rebased chatchannels) |
|
||||
| sm-bm-app | plugins/golem15/mfa | 463c750 | v0.1.3 (544017c) |
|
||||
| sm-bm-app | plugins/golem15/oauthserver | df2eddd | v0.1.6 (d600e2c) |
|
||||
| sm-bm-app | plugins/golem15/sociallogin | ecfb8a7 | v0.1.6 (47b3bb2) |
|
||||
| sm-bm-app | plugins/golem15/user | 66fc107 | v0.1.19 (97f83cc) |
|
||||
| sm-summercmsio-app | plugins/golem15/journal | ffa2c8f | v0.2.6 (5b38a3b) |
|
||||
| sm-summercmsio-app | plugins/golem15/translate | 1134dd1 | v0.1.3 (7e64224) |
|
||||
| sm-summercmsio-app | plugins/golem15/user | 50da92c | v0.1.19 (97f83cc) |
|
||||
| sm-summercmsio-app | plugins/golem15/summercms | ca59415 | v0.1.0 (c52e721) |
|
||||
| sm-summercmsio-app | vue-summercmsio-app | b3bfc5d | 8267c55 (pushed master) |
|
||||
| fonoteka.go | plugins/golem15/user | a1b78b8 | v0.1.19 (97f83cc) |
|
||||
| sm-demo-app | plugins/golem15/golem | 37748ae | v0.1.2 (89fd90a) |
|
||||
| sm-demo-app | plugins/golem15/feedback | ce4b43e | v0.1.2 (dd78bd2) |
|
||||
| sm-demo-app | plugins/golem15/quizzes | 362dc02 | v0.1.1 (67a83db) |
|
||||
| sm-bm-app | plugins/golem15/quizzes | 362dc02 | v0.1.1 (67a83db) |
|
||||
| fonoteka.go | plugins/golem15/golem | 37748ae | v0.1.2 (89fd90a) |
|
||||
| fonoteka.go | plugins/golem15/feedback | ce4b43e | v0.1.2 (dd78bd2) |
|
||||
| sm-demo-app | plugins/golem15/presentations | 5bcbaaf | v0.1.2 (3eb4de7) |
|
||||
| sm-bm-app | plugins/golem15/presentations | d205cd7 (committed in 9929195) | v0.1.2 (3eb4de7) |
|
||||
|
||||
40 plugin gitlinks plus the vue app link (the last eight rows come from the SC-1 gap closure). Every non-chat bump is a fast-forward (checked in 34-02; the gap-closure targets are fast-forwards of the commit each app points at today: golem, feedback and quizzes one commit on top, presentations 13 commits after sm-demo-app's v0.1.1 and 2 after sm-bm-app's d205cd7). No application mounts chatvideo, forum, herald or userfriends: those need only their tag. sm-grzybyfunkcjonalne-app (1) and fonoteka.go (39) already carry unpushed commits of their own; sm-summercmsio-app's working tree already shows its two gitlinks modified (left uncommitted by 34-03 and 34-06 on purpose).
|
||||
|
||||
For sm-summercmsio-app the framework bump is already committed (97c0c08 `chore: require summercms v0.6.0`); `scripts/build.sh dev` regenerates `main.go` and `plugins.gen.go`, which 34-03 restored to the committed versions: commit the regeneration in step 3.
|
||||
|
||||
## summercms.io deploy
|
||||
|
||||
From `../sm-summercmsio-app/DEPLOY.md`, "Plugin marketplace (the release that brings it)", after step 5 for sm-summercmsio-app:
|
||||
|
||||
1. `scripts/check-deploy.sh` (nginx and supervisor configs).
|
||||
2. git on rome: `ssh rome 'command -v git && git --version'`; install git if empty (the ingester clones over https).
|
||||
3. Release build and deploy per DEPLOY.md "Release": `migrate` creates the registry tables and the River queue tables; `config/queue.yaml` sets `work_in_serve: true`, so `serve` runs the hourly `summercms:registry-ingest` and the ingest-now jobs (`sudo supervisorctl tail summercms-io` shows the worker).
|
||||
4. Reinstall the nginx site file (`/marketplace` to Node, POST only on `= /api/registry/v1/ingest`, `client_max_body_size 8k` there) and the changelog supervisor program (`NUXT_REGISTRY_API_BASE`), as under "Cutover", step 3; `nginx -t` and reload.
|
||||
5. Mint the registry token on rome (Jakub only; never by an agent): `sudo -u summercms ./bin/summercms-io admin:token:create <you> --name registry --scope registry --expires 365d`, stored in a password manager and `~/.config/summercms/registry-token` (mode 600). With no plugin posts this release it is not needed yet; it serves later `changelog-post.py` plugin posts.
|
||||
6. Curate the fourteen launch repositories once their new tags are pushed:
|
||||
|
||||
```
|
||||
https://git.golem15.com/golem15/sm-chat-plugin.git
|
||||
https://git.golem15.com/golem15/sm-faq-plugin.git
|
||||
https://git.golem15.com/golem15/sm-journal-plugin.git
|
||||
https://git.golem15.com/golem15/sm-menu-plugin.git
|
||||
https://git.golem15.com/golem15/sm-mfa-plugin.git
|
||||
https://git.golem15.com/golem15/sm-oauthserver-plugin.git
|
||||
https://git.golem15.com/golem15/sm-paymentgateway-plugin.git
|
||||
https://git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin.git
|
||||
https://git.golem15.com/golem15/sm-pgstripe-plugin.git
|
||||
https://git.golem15.com/golem15/sm-seo-plugin.git
|
||||
https://git.golem15.com/golem15/sm-sitemap-plugin.git
|
||||
https://git.golem15.com/golem15/sm-sociallogin-plugin.git
|
||||
https://git.golem15.com/golem15/sm-translate-plugin.git
|
||||
https://git.golem15.com/golem15/sm-user-plugin.git
|
||||
```
|
||||
|
||||
`sudo -u summercms ./bin/summercms-io summercms:registry-add <the fourteen URLs>` then `summercms:registry-ingest` (first ingest, one line per repository). Only tags carrying a `plugin.yaml` are listed, so each repository publishes from its new tag; older tags are noted and skipped. The six repositories tagged for the first time here (chatchannels, chatvideo, forum, herald, userfriends, chatbots) can be added the same way when Jakub wants them listed, and so can the four of the SC-1 gap closure (`https://git.golem15.com/golem15/sm-golem-plugin.git`, `sm-feedback-plugin.git`, `sm-quizzes-plugin.git`, `sm-presentations-plugin.git`), which already had releases.
|
||||
7. Curl the API:
|
||||
|
||||
```sh
|
||||
curl -s https://summercms.io/api/registry/v1/plugins | head -c 300
|
||||
curl -sI -H 'Origin: https://example.com' https://summercms.io/api/registry/v1/plugins
|
||||
curl -sI https://summercms.io/marketplace
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://summercms.io/api/registry/v1/ingest # 401
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://summercms.io/api/registry/v1/plugins # 403
|
||||
```
|
||||
|
||||
8. Visual check of `/marketplace` (list, search, a category, a detail page) and of an application's Settings -> System -> Plugins screen against the live registry; then a real `summer plugin:install golem15.faq` on a scratch application (34-04's open manual item).
|
||||
|
||||
## Framework v0.6.0: tag message (draft)
|
||||
|
||||
The annotated tag message; the changelog notes reuse it without the first line.
|
||||
|
||||
```
|
||||
SummerCMS v0.6.0: plugin marketplace
|
||||
|
||||
Plugins now describe themselves in a generated plugin.yaml, applications
|
||||
install and update them with one summer command, and the admin shows what
|
||||
an application was built with. summercms.io lists the plugins in its new
|
||||
marketplace.
|
||||
|
||||
## Highlights
|
||||
|
||||
- plugin.yaml: every plugin carries a manifest generated from its own
|
||||
sources (ID and required plugins from plugin.go, module and version
|
||||
minimums from go.mod, name and description from the README). It is
|
||||
committed next to plugin.go and never edited by hand.
|
||||
- summer plugin:install <vendor.name> adds a plugin from the registry: the
|
||||
newest release your framework version allows, with the plugins it
|
||||
requires, after printing the plan and asking (or --yes). --source and git
|
||||
URLs install it as a git submodule instead.
|
||||
- summer plugin:update moves one or all installed plugins to their newest
|
||||
compatible release.
|
||||
- The admin has a Plugins screen (Settings -> System -> Plugins): installed
|
||||
plugins with versions and sources, update badges and a marketplace search,
|
||||
each with the summer command to copy.
|
||||
|
||||
## Added
|
||||
|
||||
- New module kiosk: the plugin.yaml format (Manifest, Marshal, Parse,
|
||||
Validate), Derive/Generate/Write/Check, CheckT for a one-line go test
|
||||
guard in plugin repositories, the registry API v1 wire types, version
|
||||
rules (ValidVersion, CompareVersions, BaseVersion, Satisfies,
|
||||
NewestCompatible) and the registry client (kiosk.Client, NewClient,
|
||||
RegistryURL).
|
||||
- summer plugin:manifest [dir] with --check, --pretend, --name and
|
||||
--description. Plugin IDs and requirements may be literals or constants
|
||||
of the same package, another package or another module; nothing is run.
|
||||
- summer plugin:install and summer plugin:update, with --yes and
|
||||
--offline; plugin:install also takes vendor.name@vX.Y.Z and --source.
|
||||
- summer build bakes PluginInfo (name, description, module, package,
|
||||
version and source of each plugin) and FrameworkVersion into
|
||||
plugins.gen.go; the generated main publishes them with party.Describe,
|
||||
and party.Installed exposes them to plugins.
|
||||
- Config key plugins.registry_url (default https://summercms.io;
|
||||
SUMMER_PLUGINS__REGISTRY_URL overrides it; an empty value disables every
|
||||
registry call).
|
||||
- Permission backend.view_plugins (cabana.PermissionViewPlugins) for the
|
||||
Plugins screen; superusers always have it. Admin API GET /plugins and
|
||||
GET /plugins/marketplace; settings entries may open an admin route
|
||||
(SettingsEntry.Path).
|
||||
|
||||
## Changed
|
||||
|
||||
- summer plugin:add reads constant plugin IDs, and plugin:add and
|
||||
plugin:install append to summer.yaml in place: comments, blank lines and
|
||||
the item layout stay as they were.
|
||||
- Every application's main.go and plugins.gen.go change on the first
|
||||
summer build with v0.6.0; commit the regeneration with the upgrade.
|
||||
|
||||
## Plugins released alongside
|
||||
|
||||
Every public plugin now ships a plugin.yaml and requires SummerCMS v0.6.0:
|
||||
|
||||
- sm-translate-plugin v0.1.3
|
||||
- sm-user-plugin v0.1.19
|
||||
- sm-chat-plugin v0.1.4
|
||||
- sm-faq-plugin v0.1.2
|
||||
- sm-menu-plugin v0.1.2
|
||||
- sm-seo-plugin v0.1.1
|
||||
- sm-sitemap-plugin v0.1.2
|
||||
- sm-mfa-plugin v0.1.3
|
||||
- sm-oauthserver-plugin v0.1.6
|
||||
- sm-sociallogin-plugin v0.1.6
|
||||
- sm-paymentgateway-plugin v0.1.2
|
||||
- sm-paymentgatewaysubscriptions-plugin v0.1.3
|
||||
- sm-pgstripe-plugin v0.1.4
|
||||
- sm-journal-plugin v0.2.6
|
||||
- sm-chatvideo-plugin v0.1.0 (first release)
|
||||
- sm-forum-plugin v0.1.0 (first release)
|
||||
- sm-herald-plugin v0.1.0 (first release)
|
||||
- sm-userfriends-plugin v0.1.0 (first release)
|
||||
- sm-chatchannels-plugin v0.1.0 (first release)
|
||||
- sm-chatbots-plugin v0.1.0 (first release)
|
||||
- sm-golem-plugin v0.1.2
|
||||
- sm-feedback-plugin v0.1.2
|
||||
- sm-quizzes-plugin v0.1.1
|
||||
- sm-presentations-plugin v0.1.2
|
||||
```
|
||||
|
||||
If a rebase or a check changes a plugin's version before tagging, update this list and the table above together.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never move a tag.** The Go module proxy and the checksum database cache a tagged version forever (research Pitfall 7); a wrong tag is fixed with the next patch, never by deleting and re-pushing.
|
||||
- Tags only on the default branch (master or main of each repository); each tag must contain the earlier tags of that repository.
|
||||
- The framework tag and its changelog post happen the same day. The plugin tags of this release get no posts (Jakub's decision); later plugin releases follow the normal rule (a `/changelog/plugins` post through `changelog-post.py publish --repo`).
|
||||
- Push before bump: an application pointer moves only to a commit that is pushed and tagged.
|
||||
- No `--force`, no `--no-verify`; tokens are minted and handled only by Jakub and never written into this file.
|
||||
|
||||
## Executed steps
|
||||
|
||||
| Step | Date | What was done | Result (SHA, tag object, URL) |
|
||||
|------|------|---------------|-------------------------------|
|
||||
| | | | |
|
||||
|
||||
|
||||
**Note (2026-10-30, presentations):** sm-bm-app's mount had an unpushed commit `d205cd7 feat: offer the sztab presentation theme` (gitlink committed in sm-bm-app 9929195). The README and manifest commits were rebased onto it in `../sm-presentations-plugin` (new SHAs cfa0c40, 3eb4de7), so v0.1.2 includes the theme and both app bumps are fast-forwards. Push `../sm-presentations-plugin` master (it carries d205cd7); the mount in sm-bm-app then fast-forwards to 3eb4de7.
|
||||
18
.planning/phases/34-plugin-marketplace/COVERAGE.md
Normal file
18
.planning/phases/34-plugin-marketplace/COVERAGE.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# API Coverage — SummerCMS plugin registry API v1
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions. Phase 34 builds the registry API itself (server in 34-03, contract types in 34-01) and consumes it from the CLI (34-04), cabana (34-05), the /marketplace pages (34-03) and the release script (34-03). It also reaches two outside services through their own tools: the Go module proxy through the go command and git hosting through git.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| GET /api/registry/v1/plugins with q and category (list and search) | INTEGRATE | |
|
||||
| GET /api/registry/v1/plugins/{id} (detail: README, screenshots, requires) | INTEGRATE | |
|
||||
| GET /api/registry/v1/plugins/{id}/versions (tags with their minimums) | INTEGRATE | |
|
||||
| POST /api/registry/v1/ingest (authenticated ingest-now, token scope registry) | INTEGRATE | |
|
||||
| error envelope (404 not_found, 400 invalid_query) decoded by clients | INTEGRATE | |
|
||||
| Cache-Control and ETag with 304 on If-None-Match (server side) | INTEGRATE | |
|
||||
| conditional requests (If-None-Match) sent by kiosk.Client and cabana | OPT-OUT | not needed yet: cabana keeps an in-process TTL cache and the CLI makes one-shot calls; adding revalidation later changes no contract |
|
||||
| pagination of the plugin list (page, per_page) | OPT-OUT | not needed yet: the launch listing is fourteen plugins; adding pagination later is an additive change within v1 |
|
||||
| Go module proxy (proxy.golang.org) through go get and go list -m | INTEGRATE | |
|
||||
| git hosting: anonymous HTTPS (ingester), user's https or ssh (CLI) | INTEGRATE | |
|
||||
| forge webhooks (push or tag events) | OPT-OUT | explicitly out of scope: D-13 chose a pull ingester plus ingest-now, no forge webhooks |
|
||||
| forge HTTP APIs (raw files, tag listings) | OPT-OUT | explicitly out of scope: D-13 locks anonymous clones, which keeps ingestion independent of the forge |
|
||||
212
.planning/quick-batches/261028-rqa/BATCH.json
Normal file
212
.planning/quick-batches/261028-rqa/BATCH.json
Normal file
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"batch_id": "261028-rqa",
|
||||
"created_at": "2026-10-28T18:57:57.955Z",
|
||||
"options": {},
|
||||
"base_revision": "825a3fd2daa55f7906920bad60d42dd338874bae",
|
||||
"items": [
|
||||
{
|
||||
"quick_id": "261028-rqb",
|
||||
"client_id": null,
|
||||
"description": "Admin SPA reload on deploy (todo .planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md, admin half only; site half already shipped in vue-demo-app): boardwalk exposes a build version derived from the embedded dist (header like X-Summer-Build on admin API responses and/or a version endpoint), the admin SPA records the version it booted with and shows a non-blocking reload banner when a newer build is seen (focus/visibility/interval check), plus a reload-once guard on Vite chunk-load errors (vite:preloadError + sessionStorage flag); unit tests; boardwalk README; rebuild admin and commit modules/boardwalk/dist (scripts/check-admin-dist.sh must pass)",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/boardwalk/boardwalk.go",
|
||||
"modules/boardwalk/boardwalk_test.go",
|
||||
"modules/boardwalk/README.md",
|
||||
"modules/boardwalk/dist/index.html",
|
||||
"modules/boardwalk/dist/assets/",
|
||||
"docs/backend/admin-spa.md",
|
||||
"modules/phrasebook/backend/lang/en/lang.yaml",
|
||||
"modules/phrasebook/backend/lang/pl/lang.yaml",
|
||||
"admin/index.html",
|
||||
"admin/src/app/runtime.ts",
|
||||
"admin/src/app/buildUpdate.ts",
|
||||
"admin/src/app/chunkReload.ts",
|
||||
"admin/src/components/ui/UpdateBanner.vue",
|
||||
"admin/src/App.vue",
|
||||
"admin/src/main.ts",
|
||||
"admin/tests/app/runtime.test.ts",
|
||||
"admin/tests/app/buildUpdate.test.ts",
|
||||
"admin/tests/app/chunkReload.test.ts",
|
||||
"admin/tests/ui/UpdateBanner.test.ts",
|
||||
"admin/tests/fixtures/lang.json"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "c8a22e3",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqc",
|
||||
"client_id": null,
|
||||
"description": "Quiet plugin boot logs on console commands like --help, framework side only (todo .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md): help/list/completion style commands must not print plugin Register/Boot Info logs (e.g. raise the default console log level for non-serve introspection commands or defer Boot for them), without hiding logs for serve/worker/migrate; no sm-* plugin edits (plugin-side Info->Debug changes are a separate follow-up); docs for the behaviour",
|
||||
"status": "complete",
|
||||
"depends_on": [
|
||||
"261028-rqd"
|
||||
],
|
||||
"planned_files": [
|
||||
"modules/diary/helponly.go",
|
||||
"modules/diary/diary.go",
|
||||
"modules/diary/export_test.go",
|
||||
"modules/diary/helponly_test.go",
|
||||
"modules/diary/bootquiet_smoke_test.go",
|
||||
"modules/diary/README.md",
|
||||
"docs/services/logging.md",
|
||||
"docs/console/introduction.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 2,
|
||||
"commit": "6c606c6",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqd",
|
||||
"client_id": null,
|
||||
"description": "Console review info findings IN-01..IN-13 and the delegate signal exit code 128+n (todo .planning/todos/pending/2026-10-22-console-review-info-findings.md, review .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md); all 14 verified still open on 2026-10-28 in modules/bonfire, modules/diary, cmd/summer, scripts/check-phase19.8.sh; fix each, keep behaviour backward compatible, update docs/console pages and TestDelegateExitCodePassthrough for 128+n",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/bonfire/errors.go",
|
||||
"modules/bonfire/errors_test.go",
|
||||
"modules/bonfire/execute.go",
|
||||
"modules/bonfire/root.go",
|
||||
"modules/bonfire/root_test.go",
|
||||
"modules/bonfire/globals.go",
|
||||
"modules/bonfire/globals_test.go",
|
||||
"modules/bonfire/command.go",
|
||||
"modules/bonfire/command_test.go",
|
||||
"modules/bonfire/list.go",
|
||||
"modules/bonfire/README.md",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"modules/cabana/token_commands.go",
|
||||
"modules/cabana/commands.go",
|
||||
"modules/surf/routelist_command.go",
|
||||
"modules/surf/summary_test.go",
|
||||
"modules/lighthouse/centrifugo/commands.go",
|
||||
"modules/lagoon/commands.go",
|
||||
"modules/lagoon/keygen.go",
|
||||
"modules/conga/commands.go",
|
||||
"docs/console/writing-commands.md",
|
||||
"docs/console/introduction.md",
|
||||
"scripts/check-phase19.8.sh",
|
||||
".planning/research/STACK.md",
|
||||
".planning/notes/2026-10-21-console-charm-stack.md",
|
||||
"modules/bonfire/live.go",
|
||||
"modules/bonfire/live_test.go",
|
||||
"modules/bonfire/output.go",
|
||||
"modules/bonfire/output_test.go",
|
||||
"modules/bonfire/styles.go",
|
||||
"modules/bonfire/helpers.go",
|
||||
"internal/consolekit/consolekit.go",
|
||||
"internal/consolekit/consolekit_test.go",
|
||||
"modules/diary/console.go",
|
||||
"modules/diary/diary.go",
|
||||
"modules/diary/channels.go",
|
||||
"modules/diary/diary_test.go",
|
||||
"modules/diary/console_internal_test.go",
|
||||
"modules/diary/deps_test.go",
|
||||
"modules/diary/README.md",
|
||||
"cmd/summer/runtime.go",
|
||||
"cmd/summer/runtime_test.go",
|
||||
"docs/console/scaffolding.md",
|
||||
"docs/setup/upgrading.md",
|
||||
".planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md",
|
||||
".planning/todos/completed/2026-10-22-console-review-info-findings.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "202d38b",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqe",
|
||||
"client_id": null,
|
||||
"description": "attach.Store writes non-inline types with a non-rendering disposition (todo .planning/todos/pending/2026-10-28-attach-store-public-content-type.md): in modules/lagoon/attach/store.go pass Content-Disposition attachment (blob.WriterOptions) for anything not in an inline-safe raster allow-list (jpeg, png, gif, webp, avif), keep the DB ContentType unchanged, additive and non-breaking; tests; lagoon README and docs/services/storage.md deploy note updated",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/lagoon/attach/inline.go",
|
||||
"modules/lagoon/attach/inline_test.go",
|
||||
"modules/lagoon/attach/store.go",
|
||||
"modules/lagoon/attach/store_test.go",
|
||||
"modules/lagoon/attach/thumb_test.go",
|
||||
"modules/lagoon/README.md",
|
||||
"docs/services/storage.md",
|
||||
"docs/database/attachments.md",
|
||||
"modules/surf/uploads.go",
|
||||
"modules/surf/README.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "4957e5c",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqf",
|
||||
"client_id": null,
|
||||
"description": "Audit-log successful admin action runs with the admin id (todo .planning/todos/pending/admin-action-audit-log.md): in modules/cabana/actions.go runAction log success with admin_id (bouncer.User(ctx).ID), controller, action and kind (toolbar/field), matching the existing bulk/record action log fields; tests",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/cabana/actions.go",
|
||||
"modules/cabana/action_run_log_test.go",
|
||||
"modules/cabana/README.md",
|
||||
"docs/backend/users-and-permissions.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "61d1043",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqg",
|
||||
"client_id": null,
|
||||
"description": "Show the administrators holding a role on the Roles screen (todo .planning/todos/pending/roles-screen-holders-list.md, origin .planning/phases/19.6.1-backend-administrators-screen/19.6.1-UAT.md:44): read-only list of holders on the Roles form/preview in modules/cabana backend_roles_admin.go (and admin SPA if needed), superusers hidden from non-superuser editors (D-03), assignment stays on the Administrators form; tests; if admin/ changes, rebuild and commit modules/boardwalk/dist",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/cabana/backend_roles_admin.go",
|
||||
"modules/cabana/backendscreens/models/userrole/fields.yaml",
|
||||
"modules/cabana/backendscreens/controllers/roles/_users.htm",
|
||||
"modules/phrasebook/backend/lang/en/lang.yaml",
|
||||
"modules/phrasebook/backend/lang/pl/lang.yaml",
|
||||
"modules/cabana/README.md",
|
||||
"docs/backend/users-and-permissions.md",
|
||||
"modules/cabana/backend_screens_smoke_test.go",
|
||||
"modules/cabana/backend_screens_test.go",
|
||||
"modules/cabana/roles_postgres_test.go"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 1,
|
||||
"commit": "653d95a",
|
||||
"failure_reason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: quick-261028-fsq
|
||||
plan: 01
|
||||
quick_id: 261028-fsq
|
||||
slug: sm-user-plugin-mcpdispatch-credential-ca
|
||||
type: execute
|
||||
mode: quick
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements:
|
||||
- fsq-1 # MCPUserGuard.MCPDispatch: for a personal-token subject (ClientID empty, as MCPResolvePersonalToken builds it) the in-memory *models.APIToken credential carries ID = subject.TokenID, so a handler under MCP dispatch can record which personal token made a change
|
||||
- fsq-2 # an OAuth subject (ClientID set) keeps credential ID 0 even when its TokenID is non-zero (that id is the OAuth server's token row, not a user_api_tokens row); the credential is still never saved
|
||||
- fsq-3 # MCPDispatch doc comment and the README "MCP guard" paragraph describe the new credential id; one fix commit in sm-user-plugin, no trailer, not pushed, not tagged, the fonoteka.go submodule pointer untouched
|
||||
files_modified:
|
||||
# sm-user-plugin (module git.golem15.com/golem15/sm-user-plugin), a separate git repo checked out as the
|
||||
# fonoteka.go submodule plugins/golem15/user (clean at v0.1.13 = 07196a4 on master). Edit and commit THERE only.
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
- ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 2
|
||||
confidence: low # estimate-calibration: factor 1, sample_count 0
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A subject resolved from a personal usr_ token by MCPResolvePersonalToken (ClientID empty, TokenID = the token row id) is dispatched by MCPUserGuard.MCPDispatch with a *models.APIToken credential whose ID equals that token's id, whose UserID is the user's and whose scopes are the session's."
|
||||
- "That credential, carried by the bouncer dispatch marker, still passes the plugin's own route chain: GET /_user/api/v1/token/me answers 200 with the user's email, and the user's user_api_tokens row count is unchanged afterwards (the credential is not saved)."
|
||||
- "A subject with ClientID set and a non-zero TokenID (even one equal to an existing user_api_tokens id) is dispatched with credential ID 0, UserID the user's and the session's scopes."
|
||||
- "A subject with neither ClientID nor TokenID (the existing tests' shape) still gets credential ID 0; TestUserMCPGuardSmoke, TestUserMCPGuardClientIP and the existing TestUserMCPGuard subtests pass unchanged."
|
||||
- "MCPDispatch's doc comment and the README's MCP guard paragraph say the credential carries a personal token's id and keeps ID 0 for an OAuth subject, and that it is never saved."
|
||||
- "One new commit on sm-user-plugin master (descendant of 07196a4), subject starting with 'fix:', touching exactly README.md, classes/mcp_guard.go and mcp_guard_test.go, no Co-Authored-By trailer, go.mod/go.sum unchanged, green on gofmt, GOWORK=off go vet, go test -short and the full Docker-backed go test; not pushed, not tagged; the fonoteka.go repo has nothing staged or committed for plugins/golem15/user."
|
||||
artifacts:
|
||||
- path: ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
provides: "MCPDispatch sets credential.ID = subject.TokenID when subject.ClientID is empty; updated doc comment"
|
||||
contains: "subject.ClientID == \"\""
|
||||
- path: ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
provides: "TestUserMCPGuard subtests 'dispatch credential carries the personal token id' and 'dispatch credential of an OAuth subject has no id' (Docker-backed, skip under -short)"
|
||||
contains: "dispatch credential carries the personal token id"
|
||||
- path: ../fonoteka.go/plugins/golem15/user/README.md
|
||||
provides: "MCP guard paragraph describes the credential id"
|
||||
contains: "the credential's ID is that token's id"
|
||||
key_links:
|
||||
- from: "classes/mcp_guard.go MCPResolvePersonalToken"
|
||||
to: "classes/mcp_guard.go MCPDispatch"
|
||||
via: "pact.MCPSubject{ClientID: \"\", TokenID: token.ID} built by the resolver and handed back by the framework MCP endpoint"
|
||||
pattern: "TokenID: token.ID"
|
||||
- from: "MCPDispatch credential"
|
||||
to: "bouncer.Credential(r.Context()) in route handlers / middleware.Scope"
|
||||
via: "pact.MCPDispatch.Credential -> bouncer.Dispatch.Credential"
|
||||
pattern: "Credential: credential"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the in-memory token credential that `MCPUserGuard.MCPDispatch` (sm-user-plugin) builds for an MCP tool call carry the personal API token's id when the MCP session was opened with a personal `usr_` token, so a consuming application's handler that reads `bouncer.Credential` under MCP dispatch can record which personal token made a change. For an OAuth subject the credential keeps ID 0, because that subject's `TokenID` is the OAuth server plugin's own token row id, not a `user_api_tokens` row.
|
||||
|
||||
Purpose: today the credential is always built with ID 0, so attribution of changes to a personal token is impossible under MCP dispatch (the HTTP `user.api_token` guard path already hands handlers the real row).
|
||||
Output: one `fix:` commit in the sm-user-plugin repo (code, tests, README), local only.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/mcp_guard_smoke_test.go
|
||||
|
||||
Established facts (do not re-investigate):
|
||||
|
||||
- Work directory for all code edits, tests and the commit: `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user` (its own git repo, `origin` = git.golem15.com/golem15/sm-user-plugin, branch master, clean at tag v0.1.13 = `07196a4`). It is a submodule of fonoteka.go; committing in it makes fonoteka.go show the pointer as modified. Do NOT stage or commit that pointer in fonoteka.go. Nothing in summercms.go is edited by this plan (the orchestrator commits the planning docs).
|
||||
- Run Go commands with `GOWORK=off` from the plugin directory (fonoteka.go/go.work lists this plugin; GOWORK=off tests it against its own go.mod requirement, summercms v0.5.0, as the previous user-plugin quick tasks did). Docker is reachable directly (no sudo needed).
|
||||
- Interfaces in play (framework, `git.golem15.com/golem15/summercms/modules/pact`): `MCPSubject{Guard string; UserID uint; Scopes []string; ClientID string; TokenID uint}`; `MCPDispatch{Principal any; Credential any; Guards []string}`. `MCPPersonalTokenResolver` doc: a personal-token subject's UserID and TokenID are both set and TokenID identifies the token row. Framework convention (modules/pier/backend_guard.go): `subject.ClientID == "" && subject.TokenID != 0` identifies a personal-token subject.
|
||||
- `models.APIToken.ID` is `uint` (column id, table `user_api_tokens`), the same type as `pact.MCPSubject.TokenID`.
|
||||
- `MCPResolvePersonalToken` (same file) returns `pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: ..., TokenID: token.ID}` with ClientID empty.
|
||||
- Test harness (package `user`, root of the plugin): `newAdminEnv(t)` skips under `-short` (adminHarnessDB: "requires testcontainers postgres"); `env.seedUser(t, label, nil)`, `env.mintUserToken(t, user, scopes...) (secret, *models.APIToken)` (stores the row), `env.gdb` (*gorm.DB), `env.h` (http.Handler of the app), `adminStamp()` (unique suffix). `TestUserMCPGuard` already looks up `guard` (`guards.Lookup("user")`) and `resolver` (`env.app.Lookup[pact.MCPPersonalTokenResolver]()`) and imports bouncer, models, pact, httptest, http, strings. Its subtest "dispatch credential against user.scope" shows how to run a route in-process with the dispatch marker (`bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential})` then `env.h.ServeHTTP`).
|
||||
- Existing assertions that the credential has ID 0 (`mustDispatchCredential` in mcp_guard_test.go, line ~113; TestUserMCPGuardSmoke line ~121) use subjects without TokenID and without ClientID; they stay correct and must not be changed in meaning.
|
||||
- Within the plugin, nothing reads the credential's ID (only `middleware.Scope` reads its scopes) and nothing saves it; the user-api throttle keys on the JWT subject, not the credential.
|
||||
- The plugin keeps no CHANGELOG and no version file; releases are git tags only. No changelog entry is planned; the v0.1.14 tag needs the user's go-ahead and is out of scope.
|
||||
- Commit style in this repo: `fix: <sentence>` subject plus a short prose body; fix commits include their tests and README edits; no trailers.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Personal-token MCP dispatch credential carries the token id, end to end (tests first, then fix, doc comment, README)</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/mcp_guard_test.go, ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<behavior>
|
||||
- Subtest "dispatch credential carries the personal token id" (in TestUserMCPGuard): seed a user (label "mcp-token-id"), mint a personal token with scope read via env.mintUserToken, resolve its secret with resolver.MCPResolvePersonalToken (expect matched, nil error, ClientID empty, TokenID equal to the minted row's ID), dispatch that resolved subject with guard.MCPDispatch(ctx, subject, http.MethodGet); the credential is a *models.APIToken with ID equal to the minted token's ID, UserID equal to the user's, HasScope("read") true, HasScope("write") false. Running GET /_user/api/v1/token/me in-process with the bouncer dispatch marker carrying that principal and credential answers 200 and contains the user's email. Afterwards the count of user_api_tokens rows with that user_id is exactly 1 (counted through env.gdb with the models.APIToken model), proving the credential was not saved.
|
||||
- Subtest "dispatch credential of an OAuth subject has no id": seed a user (label "mcp-oauth-id"), mint a personal token for it (only to obtain a real user_api_tokens id), then dispatch pact.MCPSubject with Guard "user", that user's ID, Scopes read, ClientID "oauth-client-" plus adminStamp(), and TokenID deliberately equal to the minted token's ID; the credential is a *models.APIToken with ID 0, UserID the user's and HasScope("read") true.
|
||||
- Unchanged: a subject without ClientID and without TokenID still yields credential ID 0 (mustDispatchCredential and TestUserMCPGuardSmoke keep asserting this).
|
||||
- RED expectation: before the fix the first subtest fails on the credential ID (0 instead of the token id); the OAuth subtest already passes (it guards against over-applying the fix).
|
||||
</behavior>
|
||||
<action>
|
||||
All paths relative to /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user. Do not commit in this task (Task 2 commits after the full gates).
|
||||
|
||||
1. RED (mcp_guard_test.go): add the two subtests from the behavior block to TestUserMCPGuard, after the existing "dispatch credential against user.scope" subtest, with exactly those t.Run names. Reuse ctx, guard, resolver and env from the enclosing test; build the in-process request the same way the existing scope subtest does (type-assert d.Principal to *bouncer.Principal, wrap the request context with bouncer.WithDispatch, serve through env.h with httptest). Fail with t.Fatalf messages that print the got values (credential via %+v). Extend TestUserMCPGuard's doc comment with one clause saying the dispatch credential carries a personal token's id (TokenID) and keeps ID 0 for an OAuth subject. Update the doc comment of mustDispatchCredential to say it builds the credential for a read subject without a TokenID, which therefore has ID 0 and is not saved; keep its assertion unchanged. Run the RED command `GOWORK=off go test -count=1 -run '^TestUserMCPGuard$' .` and confirm the personal-token subtest fails on the ID and the OAuth subtest passes.
|
||||
|
||||
2. GREEN (classes/mcp_guard.go, MCPDispatch): keep the credential literal as is (UserID and the cloned Scopes); right after it, when subject.ClientID is empty, set the credential's ID to subject.TokenID (a personal-token subject; with TokenID 0 this leaves ID 0, matching the framework convention in pier's backend guard). For a subject with ClientID set leave ID 0: its TokenID belongs to the OAuth server plugin's token table. Do not load the token row from the database, do not copy other columns (Name, CollectionIDs, hash), do not change Guards or the principal. Keep the credential in memory only.
|
||||
|
||||
3. Doc comment of MCPDispatch: keep the first sentence (starting "MCPDispatch builds the principal") and the last one about the marker; replace the sentence in between that states the credential always has ID 0 with this text, wrapped to the file's comment width: "For a personal-token subject (ClientID empty) the credential's ID is the subject's TokenID, the user_api_tokens row the session was opened with, so a handler can record which token made a change; an OAuth subject's TokenID is the OAuth server's own token row, not one of this plugin's, and its credential keeps ID 0. The credential is never saved."
|
||||
|
||||
4. README.md, the "MCP guard and the `user_me` tool." paragraph (Features list): replace the parenthetical that follows "an in-memory token credential carrying the session's scopes" (it currently states the id is always 0) so the sentence reads: "A tool call runs the route with the user and an in-memory token credential carrying the session's scopes; for a session opened with a personal token the credential's ID is that token's id (the subject's `TokenID`), so a handler can record which token made a change, while an OAuth session's credential has ID 0. The credential is never saved." Leave the rest of the paragraph and the Testing section unchanged.
|
||||
|
||||
5. gofmt the two Go files, then run the verify command below until green.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user && test -z "$(gofmt -l classes/mcp_guard.go mcp_guard_test.go)" && GOWORK=off go vet ./... && O=$(GOWORK=off go test -count=1 -v -run '^TestUserMCPGuard$/^dispatch_credential_(carries_the_personal_token_id|of_an_OAuth_subject_has_no_id)$' .) && grep -qF -- '--- PASS: TestUserMCPGuard/dispatch_credential_carries_the_personal_token_id ' <<<"$O" && grep -qF -- '--- PASS: TestUserMCPGuard/dispatch_credential_of_an_OAuth_subject_has_no_id ' <<<"$O" && GOWORK=off go test -count=1 -run '^(TestUserMCPGuard|TestUserMCPGuardSmoke|TestUserMCPGuardClientIP)$' . && F=$(awk '/^func \(g \*MCPUserGuard\) MCPDispatch\(/,/^}/' classes/mcp_guard.go) && grep -qF 'subject.ClientID == ""' <<<"$F" && grep -qF 'subject.TokenID' <<<"$F" && ! grep -qF 'Find(' <<<"$F" && D=$(awk '/^\/\/ MCPDispatch builds the principal/,/^func \(g \*MCPUserGuard\) MCPDispatch\(/' classes/mcp_guard.go) && grep -qF 'TokenID' <<<"$D" && grep -qF 'keeps ID 0' <<<"$D" && grep -qF 'never saved' <<<"$D" && ! grep -qF 'has ID 0 and is never saved' <<<"$D" && R=$(grep -F '**MCP guard and the `user_me` tool.**' README.md) && grep -qF "the credential's ID is that token's id" <<<"$R" && grep -qF "OAuth session's credential has ID 0" <<<"$R" && ! grep -qF '(ID 0, never saved)' <<<"$R" && grep -qF 'tok.ID != 0' mcp_guard_test.go</automated>
|
||||
</verify>
|
||||
<done>Both new subtests exist and pass against Docker Postgres (not skipped); the personal-token subtest failed before the fix; MCPDispatch sets the credential ID from TokenID only for a subject without ClientID; doc comment and README paragraph describe the id and the OAuth exception; TestUserMCPGuardSmoke, TestUserMCPGuardClientIP and the existing TestUserMCPGuard subtests pass unchanged; nothing committed yet.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Full gates and the single fix commit in sm-user-plugin (local only)</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go, ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<action>
|
||||
In /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user:
|
||||
|
||||
1. Confirm `git status --short` lists only README.md, classes/mcp_guard.go and mcp_guard_test.go as modified (if anything else is dirty, another session is working here: stop and report instead of committing) and that HEAD is still 07196a4 or a descendant of it.
|
||||
2. Run the gates: gofmt on the two Go files, `GOWORK=off go vet ./...`, `GOWORK=off go test -short -count=1 ./...`, then the full Docker-backed `GOWORK=off go test -count=1 ./...`. Fix any failure caused by Task 1's change before committing (do not weaken an existing test).
|
||||
3. Stage exactly the three files by path (never `git add -A` or `git add .`) and commit on master with subject `fix: the MCP dispatch credential carries the personal token id` and this body (wrapped at ~72 columns): "MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the subject's TokenID when the subject came from a personal usr_ token (no ClientID), so a handler running under MCP dispatch can record which token made a change. An OAuth subject's TokenID names the OAuth server's token row, so its credential keeps ID 0. The credential is still never saved." No attribution trailers of any kind (user and project rule).
|
||||
4. Do not push, do not tag (v0.1.14 needs the user's go-ahead), do not touch go.mod/go.sum. In the parent fonoteka.go repo do not stage or commit the plugins/golem15/user pointer; leave it showing as modified.
|
||||
5. In the summary, record the new commit's short SHA, the gate results (and whether the Docker suite ran rather than skipped), and that the release tag and the fonoteka.go pointer bump are pending the user's go-ahead.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user && test -z "$(gofmt -l classes/mcp_guard.go mcp_guard_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='fix: the MCP dispatch credential carries the personal token id' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && git merge-base --is-ancestor 07196a4bcbfee1f200c17f66cad147a4c091cb26 "$C" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md classes/mcp_guard.go mcp_guard_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go log --format=%h e36577a..HEAD -- plugins/golem15/user)"</automated>
|
||||
</verify>
|
||||
<done>sm-user-plugin master has one new local `fix:` commit (descendant of 07196a4) touching exactly README.md, classes/mcp_guard.go and mcp_guard_test.go, with no trailer; gofmt, vet, the -short suite and the full Docker-backed suite are green; the commit is neither pushed nor tagged; fonoteka.go has no staged or committed pointer change for plugins/golem15/user.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP endpoint -> guard provider | The framework MCP endpoint hands MCPDispatch a subject it resolved earlier (personal token via this plugin's resolver, or an OAuth token via the OAuth server plugin); the subject is trusted in-process data, not client input. |
|
||||
| guard provider -> route handlers | The in-memory credential is placed on the request context through the bouncer dispatch marker; handlers of this plugin and of the consuming application read it. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-fsq-01 | Spoofing / Repudiation | MCPDispatch credential ID for OAuth subjects | medium | mitigate | The ID is copied only when subject.ClientID is empty, so an OAuth subject's TokenID (an OAuth server row id) is never presented as a user_api_tokens id and a change is never attributed to an unrelated personal token; the OAuth subtest uses a TokenID equal to a real user_api_tokens id and asserts credential ID 0. |
|
||||
| T-fsq-02 | Tampering | in-memory credential with a real row id | medium | mitigate | With a non-zero ID a handler that saved the credential would overwrite the real token row (empty hash, session scopes). The plugin never saves it (only middleware.Scope reads scopes); the doc comment and README state it is never saved; the personal-token subtest asserts the user's user_api_tokens row count is unchanged after an in-process route run. Consumer handlers are documented to treat it as read-only. |
|
||||
| T-fsq-03 | Information Disclosure | token id visible to in-process handlers | low | accept | The id is a non-secret row id that the HTTP user.api_token guard already exposes to the same handlers through the loaded row; no hash or secret is copied into the credential. |
|
||||
| T-fsq-04 | Elevation of Privilege | scopes on the dispatch credential | low | mitigate | Scopes stay the session's (cloned from the subject) and are not reloaded from the token row; the existing user.scope subtest and the new subtest's HasScope("write") false check keep the scope boundary. |
|
||||
| T-fsq-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs and no go.mod/go.sum change in this plan; Task 2's verify asserts go.mod and go.sum are unchanged in the commit. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 verify: the two new TestUserMCPGuard subtests are reported as PASS (not skipped) against testcontainers Postgres; TestUserMCPGuard, TestUserMCPGuardSmoke and TestUserMCPGuardClientIP pass; the MCPDispatch body gates the ID on an empty ClientID without a DB lookup; doc comment and README carry the new wording and no longer claim the ID is always 0.
|
||||
- Task 2 verify: gofmt, GOWORK=off go vet, go test -short and the full Docker-backed go test are green on the committed tree; exactly one new local fix commit with exactly the three files, no trailer, untagged, unpushed; the fonoteka.go pointer is neither staged nor committed.
|
||||
- Note for the summary: the new tests are Docker-backed and skip under -short (newAdminEnv), like the rest of TestUserMCPGuard.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A personal-token MCP session's tool call gives handlers a credential whose ID is the personal token's id (fsq-1).
|
||||
- An OAuth MCP session's credential keeps ID 0 (fsq-2).
|
||||
- Docs match the behaviour; one clean local `fix:` commit in sm-user-plugin, no push, no tag, no pointer bump (fsq-3).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261028-fsq-sm-user-plugin-mcpdispatch-credential-ca/261028-fsq-SUMMARY.md` when done (planning docs stay in summercms.go; the orchestrator commits them).
|
||||
</output>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: quick-261028-fsq
|
||||
plan: 01
|
||||
quick_id: 261028-fsq
|
||||
subsystem: sm-user-plugin (MCP guard)
|
||||
tags: [mcp, api-tokens, attribution, sm-user-plugin]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "MCPUserGuard.MCPDispatch credential carries the personal token id (subject.TokenID) when subject.ClientID is empty"
|
||||
affects:
|
||||
- "consuming applications' handlers reading bouncer.Credential under MCP dispatch"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "personal-token subject = ClientID empty (framework convention from pier's backend guard)"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
|
||||
- ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
decisions:
|
||||
- "The MCP dispatch credential's ID comes from subject.TokenID only when ClientID is empty; an OAuth subject's TokenID (an OAuth server row) is never presented as a user_api_tokens id"
|
||||
metrics:
|
||||
duration: "~4 min"
|
||||
completed: 2026-10-28
|
||||
requirements: [fsq-1, fsq-2, fsq-3]
|
||||
repo: sm-user-plugin (fonoteka.go/plugins/golem15/user)
|
||||
commits: 1
|
||||
plan_head_before: 07196a4bcbfee1f200c17f66cad147a4c091cb26
|
||||
plan_head_after: a1b78b8
|
||||
actuals:
|
||||
tokens: 25113
|
||||
tasks: 2
|
||||
commits: 1
|
||||
---
|
||||
|
||||
# Quick 261028-fsq Plan 01: MCP dispatch credential carries the personal token id Summary
|
||||
|
||||
`MCPUserGuard.MCPDispatch` in sm-user-plugin now sets the in-memory `*models.APIToken` credential's `ID` to `subject.TokenID` for a personal `usr_` token subject (`ClientID` empty). This lets handlers under MCP dispatch record which personal token made a change. OAuth subjects keep ID 0, and the credential is still never saved.
|
||||
|
||||
## What was done
|
||||
|
||||
- **classes/mcp_guard.go:** after the credential literal, `if subject.ClientID == "" { credential.ID = subject.TokenID }`. There is no DB lookup and no other column is copied. Guards and the principal are unchanged. The doc comment now describes the token id, the OAuth exception and that the credential is never saved.
|
||||
- **mcp_guard_test.go:** two new `TestUserMCPGuard` subtests:
|
||||
- `dispatch credential carries the personal token id` resolves a minted token through `MCPResolvePersonalToken` and dispatches it. It asserts the credential's ID, UserID and scopes (read yes, write no), then runs `GET /_user/api/v1/token/me` in-process with the dispatch marker and expects 200 with the user's email. It finally asserts the user still has exactly 1 `user_api_tokens` row, which shows the credential was not saved.
|
||||
- `dispatch credential of an OAuth subject has no id` uses a subject with ClientID set and TokenID equal to a real `user_api_tokens` id, and asserts the credential ID is 0.
|
||||
|
||||
The doc comments of `TestUserMCPGuard` and `mustDispatchCredential` were updated. The meaning of the existing assertion did not change.
|
||||
- **README.md:** the "MCP guard and the `user_me` tool." paragraph now describes the credential id and the OAuth exception.
|
||||
|
||||
## Commits (sm-user-plugin, master, local only)
|
||||
|
||||
- `a1b78b8`: fix: the MCP dispatch credential carries the personal token id (README.md, classes/mcp_guard.go, mcp_guard_test.go; no trailer)
|
||||
|
||||
## TDD gate
|
||||
|
||||
- RED: `GOWORK=off go test -count=1 -run '^TestUserMCPGuard$' .` failed. The personal-token subtest got credential `ID:0` instead of the token id 3, and the OAuth subtest passed, as the plan predicted. RED was not committed separately because the plan asks for one fix commit that includes its tests.
|
||||
- GREEN: both subtests pass after the fix.
|
||||
|
||||
## Gate results
|
||||
|
||||
- gofmt on both Go files: clean
|
||||
- `GOWORK=off go vet ./...`: clean
|
||||
- `GOWORK=off go test -short -count=1 ./...`: ok
|
||||
- `GOWORK=off go test -count=1 ./...` (full, Docker-backed testcontainers Postgres; ran, not skipped, root package 81.8s): ok
|
||||
- Task 1 verify and Task 2 post-commit verify both passed. Task 2 checked: the commit is a descendant of 07196a4, it touches exactly 3 files, go.mod and go.sum are unchanged, there is no co-author trailer, no tag and no remote branch, the working tree is clean, and the fonoteka.go pointer is neither staged nor committed.
|
||||
|
||||
Note: the new subtests are Docker-backed and skip under `-short` (`newAdminEnv`), like the rest of `TestUserMCPGuard`.
|
||||
|
||||
## Pending (user's go-ahead)
|
||||
|
||||
- Push sm-user-plugin master and tag `v0.1.14`.
|
||||
- Bump the `plugins/golem15/user` submodule pointer in fonoteka.go. It currently shows as modified and is left unstaged on purpose.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed exactly as written. The pre-commit protected-branch assertion was overridden for this repo because the orchestrator's submodule guard and the plan both require the commit on sm-user-plugin `master`.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The change stays within the plan's threat model: T-fsq-01 is covered by the OAuth subtest, T-fsq-02 by the row-count assertion and the docs, and T-fsq-04 by the HasScope("write") false check.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: classes/mcp_guard.go, mcp_guard_test.go, README.md (modified, committed)
|
||||
- FOUND: commit a1b78b8 on sm-user-plugin master
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
phase: quick-261028-pq8
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/build/artifact.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/leaf.go
|
||||
- internal/build/registry.go
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/artifacts.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- internal/build/stubs/registry.tmpl
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/build_test.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/testdata/new/*.golden
|
||||
- internal/build/testdata/new-no-plugin/*.golden
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/setup/porting-a-plugin.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/plugins/registration.md
|
||||
- docs/plugins/testing.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/database/migrations.md
|
||||
- modules/cabana/README.md
|
||||
- docs/examples/blog/scaffold_layout_test.go
|
||||
- docs/examples/blog/plugin.go
|
||||
- docs/examples/blog/registry.gen.go
|
||||
- docs/examples/blog/controllers/posts.go
|
||||
- docs/examples/blog/controllers/posts/config_form.yaml
|
||||
- docs/examples/blog/controllers/posts/config_list.yaml
|
||||
- docs/examples/blog/models/post/fields.yaml
|
||||
- docs/examples/blog/models/post/columns.yaml
|
||||
- docs/examples/blog/models/post.go
|
||||
- docs/examples/blog/console/publish.go
|
||||
- docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go
|
||||
- docs/examples/blog/updates/20260101000100_add_published_at.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:2026-10-28-make-plugin-ignores-vendor-namespace
|
||||
- todo:scaffold-same-second-migration-order
|
||||
- todo:scaffold-admin-controller-incomplete
|
||||
- todo:scaffold-generated-header-and-command-deps
|
||||
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "summer make:plugin pixelpixel.servers creates plugins/pixelpixel/servers with module <app>/plugins/pixelpixel/servers, and acme.blog and pixelpixel.blog can both be scaffolded in one application"
|
||||
- "make:model/migration/command/job/admin-controller find a plugin through summer.yaml, then plugins/<vendor>/<name>, then the legacy plugins/<name>; an existing app with plugins/<name> keeps working unchanged (no breaking change)"
|
||||
- "summer new puts its starter plugin golem15.<name> at plugins/golem15/<name> and the generated app still builds"
|
||||
- "A new migration always gets a timestamp later than every migration already in updates/, so make:model then make:migration in the same second list CreateX before AddY in registry.gen.go"
|
||||
- "Files the developer edits (model, migration, command, job, admin controller) start with //summer:make <kind>; registry.gen.go keeps its Code generated header; files with either the new directive or the old header are listed in registry.gen.go"
|
||||
- "make:command writes func <Name>Command(app *backpack.App) bonfire.Command, the registry lists it and passes the app; a scaffolded plugin keeps the app from Boot and returns generatedCommands(p.app); an older plugin.go that calls generatedCommands() still compiles"
|
||||
- "make:admin-controller acme.blog Posts writes ModelName/modelClass Post and YAML under models/post/, returns &models.Post{} from NewRecord when that model exists, offers create and delete with showCheckboxes: true, and --model overrides the singular"
|
||||
- "A scaffolded plugin implements pact.AdminAssets through generatedAdminFS(), an empty tree until the first controller's YAML exists and the embedded YAML afterwards"
|
||||
- "Docs and CLI help describe the new behavior, include an optional-move upgrading note, and summer docs:build --check stays green"
|
||||
artifacts:
|
||||
- path: internal/build/artifact.go
|
||||
provides: "nextMigrationFile ordering, plugin lookup with vendor + legacy fallback, MakeAdminController model argument, singularize"
|
||||
- path: internal/build/registry.go
|
||||
provides: "scaffold-marker detection (new directive + legacy header), app-taking command scan, admin YAML embed patterns"
|
||||
- path: internal/build/stubs/registry.tmpl
|
||||
provides: "generatedCommands(app ...*backpack.App) and generatedAdminFS()"
|
||||
- path: internal/build/stubs/plugin.tmpl
|
||||
provides: "Plugin keeps app from Boot, Commands passes it, AdminFS wired"
|
||||
- path: internal/build/stubs/artifacts.tmpl
|
||||
provides: "//summer:make <kind> markers, app-taking command stub, model-named admin controller and YAML"
|
||||
- path: docs/console/scaffolding.md
|
||||
provides: "Updated scaffolder reference and upgrading note"
|
||||
key_links:
|
||||
- from: internal/build/stubs/plugin.tmpl
|
||||
to: internal/build/stubs/registry.tmpl
|
||||
via: "plugin.go calls generatedCommands(p.app) and generatedAdminFS(), which registry.gen.go defines"
|
||||
pattern: "generatedCommands\\(p\\.app\\)|generatedAdminFS\\(\\)"
|
||||
- from: docs/examples/blog/scaffold_layout_test.go
|
||||
to: internal/build
|
||||
via: "TestScaffoldLayout runs the make commands and compares the file set with docs/examples/blog"
|
||||
pattern: "plugins\", \"acme\", \"blog\""
|
||||
- from: internal/build/artifact.go
|
||||
to: internal/build/leaf.go
|
||||
via: "one shared default-plugin-dir helper used by locatePluginDir and checkModelsLeaf"
|
||||
pattern: "plugins/<vendor>/<name>"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the four pending `internal/build` scaffolder todos:
|
||||
1. `make:plugin` drops the vendor.
|
||||
2. Migrations created in the same second run out of order.
|
||||
3. Every editable file carries a header saying it is generated and must not be edited, and `make:command` has no way to reach the application.
|
||||
4. `make:admin-controller` output does not boot as a working screen.
|
||||
|
||||
Purpose: `summer make:*` should produce plugins that work without the hand fixes `docs/setup/porting-a-plugin.md` now lists under "What the scaffolder leaves to you". Applications that already use `plugins/<name>`, the old header or zero-argument `generatedCommands()` must keep working, because the core plugins run in many apps.
|
||||
|
||||
Output: three tasks and four code+docs commits (Task 1 makes two commits because the project's CLAUDE.md asks for one logical change per commit). The four todo files are moved by the orchestrator, not by this plan.
|
||||
|
||||
Shape: tracer-first does not apply here. These are three independent fixes on a scaffolder path that already works. Each task is proven end to end by the scaffold tests, which run the make commands in a copy of examples/hello and then build the generated app.
|
||||
|
||||
Orchestrator defaults (fixed, do not re-ask):
|
||||
- OD-1: the starter plugin of `summer new` moves to `plugins/golem15/<name>`.
|
||||
- OD-2: the registry scanner keeps recognizing the old header.
|
||||
- OD-3: the docs get an upgrading note saying that moving `plugins/<name>` is optional.
|
||||
- OD-4: docs and help text change in the same commit as the code they describe, and `summer docs:build --check` stays green.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@./CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/todos/pending/2026-10-28-make-plugin-ignores-vendor-namespace.md
|
||||
@.planning/todos/pending/scaffold-same-second-migration-order.md
|
||||
@.planning/todos/pending/scaffold-admin-controller-incomplete.md
|
||||
@.planning/todos/pending/scaffold-generated-header-and-command-deps.md
|
||||
@internal/build/scaffold.go
|
||||
@internal/build/artifact.go
|
||||
@internal/build/registry.go
|
||||
@internal/build/leaf.go
|
||||
@internal/build/stubs/artifacts.tmpl
|
||||
@internal/build/stubs/plugin.tmpl
|
||||
@internal/build/stubs/registry.tmpl
|
||||
|
||||
Facts read at planning time. Use these, not the line numbers in the todos:
|
||||
- The admin-controller stub ALREADY implements `NewRecord` (returns nil with a TODO) and `RequiredPermissions` with `<id>.access_<snake>` (commit 20a79c5). What is left from that todo: the model name, the YAML directory, a real `NewRecord` when the model exists, `AdminFS` and list `delete`.
|
||||
- `party.Activate` runs Register then Boot, and only after that does the generated `main` call each plugin's `Commands()` (internal/build/build.go). So `Commands()` can use an app saved in Boot.
|
||||
- `docs/examples/blog` is part of the root module. Its `TestScaffoldLayout` runs MakePlugin, MakeModel, MakeMigration, MakeAdminController("Posts") and MakeCommand on acme.blog in a copy of examples/hello, then compares the FILE SET (not the contents) with docs/examples/blog. Every go fence in the porting page must be a `src=` copy of docs/examples/blog (cmd/summer/phase11_1_acceptance_test.go). After editing the example's source, run `go run ./cmd/summer docs:sync`.
|
||||
- examples/hello keeps the old layout (`plugins/base`, `plugins/greeter`, `plugins/optional`). It is the fixture `copyHelloApp` copies, so it covers the legacy-path fallback. Do not move it.
|
||||
- Existing users of the old header and of zero-argument `generatedCommands()` outside this repo include sm-demo-app/plugins/golem15/quizzes and many shared plugins. sm-faq-plugin declares its own `adminFS` var. New registry identifiers must not collide with these, so use `generatedAdminTree` and `generatedAdminFS`; grep found no existing uses of either name.
|
||||
- cabana rules: `modelClass` must equal `ModelName()`. The `delete` toolbar button requires `showCheckboxes: true`. A plugin that has admin controllers but no non-nil `AdminFS()` stops start-up. A zero `embed.FS` is a valid, empty, non-nil fs.FS.
|
||||
- Golden files: `go test ./internal/build -run 'TestNewGolden' -update` rewrites them; `goldenName` maps "/" to "__".
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Migration order, then vendor-namespaced plugin directories (two commits)</name>
|
||||
<files>internal/build/artifact.go, internal/build/scaffold.go, internal/build/leaf.go, internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/build_test.go, internal/build/new_test.go, internal/build/new_golden_test.go, internal/build/testdata/new/*.golden, internal/build/testdata/new-no-plugin/*.golden, cmd/summer/main.go, cmd/summer/main_test.go, docs/examples/blog/scaffold_layout_test.go, docs/console/scaffolding.md, docs/setup/porting-a-plugin.md, docs/architecture/go-modules-and-workspaces.md, docs/plugins/registration.md, docs/plugins/testing.md, docs/database/migrations.md</files>
|
||||
<behavior>
|
||||
- nextMigrationFile with a frozen migrationNow: two different slugs created in the same second get strictly increasing timestamps, so the second file name sorts after the first.
|
||||
- If updates/ already holds a migration whose timestamp is later than now (for example 20990101000000_x.go), the new one gets that timestamp plus one second. Files that do not start with 14 digits and an underscore (doc.go, *_test.go) are ignored.
|
||||
- TestScaffoldCoreArtifacts with a frozen clock: MakeModel Album, then MakeMigration AddWidgetIndex. registry.gen.go lists updates.CreateAlbums() before updates.AddWidgetIndex().
|
||||
- MakePlugin "golem15.demo" returns <app>/plugins/golem15/demo with module <app module>/plugins/golem15/demo. MakePlugin acme.blog and pixelpixel.blog in one hello copy give two directories, each of which builds.
|
||||
- The existing-directory error hint names plugin:add plugins/<vendor>/<name>.
|
||||
- A plugin moved to the legacy plugins/<name> and not listed in summer.yaml is still found by MakeModel (the fallback). Lookup tries plugins/<vendor>/<name> first.
|
||||
- summer new: the starter plugin is at plugins/golem15/demo; go.work, go.mod and summer.yaml point there; the app still builds (TestNewAppSmoke and the goldens).
|
||||
</behavior>
|
||||
<action>
|
||||
COMMIT 1a: migration order.
|
||||
- In internal/build/artifact.go nextMigrationFile, before choosing a timestamp, read the updates/ directory once.
|
||||
- Collect the 14-digit timestamp prefix of every .go file whose name matches 14 digits followed by an underscore.
|
||||
- Start the candidate at migrationNow(). If the candidate is not later than the newest existing timestamp, use the newest plus one second.
|
||||
- Keep the underRoot check, the exact-name existence guard and the MkdirAll.
|
||||
- Add the unit test TestNextMigrationFileSortsAfterExisting with the behavior cases. Freeze migrationNow and restore it in t.Cleanup; do not use t.Parallel.
|
||||
- In TestScaffoldCoreArtifacts, freeze the clock around the MakeModel/MakeMigration pair and assert the order of the registry lines (use the index of each string).
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: replace the paragraph saying that migrations with different names in the same second share a timestamp. New text: each new migration gets a timestamp after every file already in updates/, so registry order is creation order.
|
||||
- docs/setup/porting-a-plugin.md: remove the "Check the migration order" bullet. If the fixed example timestamps 20260101000000/20260101000100 are explained elsewhere on the page, keep that explanation.
|
||||
- grep docs/ and cmd/summer/main.go for "same second" and fix any other mention, including docs/database/migrations.md if it has one.
|
||||
- Commit as fix(build): order scaffolded migrations after every existing one. Stage by explicit path.
|
||||
|
||||
COMMIT 1b: vendor path. Per OD-1 and the todo; nothing breaks for existing apps.
|
||||
- In internal/build/scaffold.go:
|
||||
- Add a helper that returns the vendor (the part before the dot) next to pluginName.
|
||||
- Add one helper that returns the default plugin directory candidates for an ID, in this order: plugins/<vendor>/<name>, then plugins/<name>.
|
||||
- makePlugin creates plugins/<vendor>/<name>. The module path is m.Module + "/plugins/" + vendor + "/" + name. The Go package name stays <name>.
|
||||
- The already-exists hint becomes plugin:add plugins/<vendor>/<name>, slash-separated.
|
||||
- Update the MakePlugin doc comment.
|
||||
- frameworkReplaceFor already computes the replace relative to pluginDir, so it needs no change. Check that the plugin go.mod of the generated starter has one more ../ in its relative replace.
|
||||
- In artifact.go locatePluginDir: keep the summer.yaml/replace lookup first, then try the candidates in order, applying underRoot to each. The first existing directory wins. The not-found error and hint stay as they are.
|
||||
- In leaf.go checkModelsLeaf: when localModuleDir is empty, use the same candidates (the first existing one). Do not duplicate the list.
|
||||
- In new.go:
|
||||
- addStarterPlugin registers filepath.Join("plugins", "golem15", name).
|
||||
- In ValidateAppName, the reserved-name messages name plugins/golem15/main and plugins/golem15/internal. Both reasons still hold: package main, and the internal path element.
|
||||
- In stubs/new.tmpl, change the CLAUDE.md lines to make:plugin golem15.<name> under plugins/golem15/<name> and plugin:add plugins/<vendor>/<name>, the starter path to plugins/golem15/{{.Name}}, and the next-steps plugin:add line.
|
||||
- Tests:
|
||||
- Update every plugins/demo pin in internal/build/build_test.go, internal/build/new_test.go, internal/build/new_golden_test.go and cmd/summer/main_test.go to plugins/golem15/demo. This covers wantDir, AddPlugin paths, the module suffix check in assertScaffoldFiles and the go.work strings.
|
||||
- In docs/examples/blog/scaffold_layout_test.go, the scaffolded directory becomes plugins/acme/blog.
|
||||
- Add TestMakePluginVendorDirs: acme.blog and pixelpixel.blog in one hello copy.
|
||||
- Add TestLocatePluginLegacyLayout: scaffold golem15.demo, os.Rename it to plugins/demo (copyHelloApp makes the framework replace absolute, so the move is safe), then MakeModel with --no-migration succeeds there.
|
||||
- Regenerate the goldens with go test ./internal/build -run TestNewGolden -update. git rm the old plugins__demo__*.golden files. Review the golden diff: only path changes are expected.
|
||||
- cmd/summer/main.go help text:
|
||||
- make:plugin Long says plugins/<vendor>/<plugin>.
|
||||
- Examples: "Create plugins/acme/blog with the ID acme.blog" and plugin:add plugins/acme/blog.
|
||||
- Every "Inside plugins/blog" and "from inside plugins/blog" becomes plugins/acme/blog.
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: the starter-plugin table row, "Creating a plugin" and the cd example.
|
||||
- docs/architecture/go-modules-and-workspaces.md, around the plugins/<name> paragraph and the plugin:add example.
|
||||
- docs/plugins/registration.md: the directory sentence and the tree root.
|
||||
- docs/plugins/testing.md: go test ./plugins/acme/blog/...
|
||||
- docs/setup/porting-a-plugin.md: the plugin:add lines, the make:plugin and plugin:add table rows, and the module example.
|
||||
- grep docs/, README.md and modules/*/README.md for plugins/blog and plugins/<name>, and fix the remaining layout mentions. Manifest-only module strings in tag tests can stay.
|
||||
- Add the OD-3 upgrading note to docs/console/scaffolding.md under "Creating a plugin":
|
||||
- An existing plugins/<name> keeps working, because make:* finds both layouts and summer build follows summer.yaml.
|
||||
- Moving is optional. To move: move the directory, drop the old go.work use line (go work edit -dropuse), run summer plugin:add plugins/<vendor>/<name> (the module path and the summer.yaml entry stay), and add one ../ to a relative framework replace in the plugin go.mod.
|
||||
- Verify these exact steps in a scratch copy of examples/hello under the session scratchpad before writing them down. Correct the note to match what actually works.
|
||||
- Run go vet ./... and go test -short ./... before committing.
|
||||
- Commit as fix(build): scaffold plugins under plugins/<vendor>/<name>.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 ./internal/build/ ./cmd/summer/ ./docs/examples/blog/... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>Two commits exist. Migration timestamps always sort after the existing ones, with a frozen-clock unit test and a registry-order check. make:plugin writes plugins/<vendor>/<name>. make:* and the models-leaf check find both layouts. The summer new starter is at plugins/golem15/<name> and the goldens are regenerated. Help text and docs, including the verified upgrading note, match. go vet, go test -short ./... and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: //summer:make marker for editable files and app-taking console commands (one commit)</name>
|
||||
<files>internal/build/registry.go, internal/build/artifact.go, internal/build/stubs/artifacts.tmpl, internal/build/stubs/plugin.tmpl, internal/build/stubs/registry.tmpl, internal/build/build_test.go, internal/build/testdata/new/*.golden, cmd/summer/main.go, docs/console/scaffolding.md, docs/console/writing-commands.md, docs/setup/porting-a-plugin.md, docs/examples/blog/plugin.go, docs/examples/blog/registry.gen.go, docs/examples/blog/models/post.go, docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go, docs/examples/blog/updates/20260101000100_add_published_at.go, docs/examples/blog/controllers/posts.go, docs/examples/blog/console/publish.go</files>
|
||||
<behavior>
|
||||
- The model, migration, command, job and admin-controller stubs begin with the line //summer:make model / migration / command / job / admin-controller, followed by a blank line. registry.gen.go still begins with the Code generated by summer make header.
|
||||
- A console/ file carrying the old header with a zero-argument command function is still listed (OD-2). A console/ file with neither marker is not listed.
|
||||
- A command function taking exactly one *backpack.App parameter (named or unnamed) is listed. registry.gen.go calls it with the app passed to generatedCommands. A value backpack.App parameter, two parameters, or any other parameter type is skipped.
|
||||
- A freshly scaffolded plugin keeps the app from Boot, and Commands returns generatedCommands(p.app). make:command writes a function taking app *backpack.App. The plugin builds, and the built app runs the scaffolded command.
|
||||
- A plugin.go in the older form, with an empty Plugin struct and a Commands that returns generatedCommands() without arguments, still compiles after make:command. The make:command hint then explains that the app arrives as nil and how to pass it.
|
||||
</behavior>
|
||||
<action>
|
||||
Per the todo and OD-2. The aim is to stay backward compatible with every existing plugin.go.
|
||||
- In internal/build/registry.go:
|
||||
- Keep the legacy marker constant.
|
||||
- Add the directive constant //summer:make.
|
||||
- Replace isGeneratedFile with a scaffold-marker check: a comment whose text is the directive, or the directive followed by a space, OR any comment containing the legacy marker.
|
||||
- In funcRefs, for the command scan kind only, accept zero parameters or exactly one parameter field with at most one name whose type is a pointer (ast.StarExpr) to the selector backpack.App. astTypeName strips the star, so check for the StarExpr explicitly.
|
||||
- Record a TakesApp flag on registryRef.
|
||||
- Every other kind still accepts zero parameters only.
|
||||
- In stubs/registry.tmpl:
|
||||
- Always import modules/backpack.
|
||||
- Declare generatedCommands with a variadic app parameter of type *backpack.App. A variadic parameter keeps every existing plugin.go that calls generatedCommands() compiling; do not use a plain parameter.
|
||||
- Call zero-argument refs as they are called now.
|
||||
- Call TakesApp refs with the first variadic value, or nil when none was passed. Declare that local only when at least one TakesApp ref exists.
|
||||
- Keep the header line and the sort order unchanged.
|
||||
- In stubs/artifacts.tmpl:
|
||||
- Swap the first line of each of the five editable stubs for the matching //summer:make <kind> directive line.
|
||||
- The command stub imports modules/backpack, and the function takes app *backpack.App. Add a doc comment saying that app gives the command the config and published services (app.Lookup), and that it is the app the plugin kept from Boot. The Run body stays unchanged.
|
||||
- In stubs/plugin.tmpl:
|
||||
- The Plugin struct holds the app.
|
||||
- Boot stores it.
|
||||
- Commands returns generatedCommands(p.app), with a comment that Commands is collected after Boot.
|
||||
- Regenerate the summer new goldens with -update and review the diff.
|
||||
- In artifact.go MakeCommand: when the plugin's plugin.go contains a generatedCommands call with empty parentheses (a whitespace-tolerant regexp), append a note to the result Hint. The note says that <Func> receives a nil *backpack.App until Boot keeps the app and Commands returns generatedCommands(p.app).
|
||||
- Tests in internal/build/build_test.go cover each behavior line.
|
||||
- Run the built app binary's demo:purge after the App build in TestScaffoldAllArtifacts. If that binary cannot start without services the test environment lacks, write a throwaway _test.go into the temporary plugin instead. It calls Boot with an app and finds demo:purge in Commands(); run it with execGo go test.
|
||||
- Legacy plugin.go case: scaffold, rewrite plugin.go to the older shape, run MakeCommand, then go build ./... in the plugin and check the hint text.
|
||||
- docs/examples/blog:
|
||||
- Switch the five editable files' first line to their //summer:make <kind> directive.
|
||||
- Update registry.gen.go by hand to what the new template renders for this plugin: the backpack import and the variadic generatedCommands. It lists no commands, because PublishCommand takes withDB.
|
||||
- plugin.go Commands appends to generatedCommands(p.app).
|
||||
- Run go run ./cmd/summer docs:sync to refresh the src= copies.
|
||||
- cmd/summer/main.go make:command Long: replace "the generated function takes no arguments" with: the function receives the application the plugin kept from Boot, and a function with another parameter (such as the porting guide's withDB) is returned from Commands by hand.
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: describe the directive line and the note that registry.gen.go lists only files that carry it or the older header; describe app-taking commands.
|
||||
- docs/console/writing-commands.md: the "Registering commands" paragraph and the make:command starting point. A command that needs the database or services takes app *backpack.App.
|
||||
- docs/setup/porting-a-plugin.md:
|
||||
- Rewrite the generated-code-header bullet in "What the scaffolder leaves to you" around the directive. Keep the line, or the file drops out of registry.gen.go; linters now lint these files.
|
||||
- In the console section, explain that the generated accessor lists commands taking *backpack.App. This example passes the narrower withDB, which its tests inject, so it is still added by hand.
|
||||
- Any new go fence must be a src= copy of docs/examples/blog.
|
||||
- Run go vet ./... and go test -short ./... before committing.
|
||||
- Commit as feat(build): mark scaffolded files with //summer:make and pass the app to generated commands.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 ./internal/build/ ./cmd/summer/ ./docs/examples/blog/... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>One commit exists. Editable stubs carry //summer:make <kind>. registry.gen.go keeps the Code generated header. Both markers are recognized. App-taking commands are listed and receive the app. New plugins pass p.app. Older plugin.go files compile and get a hint. The docs, the blog example and the help text match, and the checker is green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: make:admin-controller names the model, embeds its YAML and offers delete (one commit)</name>
|
||||
<files>internal/build/artifact.go, internal/build/registry.go, internal/build/stubs/artifacts.tmpl, internal/build/stubs/plugin.tmpl, internal/build/stubs/registry.tmpl, internal/build/build_test.go, internal/build/testdata/new/*.golden, cmd/summer/main.go, docs/examples/blog/scaffold_layout_test.go, docs/examples/blog/plugin.go, docs/examples/blog/registry.gen.go, docs/examples/blog/controllers/posts/config_form.yaml, docs/examples/blog/controllers/posts/config_list.yaml, docs/examples/blog/models/post/fields.yaml, docs/examples/blog/models/post/columns.yaml, docs/console/scaffolding.md, docs/setup/porting-a-plugin.md, docs/backend/admin-controllers.md, modules/cabana/README.md</files>
|
||||
<behavior>
|
||||
- singularize: Posts→Post, Albums→Album, BlogPosts→BlogPost, Categories→Category, Boxes→Box, Matches→Match, Wishes→Wish, Addresses→Address, Genres→Genre, Status→Status, Post→Post. For every word pluralize handles, singularize(pluralize(w)) == w.
|
||||
- MakeAdminController(ctx, dir, "golem15.demo", "Albums", "") writes controllers/albums.go (ID golem15.demo.albums, ConfigDir controllers/albums, ModelName "Album"), controllers/albums/config_form.yaml and config_list.yaml (modelClass: Album, form/list paths ~/plugins/golem15/demo/models/album/...), and models/album/fields.yaml and columns.yaml. cabana.CompileForm and CompileList pass strictly.
|
||||
- A non-empty model argument overrides the default ("People" with "Person" gives models/person/ and modelClass: Person). An invalid model identifier fails before anything is written.
|
||||
- config_list.yaml has showCheckboxes: true and buttons: [create, delete], and CompileList accepts it.
|
||||
- With models.Album already present (make:model first), NewRecord returns &models.Album{} and the plugin builds. Without it, NewRecord keeps today's nil body with its TODO naming make:model, and the result Hint says so.
|
||||
- A fresh make:plugin implements pact.AdminAssets: AdminFS returns generatedAdminFS(), and registry.gen.go has no go:embed line while no admin YAML exists. After make:admin-controller, registry.gen.go embeds controllers/*/*.yaml models/*/*.yaml, and both YAML files can be read through (&Plugin{}).AdminFS() in the built plugin.
|
||||
- make:admin-controller in a plugin whose root .go files declare no AdminFS method appends a hint naming generatedAdminFS().
|
||||
</behavior>
|
||||
<action>
|
||||
Per the todo; the remaining items only, since NewRecord and RequiredPermissions already exist.
|
||||
- In internal/build/artifact.go:
|
||||
- Add singularize next to pluralize, with rules that invert pluralize: ies to y; the es suffix after s, x, z, ch and sh is removed; words ending in ss, us or is stay unchanged; a trailing s is removed. Irregular forms are covered by --model.
|
||||
- Change the signature to MakeAdminController(ctx, startDir, pluginID, name, model string). An empty model means singularize(name). Validate it with validateExportedIdent and identHint.
|
||||
- Add ModelIdent and ModelSnake to artifactData.
|
||||
- The YAML directory is models/<model snake>/. The controller ID, ConfigDir, redirect URLs and Permission keep the controller snake.
|
||||
- The duplicate-file checks cover the new paths.
|
||||
- Add Module to pluginRef, set from the go.mod that resolvePlugin already reads.
|
||||
- Decide NewRecord by parsing models/*.go (all files, not only marked ones; skip _test.go) for an exported struct named ModelIdent. When it exists, render NewRecord returning a pointer to it and import <module>/models. When it does not, render the current nil body and add a Hint line: run summer make:model <id> <Model>, then return &models.<Model>{}.
|
||||
- If no non-test .go file in the plugin root declares a method named AdminFS, append a Hint line that names generatedAdminFS() and pact.AdminAssets.
|
||||
- In stubs/artifacts.tmpl:
|
||||
- ModelName returns ModelIdent, and the NewRecord branch is conditional.
|
||||
- config_form.yaml: name is the model, modelClass is the model, and form points at models/<model snake>/fields.yaml.
|
||||
- config_list.yaml: modelClass is the model, title is the controller, the list path points at models/<model snake>/columns.yaml, showCheckboxes: true is added, and the buttons are [create, delete].
|
||||
- In internal/build/registry.go:
|
||||
- refreshRegistry sets AdminPatterns: each of controllers/*/*.yaml and models/*/*.yaml that has at least one filepath.Glob match in the plugin dir, in that order.
|
||||
- emptyRegistry sets none.
|
||||
- In stubs/registry.tmpl:
|
||||
- Always import embed and io/fs.
|
||||
- Declare var generatedAdminTree embed.FS, with a go:embed line listing AdminPatterns only when there are any; a zero embed.FS is an empty, non-nil tree.
|
||||
- Add func generatedAdminFS() fs.FS returning it.
|
||||
- Note in a template comment that the go:embed globs are re-evaluated at compile time, and that the line is rewritten by the next make: run.
|
||||
- In stubs/plugin.tmpl, add the pact.AdminAssets assertion and an AdminFS method returning generatedAdminFS(). Regenerate the summer new goldens with -update.
|
||||
- Update every MakeAdminController call site to pass "" or a model: cmd/summer/main.go, internal/build/build_test.go and docs/examples/blog/scaffold_layout_test.go.
|
||||
- In cmd/summer/main.go, add a --model flag to make:admin-controller (a bonfire.Flag with a value, defaulting to the singular of the name) and update the Long text and Examples (Posts to Post; People --model Person).
|
||||
- Tests in internal/build/build_test.go:
|
||||
- Update TestMakeAdminControllerLayout: Albums uses model Album and models/album; the duplicate-asset subtest pre-creates models/genre/fields.yaml; scaffoldAlbumsController.ModelName returns "Album"; buttons are [create, delete] with showCheckboxes.
|
||||
- Add a table test for singularize.
|
||||
- Add the --model override case.
|
||||
- Add the NewRecord-with-model case, with go build ./... in the plugin.
|
||||
- Add an AdminFS end-to-end case: write a throwaway _test.go into the temporary plugin that reads both YAML files through (&Plugin{}).AdminFS(), then run go test there with execGo.
|
||||
- assertWinterPluginShape must still pass for a fresh plugin, because no new files are written.
|
||||
- docs/examples/blog:
|
||||
- git mv models/posts to models/post.
|
||||
- Point config_form.yaml and config_list.yaml at ~/plugins/acme/blog/models/post/.
|
||||
- plugin.go AdminFS returns generatedAdminFS(); drop the hand-written adminFS embed var and update its comment.
|
||||
- registry.gen.go gets the generatedAdminTree embed of both patterns plus generatedAdminFS.
|
||||
- grep docs/examples/blog for models/posts and fix the remaining references, including the tests.
|
||||
- Run go run ./cmd/summer docs:sync.
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: the make:admin-controller row (models/<model>/, --model, NewRecord, delete) and the make:plugin AdminFS.
|
||||
- docs/setup/porting-a-plugin.md:
|
||||
- The make:admin-controller table row becomes models/post/.
|
||||
- The sentence saying that fields sit under models/posts/ becomes models/post/, as in WinterCMS.
|
||||
- Point the src= paths at models/post/.
|
||||
- Replace the two admin bullets of "What the scaffolder leaves to you" with what is still manual: declare the permission in Permissions(), give the model Fillable and Rules, and fill fields.yaml and columns.yaml.
|
||||
- Update the sentence that says AdminFS was added by hand.
|
||||
- docs/backend/admin-controllers.md: the make:admin-controller paragraph.
|
||||
- modules/cabana/README.md: the scaffold mention.
|
||||
- Run go vet ./..., go test -short ./... and go -C examples/hello test ./... before committing.
|
||||
- Commit as feat(build): scaffold working admin controllers with model, AdminFS and delete.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 ./... && go -C examples/hello test ./... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>One commit exists. make:admin-controller writes model-named YAML and controller, a real NewRecord when the model exists, and create/delete with showCheckboxes. --model overrides the singular. Scaffolded plugins serve AdminFS from registry.gen.go, empty until the first controller. The docs example, the porting page, the admin docs, the cabana README and the help text match. Full go test -short, examples/hello and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| CLI argument → filesystem | The plugin ID, artifact name and --model value from the developer's command line become directory and file paths |
|
||||
| scaffolded source → registry generator | Files in a plugin's leaf directories are parsed and decide what registry.gen.go calls and embeds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-q261028-01 | Tampering | makePlugin / locatePluginDir vendor segment | medium | mitigate | The vendor comes from an ID that ValidatePluginID has already checked (lowercase vendor.plugin, no slashes). Every candidate dir (vendor and legacy) still goes through underRoot before use; TestMakePluginRejectsPathTraversalAndShellMeta stays green |
|
||||
| T-q261028-02 | Tampering | MakeAdminController --model | low | mitigate | The model goes through validateExportedIdent before any path is built, and rejectDuplicateFile/underRoot cover models/<model snake>/ |
|
||||
| T-q261028-03 | Information disclosure | generatedAdminTree go:embed | low | mitigate | The patterns are limited to controllers/*/*.yaml and models/*/*.yaml, so Go sources are never embedded in the binary (the same choice sm-faq-plugin made) |
|
||||
| T-q261028-04 | Denial of service | app-taking commands under legacy wiring | low | mitigate | The variadic generatedCommands passes nil only when plugin.go omits the app; make:command prints a hint naming the fix, and the default stub does not dereference app |
|
||||
| T-q261028-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependencies; only stdlib embed/io/fs and the existing modules/backpack |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... and go test -short ./... are green after each of the four commits (the CLAUDE.md rule).
|
||||
- go -C examples/hello test ./... is green; examples/hello is not modified.
|
||||
- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green after each commit.
|
||||
- TestScaffoldLayout (docs/examples/blog) passes: the walkthrough file set equals the scaffolder output under plugins/acme/blog with models/post/.
|
||||
- git log shows four commits without co-author tags, each staging only its own paths; other sessions' STATE.md, state.json and phase-20 files are left alone.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four todos' problems are fixed, as the must_haves truths describe.
|
||||
- Nothing breaks for existing apps: plugins/<name>, the old header and zero-argument generatedCommands() calls keep working.
|
||||
- Docs, help text and the porting walkthrough describe the new behavior and include the upgrading note.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/261028-pq8-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
phase: quick-261028-pq8
|
||||
plan: 01
|
||||
subsystem: internal/build (scaffolder)
|
||||
status: complete
|
||||
tags: [scaffolder, make-plugin, migrations, admin-controller, registry, docs]
|
||||
requires: []
|
||||
provides:
|
||||
- migration timestamps after every existing updates/ file
|
||||
- plugins/<vendor>/<name> layout with legacy plugins/<name> fallback
|
||||
- //summer:make <kind> marker; app-taking console commands
|
||||
- make:admin-controller with model name, real NewRecord, AdminFS and delete
|
||||
affects: [cmd/summer, docs/console, docs/setup, docs/backend, docs/plugins, docs/architecture, docs/examples/blog, modules/cabana/README.md]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "variadic generatedCommands(app ...*backpack.App) keeps old zero-arg callers compiling"
|
||||
- "registry.gen.go embeds admin YAML only for globs that match (go:embed rejects empty patterns)"
|
||||
key-files:
|
||||
created:
|
||||
- internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden (renamed from plugins__demo__)
|
||||
- internal/build/testdata/new/plugins__golem15__demo__routes.go.golden (renamed from plugins__demo__)
|
||||
- docs/examples/blog/models/post/ (moved from models/posts/)
|
||||
modified:
|
||||
- internal/build/artifact.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/leaf.go
|
||||
- internal/build/new.go
|
||||
- internal/build/registry.go
|
||||
- internal/build/stubs/artifacts.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- internal/build/stubs/registry.tmpl
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/build_test.go
|
||||
- cmd/summer/main.go
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/porting-a-plugin.md
|
||||
decisions:
|
||||
- "make:plugin writes plugins/<vendor>/<name>; make:* looks up summer.yaml, then plugins/<vendor>/<name>, then legacy plugins/<name> (OD-1)"
|
||||
- "Registry scan recognises both //summer:make and the old Code generated header (OD-2)"
|
||||
- "Moving an existing plugins/<name> is optional; documented, verified upgrading steps (OD-3)"
|
||||
- "New registry identifiers are generatedAdminTree / generatedAdminFS / bootApp to avoid collisions with handwritten adminFS vars"
|
||||
metrics:
|
||||
duration: ~75min
|
||||
completed: 2026-10-28
|
||||
estimate_ref: "plan estimate 150000 tokens, 3 tasks"
|
||||
actuals:
|
||||
tokens: 35400
|
||||
tasks: 3
|
||||
commits: 4
|
||||
commits: 4
|
||||
plan_head_before: 017e0e9
|
||||
plan_head_after: 8bc18c9
|
||||
---
|
||||
|
||||
# Quick 261028-pq8: Fix the four internal/build scaffolder todos Summary
|
||||
|
||||
`summer make:*` now writes vendor-namespaced plugins (`plugins/acme/blog`), orders migrations correctly within one second, marks editable files with `//summer:make <kind>` and passes the `*backpack.App` to generated commands, and produces admin controllers that boot: model-named YAML under `models/<model>/`, a real `NewRecord`, `AdminFS` from `registry.gen.go`, and create and delete buttons. Existing apps keep working: `plugins/<name>`, the old header and zero-argument `generatedCommands()` all still work.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1a | a941ab2 | fix(build): order scaffolded migrations after every existing one |
|
||||
| 1b | a95a014 | fix(build): scaffold plugins under plugins/<vendor>/<name> |
|
||||
| 2 | 70e4b41 | feat(build): mark scaffolded files with //summer:make and pass the app to generated commands |
|
||||
| 3 | 8bc18c9 | feat(build): scaffold working admin controllers with model, AdminFS and delete |
|
||||
|
||||
## What was built
|
||||
|
||||
**1a: Migration order.** `nextMigrationFile` reads `updates/` once, takes the newest `\d{14}_*.go` timestamp, and starts one second after it when now is not later. Unit test `TestNextMigrationFileSortsAfterExisting` (frozen clock; same second, a future-dated existing file, non-migration files ignored, older existing). `TestScaffoldCoreArtifacts` freezes the clock and asserts `CreateAlbums` before `AddWidgetIndex` in `registry.gen.go`. The docs no longer mention the same-second caveat or the manual order check.
|
||||
|
||||
**1b: Vendor path.**
|
||||
- `makePlugin` writes `plugins/<vendor>/<name>` with module `<app>/plugins/<vendor>/<name>`. The package stays `<name>`.
|
||||
- `pluginDirCandidates` and `defaultPluginDir` in `scaffold.go` hold the shared lookup order (vendor dir, then legacy), used by both `locatePluginDir` and `checkModelsLeaf`.
|
||||
- `summer new` puts the starter plugin at `plugins/golem15/<name>`. The plugin's framework replace gets one more `../` (`../../../../summercms`), and the goldens were regenerated (paths only).
|
||||
- Updated: help text, the `new.tmpl` CLAUDE.md and next steps, and the docs. `scaffolding.md` gained an upgrading note ("Upgrading: plugins without a vendor directory").
|
||||
- New tests: `TestMakePluginVendorDirs` (acme.blog and pixelpixel.blog side by side, both build, the hint names `plugin:add plugins/acme/blog`) and `TestLocatePluginLegacyLayout`.
|
||||
|
||||
**2: Marker and app-taking commands.**
|
||||
- Each editable stub starts with `//summer:make model|migration|command|job|admin-controller`. `registry.gen.go` keeps `// Code generated by summer make. DO NOT EDIT.`
|
||||
- `isScaffoldedFile` accepts the directive (alone, or followed by a space) and the old header.
|
||||
- The command scan also accepts exactly one `*backpack.App` parameter, named or unnamed, and records it as `TakesApp`.
|
||||
- `generatedCommands(app ...*backpack.App)` calls `TakesApp` refs with `bootApp`. That local exists only when at least one such ref does.
|
||||
- The plugin stub keeps the app from `Boot` and returns `generatedCommands(p.app)`.
|
||||
- `MakeCommand` adds a nil-app note when `plugin.go` matches `generatedCommands\(\s*\)`.
|
||||
- Tests:
|
||||
- `TestRegistryScanMarkersAndAppCommands`
|
||||
- `TestMakeCommandLegacyPluginGo`: older plugin.go plus make:command, then `go build` and `go vet`, and the hint is checked.
|
||||
- `TestScaffoldAllArtifacts` checks the markers, patches `purge.go` to print `app=%t`, and runs `bin/hello demo:purge` on the built app, which must print `app=true`.
|
||||
|
||||
**3: Admin controller.**
|
||||
- `singularize` sits next to `pluralize`. `MakeAdminController(ctx, dir, id, name, model)` takes the new model argument, and the CLI has a `--model` flag.
|
||||
- `ModelName`, `name:` and `modelClass:` use the model, and the YAML goes under `models/<model snake>/`. The controller ID, ConfigDir, redirects and permission keep the controller snake case.
|
||||
- `NewRecord` returns `&models.<Model>{}` when `models/*.go` (package models, non-test) declares the struct. Otherwise the nil body plus TODO stays, and a hint names `make:model`.
|
||||
- `config_list.yaml` has `showCheckboxes: true` and `buttons: [create, delete]`.
|
||||
- `registry.gen.go` declares `var generatedAdminTree embed.FS`. It only gets a `//go:embed controllers/*/*.yaml models/*/*.yaml` line for globs that match a file, and `generatedAdminFS()` returns the tree.
|
||||
- The plugin stub asserts `pact.AdminAssets` and returns `generatedAdminFS()` from `AdminFS`. Plugins with no `AdminFS` method get a hint.
|
||||
- The blog example moved to `models/post/`, and its hand-written `adminFS` embed var was dropped.
|
||||
- Tests:
|
||||
- `TestSingularize`: the table plus the round trip.
|
||||
- `TestMakeAdminControllerModel`: `--model`, an invalid model writes nothing, the AdminFS hint on the greeter plugin.
|
||||
- `TestScaffoldAdminFSEndToEnd`: a probe `_test.go` runs with `go test` inside the plugin. It sees an empty tree first, then reads all four YAML files, and checks that no Go source is embedded.
|
||||
- Plus a CLI `--model` check and updated layout and strict-compile tests.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` passed and `go test -short ./...` was green, both after each task.
|
||||
- `go -C examples/hello test ./...` was green, and examples/hello is unchanged.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` passed: no problems found.
|
||||
- The non-short `go test ./internal/build -run TestNew` was green (TestNewAppSmoke, TestNewNoPluginBuilds).
|
||||
- I checked the upgrading note by hand on a copy of examples/hello under the scratchpad:
|
||||
- Set-up: relative framework replaces, go.work from `plugin:add`.
|
||||
- Moved `plugins/greeter` to `plugins/golem15/greeter`, ran `go work edit -dropuse ./plugins/greeter`, then `summer plugin:add plugins/golem15/greeter`.
|
||||
- The build failed until the plugin `go.mod` replace got one more `../`. After that, `summer build` worked, `bin/hello greeter:hello` ran, and `make:model` and `make:job` (the latter from inside the moved directory) found the plugin.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing guard] make:plugin refuses a nested or duplicate plugin**
|
||||
- **Found during:** Task 1b
|
||||
- **Issue:** With the vendor layout, an ID whose vendor matches a legacy `plugins/<vendor>` plugin module (for example `greeter.extra` in examples/hello) would nest a module inside another plugin. Separately, a legacy `plugins/<name>` that already holds the same ID would get a second copy.
|
||||
- **Fix:** `makePlugin` errors with a hint in both cases. Both are covered in `TestMakePluginVendorDirs` and `TestLocatePluginLegacyLayout`.
|
||||
- **Commit:** a95a014
|
||||
|
||||
**2. [Plan detail] singularize rules refined**
|
||||
- The plan's literal rule ("es after s removed") would turn Courses into Cours, Houses into Hous and Responses into Respons.
|
||||
- The rules now drop `es` only after `ss`, `us` (but not `ous`/`aus`), `sis`, `x`, `ch`, `sh` and `zz`. `quizzes` gives `quiz`, and otherwise a trailing `s` is dropped.
|
||||
- All plan cases pass. The round trip `singularize(pluralize(w)) == w` holds for the tested word list. It fails for words like `gas`, where pluralize gives `gases` and that singularizes to `gase`; `--model` covers those.
|
||||
- **Commit:** 8bc18c9
|
||||
|
||||
**3. [Docs] Fixed example timestamps kept**
|
||||
- The fixed example timestamps (20260101000000/20260101000100) were explained only in the bullet the plan removed.
|
||||
- I moved that explanation into the porting page's migration paragraph.
|
||||
- **Commit:** a941ab2
|
||||
|
||||
**4. [Docs] Extra layout mentions updated**
|
||||
- The summer.yaml examples in `docs/architecture/go-modules-and-workspaces.md` and `docs/services/storage.md` now use `.../plugins/acme/blog`, to match what make:plugin writes.
|
||||
- `docs/plugins/registration.md` gained the admin YAML embed in the tree.
|
||||
- **Commits:** a95a014, 8bc18c9
|
||||
|
||||
**5. [Docs] Upgrading note step added**
|
||||
- The verified steps include `mkdir -p plugins/<vendor>` before the `mv`.
|
||||
- The note also says that other relative replaces in the plugin `go.mod` need the extra `../` too.
|
||||
- **Commit:** a95a014
|
||||
|
||||
### Out of scope (not fixed)
|
||||
- `cmd/summer/main.go`, `cmd/summer/runtime.go` and `cmd/summer/runtime_test.go` were already not gofmt-clean at HEAD 017e0e9. I left them alone.
|
||||
- `internal/build/registry.go`'s const-block alignment got fixed in passing, because Task 2 rewrote that block.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None in this plan's code. The scaffolded admin controller deliberately keeps `NewRecord() any { return nil }` with a TODO when the model does not exist yet. That is intended scaffold output, and the command prints a hint about it.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The threat-model mitigations are in place:
|
||||
- **T-01:** Vendor and legacy candidates both go through `underRoot`, and the existing traversal tests stay green.
|
||||
- **T-02:** `--model` is validated by `validateExportedIdent` before any path is built.
|
||||
- **T-03:** The embed patterns are limited to `*.yaml` under `controllers/*/` and `models/*/`; the probe checks that no Go source is embedded.
|
||||
- **T-04:** The variadic `generatedCommands` keeps existing callers compiling, and the stub never dereferences `app`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden
|
||||
- FOUND: internal/build/testdata/new/plugins__golem15__demo__routes.go.golden
|
||||
- FOUND: docs/examples/blog/models/post/fields.yaml, docs/examples/blog/models/post/columns.yaml
|
||||
- FOUND commits: a941ab2, a95a014, 70e4b41, 8bc18c9 (none carry a co-author line)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user