Compare commits

..

26 Commits

Author SHA1 Message Date
d77393b4d1 docs(16.1-01): update state and roadmap after plan 01 2026-10-09 21:05:55 +02:00
0ec2e31ffa docs(16.1-01): complete journal admin fixes plan 2026-10-09 21:05:31 +02:00
9cac45c389 fix(admin): keep list state in the URL and browser storage
- listState.ts stores each list's query (search, sort, dir, page, per_page,
  filter[name]) under summer-admin.list:<controller>; an empty query is
  never stored, malformed stored values are dropped like a hand-edited URL
- ListView restores the stored query into the URL before its first list
  request when entered without list params; a URL with list params wins
- a 422 forgets the stored query; an out-of-range page clamps to last_page
- logout clears every stored list query
- check-phase10.sh allows browser storage in useSidebar.ts and listState.ts
- list smoke tests start from a clean list state
- rebuilt modules/boardwalk/dist
2026-10-09 21:04:00 +02:00
bd8626e5fc fix(cabana): send the default content locale first in enabledLocales
- enabledContentLocales moves TranslationWriter.DefaultLocale to index 0
  and keeps the other enabled codes in the writer's order
- every ML control opens on enabledLocales[0], so the form now shows the
  host-column values (content, excerpt, title, slug) instead of an empty
  non-default locale
- a DefaultLocale error is logged and keeps the writer's order
- README and forms docs: default locale first
2026-10-09 20:58:12 +02:00
06d75cb654 fix(cabana): search and sort to-many relation columns through subqueries
- relationSubquery reads the pivot, key and table names from GORM's parsed
  relationship (Schema.Relationships.Relations), never from a convention
- list search matches every relation column through a correlated EXISTS,
  so the count query gains no JOIN and belongsToMany search stops failing
- sorting a hasMany or belongsToMany column orders by a correlated
  string_agg subquery before the primary-key tiebreak
- polymorphic relations and unknown related columns answer 422
- DryRun smoke test TestListRelationSearchSmoke needs no Docker
2026-10-09 20:56:53 +02:00
3ca6a0d4b2 docs(16.2): capture phase context 2026-10-09 20:52:50 +02:00
24f258f149 docs(16.1): create phase plan 2026-10-09 20:51:02 +02:00
ef6feb3441 docs(16.1): list state via URL + localStorage, three plans 2026-10-09 20:25:34 +02:00
17ee383a4f docs(phase-16.1): add research and validation strategy 2026-10-09 20:19:59 +02:00
1ef21b0e43 docs(16.1): insert phases 16.1 and 16.2 with 16.1 context 2026-10-09 20:19:29 +02:00
1c2dc28588 docs(16-05): complete unit tests and phase gate plan 2026-10-09 20:18:50 +02:00
a8c65bc71a docs(16-05): Phase 16 validation sign-off
Per-task rows bound to their plan tasks with threat refs, all green after
scripts/check-phase16.sh --all; Wave 0 ticked; nyquist_compliant: true.
2026-10-09 20:16:02 +02:00
c5e03f4a3a test(16-05): Phase 16 gate
scripts/check-phase16.sh runs the layer unit tests, the layer playground
SSR smoke, the site app install/build/tests/smokes, the host boot tests,
the grzyby command tests with -race and check-deploy. Each stage fails on
not ok, fail/skip/todo/cancelled counts, missing named tests, go test
failures, skips, no tests to run, data races or a smoke without ok; it
ends with "Phase 16 gate passed".
2026-10-09 20:15:14 +02:00
679d5544f1 docs(state): record phase 19.1 context session 2026-10-09 20:10:22 +02:00
cbb8f4c6f5 docs(19.1): capture phase context 2026-10-09 20:10:21 +02:00
b7649581da docs(16-04): complete staging deploy plan 2026-10-09 19:56:16 +02:00
4ef6776382 docs(state): record phase 19 context session 2026-10-09 19:53:17 +02:00
bf297d5244 docs(19): capture phase context 2026-10-09 19:53:16 +02:00
90cdc7145c docs(state): record phase 18.1 context session 2026-10-09 19:19:43 +02:00
9248b26f87 docs(18.1): capture phase context 2026-10-09 19:19:36 +02:00
01a34f87f8 docs(roadmap): insert phase 18.1 payment gateway subscriptions 2026-10-09 19:19:36 +02:00
296b9cda87 docs(16-03): complete host, migration runbook and staging deploy plan 2026-10-09 18:56:51 +02:00
6ff7d5da3e docs(16-03): todo for Journal HTML content re-save 2026-10-09 18:45:59 +02:00
719830410e docs(state): record phase 18 context session 2026-10-09 18:45:15 +02:00
65d577b828 docs(todos): capture plugin-registered cabana field types 2026-10-09 18:45:14 +02:00
6ea3652303 docs(18): capture phase context 2026-10-09 18:45:14 +02:00
48 changed files with 5706 additions and 96 deletions

View File

@@ -959,7 +959,7 @@ Plans:
3. Public pages that PHP Journal + the current theme rendered have a named successor on Go; sitemap is in if the site still needs it.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 2/5 plans executed
**Plans:** 5/5 plans executed
Plans:
**Wave 1**
@@ -969,13 +969,40 @@ Plans:
- [x] 16-02-PLAN.md — vue-grzybyfunkcjonalne-app: SSR site on the layer with the PHP URL map, homepage/menu/FAQ/kontakt content port, GA via runtime config; submodule in the host
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 16-03-PLAN.md — sm-grzybyfunkcjonalne-app: /gfadmin, app plugin with featured-image and category-content commands, migration runbook, two-process deploy files, urls.txt and local rehearsal
- [x] 16-03-PLAN.md — sm-grzybyfunkcjonalne-app: /gfadmin, app plugin with featured-image and category-content commands, migration runbook, two-process deploy files, urls.txt and local rehearsal
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 16-04-PLAN.md — Staging deploy on the PHP site's server (human checkpoint) and smoke over every old URL
- [x] 16-04-PLAN.md — Staging deploy on the PHP site's server (human checkpoint) and smoke over every old URL
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 16-05-PLAN.md — Unit tests: layer and app node --test (incl. SSR), Go command and boot tests, phase gate
- [x] 16-05-PLAN.md — Unit tests: layer and app node --test (incl. SSR), Go command and boot tests, phase gate
### Phase 16.1: Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)
**Goal:** The Journal posts list and post form in the admin work on real imported data, found on UAT (uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts): (1) list search no longer fails with "Server error" — the Go log shows `ERROR: column Categories.post_id does not exist (SQLSTATE 42703)` from summercms@v0.1.9 modules/cabana/query.go:129, i.e. search across a belongsToMany (pivot) relation builds a hasMany join; (2) list sort and filters are remembered after opening a record and going back, as WinterCMS persisted list state; (3) the post form shows the stored content (DB has content/content_html on 104/104 posts, the form shows it empty); (4) the post form shows the stored excerpt (100/104 posts have it in the DB). Fixes span summercms.go core (modules/cabana — coordinate with the concurrent session working in modules/) and sm-journal-plugin; release a summercms tag, bump the host, redeploy to UAT and re-run the staging smoke.
**Requirements**: TBD
**Depends on:** Phase 16
**Plans:** 1/3 plans executed
Plans:
**Wave 1**
- [x] 16.1-01-PLAN.md — All fixes: cabana relation search and to-many sort through EXISTS/correlated subqueries (D-02), default locale first in form enabledLocales (D-03), Journal FormatHTML keeps raw HTML behind the gate (D-03), admin list state in the URL and browser storage (D-01)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 16.1-02-PLAN.md — Release gate (human), summercms tag, journal and host bumps, UAT redeploy and on-server smoke, re-save posts 53 and 64 (D-04)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 16.1-03-PLAN.md — Unit tests: DryRun SQL matrix, locale order, FormatHTML keep/refuse, list-state vitest; Postgres tests for a Docker host; phase gate
### Phase 16.2: Plugin-provided field types and the Blog Editor formwidget (INSERTED)
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
**Requirements**: TBD
**Depends on:** Phase 16.1
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 16.2 to break down)
### Phase 17: Blog companions
@@ -1020,6 +1047,27 @@ Plans:
Plans:
- [ ] TBD (run $gsd-plan-phase 18 to break down)
### Phase 18.1: Payment Gateway Subscriptions (INSERTED)
**Goal:** The generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS. Horoskopia's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP.
**Mode:** mvp
**Depends on:** Phase 18
**Repos:** `sm-paymentgatewaysubscriptions-plugin` (new, under `git.golem15.com/golem15/`); a subpackage of `sm-pgstripe-plugin`; `vue-paymentgateway-layer`; `summercms.go` only if a framework feature is missing
**Port sources:** `Golem15.PaymentGatewaySubscriptions` at `ac3d73c` plus `5fb9dac`, and pgstripe `8b47457`, at `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/{paymentgatewaysubscriptions,pgstripe}`. Quotify's lead fees, featured placements and analytics are not ported here.
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. Subscription and SubscriptionPlan port on a squashed, idempotent v2.4.0 baseline that adopts existing PHP databases, with polymorphic subscribers registered at build time and a built-in `user` subscribable.
2. The `SubscriptionGateway` contract (including `syncPlanToGateway`) has a Stripe implementation. `POST /webhook/stripe/subscriptions` and its legacy alias verify the event, enqueue a job and process it idempotently, and fire the six `golem15.pgsub.*` events.
3. The Subscriptions and SubscriptionPlans admin screens, a Go service API, a headless customer API and subscription components in `vue-paymentgateway-layer` are delivered.
4. A host test proves checkout, activation, `invoice.paid`, `past_due` with its grace period, cancel and duplicate-event idempotency against a Stripe fake.
5. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 18.1 to break down)
### Phase 19: Chat and ChatChannels
**Goal:** `Golem15.Chat` and `Golem15.ChatChannels` run on SummerCMS as shared plugins, with realtime delivery through `lighthouse`, ready for the Wavepath port.
@@ -1079,7 +1127,7 @@ Plans:
## Progress
**Execution Order:**
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 19 → 19.1 → 20
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 20
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
| Phase | Plans Complete | Status | Completed |
@@ -1104,9 +1152,10 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | In Progress| |
| 15. Journal plugin | 4/4 | Complete | 2026-10-06 |
| 16. grzybyfunkcjonalne.pl on reusable blog views | 2/5 | In Progress| |
| 16. grzybyfunkcjonalne.pl on reusable blog views | 5/5 | In Progress| |
| 17. Blog companions | 0/TBD | Not started | - |
| 18. Payment Gateway | 0/TBD | Not started | - |
| 18.1. Payment Gateway Subscriptions | 0/TBD | Not started | - |
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
| 19.1. Chat satellites | 0/TBD | Not started | - |
| 20. Płytarium cutover | 0/TBD | Not started | - |

View File

@@ -1,19 +1,19 @@
---
msd_state_version: "1.0"
milestone: v1.0
current_phase: 16
current_phase_name: grzybyfunkcjonalne.pl on reusable blog views
current_phase: "16.1"
current_phase_name: "Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)"
status: executing
stopped_at: Completed 16-02-PLAN.md
last_updated: "2026-10-09T16:30:06.798Z"
stopped_at: Completed 16.1-01-PLAN.md
last_updated: "2026-10-09T19:05:48.037Z"
last_activity: 2026-10-09
last_activity_desc: Phase 16 execution started
state_head: 070da81907bdde9d5c855a37db2538f6eb4232e4
last_activity_desc: Phase 16.1 execution started
state_head: 0ec2e31ffa19b38470109a4ff0eefa518ec0e803
progress:
total_phases: 31
total_phases: 34
completed_phases: 13
total_plans: 148
completed_plans: 141
total_plans: 151
completed_plans: 145
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -25,14 +25,14 @@ milestone_name: milestone
See: .planning/PROJECT.md (updated 2026-09-16)
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
**Current focus:** Phase 16 — grzybyfunkcjonalne.pl on reusable blog views
**Current focus:** Phase 16.1 — Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)
## Current Position
Phase: 16 (grzybyfunkcjonalne.pl on reusable blog views) — EXECUTING
Plan: 3 of 5
Phase: 16.1 (Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)) — EXECUTING
Plan: 2 of 3
Status: Ready to execute
Last activity: 2026-10-09 - Completed quick task 261009-mys: subtitle, avatar and My account page for backend users
Last activity: 2026-10-09 — Phase 16.1 execution started
Progress: [██████████] 100%
@@ -196,6 +196,10 @@ Progress: [██████████] 100%
| Phase 14.3 P04 | 19min | 3 tasks | 32 files |
| Phase 16 P01 | 1h 34m | 4 tasks | 55 files |
| Phase 16 P02 | 16 min | 3 tasks | 44 files |
| Phase 16 P03 | 22 min | 3 tasks | 32 files |
| Phase 16 P04 | 40min | 3 tasks | 10 files |
| Phase 16 P05 | 19 min | 3 tasks | 27 files |
| Phase 16.1 P01 | 11 min | 4 tasks | 20 files |
## Accumulated Context
@@ -218,6 +222,9 @@ Progress: [██████████] 100%
- Phase 20 edited: Płytarium production cutover moved here from Phase 15
- Phase 14.2.1 inserted after Phase 14.2: sm-translate-plugin so Journal can keep translatable fields (URGENT)
- Phase 14.3 inserted after Phase 14.2.1: One-command app scaffolding: summer new + summercms.io/new bootstrap script; ships as v0.1.13 (URGENT)
- Phase 18.1 inserted after Phase 18: Payment Gateway Subscriptions (split from Phase 18 per 18-CONTEXT D-01)
- Phase 16.1 inserted after Phase 16: Journal admin urgent fixes: relation search, list state, post form content and excerpt (found on UAT) (URGENT)
- Phase 16.2 inserted after Phase 16.1: Plugin-provided field types and the Blog Editor formwidget
### Decisions
@@ -628,6 +635,20 @@ Recent decisions affecting current work:
- [Phase 16]: Under routeRules swr the site app records the visitor address in event.context (Nitro request hook) and overrides useJournalApi, because Nitro's cached handler strips request headers; the proper fix belongs in vue-journal-layer
- [Phase 16]: Site page URLs answer errors with the HTML error page even for fetch-like requests (server/plugins/html-errors.ts); /api/, /_ and .json paths keep Nuxt JSON errors
- [Phase 16]: Site rich-text copy stays in pl.json (i18n compilation.strictMessage false); contact data lives in app/data/contact.ts
- [Phase 16]: Plugin commands in a summer-build host open and publish the database themselves (lookupDB via lagoon); the journal import runs as grzyby:import-posts because the generated main.go publishes no DB for plugin commands
- [Phase 16]: migrate.sh imports from a copy of the PHP export with empty translations [] arrays normalized to {}; the importer fix is deferred to sm-journal-plugin
- [Phase 16]: The URL rehearsal gives each URL its own X-Forwarded-For visitor (203.0.113.N) so ~90 SSR pages stay under Go's 120/min per-visitor journal limit
- [Phase 16]: The staging nginx image location declares its own types map and matches extensions case-insensitively so images keep an image type under nosniff
- [Phase 16]: 16-04: staging keeps the UAT basic-auth gate (off for loopback via nginx geo); smoke and curl checks run on the server
- [Phase 16]: 16-04: staging host is uat.grzybyfunkcjonalne.pl; first-journal-post skipped in the import so Go serves the 62 live posts
- [Phase 16]: 16-04: fileblob uploads bucket uses no_tmp_dir=true because /tmp on the server is tmpfs
- [Phase 16]: No layer util needed a fix in 16-05, so vue-journal-layer gets no v0.1.1 tag; the app keeps v0.1.0
- [Phase 16]: The malformed-GA-id SSR test asserts the id appears only as the JSON string in window.__NUXT__.config, since Nuxt serializes public runtime config
- [Phase 16]: The grzyby console test harness creates an ICU pl-PL database via GRZYBY_TEST_ADMIN_DSN or testcontainers and fails, not skips, without one outside -short
- [Phase 16.1]: Relation list search uses correlated EXISTS for every relation type (no outer JOIN); to-many sort uses a correlated string_agg subquery; names come from GORM's parsed relationship
- [Phase 16.1]: meta.enabledLocales lists the default content locale first, then the writer's order
- [Phase 16.1]: Journal FormatHTML renders raw HTML (goldmark WithUnsafe) behind the unchanged rejectUnsafe gate
- [Phase 16.1]: List state: URL plus per-list localStorage key summer-admin.list:<controller>, restored before the first list request, cleared on logout
### Pending Todos
@@ -697,6 +718,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-09T16:30:06.282Z
Stopped at: Completed 16-02-PLAN.md
Last session: 2026-10-09T19:05:47.263Z
Stopped at: Completed 16.1-01-PLAN.md
Resume file: None

View File

@@ -0,0 +1,373 @@
---
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
plan: 03
subsystem: infra (Go host, migration runbook, staging deploy)
tags: [summercms, go, journal, migration, postgres, nginx, supervisor, smoke, nuxt-ssr]
status: complete
requires:
- phase: 16-02
provides: "vue-grzybyfunkcjonalne-app (submodule b50c808) answering the PHP URL map with SSR"
- phase: quick-261009-i6e
provides: "D-20 anonymous author endpoints (sm-journal-plugin 908ade9, 57a347c)"
- phase: quick-261009-mgk
provides: "D-21 category content column and API field (sm-journal-plugin 740dcd0, f22d089, 3d533d4)"
provides:
- "admin at /gfadmin (config/backend.yaml uri: /gfadmin); no /backend route"
- "app plugin golem15.grzyby (plugins/grzyby, commands only): grzyby:import-posts, grzyby:import-featured-images, grzyby:import-categories"
- "scripts/migrate.sh: the ordered D-25/D-17 runbook (migrate, fresh-db guard, pl default, import once, categories, media, featured images, author, gap report)"
- "scripts/php-export/{featured-images.sql,categories.sql,README.md}: JSON Lines exports from the PHP database"
- "deploy/: staging nginx vhost, supervisor programs grzybyfunkcjonalne-go (8096) and grzybyfunkcjonalne-nuxt (3016), env templates"
- "scripts/build.sh, scripts/check-deploy.sh, scripts/smoke.sh (full, --go-only, --url) and scripts/urls.txt (71 live sitemap URLs + 27 more)"
- "host README; todo journal-imported-html-admin-resave"
affects: [16-04, 16-05]
actuals:
tokens: 25500 # chars/4 over the realized diff (host 33bc5e0..8f7f2de without go.sum and the PNG, plus the todo file)
tasks: 3
commits: 9 # MEASURED in sm-grzybyfunkcjonalne-app: git rev-list --count 33bc5e0..HEAD; plus 1 docs commit in summercms.go (6ff7d5d)
commits: 9
plan_head_before: 33bc5e04bfc2ad2c79c33b01c962d49ed21dd7cc # sm-grzybyfunkcjonalne-app
plan_head_after: 8f7f2de7650d9946970bc50e84cc9cc632057059 # sm-grzybyfunkcjonalne-app, pushed to origin/master
tech-stack:
added: []
patterns:
- "App-local plugin as an in-repo module (plugins/grzyby) registered by summer plugin:add; commands only, no routes"
- "Plugin commands open and publish the database themselves (lookupDB via lagoon.OpenFromApp + lagoon.Publish)"
- "JSON Lines maps produced by read-only SQL on the PHP database drive idempotent Go import commands"
- "Runbook script with a fresh-database guard and an asserted locale switch before a one-shot import"
- "URL rehearsal from a line grammar shared with the app smoke ([remote] STATUS PATH [contains|notcontains|type])"
key-files:
created:
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_posts.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
- ../sm-grzybyfunkcjonalne-app/scripts/build.sh
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
- ../sm-grzybyfunkcjonalne-app/README.md
- .planning/todos/pending/journal-imported-html-admin-resave.md
modified:
- ../sm-grzybyfunkcjonalne-app/config/backend.yaml
- ../sm-grzybyfunkcjonalne-app/boot_test.go
- ../sm-grzybyfunkcjonalne-app/summer.yaml
- ../sm-grzybyfunkcjonalne-app/go.work
- ../sm-grzybyfunkcjonalne-app/go.mod
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
- ../sm-grzybyfunkcjonalne-app/.gitignore
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
key-decisions:
- "Plugin commands in a summer-build host open and publish the database themselves, because the generated main.go publishes none for them; the journal import runs as grzyby:import-posts (the Journal command's own Run after the DB is opened)"
- "migrate.sh imports from a copy of the export in which only empty \"translations\": [] arrays become {}; the export directory stays untouched and the importer fix is deferred to sm-journal-plugin"
- "The URL rehearsal gives each URL its own X-Forwarded-For visitor (203.0.113.N): one address for ~90 SSR pages exceeds Go's 120/min per-visitor journal limit, and a broken XFF chain would still surface as 429s"
- "The nginx image location declares its own types map (jpeg/png/gif/webp, octet-stream default) so images keep an image type under nosniff whatever the server's mime.types; the regex is case-insensitive (~*) for upper-case extensions"
- "The boot test loads a temp copy of config/ so a developer's gitignored .env cannot override backend.uri"
patterns-established:
- "scripts/smoke.sh --url BASE as the deployed-site check over urls.txt (16-04 reuses it on staging)"
requirements-completed: [SC-1, SC-3]
coverage:
- id: D1
description: "Admin at /gfadmin, no /backend route; the host journal gitlink points at 3d533d4 with the D-20 and D-21 additions (D-09)"
requirement: SC-1
verification:
- kind: unit
ref: "go -C ../sm-grzybyfunkcjonalne-app test ./... -run '^(TestBootUserTranslateJournal|TestCORS)$' (assertAdminMount, assertGrzybyCommands)"
status: pass
- kind: integration
ref: "scripts/smoke.sh --go-only: GET /gfadmin non-404, GET /backend 404"
status: pass
human_judgment: false
- id: D2
description: "grzyby:import-featured-images copies the original under attach.BlobKey and attaches it to the post; the post answers on its pl slug with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png; a second run creates nothing (D-22)"
requirement: SC-1
verification:
- kind: integration
ref: "scripts/smoke.sh --go-only -> smoke: ok (go) (featured_image_url, file on disk equals source, base slug 404, 'Results: 0 created, 1 skipped, 0 failed')"
status: pass
human_judgment: false
- id: D3
description: "grzyby:import-categories fills name, description and content preferring pl; cordyceps carries the content marker, amanita-muscaria is listed with post_count 0; a rerun reports 0 updated, 1 unchanged (D-21, D-24)"
requirement: SC-3
verification:
- kind: integration
ref: "scripts/smoke.sh --go-only (categories?include_empty=1 checks and the second run)"
status: pass
- kind: integration
ref: "scripts/php-export/categories.sql and featured-images.sql run on a throwaway MariaDB 12.3 fixture; output parsed by the Go command (--dry-run)"
status: pass
human_judgment: false
- id: D4
description: "scripts/migrate.sh runs migrate, the fresh-database guard, the asserted pl default, the one-shot import, categories, media, featured images, author and the gap report in that order (D-25, D-17, D-15)"
requirement: SC-1
verification:
- kind: integration
ref: "scripts/smoke.sh (full) runs migrate, admin:create and migrate.sh --author-login admin --author-slug admin; 63 published posts served (meta.total)"
status: pass
- kind: manual_procedural
ref: "migrate.sh --dry-run and the unknown-author path run by hand on a scratch database (dies with 'author login ... does not exist')"
status: pass
human_judgment: false
- id: D5
description: "Staging deploy files: nginx vhost passes nginx -t; both supervisor programs parse with the required keys; /backend 404, image regex with nosniff, plain /storage/ 404, GET/HEAD only to Nuxt (D-07, D-10)"
requirement: SC-1
verification:
- kind: integration
ref: "scripts/check-deploy.sh -> check-deploy: ok"
status: pass
- kind: manual_procedural
ref: "the vhost run under a non-root nginx 1.30.4 on loopback: images 200 image/png with nosniff and HSTS, x.php and config.yaml 404, /backend 404, POST / 403, HTTP 301 to HTTPS"
status: pass
human_judgment: false
- id: D6
description: "Local rehearsal answers every non-remote urls.txt line: all 71 live sitemap URLs, /blog?page=2 keeping ?page=2, /amanita-muscaria 200, /autor/admin 200, /backend 404, /en/blog 404, /rss.xml as RSS (D-15, D-18)"
requirement: SC-3
verification:
- kind: e2e
ref: "scripts/build.sh && scripts/smoke.sh -> smoke: ok (95 urls)"
status: pass
human_judgment: false
- id: D7
description: "The runbook and deploy files work on the real PHP server (table names, morphs, Postgres and Node versions, paths)"
verification: []
human_judgment: true
rationale: "Only the staging deploy in 16-04 can confirm the server-side values the SQL files and deploy files assume"
duration: 22 min
completed: 2026-10-09
---
# Phase 16 Plan 03: Host, migration runbook and staging deploy Summary
**`sm-grzybyfunkcjonalne-app` is now the deployable host.**
- The admin is at `/gfadmin`.
- An app plugin `golem15.grzyby` imports featured images under their Winter disk names and the category pillar content from JSON Lines maps produced by SQL on the PHP database.
- One ordered runbook does the migration: migrate, an asserted pl default, a one-shot journal import, categories, media, featured images, the author and a gap report.
- Staging deploy files cover two processes (Go on 8096, Nuxt on 3016 behind one nginx vhost).
- A local rehearsal on a throwaway database answers all 95 local URL checks, including every one of the 71 live sitemap URLs.
## Performance
- **Duration:** 22 min
- **Started:** 2026-10-09T16:31:36Z
- **Completed:** 2026-10-09T16:54:03Z
- **Tasks:** 3 (Task 1 a tracer, auto-verified end to end before expansion)
- **Files:** 32 changed in the host (2,701 insertions), plus 1 todo and `deferred-items.md` in summercms.go
## Accomplishments
- **D-09.** `config/backend.yaml` sets `uri: /gfadmin`. The boot test asserts that some route starts with `/gfadmin/` and that none is under `/backend`. The nginx vhost answers 404 for `/backend`.
- **Journal gitlink.** It now points at `3d533d4`, which carries the D-20 author endpoints and the D-21 category content.
- The nested clone had working-tree drift. I saved it as a patch and checked it against the upstream commits; all of it was already contained in `be4f33a..3d533d4`.
- I then discarded the drift file by file and checked out `3d533d4`.
- **`golem15.grzyby`.** It was scaffolded with `summer make:plugin` and registered with `summer plugin:add`. `summer build` regenerated `plugins.gen.go` (`main.go` was unchanged). I stripped the plugin down to commands only:
- `grzyby:import-featured-images` (D-22, T-16-12): validates the disk name, applies the content-type allowlist and sniffs the bytes, keeps the source path inside `--source` and checks the size. It falls back from the base slug to the pl slug through the export, writes the blob under `attach.BlobKey` and inserts the `system_files` row. It is idempotent.
- `grzyby:import-categories` (D-21): prefers pl values and finds the category by pl slug, then base slug. It accepts `translations.pl` as an object, a JSON string (as MariaDB nests it) or null. Equal values count as unchanged.
- `grzyby:import-posts`: see deviation 1.
- **The runbook (`scripts/migrate.sh`).** Steps run in the D-25 order.
- It refuses a database that already holds posts (Pitfall 3).
- It asserts that `pl` is the only enabled default before importing (Pitfall 1).
- The author step sets `golem15_bloghub_author_slug` and assigns every post without an author.
- The gap report flags and does not patch (D-17).
- `--dry-run` runs only the steps that write nothing.
- **PHP exports.**
- `featured-images.sql` and `categories.sql` emit one `JSON_OBJECT` per row. I ran both against a throwaway MariaDB 12.3 with a fixture schema, and the Go command parsed the output.
- The README names the server checks: the post and category morphs and tables, and the `winter_`/`rainlab_`/`golem15_` translate table prefixes. It also gives the `sed` rewrites for the Blog plugin and the copy steps.
- **Deploy.**
- nginx vhost with:
- `/gfadmin` and `/_journal/` proxied to Go (64 MiB body limit);
- the image regex location with nosniff, an explicit types map and 30-day expiry;
- a plain `/storage/` prefix answering 404;
- `limit_except GET HEAD` for Nuxt;
- `X-Forwarded-For` on every proxy.
- Supervisor programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt` (Node `--env-file`).
- `env.example` (adds `SUMMER_URL`) and `nuxt.env.example` (`NUXT_SITE_INDEXABLE=false`, empty GA).
- **Build and checks.**
- `scripts/build.sh` builds `bin/grzybyfunkcjonalne` (linux/amd64) and `build/nuxt/.output`, and refuses generated-source drift.
- `scripts/check-deploy.sh` runs nginx -t in a temp prefix, checks both supervisor programs with configparser, and greps the vhost for its security locations.
- **`scripts/urls.txt`.**
- It holds the **71 live sitemap URLs** fetched 2026-10-09 (`/`, `/blog`, 7 categories, 62 posts), the same set as at research time.
- It adds 27 more lines: pagination, static pages, 404 cases, Go paths and 3 remote nginx lines, for 98 checks in total.
- `scripts/smoke.sh` checks all 95 non-remote lines locally and every line with `--url`.
## Task Commits
All code commits are in `../sm-grzybyfunkcjonalne-app` (`master`, pushed `33bc5e0..8f7f2de` after sm-journal-plugin reached origin):
0. `90f2dbd` chore: point journal gitlink at author endpoint and category content
1. **Task 1 (tracer):**
- `5ccbc62` feat: mount the admin at /gfadmin
- `893cf42` feat: app plugin golem15.grzyby with featured-image import
- `a3266c3` fix: open the database for grzyby commands and add grzyby:import-posts
- `3f17f1c` feat: migration runbook and Go rehearsal
2. **Task 2:**
- `30233d5` feat: grzyby:import-categories for category content
- `81ffce3` feat: runbook categories, media and author steps; PHP export queries
- `6ff7d5d` (summercms.go) docs: todo for Journal HTML content re-save
3. **Task 3:**
- `07390b2` feat: staging deploy files and full URL rehearsal
- `8f7f2de` docs: host README
**Tracer gate.** Interactive mode with `end-of-phase` and an automated-only `<verify>`. The verify was re-run and passed (vet, boot test, plugin vet, build, `smoke: ok (go)`, no `uri: /backend`) before expansion.
**Plan metadata:** the docs commit in summercms.go (this SUMMARY, `deferred-items.md`, and my STATE/ROADMAP lines only).
## Files Created/Modified
See `key-files` in the frontmatter. Also:
- `plugins/grzyby/go.mod`/`go.sum` (local replaces for the journal, translate and user modules).
- The host `go.mod` and `go.work` (written by `summer plugin:add`).
- The testdata 1×1 PNG at `plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png` (69 bytes).
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Plugin commands get no database in a `summer build` host**
- **Found during:** Task 1 (first rehearsal run: `journal: database is not configured`).
- **Issue:** The generated `main.go` publishes no `*gorm.DB` for plugin commands. Only lagoon, cabana and `serve` open one. So `journal:import-posts` cannot run from the host CLI at all.
- **Fix:**
- `lookupDB` opens and publishes `database.dsn` through `lagoon.OpenFromApp` and `lagoon.Publish`, which also runs the `OnDatabase` hooks.
- A thin `grzyby:import-posts` runs the Journal command's own `Run`, with the same flags, after that.
- `migrate.sh` calls it, and its step names say `journal:import-posts`.
- The framework fix is in `deferred-items.md`. This is outside this plan's files and the concurrency boundary (`modules/`).
- **Files modified:** `plugins/grzyby/console/{helpers.go,import_posts.go}`, `plugin.go`, `boot_test.go`, `scripts/migrate.sh`.
- **Verification:** The rehearsal imports 105 posts and serves 63 published.
- **Commit:** `a3266c3`.
**2. [Rule 3 - Blocking] The journal importer rejects `"translations": []`**
- **Found during:** Task 1 (61 of 63 published posts served).
- **Issue:** The PHP exporter writes empty translations as `[]`. Five files failed as "Invalid JSON", including the live post `ekstrakt-z-turkey-tail`.
- **Fix:** `migrate.sh` imports from a temp copy in which only that empty array becomes `{}`, and prints the count. The importer fix is deferred to sm-journal-plugin.
- **Commit:** `3f17f1c`.
**3. [Rule 3 - Blocking] A local `.env` overrode `backend.uri` in the boot test**
- **Issue:** compass reads `../.env` next to `config/`. The developer's gitignored host `.env`, which I did not read, keeps the admin at `/backend`, so the new `/gfadmin` assertion failed locally even though a clean work dir mounts `/gfadmin`.
- **Fix:** The boot test loads a temp copy of `config/`.
- **Commit:** `893cf42`.
**4. [Rule 1 - Plan data] `/sitemap.xml` content type**
- **Issue:** `@nuxtjs/sitemap` serves `text/xml; charset=UTF-8`, not `application/xml`.
- **Fix:** `urls.txt` expects `type=text/xml`.
- **Commit:** `07390b2`.
**5. [Rule 1 - Test design] One visitor address exhausted the Go rate limit**
- **Issue:** With `X-Forwarded-For: 203.0.113.7` on every URL, 28 checks got 429. About 90 SSR pages make more than 120 API calls a minute for that one visitor. This also proved the XFF chain works: the bucket used was the visitor's, not Nuxt's.
- **Fix:** Each URL uses its own `203.0.113.N`.
- **Commit:** `07390b2`.
**6. [Rule 2 - Missing critical] Image types under nosniff**
- **Issue:** Under a bare `http {}`, as in check-deploy, nginx served the images as `text/plain` with nosniff.
- **Fix:** The image location declares its own `types` map and an octet-stream default, and matches case-insensitively (`~*`) for files like `.PNG`.
- **Verification:** On loopback, the images answer `200 image/png`.
- **Commit:** `07390b2`.
**7. [Rule 2 - Missing critical] Featured-image hardening beyond the plan**
- **Fix:** `http.DetectContentType` must match the declared type, and the file size must match the map.
- **Commit:** `893cf42`.
**8. [Scope adjustment] The UAT-host gap report**
- **Issue:** The 2 known `gf.uat.golem15.com` references are only in the base text of one export file. The pl import replaces that text, so no imported body carries them.
- **Fix:** The report lists the export file and the imported count (0) instead of claiming 2 broken bodies.
- **Commit:** `81ffce3`.
**9. [Scope adjustment] The scaffold was trimmed**
- **Issue:** `make:plugin` writes the full scaffold (config, lang, mail views, `registry.gen.go`, `routes.go`).
- **Fix:** I removed it. The plugin is `plugin.go` plus `console/`, with no routes, so D-14 is unchanged. `registry.gen.go` is refreshed only by `make:*` artifact commands, not by `summer build`.
- **Commit:** `893cf42`.
---
**Total deviations:** 9. Three were blocking fixes, two were missing-critical additions, two were plan-data or test-design corrections, and two were scope adjustments.
**Impact on plan:** Every planned artifact, command name, flag and key link exists. The only additions are `grzyby:import-posts` and the export normalization, both workarounds for upstream gaps that are now recorded in `deferred-items.md`.
## Issues Encountered
- **Precondition.** `SMOKE_ADMIN_DSN` was unset and Docker was unreachable for user `golem`.
- I used the orchestrator's sanctioned fallback: a private PostgreSQL 18.6 cluster (`initdb`, ICU, trust auth) on 127.0.0.1:55494 in the scratchpad.
- `SMOKE_ADMIN_DSN` pointed at that cluster for every rehearsal.
- The cluster and a throwaway MariaDB were stopped and removed at the end.
- No temporary override touched any repo file.
- **pnpm.** pnpm ran with `TMPDIR` set to the scratchpad and the project-drive store (`npm_config_store_dir`), as in 16-01 and 16-02.
- **`@nuxt/fonts`.** Its metadata fetches are blocked in the sandbox. The builds succeed with warnings only.
- **Pre-existing state.** The host's untracked `.gsd/` was left alone.
- **Concurrent edits.** Another session has uncommitted STATE/ROADMAP edits (Phase 18.1). This plan's metadata commit stages only its own changes and leaves theirs in the working tree.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model. T-16-10 through T-16-17 and T-16-SC are implemented as planned:
- no new Go HTTP endpoint;
- no new Go or npm module (the plugin uses gorm, gocloud.dev, the framework and sm-journal-plugin, all already in the graph);
- templates carry empty secrets, which the negative grep confirms;
- the smoke drops only its `grzyby_smoke_<hex>` database.
## User Setup Required
None for this plan. `SMOKE_ADMIN_DSN` is optional (Docker or the user's own Postgres). The server values are confirmed in 16-04.
## Next Phase Readiness
**16-04 (staging deploy).**
- On the server:
1. Run the two SQL files after the checks in `scripts/php-export/README.md`.
2. Copy the uploads, media and export.
3. Follow the README runbook (migrate, `admin:create`, `migrate.sh`).
4. Install the deploy files.
5. Run `scripts/smoke.sh --url https://staging.grzybyfunkcjonalne.pl`.
- Decide on `first-journal-post` (published in the export, absent live).
- Swap `listen ... http2` for `http2 on;` if the server's nginx is 1.25.1 or newer.
**16-05 (unit tests).** These can target:
- `ReadFeaturedImageMap` and `ImportFeaturedImages` (mem:// bucket, idempotency, path escape, type allowlist and sniffing);
- `ReadCategoryMap`, `CategoryTranslations.UnmarshalJSON` and `ImportCategories` (unchanged detection);
- `validDiskName`, `flagOrDefault` and `flagOn`.
## Self-Check: PASSED
- FOUND: every `key-files.created` path in the host and the todo in summercms.go.
- FOUND: host commits `90f2dbd 5ccbc62 893cf42 a3266c3 3f17f1c 30233d5 81ffce3 07390b2 8f7f2de` on `origin/master`, and summercms.go `6ff7d5d`.
- Plan verification, re-run after the last commit:
- `check-deploy: ok`;
- `build: bin/grzybyfunkcjonalne and build/nuxt/.output ready`;
- `smoke: ok (95 urls)`;
- `go vet ./...` and `go test ./... -count=1` ok;
- the env.example negative grep passes;
- every one of the 71 live locs has its `200 PATH` line in `urls.txt`.
---
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
*Completed: 2026-10-09*

View File

@@ -0,0 +1,319 @@
---
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
plan: 04
subsystem: infra (staging deploy on the PHP site's server)
tags: [summercms, go, nuxt-ssr, nginx, supervisor, postgres, migration, staging, basic-auth]
status: complete
requires:
- phase: 16-03
provides: "host repo with migrate.sh, smoke.sh --url, urls.txt, deploy files, PHP export queries"
provides:
- "staging at https://uat.grzybyfunkcjonalne.pl on the PHP server: grzybyfunkcjonalne-go (127.0.0.1:8096) and grzybyfunkcjonalne-nuxt (127.0.0.1:3016) behind one vhost, UAT basic auth with a loopback bypass"
- "Postgres database grzybyfunkcjonalne migrated and imported once: 104 posts (62 published), 7 categories, 35 media files, 95 featured images, author /autor/admin"
- "DEPLOY.md in the host repo (server facts, prerequisites, build and upload, first install, migration runbook, smoke, release, rollback, production cutover)"
- "migrate.sh --skip; media copied with readable modes; fileblob bucket with no_tmp_dir=true"
affects: [16-05, production cutover of grzybyfunkcjonalne.pl]
actuals:
tokens: 4541 # chars/4 over the added lines of the host diff 8f7f2de..c797ab9 (9 files)
tasks: 3
commits: 5 # MEASURED in sm-grzybyfunkcjonalne-app: git rev-list --count 8f7f2de..HEAD
commits: 5
plan_head_before: 8f7f2de7650d9946970bc50e84cc9cc632057059 # sm-grzybyfunkcjonalne-app
plan_head_after: c797ab9d23e0a9e5d6862811560ab4c832a3c57c # sm-grzybyfunkcjonalne-app, pushed to origin/master
tech-stack:
added: []
patterns:
- "UAT gate as an nginx geo realm: basic auth for everyone, off for 127.0.0.1/::1, so on-server checks need no credentials"
- "Remote checks run on the server with CURL_HOME/.curlrc 'resolve = host:443:127.0.0.1' so smoke.sh --url goes through the real vhost and TLS"
- "Server-side files installed into /etc by the operator from uploaded copies in /srv/<app>/deploy/, verified by sha256 against the repo"
key-files:
created:
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
modified:
- ../sm-grzybyfunkcjonalne-app/README.md
- ../sm-grzybyfunkcjonalne-app/config/storage.yaml
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
key-decisions:
- "Staging keeps the server's UAT basic-auth gate (/etc/nginx/.g15), off only for loopback through a geo realm; it is not public. Smoke and curl checks run on the server"
- "Staging host is uat.grzybyfunkcjonalne.pl (the server's existing UAT naming), not staging.grzybyfunkcjonalne.pl"
- "first-journal-post is left out of the staging import (migrate.sh --skip), so Go serves the 62 posts the live sitemap lists"
- "The fileblob uploads bucket uses no_tmp_dir=true, because /tmp on the server is tmpfs and the rename across filesystems fails"
- "Install root and .env files belong to the single deploy/service user grzybyfunkcjonalne (0600), because the executor has no root; the operator installed the vhost and supervisor programs into /etc"
patterns-established:
- "DEPLOY.md per host app, with the on-server smoke recipe for basic-auth-gated staging"
requirements-completed: [SC-1, SC-3]
coverage:
- id: D1
description: "Both supervisor programs RUNNING; the imported post /blog/blogbadania-nad-cordycepsem answers over HTTPS with its title from Nuxt, and the Journal API returns featured_image_url (D-18, SC-1)"
requirement: SC-1
verification:
- kind: integration
ref: "Task 2 verify on the server via loopback: check-deploy ok, 2 RUNNING, title grep, featured_image_url grep -> staging-tracer-ok"
status: pass
human_judgment: false
- id: D2
description: "Every urls.txt line passes on staging: all 71 live sitemap URLs (re-pulled 2026-10-09, unchanged), pagination, static pages, negative cases and the remote storage lines (D-15, SC-3)"
requirement: SC-3
verification:
- kind: e2e
ref: "CURL_HOME=<resolve to 127.0.0.1> scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl (on the server) -> smoke: ok (98 urls)"
status: pass
human_judgment: false
- id: D3
description: "A featured image and a media file referenced by a post body answer 200 with an image content type and nosniff; /gfadmin 200, /backend 404; no Google Tag Manager; robots.txt Disallow: / (D-09, D-17, D-22, D-23)"
requirement: SC-1
verification:
- kind: integration
ref: "on-server checks: 661e483fa0e06765454515.webp 200 image/webp nosniff; Artur%20Wolak.png 200 image/png nosniff; las.webp 200 image/webp nosniff; /gfadmin 200; /backend 404; homepage without googletagmanager; robots disallow-all"
status: pass
human_judgment: false
- id: D4
description: "The live Winter vhost is unchanged and only new files were installed (D-10)"
requirement: SC-1
verification:
- kind: manual_procedural
ref: "sha256 of /etc/nginx/sites-available/grzybyfunkcjonalne.pl = 37f9858c...7d77 before and after, mtime 2026-08-16; installed vhost and supervisor files equal the uploaded repo copies"
status: pass
human_judgment: false
- id: D5
description: "Staging reads like the live site: refreshed design, complete Polish copy, featured images and inline media on /, /blog, /blog?page=2, a post, /cordyceps, /amanita-muscaria, /kontakt and /autor/admin"
verification: []
human_judgment: true
rationale: "Visual and copy comparison against the live site needs a human with the UAT credentials; the Task 3 human-check has not been answered yet"
duration: 40min
completed: 2026-10-09
---
# Phase 16 Plan 04: Staging deploy on the PHP server Summary
**grzybyfunkcjonalne.pl runs on staging at https://uat.grzybyfunkcjonalne.pl: the Go Journal host and the Nuxt SSR server sit behind a basic-auth vhost on the PHP site's server, serving 62 imported posts with their featured images and media. Every one of the 98 URL checks passes on the server, and DEPLOY.md documents the deploy and the rollback.**
## Performance
- **Duration:** about 40 min. That includes the operator checkpoint between Task 2a/2b and the resume; the resumed part ran from 17:31Z to 17:55Z.
- **Started:** 2026-10-09T17:14:27Z
- **Completed:** 2026-10-09T17:55:00Z
- **Tasks:** 3 (Task 1 resolved by the orchestrator and operator; Tasks 2 and 3 executed)
- **Files modified:** 9 in the host repo, plus deferred-items.md
## Accomplishments
- The migration runbook ran once into the fresh staging database (D-25 order):
- 104 posts imported, 62 of them published.
- pl is the only default locale.
- 7 categories filled.
- The PHP media library copied (35 files).
- 95 featured images attached under their Winter disk names.
- Every post is assigned to `info@golem15.com` with the author slug `admin`.
- nginx -t passed before the reload. Both supervisor programs are RUNNING, and the tracer post renders over HTTPS.
- `smoke.sh --url` passes all 98 urls.txt lines. The live sitemap, re-pulled today, is unchanged (71 URLs, all covered).
- Images:
- The featured image and the media files answer 200 with an image type and nosniff.
- Admin:
- `/gfadmin` answers 200 (the SummerCMS admin shell).
- `/backend` answers 404.
- Search and analytics:
- Staging loads no analytics.
- robots.txt is `Disallow: /`.
- From outside, without credentials, the public address answers 401.
- DEPLOY.md is written and README points to it. The host repo is pushed to origin/master (c797ab9).
## Server facts
| What | Value |
|------|-------|
| SSH alias | `grzybyfunkcjonalne-uat` (user `grzybyfunkcjonalne`, host `alexandria`, 51.77.52.21) |
| Staging URL | `https://uat.grzybyfunkcjonalne.pl` (Cloudflare A record, not proxied; LE ECDSA cert `/etc/letsencrypt/live/uat.grzybyfunkcjonalne.pl/`, expires 2027-01-07) |
| nginx | 1.26.3 (`http2 on;`), user www-data. No options-ssl-nginx.conf or ssl-dhparams.pem on the server, so `ssl_protocols` is set inline. ACME webroot `/srv/http/uat.grzybyfunkcjonalne.pl` |
| Vhost | `/etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl` = repo `deploy/nginx/grzybyfunkcjonalne-staging.conf` (sha256 150f1ef6…2f0c). The placeholder it replaced is backed up at `/root/uat.grzybyfunkcjonalne.pl.placeholder-20261009` |
| Supervisor | `/etc/supervisor/conf.d/grzybyfunkcjonalne-go.conf` (d1b674d2…94d6) and `grzybyfunkcjonalne-nuxt.conf` (31f5ed46…22f0c), equal to the repo |
| Ports | Go 127.0.0.1:8096, Nuxt 127.0.0.1:3016 |
| Node | `/var/www/.nvm/versions/node/v22.22.0/bin/node` (not on the non-interactive PATH) |
| PostgreSQL | 17.11, 127.0.0.1:5432, role/db `grzybyfunkcjonalne`, ICU pl-PL |
| DSN | `postgres://grzybyfunkcjonalne:<password>@127.0.0.1:5432/grzybyfunkcjonalne?sslmode=disable` |
| Install root | `/srv/grzybyfunkcjonalne/{go,nuxt,import,deploy}`, owner grzybyfunkcjonalne. Binary sha256 94116f4a…68ea, built from summercms.go 296b9cd |
| .env files | `/srv/grzybyfunkcjonalne/go/.env` and `nuxt/.env`, owner grzybyfunkcjonalne, mode 0600. Secrets were generated on the server and never printed. `NUXT_SITE_INDEXABLE=false`, `NUXT_PUBLIC_GA_ID` empty |
| sudo | `/usr/sbin/nginx -t`, `nginx -s reload`, `systemctl reload nginx`, `supervisorctl *` |
| PHP storage | uploads `/srv/http/grzybyfunkcjonalne.pl/storage/app/uploads/public` (1082 files), media `/srv/http/grzybyfunkcjonalne.pl/storage/app/media` (35) |
| PHP database | MySQL `grzyby`. Morph types `Golem15\Journal\Models\Post` and `Golem15\Journal\Models\Category`. Translate tables `winter_translate_*`. system_files: 95 post featured_images, 7 category featured_image |
| Import maps | `/srv/grzybyfunkcjonalne/import/featured-images.jsonl` (95), `categories.jsonl` (7); journal export `import/journal-export/` (105 files) |
| Admin | `info@golem15.com`, superuser, created by the operator with the password on stdin from a file outside the deploy (never read or stored by the executor) |
| /tmp | tmpfs, a separate filesystem from `/` |
| Live Winter vhost | `/etc/nginx/sites-available/grzybyfunkcjonalne.pl` sha256 37f9858c…7d77 and mtime 2026-08-16, unchanged after the deploy |
**Files touched on the server.** The executor wrote only under `/srv/grzybyfunkcjonalne`. The operator, as root, installed the vhost, its `sites-enabled` symlink and the two supervisor programs as new files, and backed up the placeholder vhost. The executor's sudo use was `nginx -t`, `systemctl reload nginx` and `supervisorctl reread/update/status`.
## Runbook output (Task 2)
The first run of `scripts/migrate.sh`, with the flags from DEPLOY.md including `--skip first-journal-post`:
```
migrate: migrate
✓ migrations applied
migrate: make pl the default locale
migrate: export: 4 files had translations [] (imported as {})
migrate: export: 1 files skipped (--skip first-journal-post)
migrate: journal:import-posts --dry-run (grzyby:import-posts)
ℹ Results: 104 created, 0 updated, 0 skipped, 0 failed
migrate: journal:import-posts (grzyby:import-posts)
ℹ Results: 104 created, 0 updated, 0 skipped, 0 failed
migrate: grzyby:import-categories --map /srv/grzybyfunkcjonalne/import/categories.jsonl
ℹ Results: 7 updated, 0 unchanged, 0 failed (cordyceps, chaga, reishi, amanita-muscaria, turkey-tail, shiitake, lions-mane)
migrate: rsync media -> /srv/grzybyfunkcjonalne/go/storage/app/media/
migrate: grzyby:import-featured-images ...
grzyby: write 660/cfe/44a/660cfe44a36c0821793311.webp: ... rename /tmp/....tmp /srv/grzybyfunkcjonalne/go/storage/app/uploads/public/...: invalid cross-device link
EXIT=1
```
After the fix (6ef012e), steps 7 to 9 ran with the same commands and the same normalized export copy:
```
ℹ Results: 95 created, 0 skipped, 0 failed
migrate: assign unassigned posts to info@golem15.com (/autor/admin)
migrate: gap: 1 export files reference https://gf.uat.golem15.com/storage/ (broken images, not patched)
wprowadzenie-do-grzyba-cordyceps
migrate: gap: 0 imported post bodies still reference https://gf.uat.golem15.com/storage/
migrate: gap: 0 published posts have no featured image
migrate: published posts: 62
migrate: ok (resumed at step 7)
```
**Gap report:**
- 1 export file has old UAT-host image references, only in its base text.
- 0 imported bodies are affected.
- 0 published posts lack a featured image.
## Smoke and checks (Task 3)
- The live sitemap was re-pulled on 2026-10-09: 71 `<loc>`, all present as `200` lines in urls.txt. urls.txt is unchanged, so there is no test commit.
- `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl`, run on the server through the loopback bypass: `smoke: ok (98 urls)`. The first run failed one line, `remote 200 /storage/app/media/las.webp` → 403; see deviation 3.
- Featured image `/storage/app/uploads/public/661/e48/3fa/661e483fa0e06765454515.webp`: 200, image/webp, nosniff.
- Media referenced by the body of `ekstrakt-z-turkey-tail`, `/storage/app/media/Artur%20Wolak.png`: 200, image/png, nosniff. `las.webp`: 200, image/webp, nosniff.
- `/gfadmin`: 200 text/html (`<title>SummerCMS</title>`). Not logged in, as instructed. `/backend`: 404.
- Homepage: no googletagmanager. robots.txt: `User-agent: *` / `Disallow: /` (nuxt-robots, indexing disabled).
- API `meta.total`: 62.
- Without the bypass, the public address answers 401.
**Human-check: pending.** Nobody has answered "staging approved" or reported issues yet. The visual comparison with the live site, made with the UAT credentials, is left for `/msd-verify-work` (coverage D5). The admin-password part of the original human-check no longer applies (operator note).
## Task Commits
All of them are in `sm-grzybyfunkcjonalne-app` and pushed to origin/master:
1. **Task 1: Server prerequisites**: no commit (resolved by the orchestrator and the operator).
2. **Task 2: deploy and runbook (tracer)**:
- `b488fdd` (chore): match staging deploy files to the server.
- `97cfd31` (feat): migrate.sh --skip for export posts absent from the live site.
- `6ef012e` (fix): fileblob temp files next to the upload, not in /tmp.
3. **Task 3: smoke, images and DEPLOY.md**:
- `faae3c0` (fix): media copy gets readable modes, not the PHP dir's 0776.
- `c797ab9` (docs): staging deploy guide.
**Plan metadata:** see the docs(16-04) commit in summercms.go.
## Files Created/Modified
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: server facts, prerequisites, build and upload, first install, migration runbook, start, smoke (on-server recipe), release, rollback, the admin re-save warning, production cutover.
- `../sm-grzybyfunkcjonalne-app/README.md`: points to DEPLOY.md; the `--url` example now uses the uat host.
- `../sm-grzybyfunkcjonalne-app/config/storage.yaml`: `?no_tmp_dir=true` on the uploads bucket.
- `../sm-grzybyfunkcjonalne-app/scripts/migrate.sh`: `--skip NAME`; media rsync with `--chmod=Dug=rwx,Do=rx,Fug=rw,Fo=r`.
- `../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf`: uat host, certificate and webroot, `http2 on`, UAT basic auth with the loopback geo bypass, auth off on the ACME path, dotfiles 404.
- `../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf`: absolute nvm Node path.
- `../sm-grzybyfunkcjonalne-app/deploy/env.example` and `deploy/nuxt.env.example`: uat origin; owner is the service user, mode 0600.
- `../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh`: follows the new host, webroot and log paths.
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md`: 16-03 items resolved; new 16-04 items.
## Decisions Made
- **Basic-auth gate kept** (operator decision with the "installed" reply). Staging is not public. The geo realm turns auth off for 127.0.0.1 and ::1 only, so smoke and curl checks run on the server. The ACME webroot location has `auth_basic off`, so renewals keep working. Production drops the gate.
- **Host name `uat.grzybyfunkcjonalne.pl`.** It follows the server's existing UAT convention and its existing certificate.
- **`--skip first-journal-post`.** This resolves the 16-03 deferred item for staging: Go serves the same 62 posts the live sitemap lists.
- **No database drop after the featured-image failure.** Steps 1 to 6 had succeeded, the featured-image import is idempotent, and the failed write left no row behind (the resumed run reported 95 created, 0 skipped). Running steps 7 to 9 with the identical commands gives the same end state as a fresh full run, without root access to drop and recreate the database.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Featured-image import failed with "invalid cross-device link"**
- **Found during:** Task 2 (runbook, step 7).
- **Issue:** gocloud's fileblob writes temp files to `os.TempDir()` and renames them into the bucket. `/tmp` on the server is tmpfs, so the rename fails. Admin uploads would fail the same way.
- **Fix:** set `bucket_url: "file://./storage/app/uploads/public?no_tmp_dir=true"` in the host config, upload it, and run steps 7 to 9 with the same commands.
- **Files modified:** `../sm-grzybyfunkcjonalne-app/config/storage.yaml`
- **Verification:** 95 created, 0 failed; the featured images answer 200 on staging.
- **Committed in:** 6ef012e
- **Follow-up:** a framework default in `modules/lagoon/attach`, logged in deferred-items.md and not touched here (another session owns `modules/`).
**2. [Rule 3 - Blocking] Runbook resumed at step 7 instead of drop-and-rerun**
- **Found during:** Task 2.
- **Issue:** The plan says a failed run means dropping and recreating the database. The executor has no root or CREATEDB rights for that.
- **Fix:** a one-off script ran migrate.sh steps 7 to 9 verbatim (same normalized and skipped export copy, same SQL, same gap report). The script was removed from the server afterwards.
- **Verification:** 95 featured images, 0 published posts without an image, 62 published.
- **Committed in:** none (operational step); documented in DEPLOY.md.
**3. [Rule 1 - Bug] Media images answered 403**
- **Found during:** Task 3 (smoke: `remote 200 /storage/app/media/las.webp` got 403).
- **Issue:** `rsync -a` kept the PHP media directory's mode 0776, which www-data cannot traverse.
- **Fix:** the migrate.sh media step now sets modes with `--chmod=Dug=rwx,Do=rx,Fug=rw,Fo=r`. The uploaded script was used to re-sync the media on the server (idempotent; the PHP directory was only read).
- **Files modified:** `../sm-grzybyfunkcjonalne-app/scripts/migrate.sh`
- **Verification:** smoke `ok (98 urls)`; las.webp and Artur Wolak.png answer 200 image/* with nosniff.
- **Committed in:** faae3c0
**4. [Operator decision] Verify commands run on the server**
- **Issue:** The plan's verify commands curl `$STAGING_URL` from the executor's machine. Because of the kept UAT gate, they answer 401 there.
- **Fix:** run on the server with curl resolving the host to 127.0.0.1, which exercises the real vhost and TLS. The recipe is in DEPLOY.md, "Smoke".
**5. [Server constraint] .env owner and mode**
- **Issue:** The plan asks for `root:grzybyfunkcjonalne 0640`. The executor has no root.
- **Fix:** the files belong to the single deploy and service user, mode 0600, as recorded in b488fdd and in the env templates.
---
**Total deviations:**
- 3 auto-fixed: 2 blocking, 1 bug.
- 2 adaptations to server constraints and operator decisions.
**Impact on plan:** The fixes were needed to serve images on this server. No scope creep, and the live site was not modified.
## Issues Encountered
- `scripts/smoke.sh --go-only` could not run locally to pre-check the storage change: there is no Docker socket access and `SMOKE_ADMIN_DSN` is not set. The server run was the verification.
## User Setup Required
None beyond what the operator already did. The pending human visual check is tracked under coverage D5.
## Next Phase Readiness
- Staging serves the site end to end, so SC-1 is met on staging (D-18) and SC-3 is proven against every live URL. Plan 16-05 (unit tests) can proceed.
- Before the production cutover:
- the human visual approval;
- the deferred items (`journal-imported-html-admin-resave`, the cta.png size);
- the steps in DEPLOY.md, "Production cutover".
## Self-Check: PASSED
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: FOUND.
- `../sm-grzybyfunkcjonalne-app/config/storage.yaml`: FOUND.
- `../sm-grzybyfunkcjonalne-app/scripts/migrate.sh`: FOUND.
- Commits b488fdd, 97cfd31, 6ef012e, faae3c0 and c797ab9: FOUND in sm-grzybyfunkcjonalne-app and on origin/master.
---
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
*Completed: 2026-10-09*

View File

@@ -0,0 +1,303 @@
---
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
plan: 05
subsystem: testing (Nuxt layer, site app SSR, Go host and app plugin)
tags: [node-test, tap, nuxt-ssr, go-test, testcontainers, postgres, phase-gate, validation]
status: complete
requires:
- phase: 16-01
provides: "vue-journal-layer utils (routes, client, pagination, htmlGate, sitemap, seo), i18n, testing/fixture-api.mjs and nuxt-server.mjs"
- phase: 16-02
provides: "vue-grzybyfunkcjonalne-app (PHP URL map, gaHeadScripts, menu, FAQ, fixtures and expect.txt)"
- phase: 16-03
provides: "golem15.grzyby commands (import-featured-images, import-categories), boot_test.go, check-deploy.sh"
- phase: 16-04
provides: "staging deploy proven with smoke.sh --url (98 urls)"
provides:
- "vue-journal-layer: pnpm test (node --test) over 7 util test files, 78 tests; main a4dca42 (no new tag, no runtime change)"
- "vue-grzybyfunkcjonalne-app: tests/ssr.test.ts (built server against the fixture API, 16 tests), analytics and content tests; fixture post test-niebezpieczny-wpis; main 5b10bc0"
- "sm-grzybyfunkcjonalne-app: plugins/grzyby/console tests with a fail-closed Postgres harness (GRZYBY_TEST_ADMIN_DSN or testcontainers), TestBackendURIGfadmin, TestGrzybyCommandsRegistered; master 87e5353"
- "summercms.go scripts/check-phase16.sh (--self-test, --layer, --layer-smoke, --app, --host, --plugin, --deploy, --all)"
- "16-VALIDATION.md signed off (nyquist_compliant: true, wave_0_complete: true)"
affects: [phase 16 verification, production cutover of grzybyfunkcjonalne.pl, other Golem15 blogs consuming vue-journal-layer]
actuals:
tokens: 23821 # chars/4 over added lines: host c797ab9..87e5353 (go.sum excluded), app b50c808..5b10bc0, layer 783426c..a4dca42, summercms.go c5e03f4 and a8c65bc
tasks: 3
commits: 3 # MEASURED in sm-grzybyfunkcjonalne-app: git rev-list --count c797ab9..HEAD; plus app 2, layer 1, summercms.go 2 (listed below)
commits: 3
plan_head_before: c797ab9d23e0a9e5d6862811560ab4c832a3c57c # sm-grzybyfunkcjonalne-app
plan_head_after: 87e5353ffc96d8999a4567c086eaad8b177fbad4 # sm-grzybyfunkcjonalne-app, pushed to origin/master
tech-stack:
added: ["testcontainers-go v0.44.0 and modules/postgres v0.44.0 in plugins/grzyby/go.mod (the Journal plugin's pin; test-only)"]
patterns:
- "node --test over .ts with explicit .ts imports; tests in node_modules-free utils, SSR checked through the built server"
- "App SSR test imports the layer's plain .mjs testing helpers from node_modules/vue-journal-layer/testing"
- "Go DB harness: TestMain creates one random ICU database, migrates through lagoon.Migrate + party.Activate, drops only that database, fails outside -short when no database exists"
- "Trap *gorm.DB on a closed port proves a code path makes no query"
- "Phase gate with two fail-closed detectors (go test -json and node TAP), each with a planted-input self-test"
key-files:
created:
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
- ../vue-journal-layer/tests/routes.test.ts
- ../vue-journal-layer/tests/client.test.ts
- ../vue-journal-layer/tests/pagination.test.ts
- ../vue-journal-layer/tests/htmlGate.test.ts
- ../vue-journal-layer/tests/sitemap.test.ts
- ../vue-journal-layer/tests/seo.test.ts
- ../vue-journal-layer/tests/i18n.test.ts
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json
- scripts/check-phase16.sh
modified:
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
- ../vue-journal-layer/package.json
- ../vue-journal-layer/README.md
- ../sm-grzybyfunkcjonalne-app/boot_test.go
- ../sm-grzybyfunkcjonalne-app/README.md
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
key-decisions:
- "No layer util needed a fix, so vue-journal-layer gets no v0.1.1 tag; the app keeps pinning v0.1.0 and the layer's main carries only tests"
- "The malformed-GA-id SSR case asserts the id appears only as the JSON string in window.__NUXT__.config (Nuxt serializes public runtime config) and nowhere else, instead of a literal absence of alert(1), which Nuxt makes impossible"
- "The grzyby harness creates its database with ICU pl-PL like production and fails, not skips, without GRZYBY_TEST_ADMIN_DSN or Docker outside -short"
- "The gate reads go test -json and node TAP rather than grepping -v text, and requires named tests per stage"
patterns-established:
- "Phase gate per site phase: layer, layer smoke, app (install, build, tests, smokes), host, app plugin with -race, deploy config"
requirements-completed: [SC-4]
coverage:
- id: D1
description: "The built site answers every PHP URL form with the right status, static pages beat /:category, canonical and titles keep ?page=N and the site name, pl only, empty category state, category content above the list, unsafe body blocked, XFF forwarded, feed types, sitemap entries, no forms, and GA only for a valid id (D-14, D-15, D-16, D-21, D-23, D-24, T-16-01, T-16-07)"
requirement: SC-4
verification:
- kind: integration
ref: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts (pnpm build && pnpm test: 16 ssr tests pass, 0 skipped)"
status: pass
- kind: other
ref: "mutation: htmlGate isSafeJournalHtml forced true in node_modules copy, rebuild -> 'not ok 8 - T-16-01: an unsafe post body renders the blocked notice only'; restored and rebuilt -> 16/16 pass"
status: pass
human_judgment: false
- id: D2
description: "Layer util unit tests: route normalization and filling, client query building with include_empty=1 and 404 handling, pagination, the HTML gate's Go parity cases, sitemap from journal.routes, SEO values, pl/en key and placeholder parity (D-06, D-13, D-16, D-24, T-16-01)"
requirement: SC-4
verification:
- kind: unit
ref: "pnpm -C ../vue-journal-layer test -> # tests 78, # pass 78, # fail 0, # skipped 0"
status: pass
human_judgment: false
- id: D3
description: "App util and content tests: gaHeadScripts for empty, malformed and valid ids; FAQ 16 entries; MAIN_MENU kinds and label keys; every t() key in app/**/*.vue resolves (D-23, T-16-07)"
requirement: SC-4
verification:
- kind: unit
ref: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts and tests/content.test.ts (pnpm test: 25 total, 25 pass)"
status: pass
human_judgment: false
- id: D4
description: "Go tests for grzyby:import-featured-images (blob at attach.BlobKey, system_files row, idempotent, base slug through the export, traversal refused before reading, failures, dry run) and grzyby:import-categories (pl preferred, content written, unchanged rerun, unknown slug, dry run) against a throwaway database (D-21, D-22, T-16-12, T-16-23)"
requirement: SC-4
verification:
- kind: unit
ref: "GRZYBY_TEST_ADMIN_DSN=<throwaway initdb cluster> go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -count=1 -v -race -> 10 top-level --- PASS, no SKIP, no DATA RACE; database list afterwards: postgres, template0, template1"
status: pass
- kind: unit
ref: "without a DSN or Docker: FAIL with the GRZYBY_TEST_ADMIN_DSN message; with -short: 5 pure tests PASS, 5 DB tests SKIP"
status: pass
human_judgment: false
- id: D5
description: "Host boot tests: backend.uri and cabana.AdminPrefix are /gfadmin, admin routes under /gfadmin/ and none under /backend, grzyby commands registered (D-09, T-16-10)"
requirement: SC-4
verification:
- kind: unit
ref: "go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS|TestBackendURIGfadmin|TestGrzybyCommandsRegistered)$' -> 4 PASS"
status: pass
human_judgment: false
- id: D6
description: "Fail-closed phase gate across the three repos and the VALIDATION sign-off (T-16-24)"
requirement: SC-4
verification:
- kind: integration
ref: "bash scripts/check-phase16.sh --all -> PASS self-test, layer, layer-smoke (28 checks), app (25 tests, 36 + 2 smoke checks), host, plugin, deploy; last line 'Phase 16 gate passed'"
status: pass
human_judgment: false
- id: D7
description: "The grzyby harness's testcontainers path (postgres:16-alpine with ICU pl-PL) works where Docker is available"
verification: []
human_judgment: true
rationale: "Docker is unreachable for this user, so only the GRZYBY_TEST_ADMIN_DSN path ran; logged in deferred-items.md"
duration: 19 min
completed: 2026-10-09
---
# Phase 16 Plan 05: Unit tests and phase gate Summary
**Every piece of Phase 16 code now has automated tests. A `node --test` SSR test boots the built grzybyfunkcjonalne.pl server against the fixture Journal API and checks every PHP URL form, the locale, analytics and the HTML gate. 78 layer util tests and 9 app util/content tests run without a build. Go tests cover both grzyby import commands against a throwaway ICU database and the `/gfadmin` boot. `scripts/check-phase16.sh --all` runs everything and ends with "Phase 16 gate passed".**
## Performance
- **Duration:** 19 min
- **Started:** 2026-10-09T17:57:30Z
- **Completed:** 2026-10-09T18:16:14Z
- **Tasks:** 3 (Task 1 a tracer, re-verified before expansion)
- **Files:** 27 across four repositories (15 created, 12 modified)
## Accomplishments
- **SSR tracer (D-15, D-16, D-21, D-23, D-24, T-16-01).** `tests/ssr.test.ts` starts the layer's fixture API and `.output/server/index.mjs` and checks:
- 23 URLs for their status, including `/blog?page=0|abc|3`, `/404`, `/error`, `/rss`, `/feed`, `/en`, `/blog/tag/anything`;
- static pages over `/:category`, the `?page=2` canonical and "Strona 2" title, the post title suffix "| Grzyby Funkcjonalne";
- the empty-state copy on `/amanita-muscaria`, "Cordyceps w pigułce" before the first post card;
- the new fixture post `test-niebezpieczny-wpis`: the blocked class, no `onerror`, no `alert(1)`;
- XFF `198.51.100.9` reaching the fixture API, `include_empty=1` on every categories request;
- RSS content type, sitemap entries, no `<form>`;
- three servers for D-23: default (no gtag), `G-MLSV7WJ6W4` (loader and config call), malformed (no gtag).
- A mutation run (gate forced open in a copy of the layer in `node_modules`) made the T-16-01 case fail, so the case depends on the gate.
- **Layer util tests.** Seven files, 78 tests: `routes`, `client` (fake fetcher), `pagination`, `htmlGate` (the six Go `FormatHTML` cases plus upper case), `sitemap`, `seo`, `i18n` parity. No util needed a fix, so there is no new layer tag.
- **App util and content tests.** `analytics.test.ts` and `content.test.ts`: GA ids, 16 FAQ entries, menu kinds, and every `t()` key in the templates resolving against `pl.json` or the layer's `journal.*` strings.
- **Go tests (D-21, D-22, D-09, T-16-10, T-16-12, T-16-23).**
- `testmain_test.go` creates `grzyby_test_<hex>` with `LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`, migrates through `lagoon.Migrate` with translate and Journal activated, and drops only that database.
- Featured images: `TestReadFeaturedImageMap`, `TestValidDiskName`, `TestFeaturedImageSourceEscape` (pure, with a trap database), `TestImportFeaturedImagesRequiresInputs`, `TestImportFeaturedImages`, `TestImportFeaturedImagesFailures`, `TestImportFeaturedImagesDryRun`.
- Categories: `TestReadCategoryMap`, `TestImportCategories`, `TestImportCategoriesDryRun`.
- Host: `TestBackendURIGfadmin`, `TestGrzybyCommandsRegistered`.
- **Gate (T-16-24).** `scripts/check-phase16.sh` has seven stages. Its two detectors (go test JSON and node TAP) each refuse planted fail, skip, todo, zero, no-tests, race and missing-name inputs in `--self-test`.
- **Validation.** 16-VALIDATION.md rows are bound to 16-01-T2 … 16-05-T3 with threat refs and are all green. Wave 0 is ticked, and `nyquist_compliant: true`.
## Task Commits
1. **Task 1 (tracer): SSR test of the built site.**
- App `269ba3d` (test), pushed.
- Host `cae7fd1` (chore: gitlink), pushed.
- Tracer gate: interactive, `end-of-phase`, automated-only verify. Re-ran `pnpm build && pnpm test` (16/16) before expanding.
2. **Task 2: layer util tests and app util/content tests.**
- Layer `a4dca42` (test), pushed to main. No tag, since there was no runtime change.
- App `5b10bc0` (test), pushed.
- Host `a60a031` (chore: gitlink), pushed.
3. **Task 3: Go tests, gate and sign-off.**
- Host `87e5353` (test: grzyby command and boot tests), pushed.
- summercms.go `c5e03f4` (test: Phase 16 gate).
- summercms.go `a8c65bc` (docs: validation sign-off).
**Plan metadata:** the docs(16-05) commit in summercms.go (this SUMMARY, deferred-items.md, STATE, ROADMAP).
## Files Created/Modified
See `key-files`. Notes:
- `tests/fixtures/api/posts.json` gains post 15, `test-niebezpieczny-wpis`. It is dated 2024-01-15 and has no category, so the homepage's latest posts, the category listings and the 2-page `/blog` stay as they were.
- `expect.txt` gains its smoke line (36 checks now).
- The host README documents `go -C plugins/grzyby test ./... -race` and `GRZYBY_TEST_ADMIN_DSN`.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Plan data] The malformed GA id is in the page as serialized runtime config**
- **Found during:** Task 1 (first SSR test run: `the malformed id reached the page`).
- **Issue:** Nuxt writes `runtimeConfig.public` into `window.__NUXT__.config`, so `gaId:"G-X');alert(1)//"` appears once as a double-quoted JS string literal. There, the single quote cannot end the string, so nothing executes. The plan's literal "no alert(1)" cannot hold for any public config value.
- **Fix:** The test asserts that there is no `googletagmanager`, no `gtag(`, that the id appears as `gaId:"…"` (JSON-encoded), and that `alert(1)` appears nowhere else on the page.
- **Files modified:** `tests/ssr.test.ts`.
- **Commit:** `269ba3d`.
**2. [Rule 3 - Blocking] Host boot tests need the JWT secrets**
- **Found during:** Task 3 (`TestGrzybyCommandsRegistered`: `golem15.user: jwt.secret is empty`).
- **Fix:** A `setTestSecrets` helper, used by both new tests. They load the committed config through the existing `loadCommittedConfig` copy instead of `compass.Load("config")`, so a local `.env` cannot override `backend.uri` (16-03 decision).
- **Commit:** `87e5353`.
**3. [Rule 3 - Blocking] `go mod tidy` cannot run offline for the test dependencies**
- **Issue:** Tidy wants the test dependencies of testcontainers' own dependencies (rapid, pty, objx), which are not in the module cache.
- **Fix:** I ran `go get` of the pinned versions (v0.44.0, pgx v5.10.0, gorm postgres v1.6.3, translate), then `go mod tidy -e`. `go vet` passes in `-mod=readonly`, workspace and host mode.
- **go.mod changes:** The redundant `toolchain go1.27.0` line was dropped, because the go command rejects it as an update under `-mod=readonly`.
- **Commit:** `87e5353`.
**4. [Scope addition] Extra tests beyond the behavior list**
- I added `TestImportFeaturedImagesRequiresInputs`, `TestImportFeaturedImagesFailures` (also covering base slug without export, sniff mismatch and size mismatch) and `TestImportCategoriesDryRun`.
- Layer tests cover `paginationWindow`, `isNotFound`, `encodeSlugPath`, `htmlToText` and `truncateText`.
- The SSR test also asserts `include_empty=1` on every categories request.
**5. [Plan wording] The gate parses `go test -json`, not `-v` text**
- **Issue:** The plan describes grepping `--- FAIL`, `--- SKIP` and PASS lines. The gate follows `check-phase15.sh` instead: it reads `go test -json -v` events. That is the same fail-closed contract (fail, skip, no tests to run, DATA RACE, missing named PASS) without text-format fragility.
- **Node runs:** They use a TAP detector with named tests per stage.
---
**Total deviations:** 5. That is 1 plan-data correction, 2 blocking fixes and 2 scope or wording adjustments.
**Impact on plan:** Every named artifact, test name, env var and gate line exists. No runtime code changed in any repo.
## Issues Encountered
- **Postgres.** Docker is unreachable for user `golem`, and `GRZYBY_TEST_ADMIN_DSN` was not set.
- I used the sanctioned fallback: a throwaway PostgreSQL 18.6 cluster (`initdb`, ICU pl-PL, trust auth) on 127.0.0.1:55495 in the scratchpad.
- It was passed as `GRZYBY_TEST_ADMIN_DSN` through the environment only.
- The harness needs no code override, so no repo file was touched for it.
- The cluster was stopped and deleted afterwards.
- The testcontainers path is untested here (deferred-items.md).
- **pnpm.** It ran with `TMPDIR` in the scratchpad and the project-drive store, as in 16-01..16-04.
- **summercms.go Go code.** It is unchanged by this plan, which added a shell script and docs only. I did not run `go test ./...` there, because another session has uncommitted edits in that tree.
- **Concurrent work.**
- summercms.go has another session's uncommitted `ROADMAP.md` change and untracked phase 16.1/16.2/18.1 directories. They were left alone.
- `.planning/state.json` stays unstaged.
- The host's untracked `.gsd/` was left alone.
## Known Stubs
None. The plan adds tests, fixtures and docs only.
## Threat Flags
None. No runtime surface was added.
- **T-16-23:** the harness drops only `grzyby_test_<hex>`; after the run the cluster listed only `postgres`, `template0` and `template1`.
- **T-16-SC:** testcontainers-go v0.44.0 is the only Go addition, and no npm package was added.
## User Setup Required
For the full gate on another machine:
- set `GRZYBY_TEST_ADMIN_DSN` (see 16-USER-SETUP.md and the host README), or make Docker available;
- have `nginx` and `pnpm` on the PATH.
## Next Phase Readiness
- Phase 16 is complete: all five plans have SUMMARYs, VALIDATION is signed off, and the gate passes.
- Still open for `/msd-verify-work`:
- the staging visual review (16-04 D5);
- the testcontainers run;
- the deferred items (layer XFF under swr, `cta.png` size, the framework DB-for-plugin-commands gap, the fileblob `no_tmp_dir` default).
## Self-Check: PASSED
- FOUND: all 15 created files and the 12 modified files listed in `key-files`.
- FOUND commits:
- app `269ba3d` and `5b10bc0` on origin/main;
- layer `a4dca42` on origin/main;
- host `cae7fd1`, `a60a031` and `87e5353` on origin/master;
- summercms.go `c5e03f4` and `a8c65bc`.
- Plan verification:
- layer `pnpm test` gives 78/78;
- app build plus `pnpm test` gives 25/25;
- Go plugin `-race` gives 10 PASS;
- host gives 4 PASS;
- `bash scripts/check-phase16.sh --all` ends with "Phase 16 gate passed".
---
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
*Completed: 2026-10-09*

View File

@@ -3,10 +3,11 @@ phase: "16"
slug: "grzybyfunkcjonalne-pl-on-reusable-blog-views"
# 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
status: validated
nyquist_compliant: true
wave_0_complete: true
created: "2026-10-09"
validated: "2026-10-09"
---
# Phase 16 — Validation Strategy
@@ -41,20 +42,24 @@ Testable logic lives in pure TS under `app/utils/` (no `.vue` imports), because
## Per-Task Verification Map
Seeded from RESEARCH.md § Validation Architecture; the planner binds task IDs.
Built by the named plan tasks; the tests land in 16-05 (CLAUDE.md rule 3). Commands run from the repo named in brackets; `scripts/check-phase16.sh --all` (summercms.go) runs every row except the staging smoke.
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| TBD | TBD | TBD | SC-2 | — | route config: defaults, overrides, `false` disables, invalid path rejected | unit | `node --test tests/routes.test.ts` (layer) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-2 | — | client builds query (`page`, `per_page` clamp ≤30, `category`, `include_empty`), parses envelope | unit | `node --test tests/client.test.ts` (layer) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-2 | — | pagination: page validity, prev/next, 404 on out-of-range | unit | `node --test tests/pagination.test.ts` (layer) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-2 | stored XSS | HTML gate rejects the same 3 patterns as Go `FormatHTML` | unit | `node --test tests/htmlGate.test.ts` (layer) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-3 | — | sitemap entries from posts + categories use route config | unit | `node --test tests/sitemap.test.ts` (layer) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-1 | admin exposure | host boots with `backend.uri: /gfadmin`; `/backend` 404 | unit/boot | `go test ./... -run TestBoot` (host) | ✅ extend `boot_test.go` | ⬜ pending |
| TBD | TBD | TBD | SC-1 | — | featured-image migration: map parse, blob key, row fields, idempotent skip | unit | `go test ./... -run FeaturedImages` (host) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-1 | uploaded-file XSS | deploy configs: `nginx -t`, supervisor parse, storage extension allowlist | config check | `scripts/check-deploy.sh` (host) | ❌ adapt | ⬜ pending |
| TBD | TBD | TBD | SC-1 | — | built Nuxt server renders `/`, `/blog`, a post, a category against fixture API | integration | `node --test tests/ssr.test.ts` (app) | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-3 | — | every old URL → expected status; canonical keeps `?page=N`; `/rss.xml` is RSS | smoke | `scripts/smoke.sh https://<staging>` (host) | ❌ W0 | ⬜ pending |
| 16-01-T2/T4, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | — | route config: defaults, overrides, `false` disables, invalid path rejected naming `journal.routes.<key>`; path filling encodes per segment (D-06) | unit | `node --test tests/routes.test.ts` (layer) | ✅ | ✅ green |
| 16-01-T3, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | — | client builds query (`page`, `per_page` clamp 1..30, `category`, `tag`), `include_empty=1` on every categories request (D-24), 404 → null, 500 rethrown | unit | `node --test tests/client.test.ts` (layer) | ✅ | ✅ green |
| 16-01-T3, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | — | pagination: page validity, 404 on `0`/`-1`/`1.5`/`abc`/arrays and out-of-range, page window | unit | `node --test tests/pagination.test.ts` (layer) | ✅ | ✅ green |
| 16-01-T2, 16-05-T2 | 01, 05 | 1, 5 | SC-2 | T-16-01 | HTML gate rejects the Go `FormatHTML` cases (script, iframe, event handler, `javascript:`, `vbscript:`, `data:`, upper case too); headings, tables, links pass | unit | `node --test tests/htmlGate.test.ts` (layer) | ✅ | ✅ green |
| 16-01-T4, 16-05-T2 | 01, 05 | 1, 5 | SC-3 | — | sitemap entries from posts + categories use `journal.routes` (custom `/:category`, disabled routes, nested categories, lastmod) (D-06, D-13); SEO description fallback, absolute og:image, list titles; pl/en key and placeholder parity (D-16) | unit | `node --test tests/sitemap.test.ts tests/seo.test.ts tests/i18n.test.ts` (layer) | ✅ | ✅ green |
| 16-03-T1, 16-05-T3 | 03, 05 | 3, 5 | SC-1 | T-16-10 | host boots with `backend.uri: /gfadmin`; `cabana.AdminPrefix` is `/gfadmin`; admin routes under `/gfadmin/`, none under `/backend` (D-09); grzyby commands registered | unit/boot | `go test ./... -run '^(TestBootUserTranslateJournal\|TestBackendURIGfadmin\|TestGrzybyCommandsRegistered)$'` (host) | ✅ | ✅ green |
| 16-03-T1, 16-05-T3 | 03, 05 | 3, 5 | SC-1 | T-16-12 | featured-image migration: map parse, disk-name validation, traversal refused before any read, blob at `attach.BlobKey`, `system_files` row fields, idempotent skip, base slug through the export, type/size/sniff failures, dry run (D-22) | unit + DB | `go -C plugins/grzyby test ./... -race -run 'FeaturedImage\|DiskName'` (host, `GRZYBY_TEST_ADMIN_DSN` or Docker) | ✅ | ✅ green |
| 16-03-T2, 16-05-T3 | 03, 05 | 3, 5 | SC-1 | — | category import: pl values preferred, `content` written (D-21), unchanged rerun, unknown slug failed, dry run | unit + DB | `go -C plugins/grzyby test ./... -race -run 'Categor'` (host) | ✅ | ✅ green |
| 16-05-T3 | 05 | 5 | SC-4 | T-16-23 | the test harness creates `grzyby_test_<random>` and drops only that database; DB tests fail, not skip, outside `-short` | unit | `go -C plugins/grzyby test ./console` (host; fails without a database) | ✅ | ✅ green |
| 16-03-T3, 16-04-T2 | 03, 04 | 3, 4 | SC-1 | T-16-11 | deploy configs: `nginx -t`, supervisor parse, storage extension allowlist with nosniff, `/backend` 404 | config check | `scripts/check-deploy.sh` (host) | ✅ | ✅ green |
| 16-02-T1, 16-05-T1 | 02, 05 | 2, 5 | SC-1, SC-3 | T-16-01, T-16-07 | built Nuxt server against the fixture API: every PHP URL form's status (D-15), static pages over `/:category`, canonical keeps `?page=N`, titles, pl only (D-16), empty category (D-24), category content (D-21), unsafe body blocked, XFF forwarded, feeds, no forms (D-14), GA only for a valid `NUXT_PUBLIC_GA_ID` (D-23) | integration | `pnpm build && node --test tests/ssr.test.ts` (app) | ✅ | ✅ green |
| 16-02-T2/T3, 16-05-T2 | 02, 05 | 2, 5 | SC-1 | T-16-07 | `gaHeadScripts` gives nothing for empty or malformed ids; FAQ has 16 entries; menu kinds and every `t()` key resolve | unit | `node --test tests/analytics.test.ts tests/content.test.ts` (app) | ✅ | ✅ green |
| 16-05-T3 | 05 | 5 | SC-4 | T-16-24 | phase gate fails closed on fail, skip, todo, no tests, missing named tests and races | gate | `scripts/check-phase16.sh --all` (summercms.go) | ✅ | ✅ green |
| 16-03-T3, 16-04-T3 | 03, 04 | 3, 4 | SC-3 | — | every old URL → expected status; canonical keeps `?page=N`; `/rss.xml` is RSS | smoke | `scripts/smoke.sh` (local rehearsal) and `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` on the server (host) | ✅ | ✅ green (98 urls on staging, 16-04) |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -62,10 +67,10 @@ Seeded from RESEARCH.md § Validation Architecture; the planner binds task IDs.
## Wave 0 Requirements
- [ ] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (RESEARCH A1, Pitfall 10)
- [ ] `vue-journal-layer/tests/` and `package.json` test script
- [ ] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests
- [ ] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases
- [x] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (RESEARCH A1, Pitfall 10) — 16-01 `check-git-install.sh v0.0.1`
- [x] `vue-journal-layer/tests/` and `package.json` test script — 16-05 Task 2
- [x] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests — layer `testing/fixture-api.mjs` (16-01), app `tests/ssr.test.ts` (16-05 Task 1)
- [x] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases — 16-03 Task 3
---
@@ -81,11 +86,11 @@ Seeded from RESEARCH.md § Validation Architecture; the planner binds task IDs.
## 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 < 90s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 90s per touched repo (`pnpm test` in the layer under 1 s; the app's `nuxt build` plus tests about 60 s; the full gate a few minutes)
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
**Approval:** approved 2026-10-09 (16-05): `scripts/check-phase16.sh --all` ended with "Phase 16 gate passed" (layer 78 tests, app 25 tests and 36 + 2 smoke checks, host 4 named boot tests, grzyby 10 tests with `-race` against a throwaway Postgres 18.6 database, check-deploy ok). The staging visual review (Manual-Only row 2) remains a human check.

View File

@@ -5,3 +5,22 @@
- **vue-journal-layer: visitor address lost under Nitro swr route caching.** `routeRules` swr renders a page from a copy of the request that keeps only `varies` headers, so the layer's `useJournalApi` reads no `x-forwarded-for` and forwards only the Nuxt peer. Every SSR cache miss would then share one Go rate-limit bucket (RESEARCH Pitfall 5). The site app works around it with `server/plugins/client-address.ts` (records the address in `event.context` on the Nitro `request` hook) and an app-level `useJournalApi` override. Fix it in the layer (a layer Nitro plugin plus the composable reading `event.context`), release a new tag, bump the app and delete the app override. The layer's `/rss.xml` and sitemap source have the same gap on their cache misses (once per TTL).
- **vue-journal-layer smoke tool sends no Accept header.** Undici's `fetch` sends `sec-fetch-mode: cors`, so Nuxt answers errors with JSON and an expectation such as `404 /404 contains=...` cannot see the error page. The app works around it with `server/plugins/html-errors.ts`, which treats page paths as HTML requests. An `Accept: text/html` default in `testing/smoke-ssr.mjs` would let other apps check error copy without such a plugin.
- **`public/images/cta.png` is 5.5 MB** (1792×1024 PNG, kept byte for byte from the live site as the plan required). It is the CTA band and blog hero background. A WebP derivative (the hero photo is 0.47 MB as WebP) would cut page weight; decide before the production cutover.
## From 16-03 (sm-grzybyfunkcjonalne-app host, runbook and deploy)
- **summercms.go: the generated main.go publishes no database for plugin commands.** `lagoon`, `cabana` and `surf serve` open the database themselves (`lagoon.OpenFromApp`), but a plugin's `pact.HasCommands` command finds no `*gorm.DB` on the app. So `journal:import-posts`, `journal:export-posts` and `user:token:*` stop with "database is not configured" in every `summer build` host (fonoteka.go's hand-written `app.go` publishes it). The host works around it with `grzyby:import-posts` and a `lookupDB` helper that open and publish the database through lagoon. Fix it in the framework, for example as a lazy publish before plugin command `Run`, so the wrapper can go.
- **sm-journal-plugin: `journal:import-posts` rejects `"translations": []`.** The PHP exporter writes a post without translations as `[]` (json_encode of an empty PHP array), and the Go `PostFile.Translations map[string]map[string]any` fails to unmarshal it ("Invalid JSON"). This affects 5 of the 105 grzybyfunkcjonalne export files; 2 of them are published, including the live post `ekstrakt-z-turkey-tail`. `scripts/migrate.sh` imports from a copy in which only that empty array becomes `{}`. The importer should accept an empty array as an empty map (PHP parity).
- **`first-journal-post` is published in the export but not on the live site.** "First journal post" was published 2026-02-23. It is absent from the live sitemap, so Go serves 63 published posts where the live site lists 62. Unpublish or delete it after the staging import (16-04), or confirm it should stay.
- **The UAT-host image references are not in the imported bodies.** The 2 `https://gf.uat.golem15.com/storage/...` references sit only in the base (older) content of `wprowadzenie-do-grzyba-cordyceps`. With pl as the default, the pl body replaces the base text, so the gap report shows 1 export file and 0 imported bodies. Nothing is broken on the new stack. The report keeps listing the export file.
- **nginx `listen ... http2` is deprecated on nginx 1.25.1 and newer** (a warning, not an error). The staging vhost keeps the summercms.io form because the PHP server's nginx version is unknown. Switch to `http2 on;` once 16-04 confirms the version. *Resolved in 16-04 (b488fdd): the server runs nginx 1.26.3 and the vhost uses `http2 on;`.*
- *`first-journal-post`: resolved in 16-04 for staging. `migrate.sh --skip first-journal-post` leaves it out of the import, so staging serves the 62 live posts. The export file itself is unchanged.*
## From 16-04 (staging deploy)
- **summercms.go `modules/lagoon/attach`: file buckets write temp files to `os.TempDir()`.** gocloud's fileblob creates the temp file in `/tmp` and renames it into the bucket. On a server whose `/tmp` is tmpfs (the grzybyfunkcjonalne staging server), every write fails with "invalid cross-device link". The host sets `?no_tmp_dir=true` in `config/storage.yaml` (sm-grzybyfunkcjonalne-app 6ef012e). `normalizeBucketURL` could default `no_tmp_dir` for `file://` the same way it defaults `create_dir`, so no host meets this on its first upload. Not changed here: another session owns `modules/`.
- **Visual review of staging (16-04 Task 3 human-check) is pending.** The automated checks all pass. A human still opens the staging pages with the UAT credentials and compares them with the live site (design, Polish copy, featured images, inline media). The admin password step of the original human-check no longer applies: the operator created the admin with a password piped from a file outside the deploy.
- **The plan's verify commands curl `$STAGING_URL` from the executor's machine,** but staging keeps the server's UAT basic-auth gate (operator decision), so they answer 401 from outside. They ran on the server through the loopback bypass instead (DEPLOY.md, "Smoke"). A later plan that checks staging from outside needs the credentials, or should run on the server the same way.
## From 16-05 (unit tests and phase gate)
- **The testcontainers path of the grzyby console test harness has not run.** Docker is unreachable for user `golem`, so `plugins/grzyby/console` ran through `GRZYBY_TEST_ADMIN_DSN` against a throwaway Postgres 18.6 cluster (initdb, ICU) only. Whether `postgres:16-alpine` accepts `ICU_LOCALE 'pl-PL'` in the harness's `CREATE DATABASE` is unverified. Run `go -C sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -race` once with Docker and without the DSN; if the image lacks the pl-PL ICU data, switch the harness image or drop the ICU clause for the container path.

View File

@@ -0,0 +1,311 @@
---
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/cabana/query.go
- modules/cabana/query_relation_test.go
- docs/backend/lists-and-filters.md
- modules/cabana/http.go
- modules/cabana/locale_order_test.go
- modules/cabana/README.md
- docs/backend/forms.md
- ../sm-journal-plugin/classes/format_html.go
- ../sm-journal-plugin/classes/format_html_test.go
- ../sm-journal-plugin/README.md
- admin/src/state/listState.ts
- admin/src/views/ListView.vue
- admin/src/state/useAuth.ts
- admin/src/state/useSidebar.ts
- admin/tests/list/ListView.test.ts
- scripts/check-phase10.sh
- modules/boardwalk/dist/
autonomous: true
requirements: [D-01, D-02, D-03]
estimate:
tokens: 85000
raw_tokens: 85000
tasks: 4
confidence: low
must_haves:
truths:
- "D-02: a list search over a belongsToMany relation column answers 200 instead of 'Server error': the count and page queries match through EXISTS (SELECT 1 FROM the pivot JOIN the related table ...) whose pivot, key and table names come from GORM's parsed relationship (gorm.Statement.Parse, Schema.Relationships.Relations), never from a naming convention."
- "D-02: search over hasMany, hasOne and belongsTo relation columns also goes through a correlated EXISTS subquery, so the outer list query gains no JOIN during the count: a record matching through several related rows is counted and listed once, and unqualified ListExtendQuery or scope predicates (id IN (...), user_id = ?) stay unambiguous."
- "D-02: sorting by a hasMany or belongsToMany column orders by a correlated string_agg subquery followed by the primary-key tiebreak; sorting by a belongsTo or hasOne column keeps the existing association join."
- "D-02: polymorphic relations and columns missing from the related schema are refused with a 422 validation_failed on search (or sort), never with a 500."
- "D-03: the form schema's meta.enabledLocales lists the default locale first and the other enabled codes in the writer's order, so every ML control opens on the default locale where the stored host content, excerpt, title and slug live."
- "D-03: classes.FormatHTML keeps raw HTML in content (imported HTML bodies round-trip unchanged) and still refuses script, iframe, object and embed tags, event handler attributes and javascript, vbscript and data URLs on every write path (admin save, import, Journal API)."
- "D-01: list search, sort, dir, page, per_page and filter[name] stay in the list URL; every list query the view loads is also stored in browser storage under the key summer-admin.list: followed by the controller id; an empty query is never stored."
- "D-01: a list opened without list-state params (navigation rail, breadcrumb, form back, cancel, save-and-close, delete) restores the stored query into the URL before its first list request; a URL with list-state params wins and overwrites the stored query; logout removes every stored list query."
- "D-02: every cabana and admin commit of this plan is staged by explicit path and holds only this plan's files; sm-journal-plugin changes are committed in their own repository."
- "go vet stays clean in summercms.go and sm-journal-plugin, and the named smoke tests, the vitest files, TestDocsTree and scripts/check-admin-dist.sh are green at every commit."
artifacts:
- path: "modules/cabana/query.go"
provides: "EXISTS relation search and correlated to-many sort built from the parsed GORM relationship"
contains: "Relationships.Relations"
- path: "modules/cabana/query_relation_test.go"
provides: "DryRun harness, relation fixtures and the tracer smoke test"
contains: "TestListRelationSearchSmoke"
- path: "modules/cabana/locale_order_test.go"
provides: "default-locale-first smoke test"
contains: "TestEnabledLocalesDefaultFirstSmoke"
- path: "../sm-journal-plugin/classes/format_html.go"
provides: "goldmark with unsafe HTML rendering behind the unchanged safety gate"
contains: "WithUnsafe"
- path: "admin/src/state/listState.ts"
provides: "per-list browser storage of the list query"
contains: "summer-admin.list:"
key_links:
- from: "modules/cabana/query.go applyListSearch / applyListOrder"
to: "gorm.io/gorm/schema Relationship (Type, References, JoinTable, FieldSchema)"
via: "gorm.Statement{DB: db}.Parse(model) then Schema.Relationships.Relations[field]"
pattern: "Relationships.Relations"
- from: "modules/cabana/http.go enabledContentLocales"
to: "TranslationWriter.DefaultLocale"
via: "default code moved to index 0 of meta.enabledLocales"
pattern: "DefaultLocale"
- from: "admin/src/views/ListView.vue watch on listQueryKey"
to: "admin/src/state/listState.ts readListState / writeListState"
via: "restore before the first list request when the URL has no list params"
pattern: "readListState"
- from: "../sm-journal-plugin/controllers/posts.go preparePostWrite"
to: "../sm-journal-plugin/classes/format_html.go FormatHTML"
via: "content to content_html on every admin save"
pattern: "FormatHTML"
prohibitions:
- "[FLAGGED-UNVERIFIED] The relation-search fix must not be a Journal-specific workaround, and framework code, READMEs and docs must not name a consuming application (D-02, CLAUDE.md)."
- "[FLAGGED-UNVERIFIED] List state must not be stored on the server: no table, no migration, no new API route (revised D-01)."
- "[FLAGGED-UNVERIFIED] The Journal API content and content_html response shapes must not change; only the stored HTML stops losing raw HTML (API parity)."
- "[FLAGGED-UNVERIFIED] cabana.RenderMarkdown, whose output the admin preview renders with v-html, must not switch to unsafe HTML."
---
## Phase Goal
ROADMAP Goal (not in user-story format): the Journal posts list and post form in the admin work on real imported data found on UAT: (1) list search no longer fails with "Server error" (search across a belongsToMany relation built a hasMany join); (2) list sort and filters are remembered after opening a record and going back; (3) the post form shows the stored content; (4) the post form shows the stored excerpt. Then release a summercms tag, bump the host, redeploy to UAT and re-run the staging smoke.
This plan's slice (D-05: "01 all fixes"): every code fix, each with a smoke test. Release and redeploy are plan 02; full unit coverage is plan 03.
## Spec-less probe fallback
Visible skip: the phase has no REQUIREMENTS.md IDs to probe (Requirements: TBD), so no probe predicates are generated this run. CONTEXT D-01..D-05 and the RESEARCH root causes are the source of truth. The edge cases RESEARCH names (polymorphic and soft-deleting relations, self-referencing relations, stale stored list state, out-of-range restored page, the user clearing every list control, same-path navigation) are authored into the task behaviors and `must_haves.truths`; the kept must-NOTs are in `must_haves.prohibitions`.
## Artifacts this phase produces
- summercms.go `modules/cabana`: EXISTS-based relation search and correlated to-many sort in `query.go`; default-locale-first `meta.enabledLocales` in `http.go`; new test files `query_relation_test.go` and `locale_order_test.go`; README and `docs/backend/{lists-and-filters,forms}.md` sentences.
- summercms.go admin SPA: new `admin/src/state/listState.ts`; list-state entry logic in `admin/src/views/ListView.vue`; logout clears stored list queries (`admin/src/state/useAuth.ts`); rebuilt `modules/boardwalk/dist/`; the `scripts/check-phase10.sh` browser-storage allowlist names `listState.ts`.
- sm-journal-plugin: `classes/format_html.go` renders raw HTML behind the unchanged gate; a smoke test; README sentence. Committed on `main`, not pushed (plan 02 pushes).
- No server-side table, migration, route or OpenAPI change (revised D-01).
<objective>
Fix the four UAT defects in code (D-05 plan 01): relation search and to-many sort through subqueries built from GORM's parsed relationship (D-02), the default content locale first in form locales so the form shows stored content and excerpt (D-03), raw HTML kept in content_html so an admin save no longer destroys imported posts (D-03), and list state kept in the URL and browser storage (D-01).
Purpose: UAT admins can search, filter and return to the Journal posts list and edit imported posts without losing data.
Output: four commits in summercms.go (cabana search/sort, cabana locales, admin list state with rebuilt dist, nothing else) and one commit in sm-journal-plugin.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-PATTERNS.md
@modules/cabana/query.go
RESEARCH caveat: its list-state design (a backend_list_states table, a GET list-state route, a migration, an OpenAPI regeneration) is superseded by the revised D-01 (URL plus browser storage). Ignore those parts of RESEARCH and PATTERNS.
Commit rules: no co-author tags; one logical change per commit; planning docs and code in separate commits; stage every file by explicit path (never `git add -A` or `git add .`; summercms.go has untracked `.msd/` and a modified `.planning/state.json`, sm-journal-plugin has an untracked `go.work.sum`, none of which belong in these commits).
Docker is not usable on this machine, so `go test ./modules/cabana` without `-run` fails on the Postgres-backed tests for reasons unrelated to this plan. Every gate below runs the named pure tests.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a list search over a belongsToMany column answers 200 through an EXISTS subquery (GET list, ExecuteList, SQL)</name>
<precondition>git status --porcelain -- modules/cabana docs/backend prints nothing, i.e. the concurrent session has no in-flight edit in the files this task touches (D-02); otherwise stop and report the conflicting paths.</precondition>
<files>modules/cabana/query.go, modules/cabana/query_relation_test.go, docs/backend/lists-and-filters.md</files>
<read_first>modules/cabana/query.go (whole file: ExecuteList, applyListOrder, applyListSearch, qualifiedColumn, quotedIdent, tableName, relationFieldName, listRelationColumn, relatedSelectMany), modules/cabana/query_test.go (queryRow, queryArtist, queryController, newListService, getList, decodeList, listEnvelope), modules/cabana/list_schema_test.go (filterFS), modules/cabana/security_test.go (controllerRequest), modules/cabana/README.md (the rule that framework code never guesses table, pivot or foreign-key names), docs/backend/lists-and-filters.md (column options paragraph near line 62), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Root-cause evidence Defect 1, Pattern 1, Pitfall 1, Code Examples "DryRun SQL test")</read_first>
<behavior>
- GET list with search=fungi on a controller whose list has a searchable belongsToMany column (tags, select name) answers 200; the recorded count statement contains an EXISTS subquery over the pivot table aliased cabana_p0 joined to the related table aliased cabana_r0, correlated by the pivot owner key to the main table primary key, and contains no LEFT JOIN.
- GET list with sort=tags and dir=desc records an ORDER BY that holds a string_agg subquery with DESC followed by the main table primary-key tiebreak.
</behavior>
<action>Run the precondition check first (D-02). Then edit modules/cabana/query.go:
Add one unexported builder (for example relationSubquery) used by both search and sort. It takes the db, the model, the Go field name from relationFieldName, the physical column from listRelationColumn and an alias index n. It parses the model with a fresh gorm.Statement{DB: db} and Parse(model), reads Schema.Relationships.Relations[field] and never derives a table, pivot or key name from a naming convention (D-02, README rule). It refuses with listInvalid (field "search" or "sort", the caller passes it) when the relation is missing, its FieldSchema lacks the column (FieldSchema.LookUpField), the column fails identifier(), any reference is polymorphic (non-empty PrimaryValue), or the type is not one of schema.Many2Many, schema.HasMany, schema.HasOne, schema.BelongsTo. The related table is aliased cabana_r(n) and the pivot cabana_p(n), so a self-referencing relation stays correct; the main table is the parsed Schema.Table, left unaliased so it names the outer FROM. Correlation by type: Many2Many joins rel.JoinTable.Table AS the pivot alias to the related alias on the references whose OwnPrimaryKey is false (pivot foreign key equals related primary key) and correlates the references whose OwnPrimaryKey is true (pivot foreign key equals main primary key); HasMany and HasOne (OwnPrimaryKey true) correlate related foreign key to main primary key; BelongsTo correlates related primary key to main foreign key. When the related schema has a field of type gorm.DeletedAt, add that column IS NULL on the related alias (Winter whereHas applies the related soft-delete scope). Quote every identifier with quotedIdent and qualifiedColumn. The builder returns the FROM part and the WHERE correlation and carries no bound values.
applyListSearch: for a column with relation, append EXISTS (SELECT 1 FROM the builder's FROM WHERE the correlation AND LOWER(CAST(related alias.column AS TEXT)) LIKE ? ESCAPE backslash) to the existing OR group with the same bound pattern own columns use; drop the association join and its bookkeeping map. This applies to every relation type, so the count query never gains an outer JOIN (RESEARCH Pitfall 1). Own-column search is unchanged.
applyListOrder: change it to return (*gorm.DB, error) and propagate the error from ExecuteList. Keep the existing association join and column order for BelongsTo and HasOne columns (covered by the existing artist sort). For HasMany and Many2Many columns order by the correlated scalar subquery (SELECT string_agg(CAST(related alias.column AS TEXT), ', ' ORDER BY CAST(related alias.column AS TEXT)) FROM ... WHERE correlation), which is Winter's group_concat sort and matches what relatedSelectMany displays. Emit it as a clause.OrderByColumn whose clause.Column has Raw true and whose Desc follows the request, so the existing primary-key tiebreak OrderByColumn merges into the same ORDER BY (a clause.OrderBy carrying an Expression is replaced when the tiebreak column merges into it).
Create modules/cabana/query_relation_test.go (package cabana, no Docker). Fixtures: relPost (table cabana_rel_posts: ID, Title, AuthorID pointer, Author relAuthor belongsTo through AuthorID, Tags []relTag with gorm tag many2many:cabana_rel_post_tags, Notes []relNote has-many through PostID), relTag (cabana_rel_tags: ID, Name), relNote (cabana_rel_notes: ID, PostID, Body), relAuthor (cabana_rel_authors: ID, Name); a relController with ConfigDir controllers/widgets so filterFS applies and NewRecord returning a relPost pointer; columns.yaml with title (searchable), tags (relation tags, select name, searchable, sortable), notes (relation notes, select body, searchable), author (relation author, select name, searchable, sortable); config_list.yaml with showSorting true. A helper opens a DryRun GORM db with the postgres dialector on a DSN that is never dialed (host 127.0.0.1 port 1; gorm.Config DryRun true, DisableAutomaticPing true) and a recording logger (a gorm logger.Interface whose Trace appends the SQL returned by its callback and whose LogMode returns itself). A second helper builds a *service like newListService does, but with backpack.New(nil), Publish of the DryRun db and the compiled list registered as acme.demo.widgets (the id controllerRequest targets); plan 03 and Task 2 reuse both helpers. TestListRelationSearchSmoke implements the behavior above through getList.
docs/backend/lists-and-filters.md: one sentence after the column options paragraph: a searchable relation column matches a record when any related record matches (belongsTo, hasOne, hasMany and belongsToMany, each through a subquery, so a record is listed and counted once), and a sortable hasMany or belongsToMany column sorts by its comma-joined values. Use acme/blog names only. The module README needs no change (no exported identifier, config key or command changed).
Commit in summercms.go, staging exactly modules/cabana/query.go, modules/cabana/query_relation_test.go and docs/backend/lists-and-filters.md: fix(cabana): search and sort to-many relation columns through subqueries.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestListRelationSearchSmoke'</automated>
<fails_when>Non-zero exit: go vet reports a problem, the test fails or does not exist, or the line "--- PASS: TestListRelationSearchSmoke" is absent from the output.</fails_when>
<automated>go test -count=1 ./cmd/summer -run TestDocsTree</automated>
<fails_when>Non-zero exit or "FAIL" in the output: the docs checker rejects an identifier, link or application name in the edited docs page.</fails_when>
</verify>
<acceptance_criteria>
- `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` prints `--- PASS: TestListRelationSearchSmoke` on a machine without Docker.
- The test asserts the recorded count SQL contains `EXISTS (SELECT 1 FROM "cabana_rel_post_tags"`, the pivot owner key correlated to `"cabana_rel_posts"."id"`, and no `LEFT JOIN`; and that the sorted query holds `string_agg` followed by the `"cabana_rel_posts"."id"` tiebreak.
- `grep -c 'Relationships.Relations' modules/cabana/query.go` is at least 1; `grep -c 'string_agg' modules/cabana/query.go` is at least 1; `grep -c 'EXISTS (SELECT 1 FROM' modules/cabana/query.go` is at least 1.
- `go vet ./modules/cabana` exits 0 and `go test -count=1 ./cmd/summer -run TestDocsTree` passes.
- `git show --name-only --format= HEAD` lists exactly modules/cabana/query.go, modules/cabana/query_relation_test.go and docs/backend/lists-and-filters.md.
</acceptance_criteria>
<done>A search over a belongsToMany column produces valid SQL through the pivot named by GORM's relationship and the list answers 200; to-many sort uses a correlated aggregate; the change is one cabana commit.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: The post form opens on the default locale: meta.enabledLocales lists the default locale first</name>
<precondition>git status --porcelain -- modules/cabana docs/backend prints nothing besides files this plan already committed (D-02).</precondition>
<files>modules/cabana/http.go, modules/cabana/locale_order_test.go, modules/cabana/README.md, docs/backend/forms.md</files>
<read_first>modules/cabana/http.go (enabledContentLocales near line 966 and its callers near lines 536 and 727), modules/cabana/field_ml.go (TranslationWriter, hydrateMLRecord), modules/cabana/ml_smoke_test.go (recordingWriter), modules/backpack/app.go (Publish, Lookup), modules/cabana/query_relation_test.go (the DryRun db helper from Task 1), modules/cabana/README.md (ML paragraph near line 27 and the cabana.FormMeta row near line 232), docs/backend/forms.md (ML paragraph near line 333), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Defects 3 and 4, Pattern 3)</read_first>
<behavior>
- With a published TranslationWriter whose EnabledLocales returns en, pl and whose DefaultLocale returns pl, enabledContentLocales returns pl, en.
</behavior>
<action>In service.enabledContentLocales (modules/cabana/http.go), after writer.EnabledLocales succeeds, call writer.DefaultLocale(ctx, db). When it returns a non-empty code found at an index above 0, move that code to index 0 and keep the other codes in the writer's order (work on a clone; slices.Index and slices.Delete). When DefaultLocale fails, log slog.Error with the message "cabana: default locale" and the error, and return the writer's order unchanged. This is the D-03 fix: the SPA's MLField and FormView already open on enabledLocales[0], and hydrateMLRecord already puts the host column (content, excerpt, title, slug) under the default locale, so the form shows the stored values. Do not change the SPA, liftMLValues or hydrateMLRecord.
Create modules/cabana/locale_order_test.go with TestEnabledLocalesDefaultFirstSmoke: a service whose backpack app publishes the Task 1 DryRun db and, with Publish typed as TranslationWriter, a recordingWriter with defaultLocale pl and enabled en, pl; enabledContentLocales returns exactly pl, en.
Docs (exported field semantics, CLAUDE.md README rule): in modules/cabana/README.md say that cabana.FormMeta.EnabledLocales lists the default locale first, then the other enabled codes in the writer's order (the FormMeta table row and the ML paragraph); in docs/backend/forms.md add that the selector options list the default locale first, so every ML control starts on it. Both files use the words default locale first. No consuming application names.
Commit in summercms.go, staging exactly those four paths: fix(cabana): send the default content locale first in enabledLocales.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestEnabledLocalesDefaultFirstSmoke'</automated>
<fails_when>Non-zero exit: go vet reports a problem, the test fails or does not exist, or the line "--- PASS: TestEnabledLocalesDefaultFirstSmoke" is absent.</fails_when>
<automated>go test -count=1 ./cmd/summer -run TestDocsTree</automated>
<fails_when>Non-zero exit or "FAIL" in the output.</fails_when>
</verify>
<acceptance_criteria>
- `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` prints `--- PASS: TestEnabledLocalesDefaultFirstSmoke`.
- `grep -c 'DefaultLocale' modules/cabana/http.go` is greater than before the task (enabledContentLocales calls it).
- `grep -ic 'default locale first' modules/cabana/README.md docs/backend/forms.md` reports at least 1 for each file (both were 0 before the task).
- `go test -count=1 ./cmd/summer -run TestDocsTree` passes.
- `git show --name-only --format= HEAD` lists exactly modules/cabana/http.go, modules/cabana/locale_order_test.go, modules/cabana/README.md and docs/backend/forms.md.
</acceptance_criteria>
<done>Every form and relation-child schema sends the default locale first, so ML fields open on the stored host values.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Saving an imported post keeps its HTML: FormatHTML renders raw HTML behind the unchanged safety gate</name>
<reversibility rating="costly">After this change admin saves, imports and Journal API writes store raw HTML in content_html; reverting the renderer does not clean rows already saved, each would need a re-render.</reversibility>
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/README.md</files>
<read_first>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/controllers/posts.go (preparePostWrite near line 204), ../sm-journal-plugin/README.md (Posts bullet near line 21, API table row for classes.CheckHTML near line 180), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Data loss on save, Pattern 4, Pitfall 5)</read_first>
<behavior>
- FormatHTML of two HTML paragraphs separated by a newline returns them unchanged after trimming.
- FormatHTML of a markdown heading followed by a raw footer element keeps the footer element.
- Every TestFormatHTMLRejectsUnsafeHTML case still passes (script, iframe, event handler, javascript, vbscript and data URLs are refused by the gate).
</behavior>
<action>In ../sm-journal-plugin/classes/format_html.go import github.com/yuin/goldmark/renderer/html (a package of the already-required goldmark v1.8.6; no go.mod change) and add goldmark.WithRendererOptions(html.WithUnsafe()) to markdownEngine, with a comment that Winter's Markdown::parse passes raw HTML through and rejectUnsafe gates the result (D-03, PHP parity). Rename the local result variable in FormatHTML and the parameters of CheckHTML and rejectUnsafe that share the imported package's name (for example out and doc) so nothing shadows the import. Keep the rejectUnsafe call after the render, the three gate patterns and CheckHTML unchanged. Do not touch cabana.RenderMarkdown in summercms.go (the admin preview stays safe-mode).
Add TestFormatHTMLKeepsRawHTML to classes/format_html_test.go with the two keep cases from the behavior list.
../sm-journal-plugin/README.md: extend the Posts bullet: FormatHTML passes raw HTML in content through unchanged (Winter parity), and the gate still refuses script, iframe, object and embed tags, event handlers and javascript, vbscript and data URLs on every write path (admin save, journal:import-posts, Journal API writes). The API response shapes are unchanged.
Commit in ../sm-journal-plugin on main, staging exactly classes/format_html.go, classes/format_html_test.go and README.md (go.work.sum stays untracked): fix(classes): keep raw HTML in content_html behind the safety gate. Do not push; plan 02 pushes.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^TestFormatHTMLKeepsRawHTML$' -v ./classes/ 2&gt;&amp;1 | grep -- '^--- PASS: TestFormatHTMLKeepsRawHTML'</automated>
<fails_when>Non-zero exit: vet fails, any classes test fails (including TestFormatHTMLRejectsUnsafeHTML), or the line "--- PASS: TestFormatHTMLKeepsRawHTML" is absent.</fails_when>
</verify>
<acceptance_criteria>
- `go -C ../sm-journal-plugin test -count=1 ./classes/` prints `ok` for the classes package.
- `grep -c 'WithUnsafe' ../sm-journal-plugin/classes/format_html.go` is 1 and `grep -c 'rejectUnsafe(' ../sm-journal-plugin/classes/format_html.go` is unchanged from before the task.
- `go -C ../sm-journal-plugin vet ./...` exits 0.
- `git -C ../sm-journal-plugin show --name-only --format= HEAD` lists exactly classes/format_html.go, classes/format_html_test.go and README.md.
- `git -C ../sm-journal-plugin status --porcelain -- go.mod go.sum` prints nothing (no dependency change).
</acceptance_criteria>
<done>Imported HTML survives an admin save, import or API write, and the XSS gate still refuses the dangerous patterns.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: The posts list remembers its state: URL plus browser storage, restored on entry before the first list request</name>
<precondition>git status --porcelain -- admin scripts/check-phase10.sh modules/boardwalk prints nothing (no in-flight edit by the concurrent session, D-02).</precondition>
<files>admin/src/state/listState.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useSidebar.ts, admin/tests/list/ListView.test.ts, scripts/check-phase10.sh, modules/boardwalk/dist/</files>
<read_first>admin/src/views/ListView.vue (whole script block, especially replaceQuery, loadList and the watch on listQueryKey), admin/src/app/listQuery.ts (parseListQuery, toListQuery, listQueryKey), admin/src/app/controllerRoutes.ts (controllerIdFromParams, controllerPath), admin/src/state/useSidebar.ts (storage access pattern and header comment), admin/src/state/useAuth.ts (logout), admin/src/views/FormView.vue (listPath, backPath, go, onLeave, onDelete, redirectTarget: read only, it is not changed), admin/tests/list/ListView.test.ts (routes, mountApp, requestsTo, queryOf), admin/tests/helpers.ts (resetState clears storage), admin/tests/smoke/shell.smoke.test.ts (asserts only the sidebar key is stored after mounting a list), scripts/check-phase10.sh (hygiene storage rule near line 370), scripts/check-admin-dist.sh, .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md (D-01)</read_first>
<behavior>
- Mounting /acme/demo/widgets with no query after writeListState stored sort code desc and filter active true: the first list GET carries sort=code, dir=desc and filter[active]=true, and the router URL carries them.
- Mounting /acme/demo/widgets?search=blue: the stored state for acme.demo.widgets holds search blue (the URL wins).
</behavior>
<action>Implements D-01 as revised: no server call, table, migration or route.
Create admin/src/state/listState.ts, the only module besides useSidebar.ts that touches browser storage. Exports: LIST_STATE_PREFIX equal to summer-admin.list: (the key is the prefix plus the controller id, one key per list; Claude's discretion on naming, following SIDEBAR_KEY); readListState(controllerId) returning a ListQuery: it reads the key, JSON-parses it, accepts only a plain object whose values are strings, and passes it through parseListQuery so malformed stored values are dropped exactly like a hand-edited URL; anything else removes the key and returns an empty query; writeListState(controllerId, query) stores JSON of toListQuery(query) when listQueryKey(query) differs from the empty query's key and removes the key otherwise, so an empty list never leaves an entry; clearListStates() removes every key with the prefix. Wrap every storage access in try/catch like useSidebar.ts: disabled or full storage is a no-op and the URL still carries the state.
admin/src/views/ListView.vue: keep the immediate watch on listQueryKey(query) but make its handler decide before any list request. When the current query is empty and readListState(controllerId) is non-empty, call router.replace with path route.path and query toListQuery(stored) and return without loading (the watch runs again with the restored query). Otherwise call writeListState(controllerId, query.value) (the URL wins), then clear the selection, sync searchText and call loadList as today. replaceQuery calls writeListState with the next query before router.replace, so a user who clears every control stores the empty state and the following empty-query run loads unfiltered instead of restoring. The same handler covers navigating to the same list while it is filtered: the query empties without replaceQuery and the stored query comes back. In loadList, after the generation check: when the response status is 422, writeListState(controllerId, an empty query) so a stale stored filter or sort never sticks; when meta.last_page is at least 1 and meta.page is above it, replaceQuery with the current query and page set to meta.last_page. FormView is not changed: its back, cancel, save-and-close, delete and redirect navigation goes to the bare list path, and this entry restore puts the stored query into the URL before the first list request, which is how the form and the navigation return to the list with its query (D-01).
admin/src/state/useAuth.ts: logout calls clearListStates() in its finally block next to clearUser(), so the next admin on the same browser sees none of the previous searches.
admin/src/state/useSidebar.ts: rewrite the header sentence that says nothing else is ever written to browser storage so it names listState.ts as the only other writer (list query parameters only, never tokens or record data).
scripts/check-phase10.sh: in the hygiene storage rule allow admin/src/state/listState.ts in addition to useSidebar.ts (two grep -v path prefixes) and update its comment and message to name both files; the self-test plant under admin/src/__plant must still be refused.
admin/tests/list/ListView.test.ts: add the two smoke cases from the behavior list, importing writeListState and readListState from ../../src/state/listState (this also satisfies the hygiene rule that every source file is imported by a test). Full coverage is plan 03.
Rebuild the embedded SPA with npm --prefix admin run build and commit modules/boardwalk/dist in the same commit as the SPA change. Commit in summercms.go, staging exactly admin/src/state/listState.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useSidebar.ts, admin/tests/list/ListView.test.ts, scripts/check-phase10.sh and modules/boardwalk/dist: fix(admin): keep list state in the URL and browser storage.</action>
<!-- planner-discipline-allow: localStorage|sessionStorage|indexedDB|document\.cookie -->
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state</automated>
<fails_when>Non-zero exit, or the vitest summary line reports any "failed" test or file.</fails_when>
<automated>bash scripts/check-admin-dist.sh</automated>
<fails_when>Non-zero exit or "modules/boardwalk/dist is stale" on stderr.</fails_when>
<automated>test -z "$(grep -rnE 'localStorage|sessionStorage|indexedDB|document\.cookie' admin/src | grep -v -e '^admin/src/state/useSidebar.ts:' -e '^admin/src/state/listState.ts:')"</automated>
<fails_when>Non-zero exit: a browser-storage reference exists in admin/src outside useSidebar.ts and listState.ts.</fails_when>
</verify>
<acceptance_criteria>
- The vitest run passes, including the two new list-state smoke cases and the unchanged shell smoke test that expects only the sidebar key after mounting a list without query.
- `bash scripts/check-admin-dist.sh` prints "modules/boardwalk/dist matches a fresh build".
- `grep -c 'summer-admin.list:' admin/src/state/listState.ts` is 1; `grep -c 'clearListStates' admin/src/state/useAuth.ts` is at least 1; `grep -c 'admin/src/state/listState.ts' scripts/check-phase10.sh` is at least 1; `grep -c 'readListState' admin/src/views/ListView.vue` is at least 1.
- Browser-storage references in admin/src appear only in useSidebar.ts and listState.ts (third verify command).
- `git show --name-only --format= HEAD` lists only the seven staged paths (dist files under modules/boardwalk/dist).
</acceptance_criteria>
<done>The posts list keeps its search, sort, filters and page in the URL and per list in browser storage, restores them when entered without params, lets a URL win, and forgets them on logout; the embedded build matches.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin SPA → cabana list API | search, sort, dir, page, per_page and filter[name] come from the URL or browser storage and are untrusted |
| Browser storage → ListView | a stored list query may be stale, hand-edited or written by another script on the admin origin |
| Admin author → content_html → public site | post bodies with raw HTML are stored and served through the Journal API to the public Nuxt site |
| Shared browser between admins | stored list queries outlive a session unless cleared |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.1-01 | Tampering (SQL injection) | modules/cabana/query.go relation subquery builder | high | mitigate | Relation and column names come only from the compiled list schema and GORM's parsed relationship (LookUpField, identifier()); every identifier is quoted through the dialector (quotedIdent/qualifiedColumn); the search pattern is a bound parameter with LIKE escaping; unknown or polymorphic relations are refused with 422 before any SQL runs; permission is still checked by protect() before ExecuteList |
| T-16.1-02 | Tampering / Elevation (stored XSS) | ../sm-journal-plugin/classes/format_html.go | medium | mitigate | rejectUnsafe stays after the now-unsafe render on every write path (admin, import, API) and refuses script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs; authors are authenticated admins with the journal posts permission; the admin preview (cabana.RenderMarkdown, v-html) stays safe-mode; the public layer keeps its own HTML gate (T-16-01). Residual (accepted, PHP parity): inert tags such as style, footer or form pass, which PHP's Html::clean would have stripped for admins without unsafe-markdown rights |
| T-16.1-03 | Information Disclosure | admin/src/state/listState.ts | low | mitigate | Only list query parameters are stored (no tokens, no record data) under summer-admin.list: keys; logout clears them; the check-phase10.sh hygiene allowlist confines browser storage to useSidebar.ts and listState.ts |
| T-16.1-04 | Tampering | restored list state | low | mitigate | Stored values pass through parseListQuery (drops malformed page, dir, empty filters); the server re-validates every parameter against the compiled schema and answers 422; a 422 removes the stored state so it cannot stick |
| T-16.1-05 | Denial of Service | correlated EXISTS / string_agg subqueries | low | accept | One subquery per searchable relation column per row, bounded by the page and by pivot primary keys; admin-only route |
| T-16.1-SC | Tampering | npm/go installs | high | mitigate | No package is installed: github.com/yuin/goldmark/renderer/html is a package of the already-required goldmark v1.8.6 (RESEARCH Package Legitimacy Audit: no packages); no npm dependency is added; go.mod and package.json stay unchanged (Task 3 acceptance) |
</threat_model>
<verification>
- Task 1: `--- PASS: TestListRelationSearchSmoke`; Task 2: `--- PASS: TestEnabledLocalesDefaultFirstSmoke`; Task 3: classes tests `ok` and `--- PASS: TestFormatHTMLKeepsRawHTML`; Task 4: vitest green, dist matches, storage confined.
- `go vet ./...` (summercms.go) and `go -C ../sm-journal-plugin vet ./...` exit 0.
- `go test -count=1 ./cmd/summer -run TestDocsTree` passes.
- summercms.go has three new commits from this plan (cabana search and sort, cabana locales, admin list state), sm-journal-plugin one; nothing is pushed.
</verification>
<success_criteria>
- D-02: relation search over belongsToMany, hasMany, hasOne and belongsTo columns and to-many sort produce valid SQL from GORM's relationship metadata.
- D-03: forms open on the default locale; FormatHTML keeps raw HTML behind the gate.
- D-01: list state lives in the URL and per list in browser storage, restored on entry, URL wins, cleared on logout.
- No server-side storage, migration, route or OpenAPI change; no dependency added.
</success_criteria>
<output>
Create `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md` when done, listing the commit hashes per repository (plan 02 tags and pushes them).
</output>

View File

@@ -0,0 +1,230 @@
---
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
plan: 01
subsystem: admin
tags: [gorm, postgres, cabana, vue, localStorage, goldmark, i18n]
requires:
- phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
provides: Journal posts admin on cabana, the UAT deployment and its defect reports
provides:
- Relation search through correlated EXISTS subqueries and to-many sort through string_agg, both built from GORM's parsed relationship
- Default content locale first in meta.enabledLocales, so ML form controls open on the stored host values
- sm-journal-plugin FormatHTML keeps raw HTML in content_html; the unsafe-HTML gate is unchanged
- Per-list query memory in the URL and browser storage (summer-admin.list:<controller>), restored on entry and cleared on logout
affects: [16.1-02 release and redeploy, 16.1-03 unit tests, 16.2 blog editor]
actuals:
tokens: 8422 # chars/4 over the realized diff of both repos, without the generated dist bundle
tasks: 4
commits: 3 # summercms.go, measured from the ledger; sm-journal-plugin adds 1 (528d2d8)
plan_head_before: 3ca6a0d4b256a8e104154b36accaf3024ee0bdd4
plan_head_after: 9cac45c3894815bc8fea91027f762a27635f67a2
tech-stack:
added: []
patterns:
- "relationSubquery: relation SQL is built from gorm.Statement.Parse plus Schema.Relationships.Relations, aliased cabana_r<n>/cabana_p<n>, with no bound values"
- "DryRun SQL harness (openDryRunDB + sqlRecorder + newDryRunListService) tests list SQL without Docker"
- "List state: URL first, browser storage as the per-list memory, decided before the first list request"
key-files:
created:
- modules/cabana/query_relation_test.go
- modules/cabana/locale_order_test.go
- admin/src/state/listState.ts
modified:
- modules/cabana/query.go
- modules/cabana/http.go
- modules/cabana/README.md
- docs/backend/lists-and-filters.md
- docs/backend/forms.md
- admin/src/views/ListView.vue
- admin/src/state/useAuth.ts
- admin/src/state/useSidebar.ts
- admin/tests/list/ListView.test.ts
- admin/tests/smoke/list.smoke.test.ts
- scripts/check-phase10.sh
- modules/boardwalk/dist/
- ../sm-journal-plugin/classes/format_html.go
- ../sm-journal-plugin/classes/format_html_test.go
- ../sm-journal-plugin/README.md
key-decisions:
- "Every relation type is searched through EXISTS, belongsTo and hasOne included, so the count query never gains an outer JOIN; only to-one sort keeps the association join"
- "Relation sort validates through the same builder, so an unknown related column or a polymorphic relation answers 422 on sort as well as on search"
- "The selected related column is resolved through FieldSchema.LookUpField and emitted by its DBName"
- "Stored list state is the JSON of toListQuery (flat URL keys) and is read back through parseListQuery, so the stored format and the URL format are the same"
patterns-established:
- "Pure DryRun tests for cabana SQL shape: reuse openDryRunDB and newDryRunListService from query_relation_test.go"
requirements-completed: [D-01, D-02, D-03]
coverage:
- id: D1
description: "List search over belongsToMany, hasMany and belongsTo columns uses EXISTS subqueries named from GORM's relationship and answers 200; to-many sort orders by a string_agg subquery with the primary-key tiebreak"
requirement: D-02
verification:
- kind: unit
ref: "modules/cabana/query_relation_test.go#TestListRelationSearchSmoke"
status: pass
human_judgment: false
- id: D2
description: "meta.enabledLocales lists the default locale first, then the writer's order"
requirement: D-03
verification:
- kind: unit
ref: "modules/cabana/locale_order_test.go#TestEnabledLocalesDefaultFirstSmoke"
status: pass
human_judgment: false
- id: D3
description: "FormatHTML keeps raw HTML (HTML paragraphs, a raw footer) and still refuses the unsafe cases"
requirement: D-03
verification:
- kind: unit
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLKeepsRawHTML"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLRejectsUnsafeHTML"
status: pass
human_judgment: false
- id: D4
description: "List query restored from browser storage before the first list request; the URL wins and overwrites the stored query"
requirement: D-01
verification:
- kind: unit
ref: "admin/tests/list/ListView.test.ts#list state in the URL and browser storage (D-01)"
status: pass
- kind: other
ref: "bash scripts/check-admin-dist.sh"
status: pass
human_judgment: false
- id: D5
description: "On UAT the Journal posts list searches by category, remembers its filters after opening a post, and the post form shows the stored content and excerpt"
requirement: D-01
verification: []
human_judgment: true
rationale: "Needs the real UAT database and an admin login behind basic auth; plan 02 redeploys and checks it by hand"
duration: 11min
completed: 2026-10-09
status: complete
---
# Phase 16.1 Plan 01: Journal admin fixes Summary
**Fixed the four UAT defects in the Journal posts admin:**
- **Relation search:** list search runs a correlated EXISTS subquery whose pivot, key and table names come from GORM's parsed relationship (`Schema.Relationships.Relations`), and to-many sort orders by a `string_agg` subquery.
- **Form locale:** `meta.enabledLocales` lists the default content locale first.
- **Raw HTML:** goldmark renders with `html.WithUnsafe()` behind the unchanged XSS gate.
- **List state:** each list's query is kept in the URL and in browser storage.
## Performance
- **Duration:** 11 min
- **Started:** 2026-10-09T18:53:15Z
- **Completed:** 2026-10-09T19:04:24Z
- **Tasks:** 4
- **Files modified:** 17 in summercms.go (including the dist bundle) and 3 in sm-journal-plugin
## Accomplishments
- **Relation search (D-02).** Search over a belongsToMany column now answers 200. The count query reads `EXISTS (SELECT 1 FROM "<pivot>" AS "cabana_p0" JOIN "<related>" AS "cabana_r0" ON ... WHERE "cabana_p0".<owner fk> = "<main>"."id" AND LOWER(CAST(...)) LIKE ?)` and has no outer JOIN. hasMany, hasOne and belongsTo search use the same EXISTS form.
- A record that matches through several related rows is counted and listed once.
- Unqualified scope predicates (`id IN (...)`, `user_id = ?`) stay unambiguous.
- A soft-deleting related model adds `deleted_at IS NULL`, matching Winter's whereHas.
- Polymorphic relations and unknown related columns answer 422.
- **Relation sort (D-02).** A hasMany or belongsToMany column sorts by `(SELECT string_agg(..., ', ' ORDER BY ...) FROM ... WHERE <correlation>)`, followed by the primary-key tiebreak. belongsTo and hasOne sorts keep the association join.
- **Form locale (D-03).** `enabledContentLocales` moves `TranslationWriter.DefaultLocale` to index 0, so MLField and FormView open on the locale whose values are in the host columns (content, excerpt, title, slug).
- **Raw HTML (D-03).** sm-journal-plugin `FormatHTML` passes raw HTML through, as Winter does. Admin saves, imports and Journal API writes no longer replace imported bodies with `<!-- raw HTML omitted -->`.
- `rejectUnsafe` still runs after the render.
- `cabana.RenderMarkdown` (the admin preview) is untouched.
- **List state (D-01).** The list query is stored per list under `summer-admin.list:<controller id>`.
- A list entered without list params restores the stored query into the URL before its first list request. A URL with list params wins and overwrites the stored query.
- A 422 forgets the stored query, and a page past `last_page` is clamped to the last page.
- Logout clears every stored list query.
## Task Commits
summercms.go (master, not pushed):
1. **Task 1: Tracer, relation search and sort through subqueries:** `06d75cb` (fix)
2. **Task 2: Default content locale first in enabledLocales:** `bd8626e` (fix)
4. **Task 4: List state in the URL and browser storage, rebuilt dist:** `9cac45c` (fix)
sm-journal-plugin (main, not pushed; plan 02 pushes):
3. **Task 3: Keep raw HTML in content_html behind the safety gate:** `528d2d8` (fix)
Each task is one commit with the message the plan named; the plan specifies one commit per task. RED was shown before each implementation:
- Tasks 1, 2 and 3: the new test failed on its assertion against the old code (the broken `"Tags"."rel_post_id"` join, `[en pl]`, and `<!-- raw HTML omitted -->`).
- Task 4: the two new vitest cases were run against the HEAD `ListView.vue` and failed on their assertions.
## Files Created/Modified
- `modules/cabana/query.go`: `relationSubquery` / `relationSQL`, EXISTS search, to-many `string_agg` sort, and `applyListOrder` now returns an error.
- `modules/cabana/query_relation_test.go`: relation fixtures, the DryRun db and recording logger, `newDryRunListService`, and `TestListRelationSearchSmoke`.
- `modules/cabana/http.go`: `enabledContentLocales` puts the default locale first.
- `modules/cabana/locale_order_test.go`: `TestEnabledLocalesDefaultFirstSmoke`.
- `modules/cabana/README.md`, `docs/backend/forms.md`: default locale first.
- `docs/backend/lists-and-filters.md`: relation search and to-many sort semantics.
- `admin/src/state/listState.ts`: `LIST_STATE_PREFIX`, `readListState`, `writeListState` and `clearListStates`.
- `admin/src/views/ListView.vue`: restore before the first request, the URL wins, 422 forgets the state, page clamp.
- `admin/src/state/useAuth.ts`: logout clears list states.
- `admin/src/state/useSidebar.ts`: header comment names `listState.ts` as the only other storage writer.
- `admin/tests/list/ListView.test.ts`: the two D-01 smoke cases.
- `admin/tests/smoke/list.smoke.test.ts`: `beforeEach` clears list states (deviation 1).
- `scripts/check-phase10.sh`: storage allowlist now includes `listState.ts`.
- `modules/boardwalk/dist/`: rebuilt SPA.
- `../sm-journal-plugin/classes/format_html.go`: `html.WithUnsafe()`, variables renamed so nothing shadows the import.
- `../sm-journal-plugin/classes/format_html_test.go`: `TestFormatHTMLKeepsRawHTML`.
- `../sm-journal-plugin/README.md`: Posts bullet.
## Decisions Made
- All relation types are searched through EXISTS, not only to-many ones, so the count query never gains a JOIN (RESEARCH Pitfall 1).
- Relation sort validates through the same builder, so a bad related column or a polymorphic relation answers 422 on sort as well as on search.
- The related column is resolved with `FieldSchema.LookUpField` and emitted by its `DBName`.
- The stored list state uses the flat URL form (`toListQuery`) and is read back through `parseListQuery`, so stale or tampered values are dropped exactly like a hand-edited URL.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] List smoke tests leaked list state between tests**
- **Found during:** Task 4
- **Issue:** `admin/tests/smoke/list.smoke.test.ts` has its own `beforeEach`, which does not clear browser storage. Once lists remember their query, a filter set by an earlier test was restored in "hides the filter bar without filters...". That test then saw `filter[created]` and `filter[maker]` in the URL and failed.
- **Fix:** the file's `beforeEach` calls `clearListStates()`, so every test starts from a clean list. The file is not in the plan's `files_modified`; it was staged by explicit path in the Task 4 commit.
- **Files modified:** `admin/tests/smoke/list.smoke.test.ts`
- **Verification:** the full vitest suite passes (75 files, 1106 tests).
- **Committed in:** `9cac45c`
---
**Total deviations:** 1 auto-fixed (1 blocking)
**Impact on plan:** test isolation only; there is no behavior change and no scope creep.
## Issues Encountered
- `go test ./modules/cabana` without `-run` fails on the Docker-backed tests (no Docker socket permission), as the plan expected. Every failure is a Docker error, and none of those tests uses a writer whose default locale is not first. Recorded in `deferred-items.md`.
- `scripts/check-phase10.sh --hygiene` and `--self-test` refuse 5 findings in files this plan does not touch (`admin/src/api/files.ts`, `FileCaptionModal.vue`, `MLField.vue`, `mlLocale.ts`). The storage rule changed here passes. Recorded in `deferred-items.md`.
## User Setup Required
None. No external service configuration is required.
## Next Phase Readiness
- Plan 02 can tag the summercms.go release (commits `06d75cb`, `bd8626e`, `9cac45c`), push sm-journal-plugin `528d2d8`, bump the host and redeploy.
- After the deploy, re-save posts 53 and 64 to repair their `content_html`.
- Before tagging, run the Postgres-backed cabana suite on a machine with Docker; this machine has none.
- Plan 03 can reuse `openDryRunDB` and `newDryRunListService` for full coverage of the relation builder (hasOne, self-referencing, soft delete, polymorphic refusal, sort validation) and of `listState.ts`.
---
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
*Completed: 2026-10-09*
## Self-Check: PASSED
All created files exist; commits 06d75cb, bd8626e, 9cac45c (summercms.go) and 528d2d8 (sm-journal-plugin) are present.

View File

@@ -0,0 +1,240 @@
---
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
plan: 02
type: execute
wave: 2
depends_on: ["16.1-01"]
files_modified:
- ../sm-journal-plugin/go.mod
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
- ../sm-grzybyfunkcjonalne-app/README.md
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
autonomous: false
requirements: [D-04, D-03]
user_setup:
- service: UAT admin (https://uat.grzybyfunkcjonalne.pl/gfadmin)
why: "The executor holds no UAT basic-auth or admin credentials; a human runs the posts-list search and re-saves posts 53 and 64 (D-03, D-04)"
dashboard_config:
- task: "Log in to the admin through the UAT basic-auth realm with an admin account"
location: "browser, https://uat.grzybyfunkcjonalne.pl/gfadmin"
estimate:
tokens: 50000
raw_tokens: 50000
tasks: 3
confidence: low
must_haves:
truths:
- "D-04: an annotated summercms tag (v0.1.16, or the next free v0.1.x if the concurrent session tagged first) contains every 16.1-01 cabana and admin commit and is on origin; when an existing tag already contains them, no new tag is cut and that tag is recorded instead."
- "D-04: sm-journal-plugin main, holding the FormatHTML fix and a go.mod require of the release tag, is on origin, and sm-grzybyfunkcjonalne-app's plugins/golem15/journal gitlink points at it; the host README and DEPLOY.md no longer tell admins to avoid re-saving imported posts."
- "D-04: UAT runs a binary built by scripts/build.sh from the tagged framework working tree and the bumped journal gitlink; its sha256 on the server equals the local build, both supervisor programs are RUNNING, the previous binary is kept as bin/grzybyfunkcjonalne.prev, and the on-server smoke.sh --url run ends with 'smoke: ok ('."
- "D-02: after the restart, a human search in the UAT Journal posts list returns rows, and the Go log shows no SQLSTATE 42703 line since the restart."
- "D-03: after a human re-saves posts 53 and 64 in the admin, no post's content_html holds goldmark's raw-HTML placeholder comment, and post 64's content_html contains its footer element again."
- "D-01 and D-03 on UAT (end-of-phase human check): sort and a category filter survive open post and back, save-and-close and rail navigation, and stay in the URL; an imported post's form shows title, slug, content and excerpt with pl first."
artifacts:
- path: "../sm-grzybyfunkcjonalne-app/DEPLOY.md"
provides: "release and rollback procedure, without the obsolete admin re-save warning"
contains: "## Release"
- path: "../sm-journal-plugin/go.mod"
provides: "nominal require of the release tag (the replace to ../summercms.go stays)"
contains: "git.golem15.com/golem15/summercms v0.1."
key_links:
- from: "../sm-grzybyfunkcjonalne-app/plugins/golem15/journal (gitlink)"
to: "sm-journal-plugin origin/main"
via: "submodule pointer bump committed and pushed in the host"
pattern: "plugins/golem15/journal"
- from: "../sm-grzybyfunkcjonalne-app/scripts/build.sh"
to: "../summercms.go at the release tag"
via: "replace git.golem15.com/golem15/summercms => ../summercms.go in the host go.mod"
pattern: "summercms.go"
prohibitions:
- "[FLAGGED-UNVERIFIED] The release must not run write SQL on the UAT database or bulk-rewrite content_html; only posts 53 and 64 are re-saved, by a human, in the admin."
- "[FLAGGED-UNVERIFIED] The live Winter site, its vhost, php-fpm and the PHP database must not be touched by the redeploy (Phase 16 D-10 stays in force)."
- "[FLAGGED-UNVERIFIED] The executor must not request, print, store or commit any credential: UAT basic auth, admin password or a server .env secret."
---
## Phase Goal
ROADMAP Goal (not in user-story format): the Journal posts list and post form in the admin work on real imported data found on UAT, and the fixes are released as a summercms tag, the host is bumped, UAT is redeployed and the staging smoke re-runs.
This plan's slice (D-05: "02 release and redeploy to UAT, including re-saving posts 53 and 64"): the 16.1-01 fixes reach UAT behind a human release gate, and the four defects are checked on the real imported data.
Wave order (stated per the orchestrator's request): 01 (wave 1) then this plan (wave 2) then 03, the unit tests (wave 3). CLAUDE.md lean rule 3 says earlier plans "must not be blocked on coverage" and the unit-test plan is the last plan of the phase, so the release does not wait for plan 03, as Phase 16 shipped 16-04 (deploy) before 16-05 (tests). The release gate runs the 16.1-01 smoke tests; plan 03's tests land on master after the tag and ship with the next tag. Plan 03 runs after this plan so the two never commit or push in the same repositories at the same time.
## Spec-less probe fallback
Visible skip: no REQUIREMENTS.md IDs to probe. CONTEXT D-04 (release mechanics), D-03 (re-save of posts 53 and 64) and the RESEARCH "Release / deploy mechanics" and "Runtime State Inventory" sections are the source of truth. Claude's discretion (CONTEXT): the unused `en` locale on UAT is not disabled; the framework fix (default locale first) is enough for D-03, and changing UAT data was not asked for.
## Artifacts this phase produces
- A summercms annotated tag `v0.1.x` on origin (or the recorded existing tag that already contains the fixes).
- sm-journal-plugin `main` on origin: the 16.1-01 FormatHTML commit and `chore(deps): require summercms v0.1.x`.
- sm-grzybyfunkcjonalne-app `master` on origin: journal gitlink bump, README and DEPLOY.md without the admin re-save warning.
- UAT server: new `/srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne`, previous one at `bin/grzybyfunkcjonalne.prev`, both programs restarted, posts 53 and 64 re-rendered by an admin save.
<objective>
Release and redeploy (D-04): pass a human-approved release gate, tag summercms, push the plugin and host bumps, build, deploy to uat.grzybyfunkcjonalne.pl per DEPLOY.md, smoke on the server, then confirm the four defects on UAT and restore posts 53 and 64 (D-03).
Purpose: the fixes from 16.1-01 serve the real imported data on UAT.
Output: pushed tag and branches, a running UAT release, a passing on-server smoke and read-only proof that the damaged posts are repaired.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md
@../sm-grzybyfunkcjonalne-app/DEPLOY.md
Server facts (16-04-SUMMARY, DEPLOY.md "Server facts"): ssh alias `grzybyfunkcjonalne-uat` (user grzybyfunkcjonalne), install root `/srv/grzybyfunkcjonalne`, programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, sudo only for nginx -t, nginx reload and supervisorctl, PostgreSQL 17.11 reachable by peer psql as the deploy user. The UAT realm answers 401 off-server, so every HTTP check runs on the server with curl resolving the host to 127.0.0.1 (DEPLOY.md "Smoke").
The host builds the framework from the `../summercms.go` working tree through a replace (`require ... summercms v0.0.0`), so there is no framework version to bump in the host; the host bump is the journal gitlink. Commits: no co-author tags, stage by explicit path, one logical change per commit.
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 1: Release gate: local checks green, tag chosen, a human approves the one-way tag push and the UAT deploy</name>
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Rollback), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md (commit hashes per repository), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Release / deploy mechanics, Open Question 1), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md (Docker-host suites)</read_first>
<action>Before stopping, the executor does everything automatable and prints a release sheet.
Concurrency (D-02): in summercms.go, git status --porcelain -- modules admin docs scripts must print nothing; git fetch origin --tags; git log --oneline HEAD..origin/master must print nothing. If origin/master has commits this checkout lacks, the concurrent session pushed: stop and report; do not merge or rebase on your own.
Tag (D-04): if git tag --contains on the first 16.1-01 cabana commit lists a tag, the concurrent session's release already includes the fixes: propose reusing that tag and cutting none. Otherwise propose the highest tag from git tag -l 'v0.1.*' --sort=-v:refname plus one (v0.1.16 if nobody tagged after v0.1.15) and confirm with git ls-remote --tags origin that it does not exist yet.
Run the release gate (the verify command). Then print the release sheet: the proposed tag; the commits that will be pushed from summercms.go (git log --oneline origin/master..HEAD, which also carries earlier planning-doc commits) and from ../sm-journal-plugin (git log --oneline origin/main..HEAD); the Postgres-backed suites that cannot run on this machine (TestListQueryContract and the rest of modules/cabana, the sm-journal-plugin admin harness); the deploy steps from DEPLOY.md "Release"; and the rollback (move bin/grzybyfunkcjonalne.prev back and restart grzybyfunkcjonalne-go). Stop for the human.</action>
<decision>Push summercms master and the proposed annotated tag, push the plugin and host bumps, and deploy to UAT now?</decision>
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and cannot be moved, only superseded. The cabana changes are covered by DryRun smoke tests only on this machine; the real-database proof is the UAT check in Task 3, with the previous binary kept for rollback. Plan 03 adds full unit coverage after the release (lean rule 3).</context>
<options>
<option id="release-now">
<name>Release now with the proposed tag</name>
<pros>UAT gets the fixes immediately; the UAT check in Task 3 exercises the new SQL on the real imported data; rollback is one mv and a restart</pros>
<cons>The Postgres-backed suites run only later, on a Docker host; a defect found then needs the next tag</cons>
</option>
<option id="docker-first">
<name>Run the Docker-host suites first, then release</name>
<pros>go test ./... in summercms.go and sm-journal-plugin, including the existing relation-search Postgres test, runs before the tag is pushed</pros>
<cons>Needs a Docker-enabled machine and delays UAT</cons>
</option>
<option id="hold">
<name>Hold the release</name>
<pros>Nothing leaves this machine</pros>
<cons>UAT keeps the four defects</cons>
</option>
</options>
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), or "hold".</resume-signal>
<verify>
<automated>go vet ./... &amp;&amp; go -C ../sm-journal-plugin vet ./... &amp;&amp; go test -count=1 -run '^(TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 2 &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ &amp;&amp; go test -count=1 ./cmd/summer -run TestDocsTree &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; echo release-gate-ok</automated>
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than two "--- PASS: Test" lines for the two cabana smoke tests, a classes or docs test fails, a vitest file fails, or the dist drift gate reports modules/boardwalk/dist as stale.</fails_when>
</verify>
<acceptance_criteria>
- The gate printed release-gate-ok.
- `git status --porcelain -- modules admin docs scripts` printed nothing and `git log --oneline HEAD..origin/master` printed nothing at the time of the gate.
- The SUMMARY records the release sheet (proposed or reused tag, commits to push per repository, suites not run) and the human's reply verbatim.
</acceptance_criteria>
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
</task>
<task type="tracer">
<name>Task 2: Tracer: tag, push, bump, build and deploy; the on-server smoke passes on UAT</name>
<reversibility rating="one-way">A pushed annotated summercms tag is consumed by other repositories' go.mod and the Go module proxy; it cannot be moved, only superseded by a later tag.</reversibility>
<precondition>Task 1 was answered "release vX" or "docker passed, release vX", RELEASE_TAG is set to that tag in the session, and ssh grzybyfunkcjonalne-uat true exits 0.</precondition>
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/DEPLOY.md</files>
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, the admin re-save warning section before "Production cutover"), ../sm-grzybyfunkcjonalne-app/README.md (Admin section, the bold warning paragraph near line 114), ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-journal-plugin/go.mod, the Task 1 release sheet in this plan's SUMMARY</read_first>
<action>Framework (D-04): unless Task 1 recorded an existing tag that already contains the fixes, re-run git log --oneline HEAD..origin/master (must print nothing), create the annotated tag on summercms.go master HEAD with git tag -a "$RELEASE_TAG" and the message "$RELEASE_TAG: cabana relation search and sort through subqueries, default content locale first, admin list state in the URL and browser storage", then git push origin master and git push origin "$RELEASE_TAG".
sm-journal-plugin: go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG (the replace to ../summercms.go stays, so the require is nominal; precedent: "chore(deps): require summercms v0.1.10" in sm-translate-plugin); go -C ../sm-journal-plugin vet ./...; commit go.mod only with "chore(deps): require summercms $RELEASE_TAG"; git -C ../sm-journal-plugin push origin main (this also publishes the 16.1-01 FormatHTML commit).
Host (../sm-grzybyfunkcjonalne-app): git -C plugins/golem15/journal fetch origin and check out the sm-journal-plugin main commit just pushed. Delete the bold README.md paragraph in the Admin section that tells admins not to edit imported posts, and the DEPLOY.md section between "Rollback" and "Production cutover" that tells them not to re-save imported posts: since the FormatHTML fix an admin save keeps imported HTML. Commit the gitlink, README.md and DEPLOY.md together with "chore: bump journal plugin; admin saves keep imported HTML" and push origin master.
Build: bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, then bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh (it compiles the ../summercms.go working tree, which Task 1 found clean at the tagged HEAD). Record the local sha256sum of ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne.
Release per DEPLOY.md "Release": rsync the binary to /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne.new and config/, scripts/ and build/nuxt/.output/ as listed in "Build and upload" (never a local .env). On the server in /srv/grzybyfunkcjonalne/go: compare the sha256sum of bin/grzybyfunkcjonalne.new with the local one; cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev and mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne; run bin/grzybyfunkcjonalne migrate (this phase adds no migration, it must still exit 0); record RESTART_TS as the server's date in the Go log format (date +'%Y/%m/%d %H:%M:%S') in the SUMMARY and the session; sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt.
Smoke on the server with the DEPLOY.md recipe (first verify command). On any failure: move bin/grzybyfunkcjonalne.prev back, restart grzybyfunkcjonalne-go, and report.</action>
<verify>
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" &gt; "$C/.curlrc"; cd /srv/grzybyfunkcjonalne/go &amp;&amp; CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl; rc=$?; rm -rf "$C"; exit $rc' | grep '^smoke: ok ('</automated>
<fails_when>Non-zero exit: smoke.sh failed on the server, the output contains "smoke: FAIL", or no line starts with "smoke: ok (".</fails_when>
<automated>test "$(sha256sum ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne | cut -d' ' -f1)" = "$(ssh grzybyfunkcjonalne-uat 'sha256sum /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne' | cut -d' ' -f1)" &amp;&amp; ssh grzybyfunkcjonalne-uat 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 &amp;&amp; test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" &amp;&amp; echo deploy-ok</automated>
<fails_when>Non-zero exit or "deploy-ok" absent: the server binary differs from the local build, fewer than two programs are RUNNING, or the tag is missing on origin.</fails_when>
</verify>
<acceptance_criteria>
- `git ls-remote --tags origin` lists the release tag, and `git tag --contains` on the first 16.1-01 cabana commit lists it (or the SUMMARY names the existing tag Task 1 reused).
- `git -C ../sm-journal-plugin rev-parse main` equals `git -C ../sm-journal-plugin rev-parse origin/main`, and ../sm-journal-plugin/go.mod requires summercms at the release tag.
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal` shows the pushed sm-journal-plugin main commit, and the host master equals origin/master.
- `grep -ci omitted ../sm-grzybyfunkcjonalne-app/README.md ../sm-grzybyfunkcjonalne-app/DEPLOY.md` reports 0 for both files (both warnings removed).
- The on-server smoke prints a line starting with "smoke: ok (" and the second verify prints deploy-ok.
- The SUMMARY records the tag, the three pushed heads, the local and server sha256 and RESTART_TS; no credential appears in it.
</acceptance_criteria>
<done>UAT runs the released fixes from the pushed tag and branches, and every smoke URL passes on the server.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<name>Task 3: UAT acceptance: a human searches the posts list and re-saves posts 53 and 64; the executor confirms with read-only checks</name>
<precondition>Task 2 printed deploy-ok and RESTART_TS is set in the session.</precondition>
<read_first>.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md (Runtime State Inventory: posts 53 and 64; Validation Architecture, Manual), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md (Manual-Only Verifications), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Smoke, Rollback)</read_first>
<action>Print the steps for the human and stop. The executor never asks for, receives or stores a credential. Required human steps in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts: (1) search "cordyceps" and confirm rows appear instead of "The list could not be loaded" (this runs the new relation subquery on the real categories pivot, D-02); (2) open post 53 (czy-amanita-muscaria-jest-legalna) and post 64 (ekstrakt-z-turkey-tail) and press "Zapisz" on each without editing, which re-renders their content_html through the fixed FormatHTML (D-03; RESEARCH: both lost their HTML at import). Optional now, otherwise collected at end of phase: the human-check list below. After the reply, run both verify commands. If either fails, report it and offer the DEPLOY.md rollback (move bin/grzybyfunkcjonalne.prev back and restart grzybyfunkcjonalne-go). Known and accepted (RESEARCH Pitfall 9): each save also stores empty en translations; public output is unchanged.</action>
<instructions>
Claude deployed the release and passed the on-server smoke in Task 2.
The human logs in to the UAT admin (basic-auth realm plus admin account), runs the search and re-saves posts 53 and 64, then replies.
</instructions>
<verification>Read-only psql on the server: no post holds the placeholder comment, post 64 has its footer element; the Go log since RESTART_TS has no SQLSTATE 42703.</verification>
<resume-signal>Type "resaved" once the search returned rows and both posts were saved, or describe what went wrong.</resume-signal>
<verify>
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (SELECT count(*) FROM golem15_journal_posts WHERE content_html LIKE '%raw HTML omitted%') || ':' || (SELECT count(*) FROM golem15_journal_posts WHERE id = 64 AND content_html LIKE '%&lt;footer%')\"" | grep -qx '0:1'</automated>
<fails_when>Non-zero exit: the output is not exactly "0:1" (a post still holds the placeholder comment, post 64 lacks its footer element, or psql could not connect).</fails_when>
<automated>ssh grzybyfunkcjonalne-uat 'sudo supervisorctl tail -65536 grzybyfunkcjonalne-go' | awk -v since="$RESTART_TS" 'substr($0,1,19) &gt;= since' | grep -c 'SQLSTATE 42703' | grep -qx 0</automated>
<fails_when>Non-zero exit: a line with "SQLSTATE 42703" was logged after RESTART_TS (with RESTART_TS unset every line is kept, so the earlier failure makes the check fail closed).</fails_when>
<human-check>On https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts (D-01): set a sort and a category filter, open a post and press back; open another post and use "Zapisz i zamknij"; leave through the plugin rail and come back: each time the list keeps the sort and the filter and the URL shows them. Paste that URL into a new tab: same list. Log out and in again: the list opens unfiltered. (D-03): open any imported post: the locale selector shows pl first, and title, slug, content and excerpt are filled. /blog/ekstrakt-z-turkey-tail shows the author footer. Reply "UAT approved" or list the issues.</human-check>
</verify>
<acceptance_criteria>
- The SQL check prints exactly 0:1 on the server.
- The log check finds 0 "SQLSTATE 42703" lines after RESTART_TS.
- The SUMMARY records the human's reply and the human-check answer (or marks it pending for /msd-verify-work).
</acceptance_criteria>
<done>Search works on the real pivot, posts 53 and 64 have their HTML back, and the remaining UAT checks are answered or queued for verify-work.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Executor → git origin (git.golem15.com) | tags and branches pushed here are read by other repositories |
| Executor → UAT server (ssh) | the release runs next to the live Winter site on the same server |
| Human admin → UAT admin | the only path that writes post data in this plan |
| Server .env files → processes | secrets live only on the server |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.1-06 | Tampering (false-green release) | release gate | medium | mitigate | Task 1 runs vet in both Go repositories, the named smoke tests with a counted PASS check, TestDocsTree, the full vitest suite and the dist drift gate; the Docker-only suites are named in the release sheet and the human chooses release-now or docker-first at a blocking-human checkpoint |
| T-16.1-07 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | Tag only after git status is clean for modules, admin, docs and scripts and HEAD..origin/master is empty, re-checked right before the push; git tag --contains decides whether a concurrent release already includes the fixes; the uploaded binary's sha256 must equal the local build; the SUMMARY records every pushed head |
| T-16.1-08 | Information Disclosure | server .env files and credentials | high | mitigate | No .env is rsynced (DEPLOY.md); the executor never asks for, prints or stores UAT basic-auth or admin credentials; all admin steps are done by the human; the SUMMARY carries no secret |
| T-16.1-09 | Tampering | UAT database and the live Winter site | medium | mitigate | The executor runs only SELECT queries; post data changes only through two human admin saves; the Winter vhost, php-fpm and PHP database are not touched; the previous binary stays as bin/grzybyfunkcjonalne.prev for rollback |
| T-16.1-10 | Elevation of Privilege | sudo on the server | low | accept | sudo is limited by the server to nginx -t, nginx reload and supervisorctl; this plan uses only supervisorctl restart, status and tail |
| T-16.1-SC | Tampering | packages | high | mitigate | No package is installed: the binary and the Nuxt output are built locally from the committed lockfiles and go.sum by scripts/build.sh; go mod edit changes only the nominal summercms require, which resolves through the local replace |
</threat_model>
<verification>
- Task 1 printed release-gate-ok and a human chose a release option.
- Task 2: the on-server smoke prints "smoke: ok (" and deploy-ok is printed.
- Task 3: the SQL check prints 0:1 and the log check finds no SQLSTATE 42703 after RESTART_TS.
</verification>
<success_criteria>
- D-04: tag (or recorded existing tag), plugin and host bumps pushed; UAT redeployed per DEPLOY.md; on-server smoke passes.
- D-02 and D-03 proven on the real UAT data; posts 53 and 64 repaired by an admin save.
- No credential handled by the executor; no write SQL; the Winter site untouched.
</success_criteria>
<output>
Create `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md` when done, with the release sheet, the human replies, the tag and pushed heads, both sha256 values, RESTART_TS, the smoke line and the two read-only check results.
</output>

View File

@@ -0,0 +1,215 @@
---
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
plan: 03
type: execute
wave: 3
depends_on: ["16.1-01", "16.1-02"]
files_modified:
- admin/tests/form/FormView.test.ts
- admin/tests/list/ListView.test.ts
- admin/tests/state/listState.test.ts
- admin/tests/state/useAuth.test.ts
- modules/cabana/query_relation_test.go
- modules/cabana/locale_order_test.go
- ../sm-journal-plugin/classes/format_html_test.go
- ../sm-journal-plugin/posts_admin_smoke_test.go
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
autonomous: true
requirements: [D-01, D-02, D-03]
estimate:
tokens: 75000
raw_tokens: 75000
tasks: 3
confidence: low
must_haves:
truths:
- "CLAUDE.md lean rule 3: every piece of Phase 16.1 code has unit tests in this last plan: the relation subquery builder (search and sort, every relation type, refusals), enabledContentLocales ordering, FormatHTML keep and refuse, the listState helpers, the ListView entry logic, the FormView return paths and logout clearing."
- "D-01: a form's back, cancel, save-and-close and delete each land on the list with the stored query in the URL, and the first list request after the return carries it (no list request without it)."
- "D-01: ListView restores the stored query before its first list request, lets a URL with list params win and overwrite storage, removes the stored query when the user clears every control (the next entry loads unfiltered), keeps the filters when the same list is opened again from the navigation, drops the stored query on a 422, and moves a restored page beyond last_page to last_page."
- "D-01: listState helpers use the summer-admin.list: prefix plus the controller id, drop malformed stored JSON or values (and remove that key), store nothing for an empty query, survive storage that throws, and clearListStates (called by logout) removes list keys but keeps summer-admin.sidebar."
- "D-02: DryRun tests prove belongsToMany search goes through the pivot named by the gorm tag, including custom joinForeignKey and joinReferences columns (no naming convention guessed); hasMany and belongsTo correlate on the parsed keys; a self-referencing relation gets distinct aliases; a soft-deleting related model adds deleted_at IS NULL; polymorphic relations and unknown columns answer 422; the search value is bound and LIKE-escaped, never in the SQL text; combined with a ListExtendQuery user_id predicate and a scope filter the count has no outer JOIN; to-many sort orders by string_agg with the primary-key tiebreak in both directions; to-one sort keeps the association join."
- "D-03: enabledContentLocales tests cover default not first, already first, default absent, DefaultLocale error (order kept), no writer (nil) and three locales keeping their relative order."
- "D-03: FormatHTML tests prove imported HTML round-trips (paragraphs, attributes, markdown with a trailing raw footer and style) and that every unsafe case returns an error now that the gate is the only defence (script, iframe, object, embed, on* attributes in raw HTML including an svg onload, javascript, vbscript and data URLs in links and raw attributes, mixed case)."
- "Postgres-backed tests TestListRelationSearchPostgres (exact total when one post matches through two tags) and TestPostsAdminResaveKeepsHTML (an admin update of HTML content keeps content_html) exist, pass go vet, and fail rather than skip without a database outside -short; they run on a Docker host before /msd-verify-work."
- "go vet stays clean in summercms.go and sm-journal-plugin; the named pure Go tests, the full vitest suite, TestDocsTree and scripts/check-admin-dist.sh are green; this plan changes tests only, so the embedded dist does not change."
artifacts:
- path: "admin/tests/state/listState.test.ts"
provides: "unit tests of the browser-storage helpers"
contains: "clearListStates"
- path: "modules/cabana/query_relation_test.go"
provides: "DryRun SQL matrix and the Postgres relation-search test"
contains: "TestListRelationSQL"
- path: "modules/cabana/locale_order_test.go"
provides: "default-locale ordering cases"
contains: "TestEnabledLocalesDefaultFirst"
- path: "../sm-journal-plugin/posts_admin_smoke_test.go"
provides: "admin re-save keeps imported HTML (todo journal-imported-html-admin-resave acceptance test)"
contains: "TestPostsAdminResaveKeepsHTML"
key_links:
- from: "admin/tests/form/FormView.test.ts"
to: "admin/src/views/ListView.vue entry restore"
via: "form back navigation to the bare list path, then the restored query in the URL and the first list request"
pattern: "writeListState"
- from: "modules/cabana/query_relation_test.go"
to: "modules/cabana/query.go applyListSearch and applyListOrder"
via: "DryRun db with a recording logger, through service.list and ExecuteList"
pattern: "DryRun"
prohibitions:
- "[FLAGGED-UNVERIFIED] No test may touch the UAT server, the network or a shared database: pure tests use a DryRun db on a DSN that is never dialed, Postgres tests use throwaway testcontainers databases."
- "[FLAGGED-UNVERIFIED] Tests must not be weakened to pass: the existing FormatHTML refuse cases stay and become stricter, and no existing ListView, FormView or shell smoke assertion is deleted."
---
## Phase Goal
ROADMAP Goal (not in user-story format): the Journal posts list and post form in the admin work on real imported data found on UAT (relation search, remembered list state, stored content and excerpt in the form), released and redeployed.
This plan's slice (D-05: "03 unit tests"; CLAUDE.md lean rule 3): full unit coverage of every 16.1 code change, ending the phase. It runs after plan 02 (wave 3) because the release was not to wait for coverage (lean rule 3) and the two plans commit in the same repositories; its commits ship with the next tag.
## Spec-less probe fallback
Visible skip: no REQUIREMENTS.md IDs to probe. The edge cases RESEARCH and plan 01 named (polymorphic, soft-deleting and self-referencing relations, custom join keys, stale or malformed stored state, cleared controls, same-path navigation, out-of-range page, disabled storage, the gate as the only XSS defence) are each authored as a test case in `must_haves.truths`.
## Artifacts this phase produces
- New `admin/tests/state/listState.test.ts`; extended `admin/tests/list/ListView.test.ts`, `admin/tests/form/FormView.test.ts`, `admin/tests/state/useAuth.test.ts`.
- Extended `modules/cabana/query_relation_test.go` (DryRun matrix, Postgres test) and `modules/cabana/locale_order_test.go`.
- Extended `../sm-journal-plugin/classes/format_html_test.go` and `../sm-journal-plugin/posts_admin_smoke_test.go`.
- `16.1-VALIDATION.md` statuses filled with the measured results.
<objective>
Bring full unit coverage to the Phase 16.1 code (D-01, D-02, D-03) as the phase's last plan, and run the phase gate.
Purpose: the fixes stay fixed: relation subqueries, locale order, lossless HTML and remembered list state are pinned by tests that run without Docker, plus Postgres tests for a Docker host.
Output: test commits in summercms.go (admin, cabana) and sm-journal-plugin, and a filled VALIDATION.md.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-RESEARCH.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
Docker is not usable on this machine: run the pure Go tests by name and count their PASS lines; Postgres-backed tests are written and vetted here and run on a Docker host. Commits: tests only, staged by explicit path, no co-author tags, planning docs (VALIDATION.md) in a separate commit from code. Nothing is pushed or tagged by this plan.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: list, form, back round trip restores the list query; then full SPA list-state coverage</name>
<files>admin/tests/form/FormView.test.ts, admin/tests/list/ListView.test.ts, admin/tests/state/listState.test.ts, admin/tests/state/useAuth.test.ts</files>
<read_first>admin/src/state/listState.ts, admin/src/views/ListView.vue, admin/src/views/FormView.vue (go, onLeave, onSaveAndClose, onDelete, redirectTarget), admin/src/state/useAuth.ts (logout), admin/tests/helpers.ts (mountApp, requestsTo, queryOf, resetState), admin/tests/form/FormView.test.ts (routes and form fixtures, router.currentRoute assertions), admin/tests/list/ListView.test.ts (routes, the plan 01 smoke cases), admin/tests/state/useAuth.test.ts, admin/tests/state/useSidebar.test.ts (storage spies that throw), admin/tests/smoke/shell.smoke.test.ts (only the sidebar key after mounting a list), admin/tests/fixtures/typed.ts (form schema redirects)</read_first>
<action>Tracer first, in admin/tests/form/FormView.test.ts: seed writeListState for acme.demo.widgets with sort code, dir desc and filter active true; mount /acme/demo/widgets/1 with the form routes plus the list schema and list routes; click the back action; after flushPromises the router fullPath is the list path carrying sort=code, dir=desc and filter[active]=true, and every list GET after the click carries them (none without). Repeat for cancel, save-and-close (when the fixture's close redirect resolves to the list path; otherwise set it so in a cloned schema) and delete (confirm dialog accepted).
admin/tests/state/listState.test.ts (new): the key is the prefix plus the controller id; read of a missing key is empty; read of invalid JSON, a non-object, or an object with non-string values returns empty and removes the key; malformed values (page 0, dir sideways, empty filter value, unknown shapes) are dropped by the strict parser; writing an empty query removes the key; a write and read round-trip keeps search, sort, dir, page above 1, per_page and filters; clearListStates removes every prefixed key and keeps summer-admin.sidebar; with Storage getItem and setItem spied to throw, read returns empty and write and clear do not throw.
admin/tests/list/ListView.test.ts: with a stored query and an empty URL, no list GET without the stored params is made and the first carries them; a URL with params wins and overwrites storage; clearing search, sort and filters through the UI removes the key, and remounting the bare list loads unfiltered; with the list filtered, router.push to the bare list path keeps the filters and makes no unfiltered request; a 422 list response removes the stored key; a response with page above last_page replaces the URL page with last_page and requests that page.
admin/tests/state/useAuth.test.ts: logout removes every summer-admin.list: key and keeps summer-admin.sidebar, also when the logout request fails.
Keep every existing assertion, including the shell smoke expectation that mounting a list without query stores only the sidebar key. Commit in summercms.go, staging exactly the four test files: test(admin): list state round trip and storage coverage.</action>
<verify>
<automated>npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke</automated>
<fails_when>Non-zero exit, a vitest summary line reporting "failed", or "No test files found" in the output.</fails_when>
</verify>
<acceptance_criteria>
- The vitest run passes and the summary counts tests in admin/tests/state/listState.test.ts (the file exists and is collected).
- `grep -c 'writeListState' admin/tests/form/FormView.test.ts` is at least 1; `grep -c 'clearListStates' admin/tests/state/listState.test.ts` is at least 1; `grep -c 'summer-admin.list:' admin/tests/state/useAuth.test.ts` is at least 1.
- `git show --name-only --format= HEAD` lists exactly the four test files.
</acceptance_criteria>
<done>The list-to-form-to-list round trip and every list-state edge case are pinned by vitest.</done>
</task>
<task type="auto">
<name>Task 2: cabana coverage: DryRun SQL matrix for relation search and sort, locale ordering, Postgres relation search</name>
<files>modules/cabana/query_relation_test.go, modules/cabana/locale_order_test.go</files>
<read_first>modules/cabana/query.go (the relation subquery builder, applyListSearch, applyListOrder, ExecuteList), modules/cabana/query_relation_test.go (plan 01 harness and fixtures), modules/cabana/locale_order_test.go, modules/cabana/http.go (enabledContentLocales), modules/cabana/ml_smoke_test.go (recordingWriter), modules/cabana/query_test.go (newListService, getList, decodeList, the Postgres fixture setup), modules/cabana/list_schema_test.go (filterFS)</read_first>
<action>Extend modules/cabana/query_relation_test.go, reusing the plan 01 DryRun harness (never-dialed DSN, recording logger) and the service helper:
TestListRelationSQL, table-driven, asserting on the recorded SQL and on bound values: belongsToMany search through the pivot with the pivot owner key correlated to the main primary key and the related key joined; a belongsToMany declared with custom joinForeignKey and joinReferences tags and a non-default pivot name uses exactly those columns (proves no naming convention is guessed, D-02); hasMany search correlates the related foreign key to the main primary key; belongsTo search correlates the related primary key to the main foreign key; a self-referencing belongsToMany gets distinct aliases and still correlates to the unaliased main table; a related model with gorm.DeletedAt adds its column IS NULL on the related alias; a polymorphic hasMany and a select column missing from the related schema each answer 422 validation_failed with details.search through getList; a search term containing a quote, a percent sign and an underscore never appears in the SQL text and its bound value is lower-cased and LIKE-escaped; with a controller implementing ListExtendQuery that adds an unqualified user_id predicate and a scope filter adding an unqualified id IN predicate, the recorded count statement has no JOIN outside the subqueries; sort by the belongsToMany column asc and desc orders by the string_agg subquery with the matching direction followed by the main primary-key tiebreak; sort by the belongsTo column keeps the association join and its column order.
Add TestListRelationSearchPostgres (Postgres through newListService's shared container; it fails rather than skips without Docker, like the package's other Postgres tests): migrate the relation fixtures, seed one post with two tags that both match "fungi" and one post with an unrelated tag; search=fungi answers total 1 with only the first post; sort=tags orders by the joined tag names; a hasMany note search returns the right post.
Extend modules/cabana/locale_order_test.go with TestEnabledLocalesDefaultFirst, table-driven: en, pl with default pl gives pl, en; pl, en unchanged; default absent from the list leaves the order; a writer whose DefaultLocale errors (a small test double) leaves the order; no published writer returns nil; en, de, pl with default pl gives pl, en, de.
Commit in summercms.go, staging exactly the two files: test(cabana): relation subquery and locale order coverage.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^(TestListRelationSearchSmoke|TestListRelationSQL|TestEnabledLocalesDefaultFirst|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>Non-zero exit: go vet fails (including on the Postgres test), or fewer than four top-level "--- PASS: Test" lines (a named test failed, does not exist, or was filtered out).</fails_when>
</verify>
<acceptance_criteria>
- Four top-level PASS lines for TestListRelationSearchSmoke, TestListRelationSQL, TestEnabledLocalesDefaultFirst and TestEnabledLocalesDefaultFirstSmoke on a machine without Docker.
- `grep -c 'func TestListRelationSearchPostgres' modules/cabana/query_relation_test.go` is 1 and `go vet ./modules/cabana` exits 0.
- `grep -c 'joinForeignKey' modules/cabana/query_relation_test.go` is at least 1 (custom pivot keys case present).
- `git show --name-only --format= HEAD` lists exactly modules/cabana/query_relation_test.go and modules/cabana/locale_order_test.go.
</acceptance_criteria>
<done>The relation subquery builder and the locale order are covered by pure tests, with a Postgres test ready for a Docker host.</done>
</task>
<task type="auto">
<name>Task 3: Journal coverage (FormatHTML keep and refuse, admin re-save keeps HTML) and the phase gate</name>
<files>../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/posts_admin_smoke_test.go, .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md</files>
<read_first>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/posts_admin_smoke_test.go, ../sm-journal-plugin/admin_harness_test.go (TestMain, newPostsEnv, admin, expect, adminDecode), ../sm-journal-plugin/controllers/posts.go (preparePostWrite), .planning/todos/pending/journal-imported-html-admin-resave.md (its acceptance test), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md</read_first>
<action>../sm-journal-plugin/classes/format_html_test.go: extend TestFormatHTMLKeepsRawHTML with imported-style bodies: paragraphs, elements with class, https href and https img src attributes, an attribute holding an entity, and markdown followed by raw footer and style elements; each output equals the input after trimming (or, for the markdown case, contains the raw elements unchanged). Make TestFormatHTMLRejectsUnsafeHTML strict: every case must return an error (with unsafe rendering the gate is the only defence), and add raw-HTML cases: an svg element with onload, an anchor whose href is a mixed-case javascript URL, object and embed elements, a script element inside an HTML block, and a raw attribute with a data URL. Keep check_html_test.go unchanged and green.
../sm-journal-plugin/posts_admin_smoke_test.go: add TestPostsAdminResaveKeepsHTML through the admin harness: create a post whose content is two HTML paragraphs, update it through the admin update route with the same content, and assert the stored content_html equals the content after trimming and holds no goldmark placeholder comment; it runs where the harness Postgres runs (TestMain fails closed without Docker outside -short, the house rule).
Commit in ../sm-journal-plugin on main, staging exactly the two test files: test: FormatHTML keep and refuse coverage, admin re-save keeps HTML. Do not push.
Phase gate: run both verify commands and the plan 01 and Task 2 Go commands, then update 16.1-VALIDATION.md: each Per-Task Verification Map status (green, red or manual), wave_0_complete true, nyquist_compliant true only if every automated row is green, and the Docker-host suites and UAT items left as pending manual checks unless already answered. Commit VALIDATION.md separately from code (planning docs commit).</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLRejectsUnsafeHTML)$' -v ./classes/ 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>Non-zero exit: vet fails (including on TestPostsAdminResaveKeepsHTML), a classes test fails, or fewer than two top-level "--- PASS: Test" lines.</fails_when>
<automated>go vet ./... &amp;&amp; go test -count=1 ./cmd/summer -run TestDocsTree &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; echo phase-16.1-local-gate-ok</automated>
<fails_when>Non-zero exit or "phase-16.1-local-gate-ok" absent: vet, the docs checker, the typecheck, any vitest file or the dist drift gate failed.</fails_when>
<human-check>On a Docker-enabled machine run go test -count=1 ./... in summercms.go and go -C ../sm-journal-plugin test -count=1 ./... and confirm both are green, including TestListQueryContract, TestListRelationSearchPostgres and TestPostsAdminResaveKeepsHTML. Reply with the two summary lines or the failures.</human-check>
</verify>
<acceptance_criteria>
- Two top-level PASS lines for TestFormatHTMLKeepsRawHTML and TestFormatHTMLRejectsUnsafeHTML; `go -C ../sm-journal-plugin test -count=1 ./classes/` prints ok.
- `grep -c 'func TestPostsAdminResaveKeepsHTML' ../sm-journal-plugin/posts_admin_smoke_test.go` is 1 and `go -C ../sm-journal-plugin vet ./...` exits 0.
- The phase gate prints phase-16.1-local-gate-ok.
- 16.1-VALIDATION.md has no "⬜ pending" status left on an automated row; the frontmatter has wave_0_complete: true.
- `git -C ../sm-journal-plugin show --name-only --format= HEAD` lists exactly classes/format_html_test.go and posts_admin_smoke_test.go.
</acceptance_criteria>
<done>Lossless HTML and the strict gate are pinned, the todo's re-save test exists, the local phase gate is green and VALIDATION.md records the results.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test runner → databases | Postgres-backed tests create throwaway testcontainers databases |
| Test results → phase sign-off | a filter that matches nothing or a skipped test can read as green |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.1-11 | Repudiation (false green) | verify commands, VALIDATION.md | medium | mitigate | Every Go gate counts the top-level "--- PASS: Test" lines of the named tests; Postgres tests fail instead of skipping outside -short; VALIDATION.md marks Docker-host suites as manual until a run is recorded; nyquist_compliant is set only when every automated row is green |
| T-16.1-12 | Tampering / Denial of Service | test fixtures | low | mitigate | Pure tests use a DryRun db with DisableAutomaticPing on a DSN that is never dialed; Postgres tests use throwaway testcontainers databases; no test reaches the UAT server or a shared database |
| T-16.1-SC | Tampering | packages | high | mitigate | No package is installed: vitest, GORM, the postgres dialector and testcontainers-go are already pinned in package.json and go.mod; go.mod, go.sum and package.json stay unchanged |
</threat_model>
<verification>
- Task 1: vitest green with listState.test.ts collected.
- Task 2: four PASS lines for the named cabana tests; go vet clean.
- Task 3: two PASS lines for the FormatHTML tests; phase-16.1-local-gate-ok printed; VALIDATION.md filled.
- Docker-host suites recorded as a human-check for /msd-verify-work.
</verification>
<success_criteria>
- Lean rule 3 met: every 16.1 code change has unit tests in this last plan.
- The relation subquery builder, locale ordering, FormatHTML and list state are pinned by tests that run without Docker.
- The Postgres relation-search test and the admin re-save test exist for a Docker host.
</success_criteria>
<output>
Create `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-SUMMARY.md` when done, with the commit hashes per repository (unpushed; they ship with the next release), the gate output lines and the Docker-host result or its pending status.
</output>

View File

@@ -0,0 +1,43 @@
# Phase 16.1: Journal admin urgent fixes - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning (no discuss-phase run; decisions captured from the user directly)
<domain>
## Phase Boundary
Fix the four defects found on UAT in the Journal posts admin (uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts), release, and redeploy:
1. List search fails with "The list could not be loaded." (`{"error":{"code":"error","details":{},"message":"Server error"}}`). Go log: `ERROR: column Categories.post_id does not exist (SQLSTATE 42703)` at summercms@v0.1.9 `modules/cabana/query.go:129` — search across a belongsToMany (pivot) relation builds a hasMany-style join.
2. List sort and filters are lost after opening a post and going back.
3. The post form shows `content` empty on every post, although the UAT database has `content` and `content_html` on 104/104 posts and the frontend renders them.
4. The post form shows `excerpt` empty, although 100/104 posts have `excerpt` in the database.
Out of scope: plugin-provided field types and the Blog Editor formwidget (Phase 16.2).
</domain>
<decisions>
## Implementation Decisions
- **D-01: List state lives in the URL and in the browser's localStorage.** Sort, filters, search and page are reflected in the list URL query string (shareable, bookmarkable, back-button friendly); the form's back/save/close links and the admin navigation return to the list with its query. When the list is opened without list-state query parameters (for example from the admin navigation), the last query stored in localStorage for that list (keyed per list) is restored before the first list request. When the URL carries list-state parameters, the URL wins and also updates localStorage. No server-side storage: no table, no migration, no new API route (revised 2026-10-09 to keep the phase small; admin auth is a stateless JWT, so a server session would need new storage). User's words: "I may go back to Posts list through navigation and it's great if my filters were remembered + sharable URLs are nice to have."
- **D-05: Three plans.** 01 all fixes (cabana EXISTS relation search and sort, default locale first in form locales, Journal FormatHTML keeping imported HTML, list state via URL + localStorage); 02 release and redeploy to UAT, including re-saving posts 53 and 64 that lost HTML on import; 03 unit tests.
- **D-02: The relation-search fix is a framework fix in summercms.go `modules/cabana`**, generic for belongsToMany (pivot) relations, not a Journal-specific workaround. Another session is concurrently working in `modules/`; plans must keep cabana changes in their own commits, stage files explicitly by path, and check for conflicting in-flight edits before touching shared cabana files.
- **D-03: Content and excerpt must show the stored values without loss.** Root cause is to be established by research (suspects: translatable-field overlay replacing host values with empty translations; the markdown widget not displaying imported HTML). Saving a post from the admin must not destroy imported HTML (see todo `journal-imported-html-admin-resave.md`); if a full fix needs the Blog Editor, 16.1 at least prevents data loss and 16.2 delivers the editor.
- **D-04: Ship to UAT.** Release a summercms tag carrying the cabana fixes (unless the concurrent session's release already includes them), bump `sm-grzybyfunkcjonalne-app`, redeploy to uat.grzybyfunkcjonalne.pl per its DEPLOY.md, and re-run `scripts/smoke.sh --url` on the server.
### Claude's Discretion
- localStorage key naming for list state; reuse the existing list query parameters (`search`, `sort`, `dir`, `page`, `per_page`, `filter[name]`).
- Whether to disable the unused `en` locale on the UAT site during deploy (not required by the framework fix).
- Test strategy, within the lean-mode rule that unit tests are the last plan of the phase.
</decisions>
<canonical_refs>
## Canonical References
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md` — UAT server facts and deploy procedure
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md` — redeploy steps (smoke runs on the server; UAT is behind basic auth)
- `.planning/todos/pending/journal-imported-html-admin-resave.md`
- `.planning/todos/pending/cabana-richeditor-field-type.md` (16.2, context only)
</canonical_refs>
</content>
</invoke>

View File

@@ -0,0 +1,235 @@
# Phase 16.1: Journal admin urgent fixes - Pattern Map
**Mapped:** 2026-10-09
**Files analyzed:** 14 (new or modified)
**Analogs found:** 13 / 14
All analog paths are git-tracked source in summercms.go or sm-journal-plugin. No `.msd/` mirror paths are used.
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/cabana/query.go` (search EXISTS, to-many sort) | service (SQL builder) | request-response / CRUD read | itself: `applyListSearch` L323-360, `applyListOrder` L295-321, helpers L486-497 | exact (in-place edit) |
| `modules/cabana/list_state.go` (NEW) | service + handler | CRUD (upsert/read) | `modules/cabana/account.go` (`accountPrincipal` L214-223, `lockAccount` L225-240) | role-match |
| `modules/cabana/http.go` (route, save in `list`, locale order) | controller | request-response | itself: routes L253-256, `list` L986-1015, `enabledContentLocales` L966-984 | exact |
| `modules/cabana/admin_openapi.go` | config (swag docs) | n/a | `AdminListSchema` L340-354, `AdminList` L430+ | exact |
| `modules/lagoon/backend_admin_migrations.go` | migration | batch DDL | `202610090001_backend_users_metadata` L120-132 | exact |
| `modules/lagoon/backend_admin_migrations_test.go` | test | integration | prohibited-table loop L117-121 | exact |
| `modules/cabana/query_relation_test.go` (NEW) | test | DryRun SQL | none in cabana (no DryRun tests exist); use RESEARCH "DryRun SQL test" sketch | no analog |
| `modules/cabana/list_state_test.go` (NEW) | test | integration | `modules/cabana/ml_smoke_test.go` (`recordingWriter` L67-72), `ml_test.go:51` | role-match |
| `admin/src/views/ListView.vue` | component | event-driven / request-response | itself L113-153 (`replaceQuery`, `loadList`, watch) | exact |
| `admin/tests/list/ListView.test.ts` | test | vitest | itself L1-40 (`routes()`, `requestsTo`) | exact |
| `admin/tests/form/MLFields.test.ts` | test | vitest | itself (fixtures `['en','pl']` L18) | exact |
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist/` | generated | n/a | `scripts/check-admin-openapi.sh`, `npm --prefix admin run build` | regenerate only |
| `../sm-journal-plugin/classes/format_html.go` | utility | transform | itself L1-38 | exact |
| `../sm-journal-plugin/classes/format_html_test.go`, `posts_admin_smoke_test.go` | test | unit / integration | themselves (`TestFormatHTMLRejectsUnsafeHTML`) | exact |
| Docs: `modules/cabana/README.md`, `modules/lagoon/README.md`, `docs/backend/lists-and-filters.md` | docs | n/a | existing sections; no app names (use `blog`/`acme`) | exact |
## Pattern Assignments
### `modules/cabana/query.go` (service, SQL builder)
**Current bug, search** (L345-360) — replace the `db.Joins(field)` branch with an `EXISTS` term appended to `parts`/`args`:
```go
if col.Relation != "" {
field, ok := relationFieldName(model, col.Relation)
physical := listRelationColumn(controller, col.Relation, col.Select)
if !ok || !identifier(physical) {
return nil, listInvalid("search", "is not searchable")
}
// GORM aliases the joined association with the Go field name.
table = field
column = physical
if _, done := joined[field]; !done {
db = db.Joins(field)
joined[field] = struct{}{}
}
}
```
Keep the existing guards (`relationFieldName`, `listRelationColumn`, `identifier`, `listInvalid`) and `pattern := "%" + escapeLike(strings.ToLower(term)) + "%"` (L336). Build the subquery from `gorm.Statement{DB: db}.Parse(model)` → `Schema.Relationships.Relations[field]` (RESEARCH Pattern 1 `relationExists` sketch). Aliases `cabana_p<n>` / `cabana_r<n>`.
**Current bug, sort** (L303-309): `db = db.Joins(field)` — keep for `schema.BelongsTo`/`schema.HasOne`; for `HasMany`/`Many2Many` emit `clause.OrderBy{Expression: clause.Expr{SQL: "(SELECT string_agg(...) ...)"}}` with `Desc` handled in SQL. Keep the trailing pk tiebreak (L316-318).
**Identifier helpers to reuse** (L486-497):
```go
func qualifiedColumn(db *gorm.DB, table, column string) string {
return quotedIdent(db, table) + "." + quotedIdent(db, column)
}
func quotedIdent(db *gorm.DB, name string) string { ... db.Dialector.QuoteTo(&b, name) ... }
```
Never guess pivot/fk names (README rule).
---
### `modules/cabana/http.go` (controller)
**Route registration** (L253-254) — add the literal route next to `schema/list`, each followed by `constrainController(g)`:
```go
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g)
```
**Handler shape / save point** (`list`, L986-1015):
```go
func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
db, err := s.db()
if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError); return }
result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r))
var invalid *ListValidationError
if errors.As(err, &invalid) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details)
return
}
if err != nil { WriteError(...); return }
// <-- best-effort saveListState(ctx, db, principal.ID, cc id, input); slog.Error on failure
...
WriteData(w, http.StatusOK, result.Data, meta)
})
}
```
The new `listState` handler copies this exact `s.protect` + `s.db()` + `WriteData` skeleton. Controller id is built in `protect` as `vendor.plugin.controller` (L1019).
**Locale order** (`enabledContentLocales`, L966-984): after `codes, err := writer.EnabledLocales(ctx, db)`, call `writer.DefaultLocale(ctx, db)` and move it to index 0 (RESEARCH "Default locale first" sketch). Error path follows `slog.Error("cabana: enabled locales", "error", err)`.
---
### `modules/cabana/list_state.go` (NEW, service, CRUD upsert/read)
**Analog:** `modules/cabana/account.go`
Principal pattern (L214-223):
```go
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return nil
}
```
Query pattern (L227-230) — fresh session scoped to the request context, filter by principal id only:
```go
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Role").Where("id = ?", id).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) { ... }
```
Upsert: no non-test `clause.OnConflict` exists in modules yet; use `Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "controller"}}, DoUpdates: clause.AssignmentColumns([]string{"state", "updated_at"})})`. Sanitize on restore by reusing `normalizePage`, `normalizeSort`, filter allowlist, `switchArgument`/`parseDateRange`/`scopeArgument` (drop invalid parts, never 422). Cap JSON at 4 KiB.
---
### `modules/cabana/admin_openapi.go` (swag)
**Analog:** `AdminListSchema` (L340-354):
```go
// AdminListSchema documents the list schema route.
//
// @Summary Admin list schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} Envelope[ListSchema]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
func AdminListSchema() {}
```
Add `AdminListState` with `@Router /{vendor}/{plugin}/{controller}/list-state [get]`; extend `AdminList` `@Description` (L433) with one sentence. Regenerate via `scripts/check-admin-openapi.sh`.
---
### `modules/lagoon/backend_admin_migrations.go` (migration)
**Analog** (L120-132):
```go
{
// WinterCMS ...: rationale comment.
ID: "202610090001_backend_users_metadata",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
},
},
```
New entry `202610100001_backend_list_states` appended last, raw `CREATE TABLE IF NOT EXISTS` (DDL in RESEARCH Pattern 2) / `DROP TABLE IF EXISTS`. Test: extend `backend_admin_migrations_test.go` (keep the prohibited-tables loop L117-121 including `backend_user_preferences`).
---
### `admin/src/views/ListView.vue` (component)
**Current entry** (L113-153):
```ts
function replaceQuery(next: ListQuery): void {
void router.replace({ path: route.path, query: toListQuery(next) })
}
let generation = 0
async function loadList(): Promise<void> {
const current = ++generation
...
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
if (current !== generation) return
...
}
watch(() => listQueryKey(query.value), () => { selected.value = []; searchText.value = query.value.search ?? ''; void loadList() }, { immediate: true })
```
Change: drop `immediate: true`; add an entry routine that, on empty `parseListQuery(route.query)`, calls `api.GET('/{vendor}/{plugin}/{controller}/list-state', { params: { path } })` then `replaceQuery(state)` or `loadList()`. Track the last key set by `replaceQuery` for same-path re-entry. Page clamp after `loadList` on `meta.page > meta.last_page`. Reuse `parseListQuery`/`toListQuery`/`listQueryKey` from `admin/src/app/listQuery.ts`. FormView unchanged.
**Test analog** (`admin/tests/list/ListView.test.ts` L18-27):
```ts
const BASE = `${API}/acme/demo/widgets`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return { [`GET ${BASE}/schema/list`]: { body: listSchemaFixture }, [`GET ${BASE}`]: { body: listFixture }, ...overrides }
}
```
Add `[`GET ${BASE}/list-state`]` route; assert ordering with `requestsTo(calls, 'GET', BASE)` (L64, L234-258 pattern).
---
### `../sm-journal-plugin/classes/format_html.go` (utility, transform)
**Current** (L9-18):
```go
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
)
var (
markdownEngine = goldmark.New(
goldmark.WithExtensions(extension.Footnote, extension.Table),
goldmark.WithParserOptions(parser.WithAttribute()),
)
```
Add `"github.com/yuin/goldmark/renderer/html"` and `goldmark.WithRendererOptions(html.WithUnsafe())`. Note: `FormatHTML` has a local variable named `html` (L33) that will shadow the package; rename the local (e.g. `out`). Keep `rejectUnsafe` call unchanged. Do not touch `cabana.RenderMarkdown`.
## Shared Patterns
### Permission gate before SQL
**Source:** `modules/cabana/http.go` `protect` (L1016+). **Apply to:** `list`, new `listState`. Controller lookup → principal check → permission → fn.
### Error envelopes
**Source:** `WriteError`, `WriteErrorDetails`, `WriteData`, `msgServerError` as used in `list` (L990-1013). **Apply to:** list-state route. Save failures: `slog.Error("cabana: ...", "error", err)`, never fail the response.
### ML writer test double
**Source:** `modules/cabana/ml_smoke_test.go` L67-72 `recordingWriter{defaultLocale, enabled, ...}`; usage `ml_test.go:51` `&recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}`. **Apply to:** `TestEnabledLocalesDefaultFirst` with `defaultLocale: "pl"`.
### Commit hygiene (D-02)
Cabana/lagoon changes in their own commits, staged by explicit path; run `git status --porcelain -- modules admin` before each cabana task. Rebuild `modules/boardwalk/dist` and regenerate OpenAPI in the same change as SPA/swag edits; update cabana/lagoon READMEs and `docs/backend/lists-and-filters.md`.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `modules/cabana/query_relation_test.go` | test | DryRun SQL | No DryRun-based tests in cabana; use RESEARCH "DryRun SQL test (pure, no Docker)" sketch (`gorm.Config{DryRun: true, DisableAutomaticPing: true}` + `db.ToSQL`) |
## Metadata
**Analog search scope:** `modules/cabana`, `modules/lagoon`, `admin/src/views`, `admin/tests`, `../sm-journal-plugin/classes`
**Files scanned:** ~12
**Pattern extraction date:** 2026-10-09

View File

@@ -0,0 +1,680 @@
# Phase 16.1: Journal admin urgent fixes: relation search, list state, post form content and excerpt - Research
**Researched:** 2026-10-09
**Domain:** summercms.go `modules/cabana` (GORM list queries, admin JSON API, Vue admin SPA), `sm-journal-plugin` (post save path), UAT release
**Confidence:** HIGH for the three root causes (each reproduced by a probe or read from the UAT database); MEDIUM for the list-state design (Claude's discretion area)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01: List state lives in both the URL and the server session.** Sort, filters, search and page are reflected in the list URL query string (shareable, bookmarkable, back-button friendly). When the list is opened without list-state query parameters (for example from the admin navigation), the last state stored in the user's session for that list is restored. When the URL carries list-state parameters, the URL wins and also updates the session. User's words: "I may go back to Posts list through navigation and it's great if my filters were remembered + sharable URLs are nice to have."
- **D-02: The relation-search fix is a framework fix in summercms.go `modules/cabana`**, generic for belongsToMany (pivot) relations, not a Journal-specific workaround. Another session is concurrently working in `modules/`; plans must keep cabana changes in their own commits, stage files explicitly by path, and check for conflicting in-flight edits before touching shared cabana files.
- **D-03: Content and excerpt must show the stored values without loss.** Root cause is to be established by research (suspects: translatable-field overlay replacing host values with empty translations; the markdown widget not displaying imported HTML). Saving a post from the admin must not destroy imported HTML (see todo `journal-imported-html-admin-resave.md`); if a full fix needs the Blog Editor, 16.1 at least prevents data loss and 16.2 delivers the editor.
- **D-04: Ship to UAT.** Release a summercms tag carrying the cabana fixes (unless the concurrent session's release already includes them), bump `sm-grzybyfunkcjonalne-app`, redeploy to uat.grzybyfunkcjonalne.pl per its DEPLOY.md, and re-run `scripts/smoke.sh --url` on the server.
### Claude's Discretion
- Session storage mechanism and query-parameter naming for list state, following existing cabana conventions.
- Test strategy, within the lean-mode rule that unit tests are the last plan of the phase.
### Deferred Ideas (OUT OF SCOPE)
- Plugin-provided field types and the Blog Editor formwidget (Phase 16.2). (CONTEXT.md has no separate Deferred section; this is its "Out of scope" line.)
</user_constraints>
<phase_requirements>
## Phase Requirements
No requirement IDs are mapped. The CONTEXT decisions are the requirement set.
| ID | Description | Research Support |
|----|-------------|------------------|
| D-01 | List sort, filters, search and page in the URL and a per-user server store; restore when the list opens without params | "List state" pattern: the URL params already exist; add the `backend_list_states` table, a `GET .../list-state` restore route, a save inside the list handler, and the ListView entry logic |
| D-02 | Generic cabana fix for search over belongsToMany (and sort over to-many) relations | Root cause reproduced: `db.Joins("Categories")` on a many2many builds `"Categories"."post_id"`. Fix: correlated `EXISTS` subqueries built from GORM's parsed relationship |
| D-03 | Post form shows the stored content and excerpt, and a save never destroys imported HTML | Root cause 1: the first enabled locale is `en`, and no `en` translations exist. Fix: default locale first in `meta.enabledLocales`. Root cause 2: `FormatHTML` drops raw HTML. Fix: `html.WithUnsafe()` plus the existing gate. 2 UAT posts already damaged; repair them after the deploy |
| D-04 | Tag, bump, redeploy, smoke | Release mechanics: the host builds against the `../summercms.go` working tree through a replace. The bump is the journal submodule pointer. The new migration needs `bin/grzybyfunkcjonalne migrate` on the server |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
- Go 1.27, standard library first; add a dependency only when the research doc or a phase decision names it. **This phase adds none.** `github.com/yuin/goldmark/renderer/html` is a sub-package of goldmark v1.8.6, which sm-journal-plugin already requires.
- `go vet` and `go test ./...` must be green at every commit.
- Compiled plugins only; no runtime plugin loading.
- API parity: do not "improve" Journal API response shapes. The `content`/`content_html` contract stays unchanged (todo file).
- Unit tests are always the **last plan** of the phase. Before writing plans, present the plan count with one-line scopes (checkpoint).
- Commits:
- no co-author tags;
- one logical change per commit;
- planning docs and code in separate commits.
- D-02 adds three rules for this phase:
- cabana changes go in their own commits;
- stage files by explicit path;
- check for in-flight edits before touching shared cabana files.
- A change to a `modules/` package's exported API, config keys, CLI commands or dependencies updates that module's `README.md` in the same change. It also updates the affected `docs/` pages (`docs/backend/lists-and-filters.md`). `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` must pass. Every identifier named in docs must exist.
- Framework READMEs never name a consuming application. Use `blog`/`acme`, not Journal or grzybyfunkcjonalne, in cabana or lagoon docs.
- SPA changes: rebuild `modules/boardwalk/dist` (`npm --prefix admin run build`) and commit it (`scripts/check-admin-dist.sh` is the drift gate). Swag annotation changes: regenerate `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` (`scripts/check-admin-openapi.sh`, `--check` to verify).
- The PHP originals are not changed. Core plugin contracts (user, blog, pages, payment) stay unchanged.
## Summary
Each of the four UAT defects has one confirmed cause.
**(1) Relation search fails.** `applyListSearch` joins every searched relation with `db.Joins(field)` [VERIFIED: modules/cabana/query.go:351-357]. GORM's association `Joins` only fits to-one relations. On a many2many it emits `LEFT JOIN "golem15_journal_categories" "Categories" ON "golem15_journal_posts"."id" = "Categories"."post_id" AND "golem15_journal_posts"."category_id" = "Categories"."id"`, which is the UAT error, reproduced this session with a GORM v1.31.2 DryRun probe.
`applyListOrder` has the same bug for sorting by a to-many column (query.go:309). The code that ran on UAT is v0.1.15. Since v0.1.9, query.go only gained the to-many display join (`relatedSelectMany`). The bug is unchanged at HEAD.
The fix that matches Winter is a correlated `EXISTS` subquery per relation search term. Winter's Lists widget uses `orWhereHas` for search [VERIFIED: examples/golem15-wintercms-starter/modules/backend/widgets/Lists.php:529-531]. Build the subquery from GORM's own parsed relationship (`schema.Relationship.References` / `JoinTable`), not from guessed names.
**(2) List state is lost.** The list state already lives in the URL query (`search`, `sort`, `dir`, `page`, `per_page`, `filter[<name>]`) [VERIFIED: admin/src/app/listQuery.ts; modules/cabana/query.go:62-84]. The form's back link, close and save redirects, and the navigation all go to the bare list path (`go(listPath)`, FormView.vue:60/403/439), so the state is dropped.
There is no server session: admin auth is a stateless JWT cookie whose jti rotates on refresh. The table that would play this role in Winter, `backend_user_preferences`, was explicitly not ported in Phase 9; a test asserts it is absent. Recommendation: a small framework table `backend_list_states` keyed (user_id, controller), a `GET .../list-state` restore route, and a best-effort save inside the existing list handler after a successful query. ListView restores before its first load whenever it is entered with no list params.
**(3)+(4) The form shows empty content and excerpt.** The data is fine. On UAT, `golem15_translate_locales` has `en|f|t|1` and `pl|t|t|2`: en is enabled, sorted first, and not the default. There are **0** rows in `golem15_translate_attributes`. `EnabledLocales` returns codes `ORDER BY sort_order` → `["en","pl"]`. Every ML control opens on `locales[0]`, which is `en`, so the form shows the empty en values of title, slug, content and excerpt. The record heading hides this for the title, because it falls back to the first non-empty locale.
Fix in cabana: send the default locale first in `meta.enabledLocales`. This one server function fixes the form, the preview screen and relation child modals.
Separately, **saving destroys imported HTML**. `preparePostWrite` runs `classes.FormatHTML(post.Content)`, which is goldmark *without* `html.WithUnsafe()`. Probe this session: it turns all 100 imported pl bodies into `<!-- raw HTML omitted -->`. With `html.WithUnsafe()` it returns all 100 unchanged (trim-equal), and the existing 3-pattern gate still rejects script, iframe, event handlers and javascript/data URLs. This is PHP parity: Winter's `Markdown::parse` passes raw HTML through.
UAT already has **2 posts whose `content_html` was wiped at import**. They have no pl translation and raw HTML in the base content:
- id 53 `czy-amanita-muscaria-jest-legalna` (draft);
- id 64 `ekstrakt-z-turkey-tail` (**published**, missing its author footer).
Re-render both after the fix ships.
**Primary recommendation:** five changes, then release v0.1.16 and redeploy:
1. cabana: relation search and sort through `EXISTS` and correlated subqueries built from `gorm.Statement.Parse` relationships.
2. cabana: default locale first in `enabledLocales`.
3. cabana and SPA: list-state table, restore route and ListView entry logic.
4. sm-journal-plugin: `FormatHTML` with `html.WithUnsafe()` behind the existing gate.
5. After the deploy: re-save posts 53 and 64.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Relation search/sort SQL | API / Backend (cabana `query.go`) | Database (Postgres EXISTS) | Allowlisted SQL is built only server-side; the SPA sends only names |
| List state persistence | API / Backend (cabana list handler + new route) | Database (`backend_list_states`) + Browser (ListView entry logic, URL) | D-01 asks for server storage; the URL stays the shareable source |
| Restore-on-entry decision | Browser (ListView) | API (restore route sanitizes) | Only the SPA knows whether the list was entered from navigation or changed by the user |
| ML default locale ordering | API / Backend (cabana `enabledContentLocales`) | Browser (MLField uses `[0]`) | A single server ordering fixes every SPA consumer of `meta.enabledLocales` |
| content → content_html rendering | API / Backend (sm-journal-plugin `classes.FormatHTML`) | — | Shared by admin save, import and Journal API writes |
| Release / deploy | Host repo + server ops | — | The host compiles the framework working tree through a replace |
## Standard Stack
### Core (all already in go.mod / package.json; versions read this session)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| gorm.io/gorm | v1.31.2 | `gorm.Statement.Parse` → `schema.Relationship` (Type, References, JoinTable, FieldSchema) for the EXISTS builder | Already the ORM; its parsed relationship is the only reliable source of pivot/fk names [VERIFIED: probe output below] |
| gorm.io/gorm/schema | v1.31.2 | `schema.Many2Many` ("many_to_many"), `schema.HasMany` ("has_many"), `schema.BelongsTo`, `schema.HasOne` | Relation type switch [VERIFIED: probe printed `type: many_to_many`, `has_many`] |
| github.com/yuin/goldmark | v1.8.6 (sm-journal-plugin go.mod) | `goldmark.WithRendererOptions(html.WithUnsafe())` from `github.com/yuin/goldmark/renderer/html` | Same module, new import only [VERIFIED: probe compiled against the cached v1.8.6] |
| gormigrate/v2 | v2.1.7 | New migration appended to `lagoon.BackendAdminMigrations` | Existing framework migration list (`summercms.cabana` history) [VERIFIED: modules/lagoon/backend_admin_migrations.go:21, migrations.go:75] |
| vue-router | 5.1.0 | `router.replace` for restore, as ListView already does | Existing [VERIFIED: admin/package.json] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| EXISTS per relation | `Joins` + `DISTINCT` / `GROUP BY` | Duplicate rows inflate `Count`, unqualified outer predicates (`id IN (...)` in Journal `FilterCategories`, `user_id = ?` in `restrictToOwnPosts`) become ambiguous; not Winter parity |
| `backend_list_states` table | `backend_users.metadata` JSON key | metadata is Winter's TEXT jsonable copied at cutover (202610090001); read-modify-write races between tabs; pollutes a copied column |
| `backend_list_states` table | Winter `backend_user_preferences` | Phase 9 D-01 left it unported and `backend_admin_migrations_test.go:116` asserts it is absent; reviving it reverses a decision and its non-unique `user_item_index` cannot back an upsert |
| Server-side table | Browser `sessionStorage` | Contradicts D-01 ("server session") |
| Default-locale-first in cabana | Reorder in sm-translate-plugin `AdminWriter.EnabledLocales` | Plugin-specific; any other TranslationWriter would keep the bug |
| Default-locale-first | New `meta.defaultLocale` + SPA changes | More SPA surface and OpenAPI change; same user-visible result. Dropdown order changes from sort_order to default-first (cosmetic) |
| `html.WithUnsafe()` | Detect HTML (`^\s*<`) and store as-is through `CheckHTML` | Heuristic, and post 64 is markdown with trailing raw HTML (would still be wiped); not PHP parity |
**Installation:** none, because no new modules are added.
## Package Legitimacy Audit
No external packages are installed. `github.com/yuin/goldmark/renderer/html` is a package inside the already-required `github.com/yuin/goldmark v1.8.6`. No npm packages are added.
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none
## Root-cause evidence
### Defect 1: relation search (D-02)
The search code [VERIFIED: modules/cabana/query.go:345-357, read this session]:
```go
field, ok := relationFieldName(model, col.Relation)
physical := listRelationColumn(controller, col.Relation, col.Select)
...
// GORM aliases the joined association with the Go field name.
table = field
column = physical
if _, done := joined[field]; !done {
db = db.Joins(field)
```
The sort code does the same, `db = db.Joins(field)` [VERIFIED: query.go:309].
The Journal model declares the relation as `Categories []Category \`gorm:"many2many:golem15_journal_posts_categories;" json:"-"\`` [VERIFIED: sm-journal-plugin/models/post.go:34]. The posts list column is:
```yaml
categories:
label: golem15.journal::lang.post.categories
relation: categories
select: name
searchable: true
sortable: false
```
[VERIFIED: sm-journal-plugin/models/post/columns.yaml]
GORM v1.31.2 DryRun probe (scratch module, same model shape), output pasted:
```
JOINS: SELECT count(*) FROM "golem15_journal_posts" LEFT JOIN "golem15_journal_categories" "Categories" ON "golem15_journal_posts"."id" = "Categories"."post_id" AND "golem15_journal_posts"."category_id" = "Categories"."id" WHERE LOWER(CAST("Categories"."name" AS TEXT)) LIKE '%x%'
type: many_to_many jointable: golem15_journal_posts_categories
ref: fk=golem15_journal_posts_categories.post_id pk=golem15_journal_posts.id ownPrimaryKey=true pvalue=""
ref: fk=golem15_journal_posts_categories.category_id pk=golem15_journal_categories.id ownPrimaryKey=false pvalue=""
hasmany type: has_many field schema: comments
ref: fk=comments.post_id pk=golem15_journal_posts.id own=true
```
The error happens at `query.go:129`, the `Count`, which is the first statement executed.
The log names `summercms@v0.1.9`, but that is only the version sm-journal-plugin's go.mod *requires* (`git.golem15.com/golem15/summercms v0.1.9`). The code that ran is the `../summercms.go` working tree at 296b9cd, which descends from v0.1.15 with docs-only changes since. At HEAD, query.go differs from v0.1.9 only by `relatedSelectMany` (display), so **the bug is still present at HEAD**.
There are no uncommitted `modules/` edits right now. The checkout is 13 docs commits ahead of origin/master.
### Defect 2: list state (D-01)
- **URL params already exist.** `listQueryFromRequest` reads `search`, `sort`, `dir`, `page`, `per_page` and `filter[<name>]` (`listSearchTerm = "search"` [VERIFIED: modules/cabana/list_schema.go:20]; query.go:62-84). The SPA mirrors them in `parseListQuery`/`toListQuery` [VERIFIED: admin/src/app/listQuery.ts], and ListView changes them with `router.replace` (ListView.vue:114-116).
- **Where the state is dropped.**
- FormView: `const listPath = controllerPath(controllerId) ?? '/'` (FormView.vue:60), `await go(listPath)` after save (403), `await go(backPath.value)` for back (439).
- The navigation and breadcrumb links use the bare controller path.
- Browser Back works today. Every in-app return loses the state.
- **ListView start-up.** It loads through `watch(() => listQueryKey(query.value), ..., { immediate: true })` (ListView.vue:146-153). The first request is fired at setup, before any restore could run.
- **No server session.**
- Login mints a JWT into an HttpOnly cookie (`auth.go` `writeSessionCookie`).
- Refresh rotates the token and jti (`bouncer.RefreshAudienceFor`).
- Logout only blacklists the jti.
- Phase 9 left out Winter's preference table ("`backend_user_groups` and Winter's user-preference and access-log tables: not needed by any Golem15 plugin yet" [VERIFIED: .planning/phases/09-.../09-CONTEXT.md:126]), and `backend_admin_migrations_test.go:116` fails if `backend_user_preferences` exists.
- **What Winter does.** It keeps sort (`putSession('sort', ...)`), search term (`putSession('term', ...)`), filter scopes and `lastVisitedPage` in the session, and per_page in user preferences [VERIFIED: Lists.php:233,289,1773; Search.php:158; Filter.php:959].
### Defects 3 and 4: empty content and excerpt (D-03)
Read-only UAT probe (ssh `grzybyfunkcjonalne-uat`, psql over the peer socket, SELECT only), output pasted:
```
code|is_default|is_enabled|sort_order
en|f|t|1
pl|t|t|2
locale|model_type|count
(0 rows)
posts|content|html|excerpt|title|content_starts_html
104|104|104|100|104|101
```
- **Where the order comes from.** The translate seed inserts `SELECT 'en', 'English', TRUE, TRUE, 1` and `SELECT 'pl', 'Polski', FALSE, TRUE, 2` [VERIFIED: sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go:13-18]. migrate.sh only runs `UPDATE golem15_translate_locales SET is_default = (code = 'pl')` (scripts/migrate.sh:173), so en stays enabled and first.
- **The writer keeps that order.** `AdminWriter.EnabledLocales` runs `Where("is_enabled = ?", true).Order("sort_order asc")` [VERIFIED: sm-translate-plugin/classes/admin_writer.go:31-35], and cabana copies the result into the form schema (`view.Meta.EnabledLocales = append([]string(nil), codes...)`, http.go:727-729).
- **The server sends the right data.** `hydrateMLRecord` puts the host column under the default locale (`if code == defaultLocale { out[code] = host`, field_ml.go:208-211). An en key with no stored translation is `out[code] = ""` (field_ml.go:217-219).
- **The SPA opens on the wrong locale.**
- `const activeLocale = ref(locales.value[0] ?? 'en')` (MLField.vue:30).
- `function firstMLLocale(): string { return schemaEnabledLocales(schema.value?.meta)[0] ?? 'en' }` (FormView.vue:227-229).
- So every ML control (title, slug, content and excerpt) opens on `en`, which is empty. The user reported only content and excerpt, but title and slug are empty in the inputs as well.
**Data loss on save.**
- The save path: `html, err := classes.FormatHTML(post.Content)` … `post.ContentHTML = html` (sm-journal-plugin/controllers/posts.go:213-219). The engine is `goldmark.New(goldmark.WithExtensions(extension.Footnote, extension.Table), goldmark.WithParserOptions(parser.WithAttribute()))`, with no unsafe renderer (classes/format_html.go:15-18).
- PHP parity: `formatHtml` stores `trim($input)` when `use_rich_editor` is on, otherwise `Markdown::enableFootnotes()->enableAttributes()->enableTables()->parse(trim($input))`, then `Html::clean` [VERIFIED: grzybyfunkcjonalne.pl/plugins/golem15/journal/models/Post.php:185-208]. Winter's Markdown passes raw HTML through [ASSUMED: Parsedown default, not re-read this session; consistent with the export, where 83/100 pl `content_html` equal `content` byte for byte and the other 17 differ only by `&quot;` → `"`].
- Probe over the 100 pl bodies of the journal export (goldmark v1.8.6, the same options), output pasted:
```
total=100 safe_omitted=100 unsafe_exact=0 unsafe_trim_equal=100
sample safe="<!-- raw HTML omitted -->\n"
sample unsafe="<p>a</p>\n<p>b</p>"
md unsafe="<h1>T</h1>\n<p>text <b>x</b></p>\n<script>alert(1)</script>"
```
The last line shows why the existing `rejectUnsafe` gate must stay after the unsafe render. None of the 105 export bodies match any of the three gate patterns, so no imported post will be refused on save.
**Already damaged on UAT** (read-only probe):
```
wiped|edited_after_import|admins
2|0|1
id 53 czy-amanita-muscaria-jest-legalna published=f content_html = <!-- raw HTML omitted -->
id 64 ekstrakt-z-turkey-tail published=t content_html ends with 3x <!-- raw HTML omitted --> (author <footer> + <style>)
```
Both come from export files with `"translations": []`: base content containing raw HTML went through the safe `FormatHTML` at import (console/import_posts.go:185). Probe with the unsafe renderer:
- `czy-amanita-muscaria-jest-legalna`: gate passes, equal to the PHP `content_html` after whitespace normalization.
- `ekstrakt-z-turkey-tail`: gate passes, includes `<footer` and `<style>`, 5643 vs 5655 bytes against PHP.
## Architecture Patterns
### System Architecture Diagram
```
Admin SPA (ListView) cabana API (Go) Postgres
──────────────────── ─────────────── ────────
enter list route ──► query empty? ──yes──► GET .../list-state ──► protect() ─► load row(user,ctl)
│ │ sanitize vs compiled schema ─► backend_list_states
│ │◄──────── {search,sort,dir,page,per_page,filter} ◄──┘
│ ├─ non-empty ─► router.replace(query) ─┐
│ no / empty stored │
▼ ▼
user changes search/sort/filter/page ─► router.replace ─► GET /{v}/{p}/{c}?search&sort&dir&page&per_page&filter[x]
│
├─ ExecuteList: ListExtendQuery → applyListSearch
│ own column: LOWER(CAST(t.col AS TEXT)) LIKE ?
│ relation col: EXISTS (SELECT 1 FROM pivot p JOIN rel r ...
│ WHERE p.owner_fk = t.pk AND LOWER(...) LIKE ?)
│ → applyListFilters → Count → applyListOrder → Preload → Find
├─ on success: upsert state(user, controller) if changed (best effort)
└─► rows + meta
FormView (back / save / close) ─► go(listPath) (bare path) ─► ListView entry ─► restore path above
```
### Recommended file touch map
```
summercms.go/
├── modules/cabana/query.go # EXISTS search + to-many sort subquery (D-02)
├── modules/cabana/list_state.go # NEW: store/load/sanitize list state (D-01)
├── modules/cabana/http.go # list(): save after success; new GET .../list-state route; enabledContentLocales default-first (D-03)
├── modules/cabana/admin_openapi.go # swag doc for the new route
├── modules/lagoon/backend_admin_migrations.go # NEW migration backend_list_states
├── modules/cabana/README.md, modules/lagoon/README.md, docs/backend/lists-and-filters.md
├── admin/src/views/ListView.vue # entry restore, non-immediate watch, page clamp
├── admin/openapi/admin.json, admin/src/api/schema.d.ts # regenerated
└── modules/boardwalk/dist/ # rebuilt SPA
sm-journal-plugin/
├── classes/format_html.go # html.WithUnsafe() + existing gate
└── README.md (admin re-save note)
sm-grzybyfunkcjonalne-app/
├── plugins/golem15/journal (submodule pointer), README.md + DEPLOY.md (drop the "do not re-save" warning)
```
### Pattern 1: relation search through EXISTS (generic, Winter `orWhereHas` parity)
**What:** for each searchable column with `relation:`, emit one `EXISTS (...)` term inside the existing OR group instead of `db.Joins(field)`. Read the relationship from GORM's schema parse. Validate the selected column against the related schema.
**When to use:** every relation type in **search**. For **sort**, keep `Joins` for `belongs_to`/`has_one`, which works and is tested (`artist` column in query_test.go). Use a correlated scalar subquery for `has_many`/`many_to_many`.
GORM wraps an OR expression in parentheses when it is combined with other `Where`s. Verified by DryRun:
```
SELECT count(*) FROM "golem15_journal_posts" WHERE user_id = 3 AND (LOWER(CAST("golem15_journal_posts"."title" AS TEXT)) LIKE '%x%' ESCAPE '\' OR EXISTS (SELECT 1 FROM "golem15_journal_posts_categories" AS "p0" JOIN "golem15_journal_categories" AS "r0" ON "r0"."id" = "p0"."category_id" WHERE "p0"."post_id" = "golem15_journal_posts"."id" AND LOWER(CAST("r0"."name" AS TEXT)) LIKE '%x%' ESCAPE '\')) AND published = true
```
Skeleton, using cabana's existing `qualifiedColumn`, `quotedIdent`, `identifier`, `tableName` and `listInvalid` helpers. The relationship API names come from the probe; the shape is a sketch:
```go
// relationExists returns "EXISTS (...)" matching LOWER(CAST(rel.column AS TEXT)) LIKE ?
// for the relation behind Go field `field`, correlated to the main table.
func relationExists(db *gorm.DB, model any, field, column string, n int) (string, error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(model); err != nil {
return "", err
}
rel, ok := stmt.Schema.Relationships.Relations[field]
if !ok || rel.FieldSchema == nil || rel.FieldSchema.LookUpField(column) == nil {
return "", listInvalid("search", "is not searchable")
}
main := tableName(model) // the unaliased outer FROM table
r := fmt.Sprintf("cabana_r%d", n) // alias: safe for self-referencing relations
match := fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\'", qualifiedColumn(db, r, column))
var on, where []string
switch rel.Type {
case schema.Many2Many:
p := fmt.Sprintf("cabana_p%d", n)
for _, ref := range rel.References {
if ref.OwnPrimaryKey { // pivot.owner_fk = main.pk
where = append(where, qualifiedColumn(db, p, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName))
} else { // pivot.related_fk = related.pk
on = append(on, qualifiedColumn(db, p, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, r, ref.PrimaryKey.DBName))
}
}
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s AS %s JOIN %s AS %s ON %s WHERE %s AND %s)",
quotedIdent(db, rel.JoinTable.Table), quotedIdent(db, p),
quotedIdent(db, rel.FieldSchema.Table), quotedIdent(db, r),
strings.Join(on, " AND "), strings.Join(where, " AND "), match), nil
case schema.HasMany, schema.HasOne: // related.fk = main.pk
for _, ref := range rel.References {
where = append(where, qualifiedColumn(db, r, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName))
}
case schema.BelongsTo: // related.pk = main.fk
for _, ref := range rel.References {
where = append(where, qualifiedColumn(db, r, ref.PrimaryKey.DBName)+" = "+qualifiedColumn(db, main, ref.ForeignKey.DBName))
}
default:
return "", listInvalid("search", "is not searchable")
}
return fmt.Sprintf("EXISTS (SELECT 1 FROM %s AS %s WHERE %s AND %s)",
quotedIdent(db, rel.FieldSchema.Table), quotedIdent(db, r), strings.Join(where, " AND "), match), nil
}
```
Notes for the planner:
- **Polymorphic references.** A reference with `ref.PrimaryValue != ""` needs an extra bound `= ?` condition. Refuse polymorphic relations with `listInvalid` in this phase unless a test covers them [ASSUMED: GORM polymorphic reference semantics, not probed].
- **Soft deletes.** If the related schema has a `gorm.DeletedAt` field, add `AND r.deleted_at IS NULL`. Winter's `whereHas` applies the related model's SoftDelete scope [ASSUMED].
- **Parse cache.** `stmt.Parse` uses `db.cacheStore`, so the parse is cached.
- **Sort for to-many.** Use `ORDER BY (SELECT string_agg(CAST(r.col AS TEXT), ', ') FROM ... correlated ...) ASC|DESC` with `clause.OrderBy{Expression: clause.Expr{SQL: ...}}`. This mirrors Winter, which sorts on the `group_concat(...)` sub-select [VERIFIED: Lists.php:568-570] and on the comma-joined value `relatedSelectMany` displays. The `sortable` default is true for relation columns (docs/backend/lists-and-filters.md:62), so any other plugin's to-many column crashes on sort today.
### Pattern 2: list state (D-01), recommended design
**Server side (cabana):**
1. **Migration.** Append to `lagoon.BackendAdminMigrations`, ID `"202610100001_backend_list_states"` (follow the `YYYYMMDDNNNN_` pattern of `202610090001_backend_users_metadata`):
```sql
CREATE TABLE IF NOT EXISTS backend_list_states (
user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
controller TEXT NOT NULL,
state TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, controller)
)
```
- `backend_users.id` is `SERIAL` [VERIFIED: backend_admin_migrations.go:36-37].
- Use `state TEXT`, matching the backend tables' TEXT-for-JSON convention (`metadata TEXT`, `permissions TEXT`).
- The table name avoids reviving Winter's `backend_user_preferences`, which the Phase 9 test prohibits.
2. **Store shape.** The same JSON shape as the SPA's `ListQuery`: `{"search":..., "sort":..., "dir":..., "page":..., "per_page":..., "filter":{...}}`. Write it with `INSERT ... ON CONFLICT (user_id, controller) DO UPDATE` (gorm `clause.OnConflict`). Skip the write when the stored state is equal. Cap the stored JSON size (for example 4 KiB) and drop oversize state.
3. **Save (URL wins).** In `service.list`, after `ExecuteList` returns no error, save the request's validated `ListInput` for `(principal.ID, controllerID(cc))`. A save failure is logged with `slog` and does not fail the list. A 422 or 500 list request never saves.
4. **Restore.**
- Route: `g.Get("/{vendor}/{plugin}/{controller}/list-state", s.listState)` + `constrainController(g)`, through `protect()`, so permission is checked before SQL.
- Response: `{data: <ListQuery>}`, `{}` when there is none.
- Sanitize the stored state against the *current* compiled schema before returning it, reusing `normalizePage`, `normalizeSort`, the filter allowlist and the `switchArgument`/`parseDateRange`/`scopeArgument` checks. Drop each invalid part; never 422. A stored filter that a later deploy removed must not break the list forever.
- Order: register the literal route before the `/{id}` show route. ServeMux would pick the literal anyway, but put it next to `schema/list` for readability.
5. **OpenAPI.**
- Add a swag block `AdminListState` for the new route.
- Add one sentence to `AdminList`'s `@Description` ("a successful list request stores its query as the administrator's list state").
- Regenerate with `scripts/check-admin-openapi.sh`.
6. **Logout.** Keep the state across logins by default (see Assumption A1). If the user wants Winter's session-only semantics, delete the user's rows in `service.logout`.
**SPA side (ListView.vue):**
1. **Entry.** Replace the `immediate: true` watch with an explicit entry routine. If `parseListQuery(route.query)` is empty, `await api.GET('/{vendor}/{plugin}/{controller}/list-state')`. If the result is non-empty, `router.replace({ path: route.path, query: toListQuery(state) })` and let the (non-immediate) watch load. Otherwise call `loadList()`. **Never fire an empty list request before the restore decision**: under "save on success", that request would overwrite the stored state with empty.
2. **Same-path re-entry.** ListView is keyed by route path, so clicking the nav item while already on the list with filters changes only the query to `{}`. Remember the key of the last query this view set itself in `replaceQuery`. When the watch sees an empty query that the view did not set, run the entry restore instead of loading empty.
3. **User clears everything.** `replaceQuery` produces `{}`. The key matches the view's own → `loadList()` with no params → the server saves the empty state. Clearing works with no marker param.
4. **Page out of range** after restore (records deleted): if `meta.last_page >= 1 && meta.page > meta.last_page`, `replaceQuery({ ...query, page: meta.last_page })`.
5. **FormView needs no change.** Its bare `listPath` navigation now restores.
### Pattern 3: ML default locale first (D-03)
In `service.enabledContentLocales` (modules/cabana/http.go:966-983), call `writer.DefaultLocale(ctx, db)` and move that code to index 0, keeping the remaining codes in sort_order. If `DefaultLocale` errors, keep the order and log. This changes three things:
- MLField's initial `activeLocale`;
- FormView's `firstMLLocale()`;
- `mergeMLValue`'s fallback for a bare host string, and `recordDisplayName`'s preferred locale. These read `enabledLocales[0]` (formState.ts:205-218, 225-247), so they become the default locale too.
`liftMLValues`/`hydrateMLRecord` call `writer.EnabledLocales` directly and do not depend on order.
### Pattern 4: lossless content_html (D-03, sm-journal-plugin)
```go
import "github.com/yuin/goldmark/renderer/html"
markdownEngine = goldmark.New(
goldmark.WithExtensions(extension.Footnote, extension.Table),
goldmark.WithParserOptions(parser.WithAttribute()),
goldmark.WithRendererOptions(html.WithUnsafe()), // Winter Markdown::parse passes raw HTML; rejectUnsafe still gates it
)
```
- `FormatHTML` keeps `rejectUnsafe(html)` after the render, unchanged. `CheckHTML` is unchanged.
- The one change covers all three write paths: admin save (`preparePostWrite`), import (`console/import_posts.go:185`) and the Journal API writes (`controllers/api/posts_helpers.go:511,567,768`).
- The response shapes are unchanged (API parity).
- Do **not** change `cabana.RenderMarkdown` (the admin preview, `field_markdown.go:21-31`). Its output goes into the SPA via `v-html`, and its "no unsafe HTML" rule is a framework security choice. The HTML preview gap belongs to 16.2's Blog Editor.
### Anti-Patterns to Avoid
- **`Joins(field)` for to-many relations.** It builds a broken ON clause for many2many and duplicates rows for has_many.
- **Guessing pivot or foreign-key names** (`<model>_id`). Read them from `schema.Relationship`. The README says "Framework code never guesses table, pivot or foreign-key names."
- **Saving list state on the SPA's first empty request.** It wipes the stored state (see Pattern 2, SPA step 1).
- **Restoring stored state without re-validation.** A stale filter turns into a permanent 422.
- **Fixing the locale bug by disabling `en` in the database only.** That fixes one site, not the framework. It may still be wanted for grzybyfunkcjonalne (Open Question 2).
- **An HTML detection heuristic instead of the unsafe render.** Post 64 is markdown with trailing raw HTML.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Pivot, foreign-key and table names of a relation | String conventions | `gorm.Statement{DB: db}.Parse(model)` → `Schema.Relationships.Relations[field]` | Honors custom `joinForeignKey`/`foreignKey` tags and the naming strategy |
| Identifier quoting | `"` concatenation | existing `quotedIdent` / `qualifiedColumn` (query.go:486-497) | Already used by the search SQL |
| Upsert | SELECT then INSERT/UPDATE | `clause.OnConflict{Columns: ..., DoUpdates: ...}` | Race-free across two tabs |
| HTML pass-through | Regex HTML detector | `html.WithUnsafe()` + existing `rejectUnsafe` | PHP parity; verified on 100/100 bodies |
| List param parsing in the SPA | New parser | existing `parseListQuery` / `toListQuery` / `listQueryKey` | Already strict about malformed input |
## Runtime State Inventory (deploy and data, not a rename)
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | UAT posts 53 and 64: `content_html` holds `<!-- raw HTML omitted -->` from the import. 0 translate attribute rows. `en` locale enabled with sort_order 1 | After deploy: re-render posts 53 and 64 by re-saving them in `/gfadmin` (this is also D-03's acceptance test), then confirm `content_html NOT LIKE '%raw HTML omitted%'` for all posts. Deciding whether `en` should be disabled is Open Question 2 |
| Live service config | None. nginx vhost and supervisor programs unchanged | None |
| OS-registered state | supervisor programs `grzybyfunkcjonalne-go`/`-nuxt` (names unchanged) | `sudo supervisorctl restart grzybyfunkcjonalne-go` (Nuxt restart optional) |
| Secrets/env vars | None changed | None |
| Build artifacts | `bin/grzybyfunkcjonalne` (sha256 94116f4a…68ea from 296b9cd); `modules/boardwalk/dist` embedded SPA | Rebuild with `scripts/build.sh` from a framework checkout at the release tag; rebuild and commit dist before the tag |
| Schema | New migration `backend_list_states` | On the server: `bin/grzybyfunkcjonalne migrate` (DEPLOY.md Release step 3) **before** the restart |
## Release / deploy mechanics (D-04)
1. **Framework tag.**
- Tags are annotated (`git for-each-ref` shows `tag` objects), cut on master after the work, and pushed to `origin` (`git@git.golem15.com:golem15/summercms.git`).
- The latest tag is `v0.1.15` (2026-10-09). Next: `v0.1.16`, unless the concurrent session tags first; then take the next free number and check `git tag --contains <fix commit>`.
- In Phase 14.3 tagging, the site bump and the deploy were user actions, so make the tag and push a `checkpoint:human-action` or explicit approval.
- Before tagging: `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check`, `go vet ./...`, `go test ./...` (needs Docker, see Environment), `go test ./cmd/summer -run TestDocsTree`.
2. **sm-journal-plugin.**
- Commit the FormatHTML change on `main`.
- Set `require git.golem15.com/golem15/summercms v0.1.16` in go.mod. It currently reads `v0.1.9`; there is a `replace ... => ../summercms.go`, so this is a nominal pin and `go mod tidy` works offline. Precedent: sm-translate-plugin's "chore(deps): require summercms v0.1.10".
- Push `origin main`.
3. **Host (`sm-grzybyfunkcjonalne-app`).**
- `go.mod` has `replace git.golem15.com/golem15/summercms => ../summercms.go` and `require ... summercms v0.0.0`, so **there is no framework version to bump**. `scripts/build.sh` compiles the `../summercms.go` working tree, so check out the tag or a clean master there before building.
- The bump is the submodule pointer: `git -C plugins/golem15/journal fetch && git -C plugins/golem15/journal checkout <new main>`, then commit `plugins/golem15/journal` (current pointer 3d533d4).
- Also update README.md:114 and DEPLOY.md "Do not re-save imported posts in /gfadmin". Push origin master.
4. **Deploy** (DEPLOY.md "Release"):
- `scripts/build.sh` and `scripts/check-deploy.sh`.
- rsync the binary to `bin/grzybyfunkcjonalne.new`, then `cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev && mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne`.
- rsync `config/` and `scripts/`. Nuxt `.output` is unchanged unless rebuilt.
- On the server in `/srv/grzybyfunkcjonalne/go`: `bin/grzybyfunkcjonalne migrate`, then `sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`.
- Compare the uploaded binary's sha256 with the local build.
5. **Smoke on the server** (UAT basic auth; loopback bypass):
```sh
C=$(mktemp -d); printf 'resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n' > "$C/.curlrc"
cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl
rm -rf "$C"
```
Expect `smoke: ok (98 urls)`. smoke.sh does not cover the admin.
6. **Admin verification** (human, UAT credentials + admin login): see Validation Architecture → Manual. The UAT gate answers 401 off-server, so automated admin checks from the executor machine are impossible without credentials.
7. **Rollback:** move `bin/grzybyfunkcjonalne.prev` back and restart. The new table is additive; the migration Rollback drops it.
## Common Pitfalls
### Pitfall 1: Ambiguous columns once anything joins
**What goes wrong:** Journal's `FilterCategories` uses `db.Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids)` and `restrictToOwnPosts` uses `db.Where("user_id = ?", principal.ID)`. Both are unqualified [VERIFIED: models/post.go:107; controllers/posts.go:95]. Any `JOIN` of a table with `id`/`user_id` makes them ambiguous.
**How to avoid:** EXISTS subqueries only, with no joins in the outer query for search. Test "search + category filter + non-superuser" together.
### Pitfall 2: The first list request clobbers the stored state
**What goes wrong:** ListView's `immediate: true` watch fires an empty request at setup. With save-on-success, that request overwrites the stored state before the restore runs.
**How to avoid:** the entry routine decides on restore before any list request. Add a vitest for this: on mount with an empty query, the `list-state` request comes before any list request, and no empty list request is made when the stored state is non-empty.
### Pitfall 3: Stale stored state breaks the list permanently
**What goes wrong:** A filter is renamed in `config_filter.yaml`, the stored state names the old filter, the restore sets it in the URL, and the list answers 422 on every visit.
**How to avoid:** the restore route sanitizes against the current compiled schema and drops invalid parts.
### Pitfall 4: The default locale is not first
**What goes wrong:** Any site where the seeded `en` stays enabled with a lower `sort_order` than the default shows empty ML fields.
**How to avoid:** order in cabana (Pattern 3), and test with a writer that returns `["en","pl"]` and default `pl`. The SPA fixtures use `['en', 'pl']` (MLFields.test.ts:18, FormView.test.ts:560); add a case where the default is not first.
### Pitfall 5: The unsafe render widens what reaches `content_html`
**What goes wrong:** `<style>`, `<footer>` and arbitrary tags now pass through. The gate blocks only `<script|iframe|object|embed`, `on*=` handlers and `javascript:|vbscript:|data:`. PHP additionally ran `Html::clean` for admins without `backend.allow_unsafe_markdown`.
**How to avoid:** keep the gate. Post authors are authenticated admins with `golem15.journal.access_posts`. Note it as an accepted risk in the security review. Re-run `classes` tests: every `TestFormatHTMLRejectsUnsafeHTML` case still errors through the gate.
**Warning sign:** a body containing the Polish word "data:" (for example "Data: 12.03") would be refused with 422. None of the 105 export bodies match today.
### Pitfall 6: The version in the error log misleads
**What goes wrong:** The Go log reports `summercms@v0.1.9`, which is sm-journal-plugin's go.mod require. The binary was built from the working tree at 296b9cd (v0.1.15 code). Do not diff against v0.1.9 to decide what UAT runs.
### Pitfall 7: Docker-only tests cannot run on this machine
**What goes wrong:** `go test ./modules/cabana/ -run TestListQueryContract` fails with `permission denied while trying to connect to the docker API` (run this session). sm-journal-plugin's TestMain is fail-closed too.
**How to avoid:** put the new SQL-shape tests in pure DryRun tests (no DB) that run anywhere. Keep the Postgres tests as integration evidence on a Docker-enabled machine (user / CI). Phase 16-05 already records the same constraint.
### Pitfall 8: Concurrent `modules/` session
**What goes wrong:** The other session edits cabana or lagoon files in parallel and creates a merge conflict or a mixed commit.
**How to avoid:** before each cabana or lagoon task, run `git status --porcelain -- modules admin` and `git log origin/master..` / `git fetch`. Stage by explicit path. Keep one logical change per commit (D-02).
### Pitfall 9: Saving the form writes empty `en` translations
**What goes wrong:** The form sends every enabled locale (`{pl: "...", en: ""}`), so a save creates `en` attribute rows with empty strings, including an empty `en` slug index. `Translated` falls back to the host for empty values, so the public output is unchanged.
**How to avoid:** this is existing behavior. Note it, and do not "fix" it in this phase.
## Code Examples
### Default locale first (cabana, sketch)
```go
// enabledContentLocales: default locale first, others in the writer's order.
codes, err := writer.EnabledLocales(ctx, db)
...
if def, err := writer.DefaultLocale(ctx, db); err == nil && def != "" {
if i := slices.Index(codes, def); i > 0 {
codes = append([]string{def}, slices.Delete(slices.Clone(codes), i, i+1)...)
}
}
return codes
```
### DryRun SQL test (pure, no Docker)
```go
// Source: probe this session; gorm.Config{DryRun: true, DisableAutomaticPing: true}
db, _ := gorm.Open(pgdriver.New(pgdriver.Config{DSN: "host=127.0.0.1 port=1 user=x dbname=x sslmode=disable"}),
&gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: logger.Discard})
sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
q, _ := applyListSearch(tx.Model(&m2mRow{}), ctl, schema, &m2mRow{}, "x")
var n int64
return q.Count(&n)
})
// assert: contains `EXISTS (SELECT 1 FROM "<pivot>"`, contains `."<owner_fk>" = "<main>"."id"`, not contains `"Categories"."post_id"`, no "JOIN" in the outer query
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `Joins(field)` for every relation column | EXISTS for search; correlated subquery for to-many sort | this phase | belongsToMany and hasMany search and sort work; counts are exact |
| URL-only list state | URL + per-user `backend_list_states` | this phase | State survives form round-trips and navigation |
| goldmark safe render | goldmark `html.WithUnsafe()` + gate | this phase | Imported HTML round-trips; PHP parity |
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | List state should persist across logins (durable per user), not end at logout like Winter's PHP session | Pattern 2 | Low: a one-line delete in `logout` switches to session semantics |
| A2 | Winter's `Markdown::parse` passes raw HTML through (Parsedown default) | Root cause D-03 | Low: the export data (content_html == content for 83/100, entities-only diffs for 17) supports it |
| A3 | Winter's `whereHas` applies the related model's soft-delete scope, so EXISTS should add `deleted_at IS NULL` for soft-deleting related models | Pattern 1 | Low: affects only soft-deleting related models; Journal Category has no soft delete [not verified] |
| A4 | GORM polymorphic references carry `PrimaryValue` needing an extra bound condition; refusing polymorphic relations in search is acceptable for now | Pattern 1 | Low: no current list column uses a polymorphic relation [not verified across plugins] |
| A5 | The PHP site's `use_rich_editor` setting value is unknown (Go UAT: `f`); 16.1 does not implement the rich-editor store-as-is branch | Pattern 4 | Low: unsafe markdown already round-trips HTML; the branch belongs with 16.2's Blog Editor |
| A6 | The new route name `list-state` and table name `backend_list_states` are acceptable names | Pattern 2 | Cosmetic |
## Open Questions (RESOLVED)
1. **Tag number vs the concurrent session.**
- What we know: the latest tag is v0.1.15. No `modules/` edits are in flight in this checkout.
- What's unclear: whether the other session will tag first.
- Recommendation: the release task checks `git fetch --tags` and uses the next free `v0.1.x`.
- RESOLVED: plan 16.1-02 Task 1 picks the next free tag or reuses an existing tag that contains the fixes.
2. **Should `en` be disabled on grzybyfunkcjonalne?**
- What we know: the site is Polish-only; the export has only `pl` translations. With en enabled, every admin save writes empty en rows (Pitfall 9).
- What's unclear: whether any client requests `locale=en`.
- Recommendation: the framework fix is enough for D-03. Ask the user whether to also set `is_enabled=false` for `en` on UAT (one SQL line, or the Locales admin) and in `migrate.sh`.
- RESOLVED: `en` stays enabled (Claude's discretion in CONTEXT.md); empty en rows on save are accepted.
3. **Repairing posts 53 and 64: admin re-save or a console command?**
- Recommendation: admin re-save after the deploy. It is two posts and doubles as the D-03 acceptance test. A reusable `journal:rebuild-html` command is out of scope unless the user wants it.
- RESOLVED: plan 16.1-02 Task 3 repairs both posts by admin re-save.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | all Go work | ✓ | go1.27.0 | — |
| Node / npm / pnpm | admin SPA build + vitest; Nuxt build | ✓ | node v22.23.2 | — |
| Docker daemon | testcontainers tests (cabana query/list, journal admin harness) | ✗ (`permission denied` on /var/run/docker.sock; user not in docker group) | client 29.7.2 | Pure DryRun tests here; Postgres suites on a Docker-enabled machine (user/CI) |
| Local Postgres | ad-hoc queries | ✗ for this user (`role "golem" does not exist`) | — | — |
| ssh `grzybyfunkcjonalne-uat` + peer psql | read-only UAT checks, deploy | ✓ | PostgreSQL 17.11 | — |
| swag v1.16.6 | OpenAPI regeneration | ✓ (module cache) | v1.16.6 | — |
| vitest suite | SPA tests | ✓ (`ListView.test.ts` + `MLFields.test.ts`: 44 passed this session) | — | — |
**Missing dependencies with no fallback:** none blocking. Postgres-backed tests need the user's Docker-enabled environment before the tag.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go `testing` (+ testcontainers-go v0.44.0 for Postgres); vitest + @vue/test-utils + happy-dom for the SPA |
| Config file | none (Go); `admin/vite.config.*` / `admin/tests/setup.ts` |
| Quick run command | `go test ./modules/cabana -run 'TestListRelation|TestListState|TestEnabledLocales' -count=1` (pure DryRun/unit parts) ; `npm --prefix admin test -- tests/list/ListView.test.ts tests/form` ; `go -C ../sm-journal-plugin test ./classes/` |
| Full suite command | `go vet ./... && go test ./...` (summercms.go, Docker required) ; `go -C ../sm-journal-plugin test ./...` (Docker required) ; `npm --prefix admin test` ; `scripts/check-admin-dist.sh` ; `scripts/check-admin-openapi.sh --check` ; `go test ./cmd/summer -run TestDocsTree` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| D-02 | Search over a many2many column emits EXISTS through the pivot, no `Joins` | unit (DryRun SQL) | `go test ./modules/cabana -run TestListRelationSearchSQL` | ❌ Wave 0 (new `query_relation_test.go`) |
| D-02 | Search over many2many / hasMany / belongsTo returns the right rows, exact `total`, combined with a scope filter that uses unqualified `id` and a ListExtendQuery `user_id = ?` | integration (Postgres) | `go test ./modules/cabana -run TestListRelationSearch` | ❌ extend `query_test.go` fixtures (add a pivot model) |
| D-02 | Sort by a to-many column orders by the aggregated value, no duplicate rows | integration | `go test ./modules/cabana -run TestListRelationSort` | ❌ |
| D-01 | Successful list GET upserts state; 422 does not; unchanged state does not write | integration | `go test ./modules/cabana -run TestListStateSave` | ❌ |
| D-01 | `GET .../list-state` returns the caller's state only, sanitizes stale filter/sort/per_page, 403 without permission | integration | `go test ./modules/cabana -run TestListStateRestore` | ❌ |
| D-01 | Migration creates `backend_list_states`; rollback drops it; `backend_user_preferences` still absent | integration | `go test ./modules/lagoon -run TestBackendAdminMigrations` | ✅ extend |
| D-01 | ListView: empty query → list-state requested first, then replaced URL; no empty list request before restore; own clear-all saves empty; same-path nav re-entry restores; page clamp | unit (vitest) | `npm --prefix admin test -- tests/list/ListView.test.ts` | ✅ extend |
| D-03 | `meta.enabledLocales` has the default first when the writer returns `["en","pl"]` with default `pl` | unit/integration | `go test ./modules/cabana -run TestEnabledLocalesDefaultFirst` | ❌ (reuse `recordingWriter` in ml_smoke_test.go) |
| D-03 | MLField opens on `enabledLocales[0]`, showing host value | unit (vitest) | `npm --prefix admin test -- tests/form/MLFields.test.ts` | ✅ extend |
| D-03 | `FormatHTML` keeps raw HTML (`<p>a</p>\n<p>b</p>` round-trips; markdown + trailing `<footer>`/`<style>` kept) and still rejects script/iframe/event/javascript/vbscript/data | unit | `go -C ../sm-journal-plugin test ./classes/ -run TestFormatHTML` | ✅ extend `format_html_test.go` |
| D-03 | Admin update of a post whose `content` is HTML leaves `content_html` equal to the HTML (re-save test from the todo) | integration (journal harness) | `go -C ../sm-journal-plugin test -run TestPostsAdminResaveKeepsHTML .` | ❌ add to `posts_admin_smoke_test.go` |
| D-04 | 98-URL smoke on staging | e2e (server) | on-server `CURL_HOME=... scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` | ✅ |
### Manual (UAT, human with credentials)
On `/gfadmin/golem15/journal/posts`:
1. Search "cordyceps": rows return, no "The list could not be loaded".
2. Set a sort and a category filter, open a post, press Back (in-app): the same sort and filter remain and are in the URL. Navigate away via the rail and back: they are restored. Copy the URL into a new tab: the same list.
3. Open any imported post: title, slug, content (HTML source) and excerpt are filled, and the locale selector shows `pl`.
4. Re-save posts 53 and 64, then on the server: `SELECT count(*) FROM golem15_journal_posts WHERE content_html LIKE '%raw HTML omitted%'` → 0. `/blog/ekstrakt-z-turkey-tail` shows the author footer.
### Sampling Rate
- **Per task commit:** the quick-run commands for the touched repo (pure Go tests + vitest file).
- **Per wave merge:** full suite of the touched repo, plus `check-admin-dist.sh` / `check-admin-openapi.sh --check` when SPA or swag files changed.
- **Phase gate:** full suites green on a Docker-enabled machine before the tag; UAT smoke + manual list before `/msd-verify-work`.
### Wave 0 Gaps
- [ ] `modules/cabana/query_relation_test.go`: DryRun SQL tests plus many2many and hasMany fixture models (pivot table) for the Postgres tests.
- [ ] `modules/cabana/list_state_test.go`: save, restore, sanitize, permission.
- [ ] `sm-journal-plugin/posts_admin_smoke_test.go`: re-save keeps HTML.
- [ ] No framework installs needed.
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no (unchanged) | existing backend guard |
| V3 Session Management | yes (new per-user state, not a session token) | State is keyed by the JWT principal ID; it carries no authority |
| V4 Access Control | yes | The list-state route goes through `protect()` (controller permission before SQL). Rows are scoped to `principal.ID`; no route reads another user's state |
| V5 Input Validation | yes | Stored state is re-validated against the compiled schema on restore. Search SQL uses bound `?` args and `quotedIdent` identifiers from GORM's schema. Size cap on stored JSON |
| V6 Cryptography | no | — |
| V14 / XSS (output) | yes | `content_html` now carries raw admin HTML; the `rejectUnsafe` gate stays on every write path (admin, import, API); the admin preview (`cabana.RenderMarkdown`, v-html) stays safe-mode |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SQL injection via relation/column names in search | Tampering | Names come from the compiled schema + `schema.Relationship`; `identifier()` + `LookUpField`; values bound |
| Stored-state poisoning (oversized or crafted state) | Tampering / DoS | Validate on save (only successful list inputs) and on restore; size cap |
| Cross-site GET that writes list state | Tampering (low) | Admin cookie is `SameSite=Strict` (auth.go `sessionCookie`); Bearer clients are not CSRF-able; the impact is only the attacker-chosen filters of the victim |
| Stored XSS through post HTML | Tampering / Elevation | 3-pattern gate (script/iframe/object/embed, `on*=`, javascript/vbscript/data URLs); authors are permissioned admins; accepted residual risk vs PHP `Html::clean` (Pitfall 5) |
## Sources
### Primary (HIGH confidence, read or run this session)
- summercms.go:
- `modules/cabana/query.go` (whole file);
- `field_ml.go`, `crud.go:405-715, 1187-1274`, `http.go:225-300, 700-745, 955-1030`;
- `list_schema.go:20, 526-541`, `schema.go:61-71`, `auth.go:180-340`;
- `modules/lagoon/backend_admin_migrations.go`, `backend_admin_migrations_test.go:100-135`;
- `admin/src/components/form/*`, `admin/src/views/{ListView,FormView}.vue`, `admin/src/app/listQuery.ts`.
- sm-journal-plugin:
- `models/post.go`, `models/post/{columns,fields}.yaml`, `controllers/posts/*.yaml`;
- `controllers/posts.go`, `classes/format_html.go`, `classes/format_html_test.go`, `console/import_posts.go`;
- `admin_harness_test.go`, `go.mod`.
- sm-translate-plugin: `classes/admin_writer.go`, `classes/translatable.go:40-223`, `updates/202610060005_seed_en_pl_locales.go`.
- sm-grzybyfunkcjonalne-app: `go.mod`, `go.work`, `.gitmodules`, `scripts/build.sh`, `scripts/migrate.sh:167-175`, `DEPLOY.md`, `README.md:114`.
- WinterCMS reference:
- `examples/golem15-wintercms-starter/modules/backend/widgets/Lists.php` (500-600, session calls), `Search.php`, `Filter.php`;
- `traits/SessionMaker.php`;
- `database/migrations/2014_01_04_000005_Db_Backend_User_Preferences.php`;
- `grzybyfunkcjonalne.pl/plugins/golem15/journal/models/Post.php:130-208`, `controllers/Posts.php:80-98`.
- Probes:
- GORM v1.31.2 DryRun (many2many Joins SQL, references, EXISTS shape);
- goldmark v1.8.6 safe vs unsafe over 100 export bodies and 2 damaged posts;
- read-only UAT psql (locales, translate attributes, post column counts, wiped posts).
### Secondary
- `.planning/phases/16-*/16-RESEARCH.md` (Pitfalls 1-2), `16-04-SUMMARY.md`, `deferred-items.md`, `16-05-PLAN.md` (Docker constraint).
## Metadata
**Confidence breakdown:**
- Root causes: HIGH. Each is reproduced by a probe or observed in UAT data.
- EXISTS builder: HIGH for many2many and hasMany (probe); MEDIUM for polymorphic and soft-delete handling (assumed).
- List-state design: MEDIUM. Discretion area; the semantics are reasoned through, but there is no Winter-identical storage.
- Release mechanics: HIGH. Read from build.sh, go.mod, go.work and DEPLOY.md.
**Research date:** 2026-10-09
**Valid until:** 2026-10-23 (fast-moving repo with a concurrent `modules/` session)

View File

@@ -0,0 +1,93 @@
---
phase: "16.1"
slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
# 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-09"
---
# Phase 16.1 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go `testing` (GORM DryRun for SQL-shape tests; testcontainers-go v0.44.0 for Postgres suites on a Docker host only); vitest + @vue/test-utils + happy-dom for the admin SPA |
| **Config file** | none (Go); `admin/vite.config.*`, `admin/tests/setup.ts`, `admin/tests/helpers.ts` (vitest) |
| **Quick run command** | `go test -count=1 -run '^(TestListRelationSearchSmoke\|TestListRelationSQL\|TestEnabledLocalesDefaultFirst\|TestEnabledLocalesDefaultFirstSmoke)$' ./modules/cabana` ; `go -C ../sm-journal-plugin test -count=1 ./classes/` ; `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/state` |
| **Full suite command (this machine, no Docker)** | `go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh` plus the quick Go commands |
| **Full suite command (Docker host)** | `go test -count=1 ./...` in summercms.go and `go -C ../sm-journal-plugin test -count=1 ./...` |
| **Estimated runtime** | ~30 s quick (Go pure tests + one vitest file set); ~3 min full local suite (dist rebuild dominates) |
Docker is not usable on the planning machine (`permission denied` on the socket): `go test ./modules/cabana` and `go test -short ./...` in sm-journal-plugin fail there for every Postgres-backed test, so every per-task gate names the pure tests with `-run` and checks their `--- PASS` lines. The Postgres suites (`TestListQueryContract`, the new `TestListRelationSearchPostgres`, the new `TestPostsAdminResaveKeepsHTML`) run on a Docker host before `/msd-verify-work`.
Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene` already refuses `MLField.vue`, `mlLocale.ts` and `FileCaptionModal.vue` ("imported by no test"). This phase only extends that script's browser-storage allowlist and checks the storage rule with a direct grep.
---
## Sampling Rate
- **After every task commit:** the task's own `<automated>` command (pure Go tests by name, vitest files, `go vet` of the touched package).
- **After every plan wave:** the full local suite above; `scripts/check-admin-dist.sh` whenever `admin/` changed; `go test ./cmd/summer -run TestDocsTree` whenever `docs/` or a module README changed.
- **Before `/msd-verify-work`:** full local suite green, Docker-host suites green, UAT smoke `smoke: ok (` on the server, and the manual UAT list below answered.
- **Max feedback latency:** 60 seconds per task gate (the dist rebuild in plan 01 Task 4 is the slowest).
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 16.1-01-01 | 01 | 1 | D-02 | T-16.1-01 | Relation and column names come from the compiled schema and GORM's parsed relationship; the search value is bound | unit (DryRun, through `service.list`) | `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` | ❌ W0 (created by the task) | ⬜ pending |
| 16.1-01-02 | 01 | 1 | D-03 | — | N/A | unit | `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` | ❌ W0 (created by the task) | ⬜ pending |
| 16.1-01-03 | 01 | 1 | D-03 | T-16.1-02 | Raw HTML passes; script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs are still refused on every write path | unit | `go -C ../sm-journal-plugin test -count=1 ./classes/` | ✅ extend `classes/format_html_test.go` | ⬜ pending |
| 16.1-01-04 | 01 | 1 | D-01 | T-16.1-03, T-16.1-04 | Only list query params are stored, under `summer-admin.list:` keys; restored state passes the strict URL parser; logout clears it | unit (vitest) + dist drift | `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state` ; `bash scripts/check-admin-dist.sh` | ✅ extend `admin/tests/list/ListView.test.ts` | ⬜ pending |
| 16.1-02-01 | 02 | 2 | D-04 | T-16.1-06, T-16.1-07 | Release only after the local gate is green and a human approves the one-way tag push | gate + checkpoint | full local suite (plan 02 Task 1 verify) | ✅ | ⬜ pending |
| 16.1-02-02 | 02 | 2 | D-04 | T-16.1-07, T-16.1-08 | Uploaded binary sha256 equals the local build; previous binary kept as `.prev`; no `.env` rsynced | e2e (server) | on-server `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` via loopback resolve | ✅ | ⬜ pending |
| 16.1-02-03 | 02 | 2 | D-02, D-03 | T-16.1-09, T-16.1-10 | Read-only SQL and log checks; only posts 53 and 64 are re-saved, by a human | integration (server, read-only) | `ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc ..."` (raw-HTML marker count 0, post 64 footer present) | ✅ | ⬜ pending |
| 16.1-03-01 | 03 | 3 | D-01 | T-16.1-11 | Storage helpers survive disabled storage; logout clears list keys only | unit (vitest) | `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts` | ❌ W0 `admin/tests/state/listState.test.ts` (created by the task) | ⬜ pending |
| 16.1-03-02 | 03 | 3 | D-02, D-03 | T-16.1-11, T-16.1-12 | Polymorphic relations and unknown columns refused (422); search value never inlined into SQL | unit (DryRun) + Postgres (Docker host) | `go test -count=1 -run '^(TestListRelationSQL\|TestEnabledLocalesDefaultFirst)$' -v ./modules/cabana` | ✅ extend files from plan 01 | ⬜ pending |
| 16.1-03-03 | 03 | 3 | D-03 | T-16.1-02, T-16.1-11 | Every unsafe case returns an error now that the gate is the only defence | unit + journal harness (Docker host) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML\|TestFormatHTMLRejectsUnsafeHTML)$' -v ./classes/` | ✅ extend | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `modules/cabana/query_relation_test.go` — DryRun harness (postgres dialector, never dialed; recording logger), relation fixtures (many2many, has_many, belongs_to) and the tracer smoke test; created by plan 01 Task 1, extended by plan 03 Task 2.
- [ ] `modules/cabana/locale_order_test.go` — created by plan 01 Task 2 (smoke), extended by plan 03 Task 2.
- [ ] `admin/tests/state/listState.test.ts` — created by plan 03 Task 1.
- [ ] No framework install: GORM, the postgres dialector, testcontainers-go and vitest are already in go.mod / package.json.
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Journal posts list search on UAT returns rows (no "The list could not be loaded") | D-02 | Needs the UAT basic-auth credentials and an admin login, which the executor does not hold | In `/gfadmin/golem15/journal/posts` search "cordyceps"; rows appear. The executor then checks the Go log has no `SQLSTATE 42703` after the restart |
| List state survives the form round trip, navigation and a shared URL | D-01 | Interactive flow on the real admin | Set a sort and a category filter, open a post, press back, then "Zapisz i zamknij" on another post: the list keeps both and the URL shows them. Leave through the rail and come back: restored. Paste the URL into a new tab: same list. Log out and in: the list opens unfiltered |
| Imported post form shows title, slug, content and excerpt | D-03 | Real imported data on UAT | Open any imported post: the locale selector shows `pl` first and every field is filled |
| Posts 53 and 64 get their HTML back | D-03 | Admin re-save needs credentials | Open each post and press "Zapisz"; the executor then runs the read-only SQL check (marker count 0, post 64 has its footer) |
| Docker-host suites | D-01, D-02, D-03 | Docker unavailable on the executor machine | On a Docker host: `go test -count=1 ./...` (summercms.go) and `go -C ../sm-journal-plugin test -count=1 ./...` are green, including `TestListQueryContract`, `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags (`vitest run` through `npm test`; `go test -count=1`)
- [ ] Feedback latency < 60s (confirmed during execution)
- [ ] `nyquist_compliant: true` set in frontmatter (after plan 03 records the green runs)
**Approval:** pending

View File

@@ -0,0 +1 @@
No external API integration: internal admin list, form locale and HTML renderer fixes; the Journal API is named only to keep its response shapes unchanged.

View File

@@ -0,0 +1,13 @@
# Phase 16.1 deferred items
Out-of-scope findings logged during execution. They were not fixed here.
## From 16.1-01
- `scripts/check-phase10.sh --hygiene` refuses 5 findings, all in files this plan does not touch. Because the clean scratch copy fails, `--self-test` also refuses ("self-test hygiene rejected the clean scratch copy"):
- `admin/src/api/files.ts:150`: `new XMLHttpRequest()` outside `admin/src/api/client.ts`.
- `admin/src/api/files.ts`: an unexpected file in `admin/src/api`.
- `admin/src/components/form/fields/FileCaptionModal.vue`, `admin/src/components/form/fields/MLField.vue` and `admin/src/components/form/mlLocale.ts`: not imported by any test.
The browser-storage rule that plan 01 changed passes: `listState.ts` is allowed, and `listState.ts` is imported by `admin/tests/list/ListView.test.ts`.
- Each Docker-backed test in `modules/cabana` fails on this machine with "permission denied" on the Docker socket (217 failures in `go test ./modules/cabana`, each reporting a Docker error). This is the known environment limit; run that suite on a machine with Docker before the plan 02 tag.

View File

@@ -0,0 +1,148 @@
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
Two deliverables, framework first:
1. **Plugin-registered, value-bearing cabana field types** (summercms.go: `modules/pact`, `modules/cabana`, admin SPA). A plugin registers its own form field type, the WinterCMS `registerFormWidgets()` successor. Unlike the Phase 10.1 `type: widget`, which is valueless and uses action/fill, the field owns a column value.
2. **The Journal Blog Editor** (sm-journal-plugin). It is built on that API: a WYSIWYG HTML editor with a source toggle for post `content`. It edits the imported HTML posts (61 of 63 published grzybyfunkcjonalne.pl posts) without losing anything, and it also replaces the textarea for category content.
The phase also includes the frontend half of embeds. The reusable Nuxt blog views layer (`vue-journal-layer`) expands the editor's embed placeholders.
Out of scope: a core cabana `richeditor` type, a media library browser, and HTML input on the public write API (see Deferred).
</domain>
<decisions>
## Implementation Decisions
### Blog Editor: what it is
- **D-01:** The Blog Editor is a **WYSIWYG HTML editor with a source (raw HTML) toggle**, built on ProseMirror through TipTap. It is not a block editor and not a markdown editor.
- **D-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after it passes `classes.CheckHTML`. The API response shape is unchanged; `content` is simply HTML, as the PHP export already carries for imported posts. — **Reversibility:** costly — every post saved from the admin becomes HTML-content. A later move back to markdown would need an HTML-to-markdown migration across live posts.
- **D-03:** **Byte-preserving unless edited.** If the post body is not touched in the editor, the save writes back exactly the stored HTML; the editor must not re-serialize an untouched document. Once the body is edited in WYSIWYG, markup the schema doesn't know (custom classes, divs, attributes) is kept as far as possible through generic node and attribute preservation. The source tab always edits the exact HTML.
- **D-04:** v1 features: basic text (headings, bold/italic, lists, links, blockquote), **images**, **tables**, and **embeds plus a raw HTML block**. The raw HTML block is still subject to `CheckHTML`.
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes `CheckHTML` unchanged. **The XSS gate is not relaxed to allow iframes.** The frontend expands the placeholder into the real iframe (D-17). — **Reversibility:** costly — the placeholder markup becomes stored content in live posts, and the layer's renderer depends on it.
- **D-06:** The editor is **translatable like today's `mlmarkdown`**: a locale switcher over a locale → HTML map, stored through the translate overlay.
### Markdown vs HTML posts
- **D-07:** **A markdown post opens with its `content_html`**, and the next admin save stores HTML (D-02). This is a one-way migration on first edit, with **no per-post format flag** and no new column.
- **D-08:** **The Blog Editor replaces `mlmarkdown` for Journal post `content`** in `models/post/fields.yaml`. `markdown` and `mlmarkdown` stay cabana core types for other plugins. The `excerpt` field is unchanged.
- **D-09:** **The public write API is unchanged.** `POST`/`PUT /_journal/api/v1/posts` keep the PHP contract: `content` is markdown and the server renders `content_html` with `FormatHTML`. Only the admin form path stores HTML.
- **D-10:** **Admin save gate: `CheckHTML` refuses, nothing is sanitized.** Unsafe HTML returns a 422 with a field error, as category content does today, and nothing is silently rewritten. No sanitizer dependency is added. `CheckHTML` must run on every admin write path for both the default and the non-default locale values. The admin save path must stop running `FormatHTML`/goldmark over HTML content, which is the current data-loss bug (todo `journal-imported-html-admin-resave.md`).
### Field type contract (framework)
- **D-11:** **Plugins register field types under a short global alias**, for example `type: blogeditor`. Boot fails loudly if two plugins, or a plugin and a cabana core type, claim the same alias. This is closer to Winter's `registerFormWidgets` aliases than to a namespaced key. — **Reversibility:** costly — aliases end up in plugin `fields.yaml` files across repos; renaming means editing every consumer.
- **D-12:** The capability is a new `pact` interface (for example `HasFormFieldTypes`). Its Go side owns four things:
1. **Option validation at boot**: the type's own YAML keys, fail-loud like the built-ins.
2. **Value decode and validation on save**: any JSON shape, with field errors returned as 422.
3. **Load and format for the form**: turns the stored column value into what the element receives. For the Blog Editor, a markdown post is served as its `content_html` (D-07).
4. **List column formatting**: for example, strip tags and truncate.
— **Reversibility:** one-way — a published plugin API in `pact`; later plugins (payment and others, per todo `cabana-plugin-registered-field-types.md`) build on it.
- **D-13:** **The client-side element loads globally on admin boot.** A plugin declares its field-type assets once, and the SPA loads them on every admin page, so the type works in any controller's form, including other plugins' forms. The element binds a value through a documented contract instead of the widget action/fill round-trip. The todo's starting point for that contract is a `value` property plus a `summer-change` event; the planner finalizes it. The element reads only the public `--c-*` CSS variables.
- **D-14:** **Translatable variants are registered by the plugin itself**: the plugin registers both `blogeditor` and `mlblogeditor`, mirroring the `markdown`/`mlmarkdown` pair. The planner decides how a plugin ML type plugs into cabana's existing lift/hydrate path (`field_ml.go`) and the translate adapter, so locale storage is not reimplemented per plugin.
- **D-15:** The form schema and the generated TS types carry plugin types as a generic shape, so the SPA build does not need to know them (per the todo). The cabana README, `docs/`, the admin OpenAPI, the generated TS types and the committed `dist/` are updated in the same change (CLAUDE.md documentation rules).
### Libraries, media, category content
- **D-16:** **The editor library is TipTap (ProseMirror, MIT).** It is wrapped in a plain custom element and bundled in **sm-journal-plugin's own JS build** (`assets/src` → `assets/dist`), with the bundle committed and embedded, like the admin SPA `dist/`. The host app needs no npm. The admin SPA itself gains no editor dependency.
- **D-17:** **Embeds render in the `vue-journal-layer` blog views.** The layer expands allowlisted embed placeholders into privacy-friendly iframes (for example `youtube-nocookie.com`), so grzybyfunkcjonalne.pl shows them. A new layer tag is pinned in `vue-grzybyfunkcjonalne-app`.
- **D-18:** **Images are uploaded through the existing backend-authenticated Journal media upload route**, by drag-and-drop or file picker. The editor inserts `<img src>` with alt text and an optional caption (`figure`/`figcaption`). There is no media library browser.
- **D-19:** **Journal category content uses the Blog Editor** (`blogeditor`, or `mlblogeditor` if the category content attribute is translatable in the PHP tree) instead of the textarea. **No core cabana `richeditor` type** is added. This closes todo `cabana-richeditor-field-type.md` inside Journal. The existing `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` stays.
### Claude's Discretion
- The exact `pact` interface name and method set, Go identifiers, the element value/event contract details, and the asset declaration shape, all within D-11 to D-15.
- How "untouched" is detected for D-03: client-side dirty tracking versus server-side comparison with the loaded value. Either is acceptable as long as an untouched save is byte-identical.
- The ProseMirror schema strategy for preserving unknown markup (generic passthrough nodes and marks, a global attribute preservation extension).
- The exact embed placeholder markup and the provider allowlist (YouTube at minimum).
- Whether a summercms tag release, the host bump and a UAT redeploy belong in this phase, following the 16.1 D-04 pattern. Recommended, because the goal is to edit imported posts on UAT.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Framework work comes before the Journal work.
- Run the security-review agent: the phase adds a plugin API to `pact`, changes HTML write paths and adds an upload path into the editor.
### Folded Todos
- **`cabana-plugin-registered-field-types.md`**: the framework half of this phase (D-11 to D-15).
- **`cabana-richeditor-field-type.md`**: resolved by reusing the Blog Editor for category content (D-19) instead of a core type.
- **`journal-imported-html-admin-resave.md`**: the data-loss part is fixed by D-02, D-07 and D-10. Its required test applies: re-save an imported HTML post and assert `content` and `content_html` are unchanged. If 16.1 already shipped a stopgap, this phase replaces it.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Todos folded into this phase
- `.planning/todos/pending/cabana-plugin-registered-field-types.md`: the framework design sketch (pact interface, element contract, generic schema shape)
- `.planning/todos/pending/cabana-richeditor-field-type.md`: category content requirement and the `CheckHTML` gate
- `.planning/todos/pending/journal-imported-html-admin-resave.md`: the data-loss bug and its required test
### Prior phase context
- `.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md`: D-03 (content/excerpt display, data-loss stopgap); 16.2 depends on 16.1
- `.planning/phases/15-journal-plugin/15-CONTEXT.md`: D-01 to D-04 (frozen PHP tree is binding), D-11 (markdown field origin), D-14 (`/_journal/api/v1` contract)
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md`: D-02 to D-05 (the `vue-journal-layer` structure and distribution, for D-17)
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md`: Pitfall 2 (goldmark wipes raw HTML)
- Phase 10.1 (`.planning/phases/10.1-runtime-admin-extension-point/`): the `type: widget` custom-element mechanism, asset serving, `--c-*` CSS variable rule
### Framework code
- `modules/pact/capabilities.go`: capability interfaces, `AdminClientAssets`
- `modules/cabana/form_schema.go`: closed `formFieldTypes` set, fail-loud key validation
- `modules/cabana/crud.go` (~line 1227): scalar type switch on save
- `modules/cabana/field_ml.go`: ML lift/hydrate for `ml*` types
- `modules/cabana/extension.go`: widget tag validation, plugin asset hashing and serving
- `admin/src/components/form/registry.ts`, `admin/src/components/form/fields/MarkdownField.vue`, `MLMarkdownField.vue`: SPA renderer registry and the ML wrapper
- `modules/cabana/README.md` and `docs/`: must be updated with the API
### Journal plugin (`../sm-journal-plugin`)
- `classes/format_html.go`: `FormatHTML` (goldmark, API path) and `CheckHTML` (gate)
- `models/post/fields.yaml`, `models/category/fields.yaml`: fields switched to the Blog Editor
- The Posts and Categories controllers' form hooks: where the admin save path runs `FormatHTML`/`CheckHTML`
- Frozen PHP reference: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/formwidgets/` (`JournalMarkdown.php`, `MLJournalMarkdown.php`) and `API.md`
### Frontend layer
- `../vue-journal-layer`: post body rendering, where embed placeholder expansion goes (D-17)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- The Phase 10.1 widget machinery (`extension.go`) already validates custom-element tags, embeds and hashes plugin assets, and serves them with ETag and `?v=` cache busting. Global field-type assets should reuse this rather than add a second asset pipeline.
- `field_ml.go`'s lift/hydrate and the translate adapter already store locale maps for `mlmarkdown`. `mlblogeditor` should go through the same path.
- `classes.CheckHTML` is the existing three-pattern XSS gate, already used for category content.
- The Journal media upload route (backend Bearer guard, Phase 15 D-15) is the upload target for D-18.
### Established Patterns
- Field types fail loudly at boot: an unknown type or key is a boot error. Plugin types must meet the same bar.
- "Framework first": cabana changes land in their own commits and may get a summercms tag before the plugin work (Phase 12.1 and 15 D-11 precedent).
- Another session may be working in `modules/` concurrently (16.1 D-02). Stage files explicitly by path and check for in-flight edits to shared cabana files.
- Committed `dist/` for built JS (admin SPA). The plugin bundle follows the same pattern.
### Integration Points
- `formFieldTypes` and the crud type switch must consult the plugin type registry.
- The SPA `registry.ts` needs a fallback route from an unknown or plugin type to a generic plugin-element renderer, instead of `UnsupportedField`.
- The list column renderer must call the plugin's list formatter.
- The `vue-journal-layer` post body renderer gets embed expansion, followed by a new layer tag and a bump in `vue-grzybyfunkcjonalne-app`.
</code_context>
<specifics>
## Specific Ideas
- The user explicitly wants an editor "beyond the current markdown field or a plain rich editor" that edits imported HTML losslessly. Lossless round-trip is the acceptance bar, and it has a test: re-save an untouched imported post and get byte-identical `content`/`content_html`.
- Embeds must keep the XSS gate strict: placeholder in storage, iframe only in the frontend.
</specifics>
<deferred>
## Deferred Ideas
- **Core cabana `richeditor`/`mlricheditor` type** for plugins without their own editor. Not needed now; any plugin can register its own type or reuse one.
- **Media library browser** for picking previously uploaded images.
- **HTML input on the public write API** (`/_journal/api/v1/posts`). This would extend the PHP contract; the API stays markdown-in for now.
- **Bulk HTML conversion command** (`journal:content-to-html`). Rejected in favour of convert-on-first-edit (D-07); revisit if a uniform format is ever needed.
- **Allowlisting iframe hosts in `CheckHTML`**. Rejected in favour of placeholders (D-05).
</deferred>
---
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,142 @@
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - 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-09
**Phase:** 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
**Areas discussed:** Blog Editor: what it is, Markdown vs HTML posts, Field type contract, Libraries/media/richeditor
---
## Blog Editor: what it is
| Option | Description | Selected |
|--------|-------------|----------|
| WYSIWYG + source toggle | ProseMirror/TipTap-style rich editing of HTML plus a raw HTML source tab | ✓ |
| Block editor | Editor.js/Gutenberg blocks stored as JSON; imported HTML must be parsed into blocks | |
| Dual mode: markdown or HTML | Source editor with preview, switching between markdown and HTML per post | |
| Storage after admin save | Selected |
|--------|----------|
| HTML in both columns (content = HTML, content_html = HTML after CheckHTML) | ✓ |
| Markdown in content (WYSIWYG HTML converted to markdown) | |
| Keep source format (needs a per-post format flag) | |
| Round-trip strictness | Selected |
|--------|----------|
| Byte-preserving unless edited; preserve unknown markup where possible; exact source tab | ✓ |
| Normalize through the editor | |
| Source-only for unknown markup | |
**v1 features (multi):** Basic text ✓, Images ✓, Tables ✓, Embeds / raw HTML block ✓
| Embeds vs the iframe gate | Selected |
|--------|----------|
| Allowlisted embed placeholder node; the frontend renders the iframe | ✓ |
| Allowlist iframe hosts in CheckHTML | |
| Raw HTML block only, gate unchanged | |
| Locales | Selected |
|--------|----------|
| ML like today (locale switcher, locale → HTML map) | ✓ |
| Single locale only | |
---
## Markdown vs HTML posts
| Markdown post opened in the Blog Editor | Selected |
|--------|----------|
| Load content_html, save as HTML (one-way on first edit) | ✓ |
| Bulk-convert at deploy | |
| Per-post format flag | |
| Keep mlmarkdown for Journal posts? | Selected |
|--------|----------|
| Blog Editor replaces it | ✓ |
| Configurable per site | |
| Public write API | Selected |
|--------|----------|
| Unchanged, PHP contract (markdown in, FormatHTML) | ✓ |
| Accept HTML too | |
| Admin save gate | Selected |
|--------|----------|
| CheckHTML refuses with 422 | ✓ |
| Sanitize, then save (bluemonday) | |
---
## Field type contract
| Naming | Selected |
|--------|----------|
| Namespaced vendor.plugin::name | |
| Short alias, registered globally (boot fails on clash) | ✓ |
| Custom-element tag | |
| Client element loading | Selected |
|--------|----------|
| Globally on admin boot | ✓ |
| Per controller, like AdminClientAssets | |
**Go side owns (multi):** Option validation at boot ✓, Value decode and validate on save ✓, Load/format for form ✓, List column formatting ✓
| ML types | Selected |
|--------|----------|
| Framework wraps any type with a translatable flag | |
| Plugin registers an ml variant | ✓ |
---
## Libraries, media, richeditor
| Editor library | Selected |
|--------|----------|
| TipTap (ProseMirror) | ✓ |
| Bare ProseMirror | |
| Lexical | |
| You decide | |
| Images | Selected |
|--------|----------|
| Upload via the Journal media route | ✓ |
| URL only | |
| Media library picker | |
| Core richeditor todo | Selected |
|--------|----------|
| Reuse the Blog Editor for category content | ✓ |
| Core cabana richeditor too | |
| Defer category content | |
| Bundle build | Selected |
|--------|----------|
| In sm-journal-plugin, dist committed | ✓ |
| You decide | |
| Embed rendering on the frontend | Selected |
|--------|----------|
| Yes, in the Phase 16 blog views layer | ✓ |
| Admin side only | |
---
## Claude's Discretion
- pact interface naming, element value/event contract, asset declaration shape
- How "untouched" is detected for byte-preserving saves
- ProseMirror schema strategy for unknown-markup preservation
- Embed placeholder markup and provider allowlist
- Whether release, host bump and UAT redeploy are in scope (recommended)
- Plan count and split
## Deferred Ideas
- Core cabana richeditor type
- Media library browser
- HTML input on the public write API
- Bulk HTML conversion command
- Allowlisting iframe hosts in CheckHTML

View File

@@ -0,0 +1,157 @@
# Phase 18: Payment Gateway - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
Port `Golem15.PaymentGateway` (about 21k LOC of PHP) and `Golem15.PGStripe` (about 2.5k LOC, minus its subscription parts) to SummerCMS as two shared core plugins, `sm-paymentgateway-plugin` and `sm-pgstripe-plugin`. The port covers:
- the Payment and Order state machines (exact money)
- build-time operators (cash, manual transfer, disabled, and Stripe with PaymentIntent and Checkout modes)
- the `_paymentgateway/{operator}/{mode?}/{result?}` notification route
- the Order and Payment events and their mails
- the admin screens and the scheduled commands
- Quotes, Shippings and Currencies
- a headless customer order API with a `vue-paymentgateway-layer` Nuxt layer
The phase is proven by a test host inside the plugin that replays the order-create, Stripe-pay and order-paid flow Horoskopia relies on.
Not in this phase: `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook (they move to Phase 18.1), and the Horoskopia app port itself.
</domain>
<decisions>
## Implementation Decisions
### Split, source and acceptance proof
- **D-01:** **Subscriptions move to a new Phase 18.1.** This covers `Golem15.PaymentGatewaySubscriptions` (Subscription, SubscriptionPlan, the `SubscriptionGateway` contract, the `golem15.pgsub.*` events) together with pgstripe's `StripeSubscriptionGateway`, `ProcessStripeSubscriptionWebhook` job and `POST /webhook/stripe/subscriptions`. Phase 18 ports pgstripe without them. Insert 18.1 into the roadmap after this discussion (`/msd-phase`). — **Reversibility:** reversible — a roadmap split.
- **D-02:** **The frozen port contract is Horoskopia's checkout:** `paymentgateway` at `4b9ecf9` (2026-06-10, equal to `origin/master`) and `pgstripe` at `8b47457` (`v1.0.1`), read from `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/{paymentgateway,pgstripe}`. Planner and researcher must not silently pick a newer `master` (the same rule as P15 D-01/D-02). Binding from that tree: tables and migrations, models, YAML forms/lists/filters, permission codes, event names, config keys, mail template codes, console command names and the notification route. Its `tests/` (unit, functional, security, reliability) are a behavioral spec: map each one to a Go case, and cover them in the last plan. — **Reversibility:** costly — every table, key and event name is copied from this tree.
- **D-03:** **The proof is a test-only host inside the plugin repo**, not an app repo. It mounts `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `sm-user-plugin` with a fake consumer plugin, runs against Postgres via testcontainers, and talks to Stripe through an `httptest` fake behind stripe-go's backend override. There is no `sm-horoskopia-app` in this phase.
- **D-04:** **The proof exercises both pay paths:**
- (a) **Operator path:** `OrderFactory.Make(cart, details, user)` → Stripe operator (PaymentIntent, and Checkout mode per D-16) → signed notification on `_paymentgateway/stripe/...` → Payment `Accepted` → Order `Paid` → `OrderPaid` event and its mail.
- (b) **Consumer-driven path:** the app creates its own Checkout Session, receives its own webhook, and moves the order to `Paid` through the plugin's public Order/Payment API. This is what Horoskopia's `BookApiController::pay` and `BookFulfilmentService` do today. Path (b) proves the API surface Horoskopia needs: the state constants (`STATE_PLACED`, `STATE_AWAITINGPAYMENT`, the paid states), the transition methods, `hash_id` lookup and the `OrderPaid` event.
### Feature scope
- **D-05:** **Port all of paymentgateway 1:1**, including Quotes/Quoteables, Shippings and Currencies with the NBP fields and the `pg:update-currencies` command. It's a shared core plugin with 7 real users, so one schema and migration history serves every app. — **Reversibility:** costly — the tables and migration IDs become the shared contract.
- **D-06:** **The Winter `Orders` component's successor is a headless customer order API plus a Nuxt layer.**
- **API:** a JSON API under `/_paymentgateway/api/v1/` (following the `/_journal/api/v1`, `/_menu` and `/_faq` conventions) mirrors the component's handlers: show order, select payment method, select shipping, process (start payment), cancel, request refund, add/remove piece and remove item.
- **Layer:** `vue-paymentgateway-layer`, a new repo under `git.golem15.com/golem15/`, distributed like `vue-journal-layer` (P16 D-02 to D-04: a pnpm git dependency pinned to a tag, a neutral CSS-token design, components the app can override). It ships a typed client and order/checkout/status components covering the PHP state partials.
- **Reversibility:** one-way — a published API that apps and the layer consume.
- **D-07:** **MoneyWidget becomes a generic cabana `money` field type**, built framework first in `summercms.go` (the P15 D-11 / P17 D-06 pattern). It holds an amount in minor units plus a currency, is formatted by locale, and fails loud on schema errors. The module README, `docs/`, admin OpenAPI, generated TS types and committed `dist/` update in the same change. **TaxWidget gets no new type**: it becomes a dropdown or number field over the tax rates. — **Reversibility:** costly — a new field type in the typed admin schema.
- **D-08:** **Mail templates become postcard Markdown under the PHP template codes** (`golem15.paymentgateway::mail.order.purchased`, `...mail.payment.accepted`, and so on, about 26 of them), with the same variables, in EN and PL only (as P15 D-06). **MailerSettings** ports as a settings page that controls which customer and employee mails are sent. **The SetUp wizard** (which redirects until the base currency is configured) becomes a settings default or a required setting, not a redirecting controller.
### Money
- **D-09:** **Money is `int64` minor units plus an ISO currency code**, a small value type that mirrors moneyphp (`Money::PLN(3984)`). It needs no new dependency. Arithmetic, allocation and rounding follow moneyphp's behavior, and the currency `ratio` (decimal 8,4) and NBP conversion use exact integer or `math/big` arithmetic, never floats. The researcher decides whether the type lives in the plugin or in `wire` (it goes in `wire` only if it's generic). — **Reversibility:** costly — every model, the cart and the consumer API take this type.
- **D-10:** **The `decimal(12,4)` / `decimal(10,4)` `*_amount_stored` columns are major units.** The Money type implements an exact `sql.Scanner`/`driver.Valuer` that parses and formats the decimal string with no float (PITFALLS.md #5). Values with more precision than the currency's minor unit are preserved or explicitly rejected, never silently rounded; the researcher checks what PHP actually writes. Unit tests cover the column ceiling and the edge cases.
- **D-11:** **`cart` and `details` (`mediumText`, JMS-serializer JSON) are read in the legacy shape and written in a versioned Go shape.** The guardrails:
1. New rows carry a format version marker (for example `"v": 2`).
2. A permanent legacy JMS reader decodes unversioned rows. It's tested against golden fixtures taken from real PHP rows (Horoskopia plus at least one shop-type app with shipping and quotes). Legacy rows are never rewritten in bulk.
3. A typed extension registry replaces JMS's `setCartClass`/`setDetailsClass` (subclass-allowlisted, PG-007) and `registerMetadataDirectory`. A plugin registers a named extension type for Item/Details extra data, with explicit JSON tags and no class injection.
4. Rollback to PHP is one-way: once Go writes v2 rows, PHP can't read them. Each app cutover treats this as a point of no return and takes a DB backup first.
- **Reversibility:** one-way — stored rows in every app's `orders` table.
### Stripe
- **D-12:** **The Stripe client is the official `github.com/stripe/stripe-go`** (current major, with a pinned API version). This decision names it as an allowed dependency for `sm-pgstripe-plugin`, and 18.1 reuses it. Tests use the SDK's backend override pointed at an `httptest` fake; no test calls the network. — **Reversibility:** reversible — it's isolated in pgstripe.
- **D-13:** **Stripe keys and secrets live in encrypted settings with a config fallback**, as in PHP `StripeSettings`. The secret key, publishable key and webhook signing secrets are admin settings stored as `lagoon.Encrypted`, with a test/live mode switch and a host-config/`SUMMER_*` env fallback when a setting is empty. The admin form never returns secrets (write-only, like password fields). App cutovers import PHP values with `lagoon.DecryptLaravelPayload`.
- **D-14:** **Webhook verification keeps PHP's behavior:** `Stripe-Signature` is checked against the mode's signing secret, a missing header or secret is refused with a log entry, and the notification route stays CSRF-free. Behavior of `_paymentgateway/{operator}/{mode?}/{result?}` (any method, `mode`/`modeResult` merged into the input, operator code de-urlized) is preserved, and only registered operators resolve.
- **D-15:** **Operators register at build time through a Go interface**, the successor of `core/Operator` and the `golem15.paymentgateway.operators` config push in `paymentgateway.booted`. The approach is a `pact`-style capability (or a registry in the paymentgateway plugin) that pgstripe implements. Cash, manual transfer, disabled and Stripe operators ship.
- **D-16:** **New beyond PHP: a Stripe Checkout mode.** The Stripe operator can create a Checkout Session (hosted page) as well as PaymentIntents, and its notification handling accepts `checkout.session.completed` (and `checkout.session.async_payment_succeeded`) to accept the payment. This is the plugin-level version of what Horoskopia hand-rolls, so its later port can drop that code. Selecting the mode is a setting or a per-order operator option (planner's call). — **Reversibility:** costly — a new operator mode and settings that apps rely on.
- **D-17:** **The client receives payment data in the process response; there's no separate keys endpoint.** The customer API's process call returns `{redirect_url}` for Checkout mode, and `{client_secret, publishable_key}` (for the active test/live mode) for PaymentIntent mode. `vue-paymentgateway-layer` defaults to Checkout and simply redirects, as `vue-horoskopia-app` does today (`window.location.href = checkout_url`). The Winter `StripeKeys` component is not ported as an endpoint.
### Claude's Discretion
- Go package, type and identifier names for the operator interface, Money type, extension registry and state-machine types, provided D-09 to D-15 hold.
- Exact JSON field names and authorization of the customer order API (order hash for guests vs the logged-in user), following the PHP component's checks. The security review covers this.
- Whether the Money type and the `money` field live in `wire`/cabana or in the plugin, as long as D-07 keeps `money` a cabana core type.
- How the five daily commands (`pg:update-currencies`, `pg:cancel-created-payments`, `pg:cancel-placed-orders`, `pg:cancel-sent-payments`, `pg:finish-orders`, plus `UpdateOrders` and `UpdateAbstractItem`) are scheduled through conga, keeping the PHP command names.
- Typed festival event shapes for the 19 Order and 5 Payment events, keeping the PHP event names as their identifiers.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". This phase is large (two plugins, a framework field, a customer API and a Nuxt layer), so expect the framework `money` field first and the layer near the end.
- Run the security-review agent: the phase handles payments, secrets, a public webhook route and a public customer API.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Roadmap and decisions
- `.planning/ROADMAP.md` § Phase 18: goal, repos, port sources, splits, success criteria
- `.planning/notes/core-plugin-port-inventory.md` § "PaymentGateway facts that matter for Phase 18": users, operator registration, webhooks, state machines, events
- `.planning/notes/core-plugins-own-repos.md`: the `sm-<name>-plugin` repo, module path, submodule and `go.work` layout, and the submodule commit workflow
- `.planning/research/PITFALLS.md` #5: fixed-decimal money, never `float64`
- `.planning/phases/15-journal-plugin/15-CONTEXT.md`: D-01/D-02 (the frozen-source rule), D-05 (PHPUnit as spec), D-06 (EN/PL only), D-11 (framework-first field type), D-14 (`/_x/api/v1` convention)
- `.planning/phases/17-blog-companions/17-CONTEXT.md`: D-06 (framework-first cabana work), D-16/D-17 (public API shape), D-18 (Nuxt layer distribution)
- `.planning/seeds/SEED-261007-j2b-horoskopia-port.md`: the later Horoskopia app port that consumes these plugins
### PHP port sources (frozen per D-02)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/`: `Plugin.php` (permissions, settings, components, mail templates, form widgets, schedule), `routes.php`, `core/` (Operator, Order, OrderFactory, OrderManager, PaymentManager, SerializationService, the mailers and event subscribers), `models/`, `valueobjects/`, `operators/`, `console/`, `updates/` (schema), `metadata/` (JMS mappings for D-11), `components/Orders.php` and `components/orders/*.htm` (D-06), `formwidgets/` (D-07), `tests/`, `README.md`, `UPGRADE.md`, `docs/`, `cron-entries.txt`
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/`: `operators/Stripe.php`, `classes/StripeSettings.php`, `classes/StripeChargeMaker.php`, `classes/StripeCreditCardProvider.php`, `components/StripeKeys.php`, `config/config.php`, `tests/`. Skip `StripeSubscriptionGateway`, `jobs/ProcessStripeSubscriptionWebhook.php` and `http/routes.php` (18.1 per D-01).
### Acceptance consumer (read-only reference)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/controllers/BookApiController.php`: `purchase` (Item/Cart/Details/OrderFactory) and `pay` (its own Checkout Session)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/classes/services/BookFulfilmentService.php`: the transition to Paid and the paid states
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/controllers/HoroscopeStripeWebhookController.php`: signature handling, two signing secrets, idempotency
- `/media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/book.ts`: the client redirect flow (D-17)
### Framework
- `modules/cabana/README.md`, `modules/cabana/form_schema.go`, `modules/cabana/field_markdown.go`: the field-type set and the precedent for adding `money` (D-07)
- `modules/lagoon/encrypted.go`, `modules/lagoon/laravel_decrypt.go`: encrypted settings and the cutover import (D-13)
- `modules/postcard/README.md`: Markdown mail templates (D-08)
- `modules/conga/README.md`: scheduled commands
- `modules/festival/README.md`: typed events
- `modules/pact/`: the capability-interface pattern for operator registration (D-15)
- `modules/wire/README.md`: JSON value types for the customer API
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `lagoon.Encrypted` (AES-256-GCM at rest, redacted in JSON) and `lagoon.DecryptLaravelPayload` (cutover import): the Stripe secrets (D-13).
- `postcard`: plugin-owned Markdown mail templates and layouts with memory, log and SMTP drivers (D-08).
- `conga`: River jobs plus `ScheduledCommandArgs` / the `scheduled` queue for the daily `pg:*` commands.
- `festival.Bus` (`Fire`, `Collect`, `UntilHandled`): the Order and Payment events.
- cabana `HasSettings` settings pages, the `dropdown`/`number` fields and `type: password`-style write-only fields: the PG settings, MailerSettings and Stripe settings.
- `sm-journal-plugin/routes.go`: the `/_x/api/v1` route, limiter and CORS pattern for the customer order API.
- `vue-journal-layer`: the layer structure for `vue-paymentgateway-layer`.
- `sm-user-plugin` (mounted in `fonoteka.go/plugins/golem15/user`): the order `user_id` owner and the authenticated customer.
### Established Patterns
- Core plugins keep PHP table names, columns, migration semantics, permission codes, event names and config keys (non-breaking across the 7 apps), except where D-11 versions the stored cart/details JSON.
- Framework first: a new cabana field type lands in `summercms.go` with README, `docs/`, OpenAPI, TS types and `dist/` in one change.
- Framework and plugin READMEs never name a consuming application.
### Integration Points
- New sibling checkouts `/media/nvme/dev/golem15/summercms.io/summercms/sm-paymentgateway-plugin/` and `.../sm-pgstripe-plugin/`, with module paths `git.golem15.com/golem15/sm-paymentgateway-plugin` and `git.golem15.com/golem15/sm-pgstripe-plugin`, packages `paymentgateway` and `pgstripe`, and plugin IDs `golem15.paymentgateway` and `golem15.pgstripe`. Confirm the remotes exist before the first push.
- `summercms.go`: the cabana `money` field (D-07), plus anything else missing in the framework that the researcher finds.
- `vue-paymentgateway-layer`: a new repo, sibling of `vue-journal-layer`.
</code_context>
<specifics>
## Specific Ideas
- Horoskopia's real checkout flow: it builds `Item(name, 1, Money::PLN(net), vatRate, meta)` → `Cart('PLN')` → `Details([], 'stripe')` with a description, email and return URL → `OrderFactory->make($cart, $details, $user)` → `order hash_id`. Paying creates a hosted Checkout Session, and the frontend redirects to `checkout_url`. The Go API must make this sequence equally short.
- JMS was chosen for an explicitly mapped value object that each app can extend. That need carries over (D-11, point 3), but the JMS wire format does not.
- Horoskopia's webhook keeps a separate signing secret per endpoint and per test/live mode. The Stripe settings must allow distinct secrets per mode (D-13).
</specifics>
<deferred>
## Deferred Ideas
- **Phase 18.1, Payment Gateway Subscriptions:** `Golem15.PaymentGatewaySubscriptions` plus pgstripe's subscription gateway, webhook and job (D-01). Insert it into the roadmap.
- **Plugin-registered, value-bearing cabana field types** (the successor to Winter `registerFormWidgets`): cabana has a closed field-type set, and `type: widget` is valueless. Captured as `.planning/todos/pending/cabana-plugin-registered-field-types.md` (major, framework). It's needed before porting plugins that ship their own fields.
### Reviewed Todos (not folded)
- `cabana-richeditor-field-type.md`: already folded into Phase 17 (P17 D-10).
- `nest-framework-packages-under-modules.md`, `journal-author-profile-fields.md`, `2026-10-01-benchmark-...`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`, `bonfire-duplicate-command-names.md`, `faq-plugin-port.md`: these matched on keywords only and are unrelated to payments.
</deferred>
---
*Phase: 18-payment-gateway*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,129 @@
# Phase 18: Payment Gateway - 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-09
**Phase:** 18-payment-gateway
**Areas discussed:** Subscriptions & acceptance, Feature scope of the port, Money representation, Stripe integration shape
---
## Subscriptions & acceptance
| Option | Description | Selected |
|--------|-------------|----------|
| Separate 18.1 | P18 ports paymentgateway and pgstripe without subscriptions; 18.1 ports subscriptions | ✓ |
| All in Phase 18 | Port all three plugins in one phase | |
| In 18, as the last wave | Same phase, subscription plans last | |
| Option | Description | Selected |
|--------|-------------|----------|
| Test host in the plugin | Test-only host with a fake consumer, testcontainers Postgres, Stripe httptest fake | ✓ |
| Skeleton sm-horoskopia-app | Start the Horoskopia Go app now | |
| Existing app (summercms.io) | Mount in sm-summercmsio-app | |
| Option | Description | Selected |
|--------|-------------|----------|
| Both paths | Operator PaymentIntent path plus the consumer-driven Checkout path | ✓ |
| Operator path only | | |
| Consumer path only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Horoskopia's checkout | pg 4b9ecf9, pgstripe 8b47457 v1.0.1 | ✓ |
| Latest origin/master at plan time | | |
| Starter 'upgrade' branch | 96b6aa6 (older) | |
**Notes:** We found that Horoskopia creates Stripe Checkout Sessions itself and handles its own webhook, beside the PaymentIntent-based operator.
---
## Feature scope of the port
| Option | Description | Selected |
|--------|-------------|----------|
| Port all 1:1 | Quotes, Shippings, Currencies with NBP included | ✓ |
| Schema 1:1, features later | | |
| Horoskopia subset only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Headless customer order API | /_paymentgateway/api/v1 mirroring the Orders component handlers | |
| Defer, Go API only | | |
| API plus a Nuxt layer | API plus vue-paymentgateway-layer | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Generic cabana `money` field | Framework first; TaxWidget becomes a dropdown/number | ✓ |
| Both as cabana field types | | |
| Plugin-local SPA widgets | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Postcard Markdown, same keys | EN+PL; MailerSettings as a settings page; SetUp wizard as a settings default | ✓ |
| Keep HTML bodies verbatim | | |
| You decide | | |
**Notes:** The user asked whether cabana lets plugins register their own field types, as WinterCMS does. It doesn't: the type set is closed, and `type: widget` is valueless. This was captured as the major todo `cabana-plugin-registered-field-types.md`, since plugins to be ported soon ship their own fields.
---
## Money representation
| Option | Description | Selected |
|--------|-------------|----------|
| int64 minor units + currency | Mirrors moneyphp, no new dependency | ✓ |
| shopspring/decimal | New dependency | |
| You decide | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Byte-compatible JMS shape | Rollback to PHP stays possible | |
| Read PHP shape, write a Go shape | Tolerant legacy reader, new Go shape | ✓ (with guardrails) |
| Migrate on import | | |
**User's choice:** "There was a reason we picked JMS serializer. I'd go with 2, if it's safe and correct for the future of this payment plugin." After the JMS rationale was explained (app-swappable Cart/Details subclasses, metadata dirs), the user confirmed option 2 with four guardrails: a versioned v2 shape, a permanent legacy reader with golden fixtures, a typed extension registry replacing setCartClass and metadata, and one-way rollback documented. A rewrite migration and the byte-compatible fallback were offered and not chosen.
| Option | Description | Selected |
|--------|-------------|----------|
| Exact scanner/valuer | decimal(12,4) major units ↔ int64 minor, no float | ✓ |
| You decide | | |
---
## Stripe integration shape
| Option | Description | Selected |
|--------|-------------|----------|
| stripe-go official SDK | Named as the decided dependency | ✓ |
| Hand-rolled net/http client | | |
| Option | Description | Selected |
|--------|-------------|----------|
| No, port the operator 1:1 | Checkout mode deferred | |
| Yes, add a Checkout mode | New behavior beyond PHP | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Encrypted settings + config fallback | lagoon.Encrypted, test/live, write-only, Laravel decrypt on import | ✓ |
| Config/env only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Process response, Checkout first | redirect_url or client_secret+publishable_key | ✓ |
| Checkout only in the layer | | |
| Separate keys endpoint | | |
**Notes:** The user asked how Horoskopia handled keys without Winter components. It never needed the publishable key: the backend returns `checkout_url` and the Nuxt app redirects (`vue-horoskopia-app/app/stores/book.ts`).
---
## Claude's Discretion
- Identifier names, the customer API's JSON field names and authorization details, where the Money type lives, the conga scheduling of the `pg:*` commands, typed event shapes, plan count and split, and whether to run the security review (yes).
## Deferred Ideas
- Phase 18.1: Payment Gateway Subscriptions.
- Plugin-registered value-bearing cabana field types (todo created).

View File

@@ -0,0 +1,168 @@
# Phase 18.1: Payment Gateway Subscriptions - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
Port the generic core of `Golem15.PaymentGatewaySubscriptions` (about 5.5k LOC of PHP) to a new shared plugin, `sm-paymentgatewaysubscriptions-plugin`. Also port pgstripe's subscription parts (`StripeSubscriptionGateway`, `ProcessStripeSubscriptionWebhook`, `POST /webhook/stripe/subscriptions`), which Phase 18 left out (P18 D-01), into a subpackage of `sm-pgstripe-plugin`. The port covers:
- the `Subscription` and `SubscriptionPlan` models, with their statuses, scopes and access, grace-period and feature checks
- the gateway-neutral `SubscriptionGateway` contract, including `syncPlanToGateway`, with a Stripe implementation
- polymorphic subscribers through a build-time registry
- the six `golem15.pgsub.*` events
- the subscription webhook (its canonical URL and the legacy alias) processed through a conga job with idempotency
- the admin screens for subscriptions and plans
- a Go service API, a generic headless customer API, and subscription components added to `vue-paymentgateway-layer`
It is proven, as in P18 D-03, by the test host inside the plugin repo.
Not in this phase: Quotify's lead fees and metered billing, featured placements and quote analytics (they go to Quotify's own port), a Google Play gateway implementation, and the Horoskopia app port, including its own `plans` and `my/subscription*` routes.
</domain>
<decisions>
## Implementation Decisions
### Source and core scope
- **D-01:** **The frozen port contract is `paymentgatewaysubscriptions` at `ac3d73c` (Horoskopia's `develop`) plus commit `5fb9dac`.** That commit is on `origin/feature/qs-update`, used by QuestStream, and not merged. It adds `syncPlanToGateway(SubscriptionPlan): array<currency, priceId>` to the `SubscriptionGateway` contract. Read the source from `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions` and run `git show 5fb9dac` for the extra method. pgstripe stays frozen at `8b47457` (P18 D-02). The P15/P18 frozen-source rule applies: do not silently pick a newer tree or another app's copy (quotify.pro, drzewo.net and getaround.world are older). The binding parts are table and column names, event names, config keys, permission codes, the webhook URLs and the subscription status and gateway string values. — **Reversibility:** costly — table, event and key names are copied from this tree into every app's DB and code.
- **D-02:** **Port only the generic core.**
- **Ported:** Subscription, SubscriptionPlan, the `Subscribable` and `SubscriptionGateway` contracts, `HasSubscriptions` behavior (as a Go helper), ProcessedWebhookEvent, the events, the webhook, and the Subscriptions and SubscriptionPlans admin screens.
- **Not ported (they move to Quotify's port):** `LeadFeeSubscriber`, `MeterService`, `UsageRecord`, the `default_lead_fee_*` config keys, `FeaturedPlacement` and its admin controller, `AnalyticsService`, the `AnalyticsDashboard` component and `QuotaExceededException`.
- **Shared schema:** the tables those features use stay in the schema (D-04), so all apps keep one migration history.
- **D-03:** **Plan features are opaque string keys.** `Plan.HasFeature(key string)` and `Subscription.HasFeature(key)` work over the stored string list (the v2.4.0 shape). The plugin defines no `FEATURE_*` constants (they belong to QuestStream) and no `hasUnlimitedQuotes` (it belongs to Quotify). Apps declare their own keys in their own packages.
- **D-04:** **The migrations are one squashed baseline equal to the final PHP schema (v2.4.0).** It includes `golem15_pgsub_subscription_plans`, `golem15_pgsub_subscriptions` (polymorphic `subscriber_type`/`subscriber_id`, `gateway`), `golem15_pgsub_processed_webhook_events`, and the dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements`. The baseline is idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations. Not carried over: the `starter`/`professional`/`premium` seed, the `golem15_quotify_professionals` ALTER and the `professional_id` → polymorphic conversion. The researcher diffs the baseline against a real PHP database, Horoskopia or QuestStream. — **Reversibility:** one-way — the migration ID and table shapes become the shared contract across five apps.
- **D-05:** **Google Play stays a valid gateway value without an implementation.** The `gateway` column, the `stripe` and `google_play` values and a gateway check (`IsFromGateway`) survive. Only Stripe implements `SubscriptionGateway`. A Google Play gateway would be a separate future plugin.
### Package layout and subscribers
- **D-06:** **New repo `sm-paymentgatewaysubscriptions-plugin`** (module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, plugin ID `golem15.paymentgatewaysubscriptions`). It requires `golem15.paymentgateway` and `golem15.user`, as PHP's `$require` does. It holds the models, contracts, gateway registry, events, admin, service API and customer API. It contains no Stripe code.
- **D-07:** **The Stripe subscription gateway lives in a subpackage of `sm-pgstripe-plugin`** (for example `pgstripe/subscriptions`). The subpackage imports pgsub, implements `SubscriptionGateway`, and registers the webhook route and job. Apps that don't import the subpackage don't link pgsub, so a payments-only app keeps a payments-only binary. It reuses P18's stripe-go client, the pinned API version and the `httptest` backend override (P18 D-12). — **Reversibility:** costly — the import paths become what apps wire in their `plugins.go`.
- **D-08:** **Subscribers register through a build-time morph registry.** It succeeds the `subscribables` config and `Relation::morphMap`. An app registers a morph alias with a resolver that implements the Go `Subscribable` interface: email, name, get and set customer ID, morph alias and key. pgsub ships a ready `user` subscribable for `sm-user-plugin`, which an app opts into; Horoskopia stores `subscriber_type = 'user'`. Other types, such as Quotify's `professional` and QuestStream's `family`, are registered by their apps. An unknown `subscriber_type` read from the DB fails loudly; it is never silently skipped.
- **D-09:** **The built-in `user` subscribable stores the gateway customer ID in `users.stripe_customer_id`,** the column Horoskopia's `horoscope` plugin already added. A pgsub migration adds it only if missing (nullable, indexed, idempotent), so Horoskopia's DB adopts it unchanged. App-registered subscribables store the ID wherever their resolver says. — **Reversibility:** costly — a column on the shared `users` table.
### Webhook surface
- **D-10:** **Two URLs share one handler.** `POST /webhook/stripe/subscriptions` (pgstripe, canonical) and `POST /api/subscriptions/webhook` (the legacy pgsub alias) both reach the same handler, so no Stripe dashboard needs repointing at cutover. Both are CSRF-free and keep PHP's `throttle:60,1` limit. pgsub's deprecated `WebhookController`, `StripeSubscriptionService`, its own `ProcessStripeSubscriptionWebhook` job and `config/stripe.php` are not ported. Commit `0673aa7` already delegated them to pgstripe. — **Reversibility:** one-way — URLs configured in each app's Stripe dashboard.
- **D-11:** **The handler verifies the event and enqueues a conga (River) job.** It keeps PHP's shape and its STRIPE-006 fix:
1. Verify `Stripe-Signature`. A bad signature or payload returns 400 with a log entry.
2. Cheap-check `golem15_pgsub_processed_webhook_events`. A duplicate returns 200.
3. Enqueue jobs only for the five handled types: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid` and `invoice.payment_failed`.
4. The job applies the change and inserts the processed-event marker in the same transaction, only on success. A failed job retries without being marked processed.
- **D-12:** **The subscription webhook signing secret is its own encrypted setting for each mode** (test and live), stored in P18's D-13 Stripe settings (`lagoon.Encrypted`, write-only in the admin) with the `SUMMER_*`/host-config fallback. It is separate from the payment-notification secret, because Stripe issues one secret per endpoint. A missing secret returns 500 with a log entry, as PHP does. App cutovers import the PHP values, `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting, through `lagoon.DecryptLaravelPayload` where they are encrypted.
### Customer API and frontend
- **D-13:** **pgsub ships a typed Go service API and a generic headless customer API.**
- **Service API:** active plans, current subscription for a subscriber, checkout session, cancel (at period end or immediately), management-portal session, and access and feature checks. App routes call it; Horoskopia's port keeps its own `plans`, `my/subscription`, `my/subscription/checkout`, `my/subscription/cancel` and `my/subscription/portal` routes, whose response shapes carry app data and are its parity contract.
- **Customer API:** a JSON API under `/_paymentgatewaysubscriptions/api/v1/`, following the `/_x/api/v1` convention (P15 D-14, P18 D-06), for the logged-in `user` subscriber. It succeeds Winter's `SubscriptionManager` component. Plans are public; the subscription endpoints require auth.
- **Reversibility:** one-way — a published API that apps and the layer consume.
- **D-14:** **`vue-paymentgateway-layer` (P18 D-06) gets the subscription pieces.** These are a typed subscriptions client plus components for a plans or pricing table, a my-subscription status, and checkout and portal redirects. They use the same distribution (a pnpm git dependency pinned to a tag), neutral CSS tokens and overridable components. There is no separate subscriptions layer repo.
### Claude's Discretion
- Go package, type and identifier names (registry, `Subscribable`, gateway interface, event types, service API), provided D-06 to D-09 hold. A short package name such as `pgsub` is fine; the plugin ID and table prefix stay as in D-01 and D-04.
- Typed festival event shapes for the six events (`golem15.pgsub.subscription.activated`, `.canceled`, `.past_due`, `.reactivated`, `golem15.pgsub.invoice.paid` and `.invoice.payment_failed`), keeping the PHP names as identifiers. Horoskopia listens to the four subscription events.
- The admin screens:
- Subscriptions list, filter and form.
- SubscriptionPlans list, form and reorder, with the bulk "Sync to Stripe" and "Sync all" actions going through `syncPlanToGateway`.
- Editing `currency_prices` and `stripe_prices`: a cabana repeater or key-value field, or P18's `money` field, whichever fits the existing field set.
- The `manage_placements` permission code: whether it stays registered or moves with FeaturedPlacement.
- Plan prices use P18's Money type (D-09 there) at the API boundary, while the stored JSON shapes (`currency_prices`, `stripe_prices`, `price_cents`) stay as PHP writes them.
- Config keys: `grace_period_days` (default 7) carries over; `subscribables` is replaced by the D-08 registry; the `default_lead_fee_*` keys go with D-02.
- The proof flows in the test host: checkout → `customer.subscription.created` → `activated` event; `invoice.paid`; `invoice.payment_failed` → `past_due` and the grace period; cancel at period end → `deleted` → `canceled`; reactivation; duplicate-event idempotency; plan sync. Map pgstripe's subscription-related `tests/` (reliability, security/DataHandling) to Go cases in the last plan.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan".
- Run the security-review agent: the phase handles payments, secrets, a public webhook and a customer API.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Roadmap and decisions
- `.planning/ROADMAP.md` § Phase 18.1 and § Phase 18: goal, repos and splits
- `.planning/phases/18-payment-gateway/18-CONTEXT.md`: D-01 (this split), D-02 (frozen pgstripe `8b47457`), D-03 (test host), D-06 (customer API and `vue-paymentgateway-layer`), D-09/D-10 (Money), D-12 (stripe-go and its test fake), D-13 (encrypted Stripe settings per mode), D-14 (webhook verification behavior)
- `.planning/notes/core-plugin-port-inventory.md`: the five subscriptions users and Horoskopia's use of `Subscription`, `SubscriptionPlan` and `SubscriptionGateway`
- `.planning/notes/core-plugins-own-repos.md`: the `sm-<name>-plugin` repo, module path, submodule and `go.work` layout
- `.planning/research/PITFALLS.md` #5: exact money, never `float64`
- `.planning/phases/15-journal-plugin/15-CONTEXT.md`: D-01/D-02 (frozen source), D-05 (PHP tests as the spec), D-06 (EN/PL), D-14 (`/_x/api/v1`)
- `.planning/seeds/SEED-261007-j2b-horoskopia-port.md`: the later Horoskopia port that consumes this plugin
### PHP port sources (frozen per D-01)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/`:
- `Plugin.php` (permissions, navigation, morph map)
- `contracts/Subscribable.php`, `contracts/SubscriptionGateway.php` (plus `git show 5fb9dac`)
- `models/Subscription.php`, `models/SubscriptionPlan.php`, `models/ProcessedWebhookEvent.php`
- `models/*/fields.yaml`, `models/*/columns.yaml`
- `traits/HasSubscriptions.php`
- `controllers/Subscriptions.php`, `controllers/SubscriptionPlans.php` and their YAML
- `http/routes.php` (the legacy alias)
- `config/config.php`, `lang/en/lang.php`
- `updates/` (schema history for the D-04 baseline), `MIGRATION.md` (the polymorphic model)
- Skip the files D-02 moves to Quotify.
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/`: `classes/StripeSubscriptionGateway.php`, `jobs/ProcessStripeSubscriptionWebhook.php`, `http/routes.php`, `http/controllers/StripeWebhookController.php`, `classes/StripeSettings.php` (`getWebhookSecret`), `tests/reliability/`, `tests/security/DataHandlingTest.php`
- `/media/nvme/dev/golem15/queststream.online/plugins/golem15/queststream/services/EntitlementService.php`: a second consumer of `syncPlanToGateway` and the gateway field (read-only reference)
### Acceptance consumer (read-only reference)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/routes.php` (around lines 364–760): the `plans` and `my/subscription*` routes that call `Subscription::forSubscriber`, `SubscriptionPlan` and the `SubscriptionGateway` binding
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/classes/SubscribableUserAdapter.php`: the `user` subscribable and `users.stripe_customer_id` (D-08, D-09)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/Plugin.php` (around lines 335–480): the User relation extension and the four `golem15.pgsub.subscription.*` listeners
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/console/ReconcileSubscriptions.php`: uses `syncSubscription` and the status constants
- `/media/nvme/dev/golem15/quotify.pro/themes/quotify/pages/subscription.htm`: the `subscriptionManager` component usage that D-13's customer API succeeds
### Framework
- `modules/conga/README.md`: River jobs for D-11
- `modules/festival/README.md`: typed events
- `modules/lagoon/encrypted.go`, `modules/lagoon/laravel_decrypt.go`: D-12
- `modules/pact/`: the capability pattern for gateway and subscriber registration
- `modules/cabana/README.md`: admin lists, forms and reorder, bulk actions, settings pages
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- From Phase 18 (once built): the stripe-go client and its `httptest` fake, the encrypted Stripe settings page with a test/live switch, the Money type, the operator registry pattern (a model for the gateway and subscriber registries), and `vue-paymentgateway-layer`.
- `conga`: River jobs for webhook processing.
- `festival.Bus`: the `golem15.pgsub.*` events.
- `sm-user-plugin`: the `users` table and the authenticated customer behind the built-in `user` subscribable.
- `sm-journal-plugin/routes.go`: the `/_x/api/v1` route, limiter and CORS pattern.
### Established Patterns
- Core plugins keep PHP table names, columns, permission codes, event names and config keys.
- Squashed, idempotent baseline migrations that adopt existing PHP databases (Phase 5).
- Framework and plugin READMEs never name a consuming application.
### Integration Points
- A new sibling checkout `/media/nvme/dev/golem15/summercms.io/summercms/sm-paymentgatewaysubscriptions-plugin/`. Confirm the remote exists before the first push.
- A new subpackage in `sm-pgstripe-plugin` (Phase 18's repo).
- New subscription pieces in `vue-paymentgateway-layer` (Phase 18's repo).
- `summercms.go` only if a framework feature is missing, for example cabana bulk list actions for the plan sync.
- Phase 18 must be executed first: 18.1 builds on its pgstripe, Money, settings and layer.
</code_context>
<specifics>
## Specific Ideas
- Horoskopia's flow: `Subscription::forSubscriber('user', $id)->latest()` gives the current subscription. Checkout goes through `app(SubscriptionGateway::class)->createCheckoutSession(adapter, plan, success, cancel)` and the frontend redirects. `createManagementSession` opens the Stripe billing portal. The Go service API must keep this sequence just as short.
- `syncPlanToGateway` returns a currency → price ID map and stores the provider IDs on the plan (`stripe_product_id`, `stripe_prices`).
- The idempotency marker is written only after the job succeeds (STRIPE-006). Keep that ordering.
</specifics>
<deferred>
## Deferred Ideas
- **Quotify-specific subscription features** (lead fees and Stripe Billing Meters via `LeadFeeSubscriber`/`MeterService`/`UsageRecord`, `FeaturedPlacement`, quote analytics via `AnalyticsService`/`AnalyticsDashboard`, and `QuotaExceededException`) go to Quotify's own port. Their tables already exist through D-04.
- **A Google Play subscription gateway** would be a future plugin implementing `SubscriptionGateway`.
- **A generic admin subscription dashboard** (MRR, churn, active counts) is a new capability. Revisit it after the Horoskopia port, when real data exists.
### Reviewed Todos (not folded)
- `mailblocker-with-mailing.md`, `refresh-fonoteka-readme.md`, `faq-plugin-port.md`, `journal-imported-html-admin-resave.md`: these matched on keywords only and are unrelated to subscriptions.
</deferred>
---
*Phase: 18.1-payment-gateway-subscriptions*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,124 @@
# Phase 18.1: Payment Gateway Subscriptions - 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-09
**Phase:** 18.1-payment-gateway-subscriptions
**Areas discussed:** Source and core scope, Package layout and subscribers, Webhook surface, Customer API and frontend
Phase 18.1 was inserted into the roadmap at the start of this session (`phase.insert 18`), as 18-CONTEXT D-01 asked.
---
## Source and core scope
| Option | Description | Selected |
|--------|-------------|----------|
| ac3d73c + 5fb9dac | Horoskopia develop plus QuestStream's unmerged `syncPlanToGateway` | ✓ |
| ac3d73c only | Strictly Horoskopia's checkout | |
| Merge all five copies | Union of every app's copy | |
| Option | Description | Selected |
|--------|-------------|----------|
| Generic core only | Lead fees, placements and analytics go to Quotify's port; their tables stay | ✓ |
| Port everything 1:1 | One plugin, all features | |
| Core + metering | Keep metered billing as a generic capability | |
| Option | Description | Selected |
|--------|-------------|----------|
| Opaque string keys | `HasFeature(key)`, no constants | ✓ |
| App-registered feature catalogue | Keys plus labels registered at build time | |
| Keep constants 1:1 | Port the `FEATURE_*` constants | |
| Option | Description | Selected |
|--------|-------------|----------|
| Squashed v2.4.0 baseline | Final schema including dormant tables; idempotent; no seed, ALTER or conversion | ✓ |
| Replay the PHP chain | One Go migration per PHP file | |
| Baseline without dormant tables | Quotify creates its tables itself | |
| Option | Description | Selected |
|--------|-------------|----------|
| Keep constant, no impl | `google_play` stays valid; only Stripe is implemented | ✓ |
| Drop it | Only `stripe` is valid | |
---
## Package layout and subscribers
| Option | Description | Selected |
|--------|-------------|----------|
| pgstripe subpackage | pgsub in its own repo; Stripe gateway in a `sm-pgstripe-plugin` subpackage | ✓ |
| Inside pgsub | pgsub imports pgstripe | |
| Third repo | `sm-pgstripe-subscriptions-plugin` | |
| Option | Description | Selected |
|--------|-------------|----------|
| Build-time registry, user built in | Morph alias plus resolver; opt-in `user` subscribable | ✓ |
| Registry only, nothing built in | Every app writes its own adapter | |
| Hard-wire to user | Drop polymorphism | |
| Option | Description | Selected |
|--------|-------------|----------|
| users.stripe_customer_id | Horoskopia's column, added idempotently | ✓ |
| pgsub mapping table | New `golem15_pgsub_customers` | |
| Built-in user leaves it to the app | Column name from config | |
---
## Webhook surface
| Option | Description | Selected |
|--------|-------------|----------|
| Both URLs, one handler | Canonical pgstripe URL plus the legacy pgsub alias | ✓ |
| Canonical only | Repoint the legacy endpoint at cutover | |
| Merge into _paymentgateway route | New URL everywhere | |
| Option | Description | Selected |
|--------|-------------|----------|
| Verify + enqueue conga job | Marker written in the job transaction after success | ✓ |
| Synchronous in the handler | One transaction in the request | |
| Option | Description | Selected |
|--------|-------------|----------|
| Own setting per mode | A separate encrypted subscription signing secret for test and live | ✓ |
| Reuse the P18 secret | One secret for both endpoints | |
---
## Customer API and frontend
| Option | Description | Selected |
|--------|-------------|----------|
| Go service API + headless /_x/api/v1 | Horoskopia keeps its own routes; new apps use the generic API | ✓ |
| Go service API only | No HTTP routes in the plugin | |
| Headless API replaces app routes | Breaks frontend parity | |
| Option | Description | Selected |
|--------|-------------|----------|
| Extend vue-paymentgateway-layer | Subscription client and components in the P18 layer | ✓ |
| Separate vue-pgsub-layer | Own repo | |
| No layer in 18.1 | API only | |
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, Quotify port | AnalyticsDashboard not ported | |
| Port a generic subscription dashboard | Admin MRR/churn widget | ✓ (then deferred) |
**Notes:** The user chose a generic dashboard. It was flagged as a new capability, and the user then placed it in Deferred Ideas instead of 18.1 scope.
---
## Claude's Discretion
- Go names for the registries, interfaces, events and service API
- Typed festival event shapes for the six `golem15.pgsub.*` events
- Admin plan editing (`currency_prices`, `stripe_prices`), the reorder and the Sync to Stripe bulk actions, and the `manage_placements` permission code
- The test-host proof flows and mapping pgstripe's tests to Go cases
- Plan count and split
## Deferred Ideas
- Quotify lead fees and metering, featured placements and quote analytics: Quotify's port
- A Google Play subscription gateway plugin
- A generic admin subscription dashboard (MRR, churn), after the Horoskopia port

View File

@@ -0,0 +1,183 @@
# Phase 19: Chat and ChatChannels - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
`Golem15.Chat` and `Golem15.ChatChannels` run on SummerCMS as the shared plugins `sm-chat-plugin` (`golem15.chat`) and `sm-chatchannels-plugin` (`golem15.chatchannels`), with realtime delivery through `lighthouse`. What they provide:
- The schema, models, context adapters, jobs and services from PHP.
- A typed Go service API.
- A generic headless JSON API (`/_chat/api/v1`, `/_chatchannels/api/v1`).
- Private attachments and message search.
The point is to have them ready for the later Wavepath port.
Wavepath's own `/_wave/api/v1/community/...` routes, the satellites (chatbots, chatvideo, forum, herald, userfriends; Phase 19.1), the Vue frontend and the Wavepath app port itself are out of scope.
**Repos touched:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`), plus additive changes to `sm-user-plugin` (D-15, D-17) and `summercms.go` only if `lighthouse`, `attach` or `beachcomber` lack a feature. The ROADMAP Phase 19 "Repos" line and success criterion 3 need updating to match D-06 to D-09 and D-15 (do it in the first planning commit).
</domain>
<decisions>
## Implementation Decisions
### Source and legacy
- **D-01:** **The frozen port contract is Wavepath's tree:** chat at `943b712` and chatchannels at `b92fb42`, read from `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`.
- The starter's chat (`4db7484`) is a strict ancestor, 97 commits behind, with nothing Wavepath lacks. Do not use it.
- The P15/P18 frozen-source rule applies. Binding: table and column names, migration-equivalent schema, realtime event names, channels and payloads, serializer output (`toApiArray` and friends), console command names, config keys and lang keys.
- **Reversibility:** costly. Names are copied into the DBs and clients of every app.
- **D-02:** **No QuestStream trace in the Go plugins.** Not ported:
- the `family_id` column and its index;
- `Conversation::LEGACY_FAMILY_CONTEXT`, `scopeForFamily`, `findOrCreateDm($family)` and `findOrCreateFamilyGroup`;
- `ChatService::getFamilyGroupChat` and `addUserToFamilyGroupChat`;
- the `migrate_family_to_context` data migration;
- the `parent_oversight` lang string;
- the QuestStream `ApiController` parent class.
No identifier, string, comment or README names QuestStream, family, parent or child. The user does not plan a QuestStream port soon.
- **D-03:** **`canActAs` stays in the context contract, fully generic.** It means "this actor may act on behalf of another member within this context", with a neutral Go name chosen by Claude (for example `CanActFor(ctx, actorID, memberID)`). Adapters that don't need it return false. The PHP session-based "pick the member to act as" flow from the `ChatWindow` component is not ported.
- **D-04:** **Migrations are one squashed, idempotent baseline per plugin,** equal to the final PHP schema minus `family_id`: chat v1.3.0 (conversations, participants, messages with `root_id`/`edited_at`/soft deletes, thread_summaries) and chatchannels v1.6.0 (channels, channel_categories, personal_categories, channel_user_prefs, channel_members, channel_mentions, channel_pins, user_context_prefs). The baseline adopts an existing Wavepath database unchanged, as in Phase 5 and P18.1 D-04. The researcher diffs it against a real Wavepath DB.
- **Reversibility:** one-way. Migration IDs and table shapes become the shared contract.
- **D-05:** **`family_id` on adopted databases is ignored.** Fresh databases never get the column. On an adopted Wavepath DB it stays, nullable and untouched; Go never reads, writes or drops it.
### HTTP surface
- **D-06:** **Both plugins ship a typed Go service API plus a generic headless JSON API.**
- **Service API:** a ChatService/ChannelService successor that mirrors the PHP services. App routes (the future Wavepath `/_wave` port) call it.
- **Generic API:** `/_chat/api/v1` (conversations, messages, DMs, replies and threads, read, typing, mute, attachments) and `/_chatchannels/api/v1` (channels, categories, personal categories, prefs, members, pins, mentions, message search). Each plugin owns its prefix (the `/_x/api/v1` convention, P15 D-14, P18 D-06), so an app that imports only chat gets no channel routes.
- **Reversibility:** one-way. It's a published API that apps will consume.
- **D-07:** **The generic API mirrors Wavepath's community routes.** It has the same operations, parameters and JSON payloads as `/_wave/api/v1/community/...` (`wavepath/routes.php` lines 488-526, 558-590, 673-721, and `threads/{rootId}/mute`), re-rooted at context-neutral paths. Both use one serializer set, so the Wavepath port can make its routes thin aliases or move the SPA onto `/_chat`.
- **D-08:** **Contexts are addressed by morph alias and id,** for example `/_chat/api/v1/contexts/{type}/{id}/conversation`. `{type}` is the alias registered at build time (D-11), and access is decided by the context adapter (`hasMember`, `canOverseeChats`, `isChatEnabled`). Conversation- and message-level routes use their own ids, and participation is checked on every call.
- **D-09:** **Auth is `jwt.auth`, with named rate-limit buckets.** The `chat-dm` bucket (10 per minute per user, PHP's `throttle:chat-dm`) guards DM start, and a general chat API bucket covers the rest. Mounting follows the `lighthouse.Mount` surfaces pattern, so the app picks the guard.
- **D-10:** **The legacy `/api/chat/dm` and `/api/chat/attachments/{messageId}/{fileId}` routes are dropped,** along with the `ChatWindow` and `ChatBadge` components and their `chat.js`. Both routes fatal on PHP today, and no client calls them (Wavepath's `dmApi.ts` avoids the attachment path explicitly).
- **Parity proof (replaces SC3's route wording):** golden tests show that the serializers (`Message.toApiArray` with attachments, thread summaries, channel, category, pref, pin and mention arrays) and every realtime event (name, channel and payload) match PHP output captured from Wavepath. Route-level parity of `/_wave` is checked at the Wavepath port.
### Extension seams
- **D-11:** **Contexts plug in through a build-time morph registry,** the successor of the `chat.context.resolver` singleton (`registerAdapter`, `registerProvider`).
- At Boot, an app or plugin registers a morph alias with a loader that returns a Go chat-context interface: members, `hasMember`, `canOverseeChats`, `isChatEnabled`, group chat name, the D-03 act-for hook, and a broadcast channel of 2 or 3 segments.
- This mirrors P18.1 D-08. An unknown `context_type` read from the DB fails loudly and is never silently skipped.
- **D-12:** **Existing PHP class names in `context_type` (and `golem15_chatchannels_channels.context_type`) are rewritten to aliases on adoption.**
- A Go migration or adoption command rewrites the stored class strings to the registered aliases once. The class → alias map comes from the registrations (each registration may declare the legacy PHP class it replaces).
- After the rewrite, PHP can no longer resolve these rows, so it's a cutover point of no return, like P18 D-11's v2 rows. The app port takes a DB backup first.
- The researcher checks the other morph columns that store PHP class names, especially `system_files.attachment_type` for chat attachments (`Golem15\Chat\Models\Message`). The attachment owner morph name must keep resolving adopted files.
- **Reversibility:** one-way. Stored rows in every adopting app's DB change.
- **D-13:** **Cross-plugin hooks are typed festival events.**
- chat fires typed events for message create, update and delete inside the write transaction. chatchannels listens to them for mention accrual, reconcile and remove, and for search index and deindex.
- User lifecycle (the `beforeDelete` GDPR cascade and handle generation on create) uses `sm-user-plugin` events. Any that don't exist yet are added there, additively.
- Plugins never patch each other's models (the successor of `Message::extend` and `User::extend`).
- **D-14:** **ChannelAccessResolver** (`canAccessContext`, `canManageContext`) **is deny-all by default, with a setter.** The app installs its own resolver at Boot, and Boot logs a Warn when chatchannels runs with the default.
- **D-15:** **`users.handle` moves into `sm-user-plugin`.**
- The user plugin owns the column (unique, added idempotently if missing so Wavepath's DB adopts unchanged), handle auto-generation (the HandleGenerator port) and the backfill.
- chatchannels uses it for `@mentions` (MentionParser regex `@([\w.-]{2,255})`).
- This is an additive change to a core plugin contract.
- **Reversibility:** costly. It adds a column and behavior on the shared `users` table across every app that mounts the user plugin.
- **D-16:** **The WebSockets `$require` is replaced by `lighthouse`.** Broadcasts go through the lighthouse service: model broadcasts through a `lighthouse.Bind` binding, explicit events through `Emit`/`Publish`. chat registers the `chat` namespace authorizer (`chat:conversation:{id}`, participants only, capabilities `["prs"]`).
- **D-17:** **`sm-user-plugin` registers the lighthouse `user` namespace authorizer** (`user:{id}` allowed only to that user) if it doesn't already. chat (`chat:message`, `chat:unread`) and chatchannels (`mentions.changed`) publish there, and Phase 19.1's herald and forum will too.
### Attachments and search
- **D-18:** **Attachments are private `lagoon/attach` files.**
- They are `system_files` rows with `is_public=false`, stored through `attach.Store` with ChatAttachmentPolicy's limits: 100 MB (`MAX_KB` 102400), at most 5 files (`MAX_ATTACHMENTS`), an extension allowlist, and SVG, HTML and XML blocked.
- Downloads go only through a `/_chat/api/v1` route that checks participation and streams the blob. They're never reachable through the public static handler.
- The attachment `kind`, `width` and `height` serialization stays as in PHP.
- **D-19:** **`chat:migrate-attachments` is not ported.** Run it on PHP before cutover.
- **D-20:** **Message search ports on `beachcomber`.**
- The read-only `MessageIndex` becomes a `beachcomber.Searchable` registration from chatchannels.
- It uses the same Typesense collection name (the scout prefix plus `messages`) and the same document fields.
- Indexing fails open, as in PHP.
- Search is exposed through `/_chatchannels/api/v1`.
### Proof and tests
- **D-21:** **A test-only host in each plugin repo,** as in P18 D-03: testcontainers Postgres, the lighthouse memory driver, beachcomber with a fake engine, a neutral fixture context (for example `acme` teams, as chatchannels' PHP tests use `Acme\Teams\Team`), and `sm-user-plugin`. Every PHP test method (59 in chat, about 130 in chatchannels) maps to a Go case or to an explicit "not applicable" note (QuestStream-only cases). Golden payloads and events are captured from Wavepath. The mapping is completed in the last plan.
### Claude's Discretion
- Go package, type and identifier names, including the context interface, the registry, the act-for hook, the service APIs and the event types.
- Exact endpoint paths of the generic API, within D-07 and D-08.
- Queue name and job shape for `MaintainThreadSummaryJob` on conga (PHP uses the `broadcasts` queue; it's dispatched after message create and delete when `root_id` is set, off-request).
- Config keys: `use_ui_avatars` (env `CHAT_USE_UI_AVATARS`) carries over under the plugin namespace.
- Lang strings port to phrasebook in EN and PL. The plugin display name becomes neutral (for example "Chat" rather than "Friends Chat"), and QuestStream strings are dropped per D-02.
- Whether the adoption rewrite (D-12) is a migration, a console command, or both.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Expect chat first, then chatchannels, then the user plugin additions where they're needed.
- Run the security-review agent. The phase adds a public authenticated API, private file downloads, channel authorization and a GDPR delete cascade.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Port sources (frozen, D-01)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/chat` at `943b712`: models, `contracts/ChatContextInterface.php`, `classes/{ContextResolver,AbstractContextAdapter,ChatService,ChatChannelAuthorizer,ChatAttachmentPolicy}.php`, `traits/ChatBroadcasting.php`, `jobs/MaintainThreadSummaryJob.php`, `updates/`, `tests/` (59 methods)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/chatchannels` at `b92fb42`: `classes/ChannelService.php` (1381 lines), `MentionParser`, `HandleGenerator`, `SearchIndexer`, `contracts/ChannelAccessResolver`, `models/MessageIndex`, `updates/`, `tests/` (about 130 methods)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/routes.php` lines 171, 488-526, 558-590, 673-721: the community routes that the generic API mirrors (D-07)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/Plugin.php` lines 163-190 (context adapter registration) and 209 (access resolver rebinding): how a host wires the seams
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/userfriends/Plugin.php` lines 41-52: the other context adapter and provider registration
- `/media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/`: the realtime events the client listens to (`created.chat.message`, `typing.chat.message`, `mentions.changed`, `chat:bot`) and the API usage in `api/dmApi.ts`
### Project decisions
- `.planning/notes/core-plugin-port-inventory.md`: why websockets isn't ported (lighthouse covers it), chat and chatchannels sizes, port order
- `.planning/notes/core-plugins-own-repos.md`: the `sm-*-plugin` repo, module, submodule and commit workflow; core plugin contracts stay non-breaking
- `.planning/notes/plugin-layout-winter-directories.md` §"Evidence (jz golem15/chat)": the classes/models edges in chat (ChatAttachmentPolicy, the ChatBroadcasting hook, the context resolver)
- `.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md` D-04, D-08, D-09: squashed baseline, build-time morph registry, and shared-users-column precedents
- `.planning/phases/18-payment-gateway/18-CONTEXT.md` D-03, D-06: the test-only host and the `/_x/api/v1` customer API precedents
### Framework modules
- `modules/lighthouse/README.md`: publisher, `Bind`, `Emit`, Registry/Authorizer, `Mount` surfaces, memory driver
- `modules/lagoon/README.md` §Attachments: `attach.Store`, `attach.Limits`, `is_public`, `attach.Owner` morph name, two-phase delete
- `modules/beachcomber/README.md`: `Searchable`, Typesense engine, kill-switch
- `modules/festival/README.md`: typed synchronous events
- `modules/conga`: River jobs, for the thread summary job
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `lighthouse`: replaces the whole WebSockets dependency. `Bind` covers the `created.chat.message` model broadcast; `Emit` and `Publish` cover the typing, read, unread, `chat:message` and `mentions.changed` events; `Registry.Register("chat", …)` covers ChatChannelAuthorizer; `MemoryDriver` serves the golden event tests.
- `lagoon/attach`: protected `system_files` storage with limits and an extension and MIME allowlist, matching ChatAttachmentPolicy.
- `beachcomber` with its `typesense` driver: the Scout successor for MessageIndex.
- `conga`: River jobs for `MaintainThreadSummaryJob`.
- `festival`: the typed event bus for the D-13 hooks.
- `sm-user-plugin`: the `jwt.auth` guard, the user model and lifecycle; it gains `handle` (D-15) and the `user` authorizer (D-17).
### Established Patterns
- A squashed idempotent baseline that adopts the PHP DB (Phase 5, P18.1 D-04).
- A build-time registry instead of PHP singletons and config pushes (P18 D-15, P18.1 D-08).
- A headless `/_x/api/v1` API per plugin (P15 D-14, P18 D-06, P18.1 D-13).
- Plugin READMEs never name a consuming app (CLAUDE.md); the fixtures use `acme`.
### Integration Points
- The app (later Wavepath) registers context aliases (D-11), installs the ChannelAccessResolver (D-14), and mounts both APIs (D-09).
- chat → chatchannels only through festival events (D-13); chatchannels requires chat.
- Message broadcasts are enqueued in the write transaction through lighthouse callbacks, so nothing is published for a write that rolls back.
</code_context>
<specifics>
## Specific Ideas
- "No trace of QuestStream / Family / Child at this stage". This applies to code, strings, comments and READMEs.
- The generic API should let the Wavepath port reuse it rather than re-implement it: identical payloads and serializers.
</specifics>
<deferred>
## Deferred Ideas
- A `vue-chat-layer` with chat window and badge components: once a client wants the generic API.
- QuestStream chat features (family contexts, parent oversight): with a future QuestStream port, in its app plugin.
- `chat:migrate-attachments`: run on PHP before the Wavepath cutover, not ported (D-19).
- Wavepath's `/_wave/api/v1/community` routes: with the Wavepath app port.
### Reviewed Todos (not folded)
- The todo matcher returned only keyword noise (journal author fields, menu/faq/sitemap ports, docs and scaffold todos). None concern chat, so none were folded.
</deferred>
---
*Phase: 19-chat-and-chatchannels*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,61 @@
# Phase 19: Chat and ChatChannels - 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-09
**Phase:** 19-chat-and-chatchannels
**Areas discussed:** Source and QuestStream, HTTP surface, Extension seams, Attachments and search
---
## Source and QuestStream
| Question | Options | Selected |
|---|---|---|
| Frozen source | Wavepath HEADs (943b712 / b92fb42); latest develop at plan time | Wavepath HEADs |
| QuestStream leftovers | Schema only; port everything 1:1; drop column too | Drop column too ("won't be migrating QuestStream anytime soon") |
| canActAs | Keep in contract; drop it | Keep, "as generic as possible, no trace of QuestStream / Family / Child" |
| Migrations | Squashed baseline; port each version | Squashed baseline |
| family_id on adopted DBs | Drop if exists; ignore if present | Ignore if present |
## HTTP surface
| Question | Options | Selected |
|---|---|---|
| Plugin HTTP surface | Service API + serializers; plus generic /_chat/api/v1; move /_wave community routes in | Plus generic /_chat/api/v1 |
| Legacy /api/chat routes | Drop both; port as-is | Drop both |
| SC3 parity proof | Golden payloads + events; test-host routes | Golden payloads + events |
| Frontend | No layer now; vue-chat-layer | No layer now |
| API shape | Mirror /_wave community; fresh REST design | Mirror /_wave community |
| Channel endpoints | /_chatchannels/api/v1; all under /_chat | /_chatchannels/api/v1 |
| Context in URL | Morph alias + id; conversation id only | Morph alias + id |
| Auth | jwt.auth + named buckets; app mounts with its guard | jwt.auth + named buckets |
## Extension seams
| Question | Options | Selected |
|---|---|---|
| Context plug-in | Build-time morph registry; keep PHP class names | Build-time morph registry |
| Morph values in existing rows | Alias + legacy class map; rewrite on adoption | Rewrite on adoption |
| Message/User hooks | Typed festival events; GORM callbacks | Typed festival events |
| users.handle | chatchannels migration; move into sm-user-plugin | Move into sm-user-plugin |
| ChannelAccessResolver default | Deny-all + setter; derive from chat context | Deny-all + setter |
| user:{id} authorizer | sm-user-plugin; chat plugin | sm-user-plugin |
## Attachments and search
| Question | Options | Selected |
|---|---|---|
| Storage | attach private + service route; signed URLs | attach private + service route |
| chat:migrate-attachments | Port it; skip | Skip |
| MessageIndex search | Port on beachcomber; defer | Port on beachcomber |
| Tests | Map PHP tests in test host; fresh Go tests | Map PHP tests |
## Claude's Discretion
Go names, exact generic API paths, the thread summary job's queue, config key namespacing, lang neutral naming, whether the adoption rewrite is a migration or a command, plan split.
## Deferred Ideas
vue-chat-layer; QuestStream chat features; chat:migrate-attachments (run on PHP); /_wave community routes (Wavepath port).

View File

@@ -0,0 +1,233 @@
# Phase 19.1: Chat satellites - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
The five remaining shared plugins that Wavepath's app plugin requires run on SummerCMS, each in its own `sm-*-plugin` repo under `git.golem15.com/golem15/`:
| PHP plugin | Go repo / plugin ID | What it is | PHP size |
|---|---|---|---|
| `Golem15.ChatBots` | `sm-chatbots-plugin` / `golem15.chatbots` | Slash-command registry and dispatcher over chat, the `bot_flags` table, the `BotAuthResolver` seam | ~1.4k LOC |
| `Golem15.ChatVideo` | `sm-chatvideo-plugin` / `golem15.chatvideo` | LiveKit video sessions per conversation, call links (`join_code`), `video:*` realtime events | ~1.1k LOC |
| `Golem15.Forum` | `sm-forum-plugin` / `golem15.forum` | Context-scoped categories, threads and posts in Markdown, read marks, a deny-all access resolver | ~2.1k LOC |
| `Golem15.Herald` | `sm-herald-plugin` / `golem15.herald` | Web Push delivery core: subscriptions, payload, send engine, 404/410 pruning, `HeraldProfile` and `PresenceGate` seams | ~1.5k LOC |
| `Golem15.UserFriends` | `sm-userfriends-plugin` / `golem15.userfriends` | Friendships, friend groups (members, invites, bans), the friends chat context adapter | ~2.1k LOC |
Each plugin provides its schema, models, a typed Go service API, a generic headless JSON API (D-05) and its events.
Out of scope:
- Wavepath's own `/_wave/api/v1/community/...` and `/_wave/api/v1/push/...` routes, its controllers, and its host bindings: `WaveForumAccessResolver`, `WaveBotAuthResolver`, `WaveHeraldProfile`, `WavePresenceGate`, `WaveNotificationDispatcher`, `CommunityVideoService` and the group-lifecycle wiring in `wavepath/Plugin.php`.
- The Vue frontend and the Wavepath app port itself.
**Repos touched:**
- The five new plugin repos.
- `sm-user-plugin`, only for additive user-lifecycle events that Phase 19 has not already added.
- `summercms.go`, for the framework Markdown module (D-17, delivered as a separate quick task), and only if `lighthouse`, `conga`, `fetchguard` or `festival` lack something.
**Roadmap corrections** (make them in the first planning commit):
- Success criterion 2: drop "Chatbots is wired to `sm-golem-plugin` where the PHP original calls Golem". The PHP chatbots plugin never calls Golem; it requires only Chat and User.
- Success criterion 2: herald's decision note names `webpush-go` (D-09). Add decision notes for LiveKit (D-13) and bluemonday (D-16).
- Success criterion 3 is already met. Fonoteka's and Wavepath's userfriends are the same commit (`182b514`, no diff), fonoteka's PHP code never references it, and fonoteka.go has no port. Wavepath's copy is the single source.
- "Repos" line: add `summercms.go` for the Markdown module.
</domain>
<decisions>
## Implementation Decisions
### Carried forward from Phase 19 (not re-decided)
- **D-01:** **The frozen port contract is Wavepath's tree.**
- The commits are chatbots `1ecf8c8`, chatvideo `25135b4`, forum `ad10b54`, herald `a749f83` and userfriends `182b514`, read from `/media/nvme/dev/jz/wavepath.org/plugins/golem15/`.
- The P15/P18/P19 frozen-source rule applies. These names and shapes are binding: tables and columns, migration-equivalent schema, realtime event names, channels and payloads, serializer output, console command names, config keys, env names and lang keys.
- **Reversibility:** costly. The names are copied into the database and clients of every app.
- **D-02:** **One squashed, idempotent baseline per plugin,** equal to the final PHP schema, that adopts an existing Wavepath database unchanged (Phase 5, P18.1 D-04, P19 D-04). The researcher diffs each baseline against a real Wavepath database.
- The demo-seed migrations (chatvideo `seed_demo_users`, userfriends `seed_demo_friends`) are not ported as behavior (D-07). Their version IDs stay recorded so that adopted databases stay consistent, and fresh databases get no demo users.
- **Reversibility:** one-way. Migration IDs and table shapes become the shared contract.
- **D-03:** **The P19 seam rules apply to every satellite.**
- Contexts register through the build-time morph registry (P19 D-11). Stored PHP class names in morph columns are rewritten to aliases on adoption (P19 D-12). That covers `golem15_forum_categories.context_type` and any satellite morph column the researcher finds.
- Cross-plugin hooks are typed `festival` events, and no plugin patches another's model (P19 D-13). The GDPR user-delete cascades listen to `sm-user-plugin` events:
- chatbots deletes the `bot_flags` row;
- forum sets `user_id` to null on threads and posts and keeps the bodies;
- userfriends hands group ownership to the oldest admin, else the oldest member, else hard-deletes the empty group, then removes the user's member and ban rows.
- Every access seam is deny-all by default and has a setter, and Boot logs a Warn while the default is active (P19 D-14). That covers `ForumAccessResolver`, `BotAuthResolver` and herald's `PresenceGate`.
- Realtime goes through `lighthouse`, and user-targeted pushes go to the `user` namespace that `sm-user-plugin` registers (P19 D-16, D-17).
- Nothing names QuestStream, family, parent or child (P19 D-02), and READMEs never name a consuming app.
- **D-04:** **A test-only host per plugin repo** (P18 D-03, P19 D-21): testcontainers Postgres, the lighthouse memory driver, fakes for external services (a LiveKit Twirp fake and a push-service fake), neutral `acme` fixture contexts, and `sm-user-plugin`, plus `sm-chat-plugin` where the plugin requires it. Every PHP test method maps to a Go case or to an explicit "not applicable" note. Golden payloads and events are captured from Wavepath. The mapping is completed in the last plan.
### HTTP surface
- **D-05:** **Every satellite ships a typed Go service API plus a generic headless JSON API under its own prefix,** following P19 D-06 to D-09:
- `/_forum/api/v1` mirrors `/_wave/api/v1/community/forums/{groupId}/...` (categories with create, rename, reorder and delete; threads, posts, edit and delete, read marks, unread count), re-rooted on context alias and id: `/_forum/api/v1/contexts/{type}/{id}/categories`. Access is decided by `ForumAccessResolver`.
- `/_chatvideo/api/v1` mirrors `/_wave/api/v1/community/groups/{id}/video` (start, join, leave, end, status) and `community/call-links/{code}`, addressed by conversation, or by context through chat's context registry.
- `/_userfriends/api/v1` mirrors `/_wave/api/v1/community/friends*` (list, requests, sent, request, accept, reject, cancel, unfriend) and the group routes (`community/groups*`: groups, members, invites, bans).
- `/_herald/api/v1/push/subscription` with POST, PATCH and DELETE, plus `GET /_herald/api/v1/push/vapid-public-key`, mirroring `/_wave/api/v1/push/*`.
- `/_chatbots/api/v1` covers the command catalog, filtered by `isAvailable`, and command dispatch, mirroring Wavepath's `CommunityCommandController`. The invoker is always the authenticated actor (PHP CMD-06).
- Auth is `jwt.auth`, mounted with the `lighthouse.Mount` surfaces pattern so the app picks the guard. PHP's named rate buckets carry over: `video-initiate` at 5 per minute per user, the friend-request bucket (`community-join`), and a general bucket per API. The actor id always comes from the token, never from the request body.
- One serializer set serves both the generic API and the future `/_wave` aliases, so payloads are identical.
- **Reversibility:** one-way. These are published APIs that apps will consume.
- **D-06:** **This reverses the PHP "herald ships no routes" rule** (herald README, D-78-04) for consistency with the other plugins. The herald README's reuse contract is rewritten around the generic API and the Go seams.
- **D-07:** **Dropped, as in P19 D-10:**
- the legacy plugin-owned routes `/api/video/*` and `/api/friends/*`, which no client calls;
- the WinterCMS components `VideoCall`, `FriendsList` and `FriendRequests` and their templates;
- chatvideo's bundled `livekit-client.umd.min.js`, `video-chat.js`, `video-vue.js` and CSS;
- the demo seeders (D-02).
- **D-08:** **Satellites couple through generic context events, never through each other's types.**
- userfriends fires typed festival events for group created, group deleted, member joined and member left.
- forum exposes a generic `DeleteContext(type, id)`, a soft-delete cascade over categories, threads and posts. It does not import userfriends.
- The app, or a small optional bridge in the app, wires group-deleted to `forum.DeleteContext`. This replaces PHP forum's `class_exists(FriendGroup)` hook.
- The other group hooks in `wavepath/Plugin.php` (spawning a chat or channel on group create, ending video on group delete) stay app wiring for the Wavepath port.
- userfriends registers its chat context adapter (the `FriendContext` provider) softly, only when chat is registered, through an optional-capability check rather than a hard requirement. This keeps PHP's "chat is the lower layer" rule.
### Herald (Web Push)
- **D-09:** **Web Push uses `github.com/SherClockHolmes/webpush-go`,** which covers VAPID (RFC 8292) and aes128gcm (RFC 8291).
- The decision note required by the roadmap goes in `.planning/notes/` before any herald code lands. It records the version, maintenance state, transitive dependencies (it should already share `golang-jwt/jwt/v5`), and how its HTTP client is swapped for fetchguard's (D-10).
- The researcher verifies that the library's `Options.HTTPClient` accepts a `fetchguard.Client`-backed doer. If it doesn't, the note records the adapter.
- **Reversibility:** reversible. It sits behind the plugin's own sender.
- **D-10:** **Every push send goes through a `fetchguard` PublicOnly client** (HTTPS only, private and reserved IPs refused at dial time).
- There is no `EndpointGuard` seam. Dial-time checks close the subscribe-then-send DNS-rebind gap that PHP's IP pinning (`CURLOPT_RESOLVE`) and the host-bound `SsrfGuard` existed for.
- Subscribe time also validates the endpoint URL (HTTPS, public host).
- This is fail-closed by default, where PHP's `AllowAllEndpointGuard` was fail-open.
- **D-11:** **Sends run as a `conga` job, one per send.**
- `SendEvent` and `SendUnconditional` enqueue a River job inside the caller's transaction, so a rolled-back write pushes nothing.
- The job runs `PresenceGate.ShouldSend` (for `SendEvent` only), builds the payload with `HeraldProfile`, sends to every subscription the user has, and prunes 404 and 410 endpoints.
- River provides retries and backoff. PHP's rate-limited OBS-01 warning for other delivery failures carries over.
- The PHP defaults carry over: TTL 2419200 (4 weeks) and urgency `normal`, both overridable per send.
- **D-12:** **VAPID keys are config keys** `golem15.herald.vapid.{subject,public_key,private_key}` with the default subject `mailto:admin@example.com`.
- The PHP env names `VAPID_SUBJECT`, `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are mapped so an adopted `.env` works unchanged.
- `summer herald:vapid-generate` (the PHP `herald:vapid-generate`) prints a new keypair. The private key is never logged and never appears in a payload.
- Boot warns when the keys are missing. Keys are never generated automatically, because rotating them invalidates every subscription.
### ChatVideo (LiveKit)
- **D-13:** **chatvideo uses the official `github.com/livekit/server-sdk-go`** for access tokens and RoomService (create, delete, remove participant, participant count, list rooms).
- A decision note in `.planning/notes/`, written before the code lands, records the version, the transitive dependency footprint (`livekit/protocol`, twirp, protobuf; flag anything heavy such as pion), and how tests fake it.
- The token claims must match PHP exactly, verified by a golden test: HS256, `iss` set to the API key, `sub` set to the user id as a string, `name`, `iat`/`nbf`/`exp` with `token_ttl` defaulting to 900, a `video` grant with `room`, `roomJoin`, `canPublish` and `canSubscribe`, and an optional JSON `metadata` string carrying the avatar.
- The admin URL keeps PHP's fallback: `livekit_admin_url`, else `livekit_url` with `wss://` or `ws://` rewritten to `https://` or `http://`. An error for a missing room is swallowed as in PHP (`participantCount` returns 0, `deleteRoom` is a no-op).
- Config keys and env names carry over: `livekit_url` and `LIVEKIT_URL`, the API key, the secret, `max_duration` (3600), `token_ttl` (900), `livekit_admin_url`, `max_participants` (8) and `screen_share`.
- **Reversibility:** reversible. It sits behind the plugin's LiveKit service.
- **D-14:** **The stale-session reconciler moves from the Wavepath app into chatvideo.**
- A generic reconcile service ends sessions whose LiveKit room is gone or empty, or that have run past `max_duration`. It is exposed as `summer chatvideo:reconcile-sessions` and as a `conga` periodic job (every 5 minutes, as Wavepath schedules it).
- The Wavepath port's `wave:reconcile-video-sessions` becomes a thin alias. The researcher reads `wavepath/console/ReconcileVideoSessions.php` and keeps only its generic part.
- **D-15:** **Realtime events are the PHP events, published through lighthouse.**
- `video:call:incoming` goes to recipients' `user:{id}` channels.
- `video:call:accepted`, `declined`, `ended`, `video:participant:joined` and `left` go to `video:session:{id}`.
- chatvideo registers the `video` namespace authorizer. It allows `video:session:{id}` only to participants of the session's conversation and grants capability `["prs"]`. It replaces `VideoChannelAuthorizer`.
- A publish failure is logged and not raised, as in PHP.
- The one-active-session-per-conversation unique index and the `card_message_id` and `join_code` columns carry over.
### Forum (Markdown)
- **D-16:** **Sanitizing uses `github.com/microcosm-cc/bluemonday`,** recorded in a decision note.
- The policy is the PHP HTMLPurifier config, 1:1:
- Allowed tags: `p, br, strong, em, b, i, h1-h6, ul, ol, li, blockquote, pre, code, a[href|rel|target], table, thead, tbody, tr, th, td, hr, del, ins, sub, sup`.
- URL schemes: `http`, `https` and `mailto`.
- Links get `target=_blank` and `rel=nofollow`.
- Conversion uses `goldmark`, already a dependency, with GFM. That covers tables, strikethrough and autolinks (the external-link behavior), with raw HTML input stripped (PHP `html_input=strip`) and unsafe links disallowed.
- **D-17:** **The renderer is a framework module, not forum code.**
- It is a small shared package in `summercms.go` (or an extension of cabana's markdown support; the quick task decides) that exports `Render(markdown)` and `Purify(html)` with a named policy.
- The user's direction: "Framework module, done with gsd-quick by summer agent. We'll need that in multiple places." It is delivered as a separate `/msd-quick` task in `summercms.go` by the summer agent. It is not a plan of this phase. The forum plans consume it, and the Wavepath port's `purify()` callers (trainings) use it too.
- If the quick task hasn't landed when forum execution starts, the forum plan blocks on it. The forum plan does not grow its own renderer.
- The module's README and `docs/` page follow the Documentation rules in CLAUDE.md.
- **D-18:** **`content_html` parity is semantic, and stored rows are kept.**
- Adopted `content_html` rows are served as stored and never re-rendered on read. New and edited posts render in Go, on write, as in PHP.
- Golden tests compare a normalized DOM (the same tags, attributes and text) against PHP output for a corpus that covers the PHP `ForumMarkdownRenderTest` XSS cases (`<script>`, `javascript:`, `<iframe>`, `onerror`), GFM tables, code blocks, links and autolinks.
- Byte-identical HTML is not required.
### Claude's Discretion
- Go package, type and identifier names for the services, seams, events and jobs. Exact generic API paths within D-05.
- How chatbots resolves bot bearer tokens through `BotAuthResolver` (deny-all default) on the generic API, and where `is_bot` and the bot flag show up in serializers. Follow Wavepath's `BotRestReachTest` and `WaveBotAuthResolverTest` for the expected behavior.
- How chatvideo addresses a call in the generic API (by conversation id, or by context alias through chat's registry), as long as Wavepath's group-video payloads are reproducible.
- Queue names and job shapes on `conga` (herald send, video reconcile).
- Lang strings port to phrasebook in EN and PL. Plugin display names are neutral.
- Whether the D-12-style alias rewrite for satellite morph columns extends chat's adoption command or each plugin ships its own.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Expect userfriends and forum early (no external services), then chatbots, then herald and chatvideo (new dependencies and their decision notes).
- Run the security-review agent. The phase adds public authenticated APIs, an outbound push sender, a token issuer for a media server, an HTML sanitizer boundary and GDPR delete cascades.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Port sources (frozen, D-01)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/chatbots/`: `Plugin.php`, `contracts/{SlashCommand,BotAuthResolver}.php`, `classes/{SlashCommandRegistry,CommandDispatcher,CommandContext,CommandResult}.php`, `models/BotFlag.php`, `tests/unit/` (7 files)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/chatvideo/`: `Plugin.php`, `classes/{LiveKitService,VideoCallNotifier,VideoChannelAuthorizer}.php`, `controllers/api/VideoApiController.php`, `config/config.php`, `models/`, `updates/` (v1.0.0 to v1.0.4)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/forum/`: `Plugin.php`, `classes/{ForumService,markdown/MarkdownRenderer}.php`, `contracts/ForumAccessResolver.php`, `models/`, `updates/` (v1.0.0 to v1.1.0), `tests/` (including `security/ForumMarkdownRenderTest.php`)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/herald/`: `Plugin.php`, `README.md` (the reuse contract), `contracts/{HeraldProfile,PresenceGate,EndpointGuard}.php`, `classes/{PushSender,PayloadBuilder,services/SubscriptionService}.php`, `config/herald.php`, `console/GenerateVapidKeys.php`, `tests/unit/`
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/userfriends/`: `Plugin.php` (soft chat registration and the GDPR cascade), `classes/` (GroupPolicy, GroupVisibilityGate, the services, FriendsContextAdapter), `models/`, `updates/`, `tests/unit/`
### How the host consumes them (route and payload shapes for D-05, host seams)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/routes.php`: lines 438-549 (groups and friends), 623-629 (group video), 631+ (forums), 830-840 (push subscription and vapid key), plus the command routes
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/Plugin.php` lines 160-460: seam bindings (forum, chatbots, herald) and group lifecycle wiring (D-08)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/`: `CommunityForumController`, `CommunityFriendsController`, `CommunityGroupController`, `CommunityVideoController`, `CommunityCommandController`, `BotRestController` (bot bearer-token reach), `PushSubscriptionController`
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/console/ReconcileVideoSessions.php`: the reconciler that D-14 generalizes
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/classes/{chatbots,notifications,forum,video}/`: the host implementations of the seams (reference only, not ported)
- `/media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/api/{friendsApi,videoApi}.ts` and the push subscription code: the client's view of the payloads
### Project decisions
- `.planning/phases/19-chat-and-chatchannels/19-CONTEXT.md`: D-02 (no QuestStream), D-04 (baseline), D-06 to D-10 (HTTP surface), D-11 to D-14 (seams), D-16 and D-17 (lighthouse and the `user` namespace), D-21 (test host)
- `.planning/notes/core-plugins-own-repos.md`: the `sm-*-plugin` repo, module, submodule and commit workflow
- `.planning/notes/core-plugin-port-inventory.md`: satellite sizes and port order
- `.planning/notes/plugin-layout-winter-directories.md`: the plugin directory layout
- `.planning/phases/18-payment-gateway/18-CONTEXT.md` D-03, D-06 and `.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md` D-04, D-08: the test-host, `/_x/api/v1`, baseline and morph-registry precedents
### Framework modules
- `modules/lighthouse/README.md`: publisher, `Emit`/`Publish`, Registry/Authorizer (the `video` namespace), `Mount`, memory driver
- `modules/fetchguard/README.md`: `NewClient`, `PublicOnlyMode` for herald sends (D-10), `TrustedMode` for the operator-configured LiveKit URL
- `modules/conga/README.md`: River jobs and periodic jobs (D-11, D-14)
- `modules/festival/README.md`: typed events (D-03, D-08)
- `modules/cabana/field_markdown.go`: the existing goldmark usage, a starting point for the D-17 module
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `lighthouse`: replaces `Golem15.WebSockets` (`CentrifugoClient::publish` and `publishToUsers`, `AuthorizerRegistry`) for chatvideo and herald's presence source.
- `fetchguard`: a guarded `http.Client` that checks the IP at dial time; it makes herald's `EndpointGuard` and IP pinning unnecessary (D-10).
- `conga`: River jobs for herald sends and the video reconciler.
- `festival`: typed events for user-delete cascades and group lifecycle (D-03, D-08).
- `goldmark` v1.8.6 is already in `go.mod` (used by cabana). bluemonday, webpush-go and livekit server-sdk-go are new dependencies, each with a decision note.
- `golang-jwt/jwt/v5`: already decided; shared with webpush-go and possibly livekit.
- `sm-chat-plugin` (Phase 19): the context registry, conversations and participants that chatvideo, chatbots and userfriends build on.
### Established Patterns
- A deny-all seam plus a setter, with a Boot warning (P19 D-14), replaces PHP's `$this->app->bind(Contract, DenyAll…)`.
- Build-time registries replace singletons: `SlashCommandRegistry` (with the reserved `help` trigger, `registerBuiltin`, and duplicate rejection) becomes a Boot-time Go registry.
- `/_x/api/v1` per plugin, with one serializer set shared by the generic API and the app aliases.
- A squashed idempotent baseline that adopts the PHP database.
### Integration Points
- chatbots, chatvideo → `sm-chat-plugin` (hard requirement). userfriends → chat (soft, optional capability). forum and herald → no chat dependency.
- All five → `sm-user-plugin` (user model, `jwt.auth`, delete events). Herald in PHP required only Apparatus and kept its own `user_id`-keyed table; keep it free of user-model imports beyond the id.
- The app wires: `ForumAccessResolver`, `BotAuthResolver`, `HeraldProfile`, `PresenceGate`, its slash commands, and group-deleted → `forum.DeleteContext`.
</code_context>
<specifics>
## Specific Ideas
- On the Markdown module: "Framework module, done with gsd-quick by summer agent. We'll need that in multiple places."
- The user picked established libraries (webpush-go, livekit server-sdk-go, bluemonday) over hand-rolled stdlib code. Each still gets a decision note under the dependency rule.
- Generic APIs everywhere, including herald and chatbots, so no host has to rewrite controllers.
</specifics>
<deferred>
## Deferred Ideas
- The framework Markdown/sanitizer module (D-17): delivered by a separate `/msd-quick` task in `summercms.go`, not by this phase's plans.
- A `vue-community-layer` or similar frontend components for friends, forum, video and push: once a client other than Wavepath wants them.
- Wavepath's `/_wave` routes and host seam implementations: with the Wavepath app port.
### Reviewed Todos (not folded)
- The todo matcher returned only keyword noise (translate editor, cabana richeditor, faq port, mailblocker). None concern the satellites, so none were folded.
</deferred>
---
*Phase: 19.1-chat-satellites*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,119 @@
# Phase 19.1: Chat satellites - 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-09
**Phase:** 19.1-chat-satellites
**Areas discussed:** HTTP surface, Herald Web Push lib, ChatVideo LiveKit, Forum Markdown
Scouting facts presented before the questions:
- chatbots never calls Golem, so success criterion 2's `sm-golem-plugin` clause is vacuous.
- userfriends is the same commit (`182b514`) in fonoteka and Wavepath and is unused by fonoteka's code.
- No client calls the satellites' own routes; Wavepath's SPA uses only `/_wave/api/v1/...`.
---
## HTTP surface
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, like Phase 19 | Generic `/_x/api/v1` for forum, chatvideo and userfriends, mirroring the `/_wave` community routes and re-rooted by context alias | ✓ |
| Service API only | Matches PHP; the app owns every route | |
| Mixed | Generic for userfriends and forum; video service-only | |
| Option | Description | Selected |
|--------|-------------|----------|
| Generic APIs for both | `/_herald/api/v1/push/*` and `/_chatbots/api/v1` commands | ✓ |
| Herald yes, chatbots no | Command dispatch stays in the app | |
| Neither | Keep the PHP shape | |
| Option | Description | Selected |
|--------|-------------|----------|
| Drop all | Legacy routes, components, assets and demo seeds dropped; seed migration IDs kept as no-ops | ✓ |
| Keep legacy routes as aliases | Mount `/api/video` and `/api/friends` as aliases | |
| Drop routes, keep seeds | Demo seeders become an opt-in command | |
| Option | Description | Selected |
|--------|-------------|----------|
| Generic context events | userfriends fires group events; forum exposes `DeleteContext`; the app wires them | ✓ |
| Forum listens directly | Optional capability check on userfriends | |
| You decide | | |
---
## Herald Web Push lib
| Option | Description | Selected |
|--------|-------------|----------|
| Hand-rolled, stdlib | crypto/ecdh, hkdf, AES-GCM, and golang-jwt for VAPID | |
| SherClockHolmes/webpush-go | The de-facto Go library | ✓ |
| You decide after research | | |
| Option | Description | Selected |
|--------|-------------|----------|
| fetchguard built in | PublicOnly client, no EndpointGuard seam, fail-closed | ✓ |
| Keep the seam, fetchguard default | A host can add a push-service allow-list | |
| Port as is | Allow-all default | |
| Option | Description | Selected |
|--------|-------------|----------|
| conga job per send | Enqueued in the caller's transaction; gate, send and prune in the job | ✓ |
| Synchronous like PHP | | |
| Both | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Config keys + env, port the command | PHP env names mapped; `herald:vapid-generate`; warn when missing | ✓ |
| Generate on first boot | | |
---
## ChatVideo LiveKit
| Option | Description | Selected |
|--------|-------------|----------|
| Hand-rolled | golang-jwt tokens plus Twirp JSON over fetchguard | |
| livekit/server-sdk-go | The official SDK, with a decision note | ✓ |
| livekit/protocol auth only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Move into chatvideo | A generic reconcile service, command and conga periodic job | ✓ |
| Leave it in the app | | |
---
## Forum Markdown
| Option | Description | Selected |
|--------|-------------|----------|
| bluemonday | The PHP HTMLPurifier policy mapped 1:1; goldmark for conversion | ✓ |
| Hand-rolled on x/net/html | | |
| Framework-level sanitizer | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Semantic parity, keep stored rows | Normalized-DOM golden tests; adopted rows served as stored | ✓ |
| Byte-identical | | |
| Re-render all on adoption | | |
| Option | Description | Selected |
|--------|-------------|----------|
| In sm-forum-plugin, exported | | |
| Framework module | | ✓ |
**User's choice:** "Framework module, done with gsd-quick by summer agent. We'll need that in multiple places."
**Notes:** The module is delivered by a separate quick task in summercms.go, not by a plan of this phase. Forum consumes it.
---
## Claude's Discretion
- Identifier names, exact generic API paths, chatbots bot-token handling on the API, how calls are addressed in the chatvideo API, conga queue and job shapes, phrasebook strings, where the satellite alias rewrite lives, and the plan split.
## Deferred Ideas
- The framework Markdown/sanitizer module, delivered by a separate quick task.
- Frontend components for friends, forum, video and push.
- Wavepath's `/_wave` routes and host seam implementations, with the Wavepath app port.

View File

@@ -93,6 +93,11 @@
"name": "Local Fonoteka frontend on local SummerCMS backend (INSERTED)",
"status": "pending"
},
{
"number": "14.3",
"name": "One-command app scaffolding (INSERTED)",
"status": "in_progress"
},
{
"number": "15",
"name": "Journal plugin",
@@ -101,7 +106,7 @@
{
"number": "16",
"name": "grzybyfunkcjonalne.pl on reusable blog views",
"status": "pending"
"status": "in_progress"
},
{
"number": "17",
@@ -113,6 +118,11 @@
"name": "Payment Gateway",
"status": "pending"
},
{
"number": "18.1",
"name": "Payment Gateway Subscriptions",
"status": "pending"
},
{
"number": "19",
"name": "Chat and ChatChannels",
@@ -130,9 +140,9 @@
}
],
"next": {
"command": "/gsd:progress --next",
"command": "/msd:progress --next",
"label": "Advance to the next step",
"reason": "Phase 14.2 of 30 · executing"
"reason": "Phase 16.1 of 34 · executing"
},
"updated_at": "2026-10-07T19:26:02.872Z"
"updated_at": "2026-10-09T18:51:01.734Z"
}

View File

@@ -0,0 +1,32 @@
---
created: 2026-10-09T16:45:00Z
title: Let plugins register their own value-bearing cabana field types (Winter registerFormWidgets successor)
area: framework
severity: major
files:
- modules/cabana/form_schema.go
- modules/cabana/crud.go
- modules/cabana/extension.go
- modules/pact
- admin/src/components/form/registry.ts
---
## Problem
WinterCMS plugins ship their own form widgets through `registerFormWidgets()` and use them as `type: <widget>` fields that own a column value. cabana has no equivalent:
- The field-type set is closed. `modules/cabana/form_schema.go` (the known-types map, around lines 25-29), `modules/cabana/crud.go` (the scalar type switch, around line 1226) and the SPA renderer registry `admin/src/components/form/registry.ts` list every type, and an unknown type fails boot.
- The Phase 10.1 `type: widget` extension (`modules/cabana/extension.go`) mounts a plugin custom element, but the field is valueless: it runs a controller action and writes back declared scalar sibling fields through `fill:`. It cannot own a value, define its JSON shape, validate and decode it on save, or render it in lists.
Plugins about to be ported deliver their own fields that cannot become cabana core types (raised during the Phase 18 discussion, 2026-10-09). Without this, each one forces a framework release or a workaround through widget actions.
## Solution
Add a capability interface in `pact` (for example `HasFormFieldTypes`) through which a plugin registers a namespaced field type (`{vendor}-{plugin}-*` or `vendor.plugin::type`):
- Server side: a Go type that validates the field's YAML options at boot (fail-loud, like the built-ins), decodes and validates the submitted value (any JSON shape), maps it to the column, and optionally formats it for list columns.
- Client side: a custom element served through the existing `pact.AdminClientAssets` mechanism. It binds a value through a documented contract (a `value` property plus a `summer-change` event) instead of the action/fill round-trip, and reads the public `--c-*` CSS variables only.
- The form schema and the generated TS types carry plugin types as a generic `plugin-field` shape so the SPA build does not need to know them.
- Update the cabana README and `docs/`, the admin OpenAPI and the generated types, and commit `dist/`, all in the same change.
Decide in a framework phase (or a decimal phase before the first plugin that needs it). Phase 18's `money` field stays a core cabana type (18-CONTEXT); this todo covers the plugins that follow.

View File

@@ -0,0 +1,18 @@
---
title: Keep imported HTML post bodies intact when a Journal post is re-saved in the admin
date: 2026-10-09
priority: high
area: sm-journal-plugin
---
Phase 16 RESEARCH Pitfall 2. The PHP journal export carries the pl post `content` as **HTML** (61 of 63 published posts on grzybyfunkcjonalne.pl). `journal:import-posts` stores it as is and the pl `content_html` is correct, so imported posts render fine. But an admin save of such a post runs `FormatHTML(content)`: goldmark without `html.WithUnsafe()` replaces raw HTML with `<!-- raw HTML omitted -->`, so the post body is wiped (probe in 16-RESEARCH.md: `pl HTML content -> out="<!-- raw HTML omitted -->\n"`).
Until this is fixed, **do not edit imported posts in `/gfadmin`** (the sm-grzybyfunkcjonalne-app README repeats the warning).
Fix in `sm-journal-plugin`, one of:
- convert HTML content to markdown at import (an HTML-to-markdown step in `journal:import-posts`), or
- pass raw HTML through on save when the stored content is HTML (detect it, keep the three-pattern `CheckHTML` gate, skip goldmark), or
- add a content format flag on the post so HTML-sourced posts are edited as HTML.
Whatever the fix, it must keep the XSS gate (`classes.CheckHTML`) on every write path and keep the PHP API contract of `content`/`content_html` unchanged. Add a test that re-saves an imported HTML post and asserts `content_html` is unchanged.

View File

@@ -0,0 +1,106 @@
// Per-list query memory (D-01). The list query (search, sort, dir, page,
// per_page and filter[<name>]) lives in the URL; every query a list loads is
// also kept in localStorage under LIST_STATE_PREFIX plus the controller id,
// one key per list, so a list opened without list params (navigation,
// breadcrumb, a form's back, cancel, save or delete) can restore it. Only
// these list query parameters are stored, never a token or record data, and
// logout removes every key. Disabled or full storage is a no-op: the URL
// still carries the state.
import type { ListQuery } from '../api/types'
import { listQueryKey, parseListQuery, toListQuery } from '../app/listQuery'
export const LIST_STATE_PREFIX = 'summer-admin.list:'
const EMPTY_KEY = listQueryKey({})
function storage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage
} catch {
return null
}
}
function keyOf(controllerId: string): string {
return LIST_STATE_PREFIX + controllerId
}
function remove(key: string): void {
try {
storage()?.removeItem(key)
} catch {
// Storage may be disabled; nothing was kept then.
}
}
/**
* The stored query of a list. Stored values pass through parseListQuery like
* a hand-edited URL; anything that is not an object of strings is removed and
* reads as the empty query.
*/
export function readListState(controllerId: string): ListQuery {
const key = keyOf(controllerId)
let raw: string | null = null
try {
raw = storage()?.getItem(key) ?? null
} catch {
return {}
}
if (raw === null) {
return {}
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
remove(key)
return {}
}
if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype ||
!Object.values(value).every((item) => typeof item === 'string')
) {
remove(key)
return {}
}
return parseListQuery(value as Record<string, string>)
}
/** Stores a list's query; the empty query removes the key instead. */
export function writeListState(controllerId: string, query: ListQuery): void {
const key = keyOf(controllerId)
if (listQueryKey(query) === EMPTY_KEY) {
remove(key)
return
}
try {
storage()?.setItem(key, JSON.stringify(toListQuery(query)))
} catch {
// Storage may be full or disabled; the URL still carries the state.
}
}
/** Removes every stored list query (logout). */
export function clearListStates(): void {
const store = storage()
if (!store) {
return
}
try {
const keys: string[] = []
for (let i = 0; i < store.length; i++) {
const key = store.key(i)
if (key !== null && key.startsWith(LIST_STATE_PREFIX)) {
keys.push(key)
}
}
for (const key of keys) {
store.removeItem(key)
}
} catch {
// Storage may be disabled; nothing was kept then.
}
}

View File

@@ -4,6 +4,7 @@ import { readonly, ref } from 'vue'
import type { Router } from 'vue-router'
import { api, onRefreshed, refreshSession } from '../api/client'
import type { AdminProfile } from '../api/types'
import { clearListStates } from './listState'
import { setNavigation } from './useNavigation'
import { setSettings } from './useSettings'
@@ -82,8 +83,9 @@ export function clearUser(): void {
/**
* Ends the session: POST /auth/logout (the server blacklists the token and
* expires the HttpOnly cookie), then clears the in-memory user, navigation
* and settings and routes to the login screen. The local state is cleared
* expires the HttpOnly cookie), then clears the in-memory user, navigation,
* settings and stored list queries (so the next admin on this browser sees
* none of the previous searches) and routes to the login screen. The local state is cleared
* and the login screen shown even when the call fails (T-10-23).
*/
export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
@@ -93,6 +95,7 @@ export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
// Network failure: the session still ends on this device.
} finally {
clearUser()
clearListStates()
setNavigation([])
setSettings([])
}

View File

@@ -2,7 +2,9 @@
// D-06, D-10). The section panel hides below about 1100px or when the admin
// collapses it. The viewport only forces the collapse and never overwrites
// the admin's own choice, which persists in localStorage as a boolean under
// SIDEBAR_KEY. Nothing else is ever written to browser storage (T-10-22).
// SIDEBAR_KEY. The only other writer of browser storage is listState.ts,
// which keeps list query parameters per list; tokens and record data are
// never stored (T-10-22, T-16.1-03).
import { computed, readonly, ref } from 'vue'
export const SIDEBAR_KEY = 'summer-admin.sidebar'

View File

@@ -17,13 +17,17 @@ import PartialHost from '../components/partial/PartialHost.vue'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { readListState, writeListState } from '../state/listState'
import { activeEntry } from '../state/useNavigation'
import { showToast } from '../state/useToasts'
// Schema-driven list (design screen 3, D-12, D-13, D-14, D-22). Search, sort,
// page, per_page and filters live in the URL query and every change is a
// router replace; any change clears the selection. The view is keyed by
// route path, so it serves one controller.
// route path, so it serves one controller. Every query the list loads is also
// kept per list in browser storage (listState.ts, D-01): entered without list
// params, the list restores the stored query into the URL before its first
// list request; a URL with list params wins and overwrites the stored query.
const SEARCH_DEBOUNCE = 300
const route = useRoute()
@@ -112,9 +116,14 @@ function rowLink(row: AdminRecord): string | null {
}
function replaceQuery(next: ListQuery): void {
// Stored first, so clearing every control stores the empty query and the
// following run loads the unfiltered list instead of restoring.
writeListState(controllerId, next)
void router.replace({ path: route.path, query: toListQuery(next) })
}
const EMPTY_QUERY_KEY = listQueryKey({})
let generation = 0
async function loadList(): Promise<void> {
@@ -129,6 +138,16 @@ async function loadList(): Promise<void> {
meta.value = result.data?.meta ?? null
listFailed.value = !result.data
loading.value = false
if (result.response?.status === 422) {
// A stale stored filter or sort never sticks.
writeListState(controllerId, {})
return
}
const lastPage = meta.value?.last_page ?? 0
if (lastPage >= 1 && (meta.value?.page ?? 0) > lastPage) {
// Records were removed since the query was stored or shared.
replaceQuery({ ...query.value, page: lastPage })
}
}
async function load(): Promise<void> {
@@ -145,7 +164,18 @@ void load()
watch(
() => listQueryKey(query.value),
() => {
(key) => {
// Entered without list params (navigation, breadcrumb, a form's back,
// cancel, save or delete, or the same list's link while it is filtered):
// restore the stored query into the URL and let this watch load it.
if (key === EMPTY_QUERY_KEY) {
const stored = readListState(controllerId)
if (listQueryKey(stored) !== EMPTY_QUERY_KEY) {
void router.replace({ path: route.path, query: toListQuery(stored) })
return
}
}
writeListState(controllerId, query.value)
selected.value = []
searchText.value = query.value.search ?? ''
void loadList()

View File

@@ -3,6 +3,7 @@ import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { readListState, writeListState } from '../../src/state/listState'
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
import {
clone,
@@ -492,3 +493,25 @@ describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
})
})
describe('list state in the URL and browser storage (D-01)', () => {
it('restores the stored query before the first list request when the URL has no list params', async () => {
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc', filter: { active: 'true' } })
const { calls, router } = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const [first] = requestsTo(calls, 'GET', BASE)
const query = queryOf(first!)
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('filter[active]')).toBe('true')
expect(router.currentRoute.value.query).toMatchObject({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
})
it('lets a URL with list params win and stores its query', async () => {
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc' })
const { calls } = await mountApp('/acme/demo/widgets?search=blue', routes())
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', BASE)[0]!).get('sort')).toBeNull()
expect(readListState('acme.demo.widgets')).toEqual({ search: 'blue' })
})
})

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { parseListQuery, toListQuery } from '../../src/app/listQuery'
import { clearListStates } from '../../src/state/listState'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
@@ -39,6 +40,8 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
beforeEach(() => {
clearUser()
// Lists remember their query (D-01); every test starts from a clean list.
clearListStates()
setNavigation([])
clearToasts()
setBundle(lang.data, 'pl')

View File

@@ -330,7 +330,7 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor with a Preview toggle. When Preview opens, and again shortly after the source changes while it is open, the SPA posts the field's source (the active locale's text for `mlmarkdown`) to the preview route below and renders only the server's answer; when the output is refused it shows the server's message as text. The server renders through `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. `POST <prefix>/api/v1/markdown/preview` renders a `{markdown}` source through `cabana.RenderMarkdown` for any signed-in administrator and answers `{html}`, or a 422 `validation_failed` on `markdown` when the output is refused.
`type: mltext`, `type: mltextarea` and `type: mlmarkdown` reuse the ordinary text, textarea and markdown editors with a locale selector. `mltextarea` takes `size` like `textarea`. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped. An `mltext` field can carry a `preset`; it follows per locale as described under [Field options](#field-options).
`type: mltext`, `type: mltextarea` and `type: mlmarkdown` reuse the ordinary text, textarea and markdown editors with a locale selector. `mltextarea` takes `size` like `textarea`. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). The options list the default locale first, then the other enabled codes in the writer's order, so every ML control starts on the default locale, whose values live in the host columns. Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped. An `mltext` field can carry a `preset`; it follows per locale as described under [Field options](#field-options).
The save body sends every locale as a JSON object of code to string. Extra YAML keys are not accepted: the types reuse `label`, `comment`, `span`, `size`, `required`, `tab` and `context`. An unknown type such as `mlunknown` stops the start-up.

View File

@@ -59,7 +59,7 @@ columns:
type: datetime
```
A column takes `label`, `type` (`text`, `datetime`, `date`, `time` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. The compiled list keeps the columns in file order:
A column takes `label`, `type` (`text`, `datetime`, `date`, `time` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. A searchable relation column matches a record when any related record matches: a `categories` column with `relation: categories` and `select: name` on a blog post finds the posts with at least one matching category, for belongsTo, hasOne, hasMany and belongsToMany relations alike, each through a subquery, so a post is listed and counted once. A sortable hasMany or belongsToMany column sorts by its comma-joined values, the text the list shows. The compiled list keeps the columns in file order:
```go src=modules/cabana/example_test.go#ExampleCompileList
// The plugin embeds its controllers/ and models/ trees; the example reads

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-D3quaPLf.js"></script>
<script type="module" crossorigin src="./assets/index-Dm9E9btv.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
</head>
<body>

View File

@@ -24,7 +24,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
@@ -229,7 +229,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
| `cabana.FormMeta` | Form (and optional list) schema meta: `Locale` is the UI locale of the response; `EnabledLocales` lists content locales for ML fields when a `cabana.TranslationWriter` is published (`json:"enabledLocales,omitempty"` so list schemas omit the key). |
| `cabana.FormMeta` | Form (and optional list) schema meta: `Locale` is the UI locale of the response; `EnabledLocales` lists content locales for ML fields when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order (`json:"enabledLocales,omitempty"` so list schemas omit the key). |
| `cabana.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save and reads exact stored translations through `TranslatedExact`. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key, including relation-child records served by `cabana.RelationService`; Show/save hydration uses `hydrateMLRecord`. |
| `cabana.TranslationWriter.TranslatedExact` | Returns the stored value for a field in a locale without D-11 fallback; `ok` is false when the non-default key is missing. The default locale still reads the host column. |
| `hydrateMLRecord` | Replaces host scalars of declared `mltext`/`mltextarea`/`mlmarkdown` fields on a Show/save record — host CRUD and relation-child create, update and show — with a locale map for every enabled code. Not used from list row projection. |

View File

@@ -980,6 +980,17 @@ func (s *service) enabledContentLocales(ctx context.Context) []string {
slog.Error("cabana: enabled locales", "error", err)
return nil
}
// The SPA opens every ML control on the first code, and the default
// locale's values live in the host columns, so the default goes first
// and the other codes keep the writer's order.
def, err := writer.DefaultLocale(ctx, db)
if err != nil {
slog.Error("cabana: default locale", "error", err)
return codes
}
if i := slices.Index(codes, def); def != "" && i > 0 {
codes = append([]string{def}, slices.Delete(slices.Clone(codes), i, i+1)...)
}
return codes
}

View File

@@ -0,0 +1,27 @@
package cabana
import (
"context"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// TestEnabledLocalesDefaultFirstSmoke pins D-03: the form schema lists the
// default locale first, so every ML control opens on the locale whose values
// live in the host columns, whatever order the writer keeps.
func TestEnabledLocalesDefaultFirstSmoke(t *testing.T) {
db, _ := openDryRunDB(t)
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
if err := app.Publish[TranslationWriter](&recordingWriter{defaultLocale: "pl", enabled: []string{"en", "pl"}}); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
if got := svc.enabledContentLocales(context.Background()); !slices.Equal(got, []string{"pl", "en"}) {
t.Fatalf("enabledContentLocales = %v, want [pl en]", got)
}
}

View File

@@ -17,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
gormschema "gorm.io/gorm/schema"
)
// ListInput is the raw admin list query. Identifiers are matched later
@@ -129,7 +130,10 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, err
}
q = applyListOrder(q, cc.Controller, cc.List, model, sortCol, sortDesc)
q, err = applyListOrder(q, cc.Controller, cc.List, model, sortCol, sortDesc)
if err != nil {
return nil, err
}
for _, col := range cc.List.Columns {
if col.Relation == "" {
continue
@@ -290,12 +294,11 @@ func normalizeSort(schema *ListSchema, in ListInput) (string, bool, error) {
return match.Key, dir == "desc", nil
}
func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, column string, desc bool) *gorm.DB {
func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, column string, desc bool) (*gorm.DB, error) {
table := tableName(model)
pk := primaryColumn(model)
if column != "" {
orderTable := table
orderColumn := column
order := clause.OrderByColumn{Column: clause.Column{Table: table, Name: column}, Desc: desc}
for _, col := range schema.Columns {
if col.Key != column || col.Relation == "" {
continue
@@ -304,22 +307,149 @@ func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSc
if !ok {
continue
}
orderTable = field
orderColumn = listRelationColumn(controller, col.Relation, col.Select)
sub, err := relationSubquery(db, model, field, listRelationColumn(controller, col.Relation, col.Select), 0, "sort")
if err != nil {
return nil, err
}
if sub.toMany {
// Winter sorts a to-many column on its group_concat sub-select;
// string_agg in the same order is the comma-joined value the
// list displays (relatedSelectMany). A raw column merges with
// the primary-key tiebreak below into one ORDER BY.
value := "CAST(" + sub.column + " AS TEXT)"
order.Column = clause.Column{
Name: fmt.Sprintf("(SELECT string_agg(%s, ', ' ORDER BY %s) FROM %s WHERE %s)", value, value, sub.from, sub.where),
Raw: true,
}
break
}
// A to-one relation is joined; GORM aliases the joined
// association with the Go field name.
db = db.Joins(field)
order.Column = clause.Column{Table: field, Name: sub.name}
break
}
db = db.Order(clause.OrderByColumn{
Column: clause.Column{Table: orderTable, Name: orderColumn},
Desc: desc,
})
db = db.Order(order)
}
if column != pk {
db = db.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}})
}
return db
return db, nil
}
// relationSQL is the FROM and WHERE of a subquery over the related records
// of one relation, correlated to the outer list query's main table. It holds
// no bound values: every name in it comes from GORM's parsed relationship and
// is quoted by the dialector.
type relationSQL struct {
// from is the related table aliased cabana_r<n>; a belongsToMany
// relation adds its pivot aliased cabana_p<n>, joined to the related
// table.
from string
// where correlates the related records to the main table's row and
// skips soft-deleted related records.
where string
// column is the selected related column, qualified with the alias.
column string
// name is the selected column's physical name.
name string
// toMany is true for hasMany and belongsToMany relations.
toMany bool
}
// relationSubquery builds the subquery parts for the relation behind the Go
// field of model, selecting column from the related table. Table, pivot and
// key names come from GORM's parsed relationship, never from a naming
// convention, so custom foreignKey, joinForeignKey and many2many tags and the
// naming strategy are honored. The aliases carry n, which keeps a
// self-referencing relation apart from the main table and two subqueries of
// one statement apart from each other. A missing relation, a column the
// related model lacks and polymorphic relations are refused as validation
// failures on param.
func relationSubquery(db *gorm.DB, model any, field, column string, n int, param string) (relationSQL, error) {
invalid := listInvalid(param, "is not a searchable relation column")
if param == "sort" {
invalid = listInvalid(param, "is not a sortable column")
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(model); err != nil {
return relationSQL{}, err
}
rel, ok := stmt.Schema.Relationships.Relations[field]
if !ok || rel == nil || rel.FieldSchema == nil || rel.Polymorphic != nil || !identifier(column) {
return relationSQL{}, invalid
}
selected := rel.FieldSchema.LookUpField(column)
if selected == nil || selected.DBName == "" {
return relationSQL{}, invalid
}
for _, ref := range rel.References {
if ref == nil || ref.PrimaryValue != "" || ref.PrimaryKey == nil || ref.ForeignKey == nil {
return relationSQL{}, invalid
}
}
main := stmt.Schema.Table
related := fmt.Sprintf("cabana_r%d", n)
out := relationSQL{
column: qualifiedColumn(db, related, selected.DBName),
name: selected.DBName,
}
var on, where []string
switch rel.Type {
case gormschema.Many2Many:
if rel.JoinTable == nil {
return relationSQL{}, invalid
}
pivot := fmt.Sprintf("cabana_p%d", n)
for _, ref := range rel.References {
if ref.OwnPrimaryKey {
// pivot owner key = main primary key
where = append(where, qualifiedColumn(db, pivot, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName))
} else {
// pivot related key = related primary key
on = append(on, qualifiedColumn(db, pivot, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, related, ref.PrimaryKey.DBName))
}
}
if len(on) == 0 || len(where) == 0 {
return relationSQL{}, invalid
}
out.from = fmt.Sprintf("%s AS %s JOIN %s AS %s ON %s",
quotedIdent(db, rel.JoinTable.Table), quotedIdent(db, pivot),
quotedIdent(db, rel.FieldSchema.Table), quotedIdent(db, related),
strings.Join(on, " AND "))
out.toMany = true
case gormschema.HasMany, gormschema.HasOne:
for _, ref := range rel.References {
// related foreign key = main primary key
where = append(where, qualifiedColumn(db, related, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName))
}
out.from = quotedIdent(db, rel.FieldSchema.Table) + " AS " + quotedIdent(db, related)
out.toMany = rel.Type == gormschema.HasMany
case gormschema.BelongsTo:
for _, ref := range rel.References {
// related primary key = main foreign key
where = append(where, qualifiedColumn(db, related, ref.PrimaryKey.DBName)+" = "+qualifiedColumn(db, main, ref.ForeignKey.DBName))
}
out.from = quotedIdent(db, rel.FieldSchema.Table) + " AS " + quotedIdent(db, related)
default:
return relationSQL{}, invalid
}
if len(where) == 0 {
return relationSQL{}, invalid
}
// Winter's whereHas applies the related model's soft-delete scope.
for _, f := range rel.FieldSchema.Fields {
if f.DBName != "" && f.FieldType == deletedAtType {
where = append(where, qualifiedColumn(db, related, f.DBName)+" IS NULL")
break
}
}
out.where = strings.Join(where, " AND ")
return out, nil
}
var deletedAtType = reflect.TypeOf(gorm.DeletedAt{})
func applyListSearch(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, term string) (*gorm.DB, error) {
term = strings.TrimSpace(term)
if term == "" {
@@ -335,32 +465,36 @@ func applyListSearch(db *gorm.DB, controller pact.AdminController, schema *ListS
return nil, listInvalid("search", "is not searchable")
}
pattern := "%" + escapeLike(strings.ToLower(term)) + "%"
joined := map[string]struct{}{}
parts := make([]string, 0, len(cols))
args := make([]any, 0, len(cols))
main := tableName(model)
n := 0
for _, col := range cols {
table := main
column := col.Key
if col.Relation != "" {
field, ok := relationFieldName(model, col.Relation)
physical := listRelationColumn(controller, col.Relation, col.Select)
if !ok || !identifier(physical) {
if !ok {
return nil, listInvalid("search", "is not searchable")
}
// GORM aliases the joined association with the Go field name.
table = field
column = physical
if _, done := joined[field]; !done {
db = db.Joins(field)
joined[field] = struct{}{}
// Every relation type is searched through a correlated EXISTS
// (Winter's orWhereHas), so the outer query gains no JOIN: a
// record matching through several related rows is counted and
// listed once, and unqualified ListExtendQuery or scope
// predicates stay unambiguous.
sub, err := relationSubquery(db, model, field, listRelationColumn(controller, col.Relation, col.Select), n, "search")
if err != nil {
return nil, err
}
} else if !identifier(column) {
n++
parts = append(parts, fmt.Sprintf("EXISTS (SELECT 1 FROM %s WHERE %s AND LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\')", sub.from, sub.where, sub.column))
args = append(args, pattern)
continue
}
if !identifier(col.Key) {
return nil, listInvalid("search", "is not searchable")
}
// CAST makes a numeric, date or boolean column searchable as its text
// form (Winter casts to TEXT too); PostgreSQL has no lower(integer).
parts = append(parts, fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\'", qualifiedColumn(db, table, column)))
parts = append(parts, fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\'", qualifiedColumn(db, main, col.Key)))
args = append(args, pattern)
}
return db.Where(strings.Join(parts, " OR "), args...), nil

View File

@@ -0,0 +1,194 @@
package cabana
import (
"context"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
pgdriver "gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// The relation fixtures cover every relation type a list column can name:
// belongsTo (Author), belongsToMany through a pivot (Tags) and hasMany (Notes).
type relAuthor struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (relAuthor) TableName() string { return "cabana_rel_authors" }
type relTag struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (relTag) TableName() string { return "cabana_rel_tags" }
type relNote struct {
ID uint `gorm:"column:id;primaryKey"`
PostID uint `gorm:"column:post_id"`
Body string `gorm:"column:body"`
}
func (relNote) TableName() string { return "cabana_rel_notes" }
type relPost struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
AuthorID *uint `gorm:"column:author_id"`
Author relAuthor `gorm:"foreignKey:AuthorID"`
Tags []relTag `gorm:"many2many:cabana_rel_post_tags"`
Notes []relNote `gorm:"foreignKey:PostID"`
}
func (relPost) TableName() string { return "cabana_rel_posts" }
type relController struct{}
func (relController) ID() string { return "acme.demo.widgets" }
func (relController) ModelName() string { return "Widget" }
func (relController) ConfigDir() string { return "controllers/widgets" }
func (relController) NewRecord() any { return &relPost{} }
var _ pact.AdminRecordSource = relController{}
const relListConfig = `modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
showSorting: true
`
const relColumns = `columns:
title:
label: Title
searchable: true
tags:
label: Tags
relation: tags
select: name
searchable: true
sortable: true
notes:
label: Notes
relation: notes
select: body
searchable: true
author:
label: Author
relation: author
select: name
searchable: true
sortable: true
`
// sqlRecorder is a GORM logger that keeps every statement GORM traces, with
// its bound values inlined, so DryRun tests can assert on the SQL shape.
type sqlRecorder struct {
mu sync.Mutex
sqls []string
}
func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
func (r *sqlRecorder) Info(context.Context, string, ...any) {}
func (r *sqlRecorder) Warn(context.Context, string, ...any) {}
func (r *sqlRecorder) Error(context.Context, string, ...any) {}
func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()
r.mu.Lock()
defer r.mu.Unlock()
r.sqls = append(r.sqls, sql)
}
// statements returns the recorded statements and forgets them.
func (r *sqlRecorder) statements() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := r.sqls
r.sqls = nil
return out
}
// statement returns the first recorded statement starting with prefix.
func (r *sqlRecorder) statement(t *testing.T, prefix string, all []string) string {
t.Helper()
for _, sql := range all {
if strings.HasPrefix(sql, prefix) {
return sql
}
}
t.Fatalf("no statement starting with %q in %q", prefix, all)
return ""
}
// openDryRunDB opens a Postgres-dialect GORM handle that never connects:
// DryRun builds every statement without executing it, and the DSN points at
// a port nothing listens on, so a test that needs no Docker can still assert
// on the SQL that ExecuteList would send.
func openDryRunDB(t *testing.T) (*gorm.DB, *sqlRecorder) {
t.Helper()
rec := &sqlRecorder{}
db, err := gorm.Open(pgdriver.New(pgdriver.Config{DSN: "host=127.0.0.1 port=1 user=cabana dbname=cabana sslmode=disable"}),
&gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: rec})
if err != nil {
t.Fatalf("dry run db: %v", err)
}
return db, rec
}
// newDryRunListService builds a service whose app publishes db and whose
// registry holds the compiled list of ctl as acme.demo.widgets, the
// controller controllerRequest targets.
func newDryRunListService(t *testing.T, db *gorm.DB, ctl pact.AdminController, config, columns string) *service {
t.Helper()
schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, ""))
if err != nil {
t.Fatalf("schema: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
return &service{app: app, reg: &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {Controller: ctl, List: schema},
}}}
}
func TestListRelationSearchSmoke(t *testing.T) {
db, rec := openDryRunDB(t)
svc := newDryRunListService(t, db, relController{}, relListConfig, relColumns)
decodeList(t, getList(t, svc, "search=fungi"), http.StatusOK)
count := rec.statement(t, "SELECT count(*)", rec.statements())
for _, want := range []string{
`EXISTS (SELECT 1 FROM "cabana_rel_post_tags" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0" ON "cabana_p0"."rel_tag_id" = "cabana_r0"."id"`,
`WHERE "cabana_p0"."rel_post_id" = "cabana_rel_posts"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`,
`EXISTS (SELECT 1 FROM "cabana_rel_notes" AS "cabana_r1" WHERE "cabana_r1"."post_id" = "cabana_rel_posts"."id"`,
`EXISTS (SELECT 1 FROM "cabana_rel_authors" AS "cabana_r2" WHERE "cabana_r2"."id" = "cabana_rel_posts"."author_id"`,
} {
if !strings.Contains(count, want) {
t.Fatalf("count statement lacks %s:\n%s", want, count)
}
}
if strings.Contains(count, "JOIN \"cabana_rel_tags\" \"Tags\"") || strings.Contains(count, "LEFT JOIN") {
t.Fatalf("count statement joins a relation:\n%s", count)
}
decodeList(t, getList(t, svc, "sort=tags&dir=desc"), http.StatusOK)
page := rec.statement(t, "SELECT * FROM", rec.statements())
order := page[strings.Index(page, "ORDER BY"):]
if !strings.Contains(order, `ORDER BY (SELECT string_agg(CAST("cabana_r0"."name" AS TEXT), ', ' ORDER BY CAST("cabana_r0"."name" AS TEXT)) FROM "cabana_rel_post_tags" AS "cabana_p0"`) ||
!strings.Contains(order, `) DESC,"cabana_rel_posts"."id"`) {
t.Fatalf("to-many sort is not a string_agg subquery with the id tiebreak:\n%s", page)
}
if strings.Contains(page, "LEFT JOIN") {
t.Fatalf("to-many sort joins the relation:\n%s", page)
}
}

View File

@@ -367,10 +367,11 @@ hygiene_checks() {
[[ -z "$hits" ]] || fail "admin/src/api/types.ts declares a shape instead of aliasing the generated schema: $hits"
hits="$(cd "$tree" && grep -nE '^export type [A-Za-z]+ = ' admin/src/api/types.ts | grep -vE "= (Schemas\['cabana\.[A-Za-z_-]+'\]|NonNullable<[A-Za-z]+Path\['get'\]\['parameters'\]\['query'\]>|[A-Za-z]+Path\['get'\]\['parameters'\]\['path'\])\$" || true)"
[[ -z "$hits" ]] || fail "admin/src/api/types.ts alias not onto the generated schema: $hits"
# Browser storage holds only the sidebar flag (T-10-22), never a token.
# Browser storage holds only the sidebar flag (T-10-22) and the per-list
# query parameters (T-16.1-03), never a token.
hits="$(cd "$tree" && grep -rnE 'localStorage|sessionStorage|indexedDB|document\.cookie' admin/src 2>/dev/null |
grep -v '^admin/src/state/useSidebar.ts:' || true)"
[[ -z "$hits" ]] || fail "browser storage outside admin/src/state/useSidebar.ts: $hits"
grep -v -e '^admin/src/state/useSidebar.ts:' -e '^admin/src/state/listState.ts:' || true)"
[[ -z "$hits" ]] || fail "browser storage outside admin/src/state/useSidebar.ts and admin/src/state/listState.ts: $hits"
# The embedded build loads nothing from another origin.
hits="$(cd "$tree" && grep -noE '(src|href)="(https?:)?//[^"]*"|url\((["'"'"']?)(https?:)?//' modules/boardwalk/dist/index.html modules/boardwalk/dist/assets/*.css 2>/dev/null || true)"
[[ -z "$hits" ]] || fail "modules/boardwalk/dist references another origin: $hits"

402
scripts/check-phase16.sh Executable file
View File

@@ -0,0 +1,402 @@
#!/usr/bin/env bash
# Phase 16 fail-closed gate (vue-journal-layer, the grzybyfunkcjonalne.pl site
# app and its SummerCMS host). Every stage exits non-zero on a failing
# command; a node --test run that reports "not ok", a failed, skipped, todo or
# cancelled test, no passing test or a missing named test; a go test run that
# fails, skips, matches zero tests, prints "no tests to run", misses a named
# required test or reports a data race; or an SSR smoke that does not end ok.
# --self-test proves both detectors fail closed on planted inputs. --all runs
# every stage and must end with "Phase 16 gate passed".
#
# The grzyby command tests need Postgres: GRZYBY_TEST_ADMIN_DSN (a
# maintenance-database DSN whose role has CREATEDB) or Docker for
# testcontainers. Without either they fail; -short is not final evidence.
#
# Environment overrides: PHASE16_LAYER (default ../vue-journal-layer),
# PHASE16_HOST (default ../sm-grzybyfunkcjonalne-app), PHASE16_APP (default
# $PHASE16_HOST/vue-grzybyfunkcjonalne-app).
set -euo pipefail
unset FORCE_COLOR
ROOT="${PHASE16_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
LAYER="${PHASE16_LAYER:-$ROOT/../vue-journal-layer}"
HOST="${PHASE16_HOST:-$ROOT/../sm-grzybyfunkcjonalne-app}"
APP="${PHASE16_APP:-$HOST/vue-grzybyfunkcjonalne-app}"
PLUGIN="$HOST/plugins/grzyby"
# One named test per layer test file.
LAYER_REQUIRE=(
"an override replaces one key and keeps the others"
"a post path without :slug throws naming journal.routes.post"
"listCategories always sends include_empty=1"
"getPost returns null for status 404 and statusCode 404"
"anything else is a 404 (null)"
"Go case vbscript is unsafe"
"Go case data in upper case is unsafe"
"a custom root category route gives /cordyceps"
"without an excerpt the description is the first 160 characters of the body text"
"pl and en have identical leaf key sets"
)
APP_REQUIRE=(
"D-15: every PHP URL form answers the expected status"
"static pages win over the root category route (RESEARCH A2)"
"D-16: Polish only, without a prefix"
"T-16-01: an unsafe post body renders the blocked notice only"
"Pitfall 5: SSR fetches forward the visitor X-Forwarded-For"
"D-23: NUXT_PUBLIC_GA_ID loads gtag for its id"
"D-23: a malformed NUXT_PUBLIC_GA_ID loads no script (T-16-07)"
"malformed ids give no scripts"
"the FAQ has the 16 live entries with question and answer"
"every t('…') key used in app/**/*.vue exists in pl.json or the layer's journal strings"
)
HOST_REQUIRE=(
TestBootUserTranslateJournal
TestCORS
TestBackendURIGfadmin
TestGrzybyCommandsRegistered
)
PLUGIN_REQUIRE=(
TestReadFeaturedImageMap
TestValidDiskName
TestFeaturedImageSourceEscape
TestImportFeaturedImages
TestImportFeaturedImagesDryRun
TestReadCategoryMap
TestImportCategories
)
usage() {
cat >&2 <<'EOF'
usage:
check-phase16.sh --self-test
check-phase16.sh --layer
check-phase16.sh --layer-smoke
check-phase16.sh --app
check-phase16.sh --host
check-phase16.sh --plugin
check-phase16.sh --deploy
check-phase16.sh --all
EOF
exit 2
}
# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
# 4 non-JSON, 5 missing required name, 6 data race.
detect_go() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
passed = set()
failed = []
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
print("refuse: non-json test output", file=sys.stderr)
sys.exit(4)
action = ev.get("Action")
test = ev.get("Test") or ""
pkg = ev.get("Package") or ev.get("ImportPath") or ""
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
print(f"refuse: build failed {pkg}", file=sys.stderr)
sys.exit(1)
text = ev.get("Output") or ""
if action == "output":
if "no tests to run" in text:
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
sys.exit(3)
if "WARNING: DATA RACE" in text:
print(f"refuse: data race in {pkg} {test}", file=sys.stderr)
sys.exit(6)
if action == "skip" and test:
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
sys.exit(2)
if action == "fail":
failed.append(f"{pkg} {test}".strip())
if action == "pass" and test:
passed.add(test)
if failed:
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
sys.exit(1)
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
missing = [n for n in require if n not in passed]
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# detect_tap reads node --test --test-reporter=tap output. Exit 1 "not ok" or
# a fail/cancelled count, 2 skip/todo, 3 no pass count or zero passes, 5
# missing required name. REQUIRE_NAMES holds one test name per line.
detect_tap() {
python3 - "$1" <<'PY'
import os, re, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()]
passed = set()
counts = {}
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.rstrip("\n")
body = line.lstrip()
if body.startswith("not ok"):
print(f"refuse: {body}", file=sys.stderr)
sys.exit(1)
m = re.match(r"ok \d+ - (.*?)(\s+#\s*(SKIP|TODO)\b.*)?$", body, re.I)
if m:
if m.group(2):
print(f"refuse: {body}", file=sys.stderr)
sys.exit(2)
if line == body:
passed.add(m.group(1))
continue
m = re.match(r"# (tests|suites|pass|fail|cancelled|skipped|todo) (\d+)$", line)
if m:
counts[m.group(1)] = int(m.group(2))
for key in ("fail", "cancelled"):
if counts.get(key, 0) > 0:
print(f"refuse: {counts[key]} {key}", file=sys.stderr)
sys.exit(1)
for key in ("skipped", "todo"):
if counts.get(key, 0) > 0:
print(f"refuse: {counts[key]} {key}", file=sys.stderr)
sys.exit(2)
if counts.get("pass", 0) < 1 or not passed:
print("refuse: no passing tests", file=sys.stderr)
sys.exit(3)
missing = [n for n in require if n not in passed]
if missing:
print("refuse: required tests did not pass: " + "; ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
go_json() {
local dir="$1"
shift
local log err rc=0 dc=0
log="$(mktemp)"
err="$(mktemp)"
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
detect_go "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
grep -v '^{' "$log" | tail -n 40 >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
return 1
fi
rm -f "$log" "$err"
}
# node_tap runs `pnpm -C <dir> test` and checks its TAP output.
node_tap() {
local dir="$1" log rc=0 dc=0
log="$(mktemp)"
pnpm -C "$dir" test >"$log" 2>&1 || rc=$?
detect_tap "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E '^\s*(not ok|# )|Error' "$log" | tail -n 40 >&2 || true
rm -f "$log"
echo "refuse: pnpm test in $dir (test=$rc detect=$dc)" >&2
return 1
fi
grep -E '^# (tests|pass) ' "$log"
rm -f "$log"
}
# smoke runs a command that must exit 0 and print "smoke-ssr: ok".
smoke() {
local log rc=0
log="$(mktemp)"
"$@" >"$log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]] || ! grep -q '^smoke-ssr: ok' "$log"; then
tail -n 40 "$log" >&2 || true
rm -f "$log"
echo "refuse: $* (exit $rc)" >&2
return 1
fi
grep '^smoke-ssr: ok' "$log"
rm -f "$log"
}
expect_go() {
local name="$1" want="$2" payload="$3" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
detect_go "$log" 2>/dev/null || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test go $name: detector exit $dc, want $want" >&2
return 1
fi
}
expect_tap() {
local name="$1" want="$2" payload="$3" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
detect_tap "$log" 2>/dev/null || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test tap $name: detector exit $dc, want $want" >&2
return 1
fi
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestImportCategories"}'
expect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestImportFeaturedImages"}'
expect_go package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p"}'
expect_go build 1 '{"Action":"build-fail","ImportPath":"p"}'
expect_go skip 2 '{"Action":"skip","Package":"p","Test":"TestImportFeaturedImages"}'
expect_go zero 3 '{"Action":"pass","Package":"p"}'
expect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
{"Action":"pass","Package":"p"}'
expect_go nonjson 4 '{"Action":"pass",'
expect_go race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"}
{"Action":"pass","Package":"p","Test":"TestA"}'
REQUIRE_TESTS="TestBackendURIGfadmin TestCORS" expect_go missing-named 5 \
'{"Action":"pass","Package":"p","Test":"TestCORS"}'
REQUIRE_TESTS="TestImportFeaturedImages" expect_go subtest-is-not-the-test 5 \
'{"Action":"pass","Package":"p","Test":"TestImportFeaturedImagesDryRun"}'
local ok='TAP version 13
ok 1 - routes ok
1..1
# tests 1
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0'
expect_tap pass 0 "$ok"
expect_tap not-ok 1 'TAP version 13
ok 1 - a
not ok 2 - b
# pass 1
# fail 1'
expect_tap nested-not-ok 1 'TAP version 13
not ok 1 - sub
ok 1 - a
# pass 1
# fail 0'
expect_tap fail-count 1 'ok 1 - a
# pass 1
# fail 2'
expect_tap cancelled 1 'ok 1 - a
# pass 1
# cancelled 1'
expect_tap skip-directive 2 'ok 1 - a # SKIP needs db
# pass 1'
expect_tap skipped-count 2 'ok 1 - a
# pass 1
# skipped 1'
expect_tap todo 2 'ok 1 - a # TODO later
# pass 1'
expect_tap no-pass-line 3 'ok 1 - a'
expect_tap zero 3 '# tests 0
# pass 0
# fail 0'
REQUIRE_NAMES=$'routes ok\nmissing name' expect_tap missing-named 5 "$ok"
REQUIRE_NAMES='routes ok' expect_tap named 0 "$ok"
local flag
for flag in --self-test --layer --layer-smoke --app --host --plugin --deploy --all; do
grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode $flag" >&2
return 1
}
done
echo "phase16 self-test passed"
}
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: $2 $1 not found" >&2
return 1
}
}
run_layer() {
require_dir "$LAYER" "layer repository"
REQUIRE_NAMES="$(printf '%s\n' "${LAYER_REQUIRE[@]}")" node_tap "$LAYER"
echo "phase16 layer passed"
}
run_layer_smoke() {
require_dir "$LAYER" "layer repository"
pnpm -C "$LAYER" build:playground >/dev/null
smoke node "$LAYER/testing/smoke-ssr.mjs" \
--output "$LAYER/.playground/.output" \
--fixtures "$LAYER/testing/fixtures/acme" \
--expect "$LAYER/testing/expect-acme.txt"
echo "phase16 layer-smoke passed"
}
run_app() {
require_dir "$APP" "site app"
pnpm -C "$APP" install --frozen-lockfile >/dev/null
pnpm -C "$APP" build >/dev/null
REQUIRE_NAMES="$(printf '%s\n' "${APP_REQUIRE[@]}")" node_tap "$APP"
smoke pnpm -C "$APP" smoke:ssr
smoke pnpm -C "$APP" smoke:ga
echo "phase16 app passed"
}
run_host() {
require_dir "$HOST" "host repository"
go -C "$HOST" vet ./...
REQUIRE_TESTS="${HOST_REQUIRE[*]}" go_json "$HOST" ./... -count=1 -v -timeout 5m
echo "phase16 host passed"
}
run_plugin() {
require_dir "$PLUGIN" "grzyby plugin"
go -C "$PLUGIN" vet ./...
REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -v -race -timeout 10m
echo "phase16 plugin passed"
}
run_deploy() {
require_dir "$HOST" "host repository"
bash "$HOST/scripts/check-deploy.sh"
echo "phase16 deploy passed"
}
run_all() {
local stage
for stage in self-test layer layer-smoke app host plugin deploy; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
echo "Phase 16 gate passed"
}
case "${1:---all}" in
--self-test) run_self_test ;;
--layer) run_layer ;;
--layer-smoke) run_layer_smoke ;;
--app) run_app ;;
--host) run_host ;;
--plugin) run_plugin ;;
--deploy) run_deploy ;;
--all) run_all ;;
*) usage ;;
esac