Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2a9529810 | |||
| b519c9c320 | |||
| 4ef5c0513f | |||
| 01bcd3efa4 | |||
| 872fd6535f | |||
| 204149ccd2 | |||
| d3bf5d8f86 | |||
| 4a5e896429 | |||
| cb2987c146 | |||
| 2181c2b5f6 | |||
| 000c9aee6e | |||
| 23f33c3911 | |||
| 46835fd064 | |||
| 71fa597830 | |||
| 69bb1cc95d | |||
| 0cdff4c258 | |||
| 9016e2a6fd | |||
| 3d42d35c8a | |||
| 55476db582 | |||
| 2e6a72b6e9 | |||
| 0ce57fbe4e | |||
| 8336b393b3 | |||
| 6f9302a8d4 | |||
| fb0d051f90 | |||
| a082a0583d | |||
| f70a0a59d6 | |||
| 1fb3fb9ac1 | |||
| 3159bc109b | |||
| bb5c2d32fa | |||
| 8b835f328f | |||
| 01d2548ab3 | |||
| 41a03b9987 | |||
| c30145ed25 | |||
| fbcfaaf05f | |||
| 18a4ceabd2 | |||
| 6258663724 | |||
| 8c6db0a522 | |||
| 78d72418cb | |||
| 2c5c0583f2 | |||
| 46eee17143 | |||
| dbdf15da76 | |||
| a2d19360d2 | |||
| b9de0610d7 | |||
| 4660f95e5c | |||
| 66dbd5a64a | |||
| 48286b67b3 | |||
| 05442174b6 | |||
| 5c75b316a7 | |||
| 3156baaf38 | |||
| ea8408e9e9 | |||
| 2158db790c | |||
| d0eeb4d291 | |||
| 2d68eab3c0 | |||
| ad1fa5abfa | |||
| 0b6d757b40 | |||
| 3408742fb0 | |||
| f31ea9387f | |||
| 77ca4b0b93 | |||
| fc7cfaf51c | |||
| 6c7434243e | |||
| 1541cd4167 | |||
| 42b5cf887f | |||
| d2fa195a91 | |||
| 07e579a264 | |||
| be82bccfc3 | |||
| b45af8ebbf | |||
| 21fdcfcba6 | |||
| a3b502bfb9 | |||
| c85cb64b6e | |||
| 7ef2e4f6cb | |||
| 73af414793 | |||
| 73074ee257 | |||
| ee6fc7a235 | |||
| 290713cce3 | |||
| a0bf8ece05 | |||
| 6933004b87 | |||
| b906bdd48b | |||
| 5913337cda | |||
| d1b043a361 | |||
| 502fae3e90 | |||
| cb4b0a9c75 | |||
| a955d2a3d4 | |||
| e368ae9c85 | |||
| 36b1bce5c5 | |||
| f4395b57b7 | |||
| e565062479 | |||
| 463541549f | |||
| 9b811f6042 | |||
| 55999b2984 | |||
| 78124ab86f | |||
| bdd393c15f | |||
| 91830e506f | |||
| c385dc79eb |
@@ -163,6 +163,9 @@ Quality
|
||||
| Framework and application in two repos from day one (`summercms.go`, `fonoteka.go`) | Retrofitting the split later is more disruptive; shared stack plugins can be extracted for keios.eu without touching the framework; the framework never imports an app | ✓ Good (Phase 3: fonoteka.go workspace, user + fonoteka plugins, app.Handler test seam) |
|
||||
| Shared core plugins live in their own `sm-<name>-plugin` repos, mounted into each application as git submodules (first: sm-user-plugin, module `git.golem15.com/golem15/sm-user-plugin`) | A second application (sm-summercmsio-app) exists and the Journal port is next; one copy per core plugin instead of forks; supersedes the Phase 1 keios.eu trigger (`.planning/notes/core-plugins-own-repos.md`) | ✓ Good (2026-10-02, quick 261002-esz) |
|
||||
| OAuth identities live in sm-user-plugin; Index/Destroy are host-mounted on the JWT group | Social-login rows are a user-plugin table (D-01); routes stay off `/_user` so the host owns Winter 404 and `throttle:10,1` | ✓ Good (Phase 14.1, 2026-10-05; corpus 175/175/0) |
|
||||
| The local dev stack is the Nuxt app proxied to the Go binary, guarded fail-closed (`fonoteka.go/scripts/dev-stack.sh`), with dev-only seed and marker route gated on `SUMMER_ENV=local` only | Proves the port in daily use before cutover without touching the production origin; `dev` is refused because shared servers often use that name (review WR-05) | ✓ Good (Phase 14.2, 2026-10-12; gate e2e 17/0/0/0, manual UAT 1-11) |
|
||||
| Framework serves public uploads itself (`storage.uploads.serve`, opt-in) with nosniff, a sandboxing CSP and attachment disposition for non-images | Local runs need WinterCMS upload URLs without nginx; the hardening keeps uploaded HTML or SVG from running on the app origin (review WR-03) | ✓ Good (Phase 14.2) |
|
||||
| The opt-in request log (`http.access_log`) records method, matched pattern, status and duration, never the URL path | Share and invitation tokens travel in paths (review WR-01); an unported route shows as a 404 with an empty pattern | ✓ Good (Phase 14.2) |
|
||||
|
||||
## Evolution
|
||||
|
||||
@@ -182,4 +185,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-10-05 after Phase 14.1 completion*
|
||||
*Last updated: 2026-10-12 after Phase 14.2 completion*
|
||||
|
||||
@@ -27,7 +27,7 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [x] **Phase 12: Płytarium API — Collections and Albums** - Core content endpoints ported with byte-level parity (completed 2026-10-02)
|
||||
- [ ] **Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes** - Remaining core API surface
|
||||
- [ ] **Phase 14: Domain jobs and external integrations** - CSV/Discogs jobs, wishlist digest, reindex, Discogs client, AI recognition, feedback
|
||||
- [ ] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go
|
||||
- [x] **Phase 14.2: Local Fonoteka frontend on local SummerCMS backend** - vue-fonoteka-app locally against fonoteka.go (completed 2026-10-12)
|
||||
- [ ] **Phase 15: Journal plugin** - Port Golem15.Journal to sm-journal-plugin
|
||||
- [ ] **Phase 16: grzybyfunkcjonalne.pl on reusable blog views** - First blog on SummerCMS
|
||||
- [ ] **Phase 17: Blog companions** - Port sitemap, menu and FAQ as shared plugins, with a framework menu-item type registry
|
||||
@@ -834,21 +834,21 @@ Plans:
|
||||
3. Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin.
|
||||
4. The new glue (env, proxy, CORS, run docs) has tests or a fail-closed check script, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 4 plans
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
**Wave 1**
|
||||
- [ ] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
|
||||
- [x] 14.2-01-PLAN.md — Go glue: `fonoteka:dev-seed`, plugin commands open their own DB (G1), dev-only marker route, framework `/storage` mount and opt-in access log with docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
|
||||
- [x] 14.2-02-PLAN.md — `scripts/dev-stack.sh` (start, fail-closed guard, logs, e2e verdict), `.env.dev.example`, `.gitignore`, run docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
|
||||
- [x] 14.2-03-PLAN.md — Nuxt `E2E_BACKEND=go` switch, realtime rewrite, edit/rate/cover, search and wishlist specs on Go and PHP, Centrifugo origin step, manual UAT session
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
|
||||
- [x] 14.2-04-PLAN.md — Unit tests (seed, commands, marker, uploads mount, access log, switch contract) and `scripts/check-phase14.2.sh`
|
||||
|
||||
### Phase 14.2.1: Translate plugin (INSERTED)
|
||||
|
||||
@@ -1044,7 +1044,7 @@ Plans:
|
||||
5. Module READMEs and `docs/` cover the token API, scopes and CLI commands.
|
||||
6. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 2/4 plans executed
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1054,10 +1054,10 @@ Plans:
|
||||
- [x] 16.3-02-PLAN.md — Admin SPA API tokens card on My account per UI-SPEC: list states, two-step create with show-once plaintext and copy, revoke, phrase keys, dist, docs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 16.3-03-PLAN.md — Journal: journal and journal:read scopes, token auth through ResolveBackend with byte-identical bodies (reverses Phase 15 D-15), Journal README and the ResolveBackend docs example; release gate (human), summercms tag after v0.1.20, Journal go.mod and host gitlink bumps
|
||||
- [x] 16.3-03-PLAN.md — Journal: journal and journal:read scopes, token auth through ResolveBackend with byte-identical bodies (reverses Phase 15 D-15), Journal README and the ResolveBackend docs example; release gate (human), summercms tag after v0.1.20, Journal go.mod and host gitlink bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 16.3-04-PLAN.md — Unit tests: scope rules, route classification, guard, resolver, account API, CLI, migration, SPA backstops, Journal token matrix; Postgres tests for a Docker host; phase gate scripts/check-phase16.3.sh; validation map
|
||||
- [x] 16.3-04-PLAN.md — Unit tests: scope rules, route classification, guard, resolver, account API, CLI, migration, SPA backstops, Journal token matrix; Postgres tests for a Docker host; phase gate scripts/check-phase16.3.sh; validation map
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
@@ -1075,10 +1075,26 @@ Plans:
|
||||
4. grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 1/6 plans executed
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 17 to break down)
|
||||
**Wave 1**
|
||||
|
||||
- [x] 17-01-PLAN.md — Framework Go: pact menu-item types and the signpost registry, seaglass sanitizer, cabana repeater, dependent dropdown, richeditor, tree reorder and singleton form
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [ ] 17-02-PLAN.md — Admin SPA (RichEditorField on TipTap, RepeaterField, dependent dropdown, ReorderView, singleton form), dist, and the framework release tag
|
||||
- [ ] 17-03-PLAN.md — Journal registers its five item types; sm-sitemap-plugin with PHP byte parity, admin and import commands
|
||||
- [ ] 17-04-PLAN.md — sm-menu-plugin and sm-faq-plugin: admin, Winter imports, and the /_menu and /_faq read APIs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [ ] 17-05-PLAN.md — grzybyfunkcjonalne.pl integration: repos, vue-menu-layer and vue-faq-layer, data copy, nginx, UAT redeploy
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [ ] 17-06-PLAN.md — Unit tests across all repositories, scripts/check-phase17.sh and the validation map
|
||||
|
||||
### Phase 18: Payment Gateway
|
||||
|
||||
@@ -1300,11 +1316,11 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | In Progress| |
|
||||
| 14. Domain jobs and external integrations | 6/6 | In Progress| |
|
||||
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 4/4 | Complete | 2026-10-12 |
|
||||
| 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 | 5/5 | In Progress| |
|
||||
| 17. Blog companions | 0/TBD | Not started | - |
|
||||
| 17. Blog companions | 1/6 | In Progress| |
|
||||
| 18. Payment Gateway | 0/TBD | Not started | - |
|
||||
| 18.1. Payment Gateway Subscriptions | 0/TBD | Not started | - |
|
||||
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "16.3"
|
||||
current_phase_name: Backend admin personal API tokens with scopes
|
||||
current_phase: 17
|
||||
current_phase_name: Blog companions
|
||||
status: executing
|
||||
stopped_at: Completed 16.3-02-PLAN.md
|
||||
last_updated: "2026-10-11T00:07:52.680Z"
|
||||
last_activity: 2026-10-11
|
||||
last_activity_desc: Completed 16.3-02 admin SPA API tokens card
|
||||
state_head: fd07e2637fce8ca9bd6294ca611e6acc4137ef4a
|
||||
stopped_at: Completed 17-01-PLAN.md
|
||||
last_updated: "2026-10-12T13:38:05.555Z"
|
||||
last_activity: 2026-10-12
|
||||
last_activity_desc: Phase 17 execution started
|
||||
state_head: 000c9aee6e98e8d0193e1aedd8eb7c2eab9136f7
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 13
|
||||
total_plans: 162
|
||||
completed_plans: 156
|
||||
completed_phases: 14
|
||||
total_plans: 168
|
||||
completed_plans: 163
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -22,25 +22,25 @@ milestone_name: milestone
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
See: .planning/PROJECT.md (updated 2026-10-12)
|
||||
|
||||
**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.3 — Backend admin personal API tokens with scopes
|
||||
**Current focus:** Phase 17 — Blog companions
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 16.3 (Backend admin personal API tokens with scopes) — EXECUTING
|
||||
Plan: 3 of 4
|
||||
Phase: 17 (Blog companions) — EXECUTING
|
||||
Plan: 2 of 6
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-11 — Completed 16.3-02 (admin SPA API tokens card)
|
||||
Last activity: 2026-10-12 — Phase 17 execution started
|
||||
|
||||
Progress: [█████░░░░░] 48%
|
||||
Progress: [████░░░░░░] 44%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 71
|
||||
- Total plans completed: 79
|
||||
- Average duration: 21 min
|
||||
- Total execution time: 104 min
|
||||
|
||||
@@ -60,6 +60,8 @@ Progress: [█████░░░░░] 48%
|
||||
| 10.2 | 2 | - | - |
|
||||
| 14.1 | 2 | - | - |
|
||||
| 16.2 | 7 | - | - |
|
||||
| 16.3 | 4 | - | - |
|
||||
| 14.2 | 4 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
@@ -211,6 +213,13 @@ Progress: [█████░░░░░] 48%
|
||||
| Phase 16.2 P06 | 43min | 3 tasks | - files |
|
||||
| Phase 16.3 P01 | 95 | 4 tasks | 43 files |
|
||||
| Phase 16.3 P02 | 9 min | 3 tasks | 17 files |
|
||||
| Phase 16.3 P03 | 658 min | 4 tasks | 12 files |
|
||||
| Phase 16.3 P04 | 30 min | 3 tasks | 16 files |
|
||||
| Phase 14.2 P01 | 16 min | 3 tasks | 18 files |
|
||||
| Phase 14.2 P02 | 27min | 4 tasks | 7 files |
|
||||
| Phase 14.2 P03 | about 4h | 4 tasks | 23 files |
|
||||
| Phase 14.2 P04 | 60min | 3 tasks | 11 files |
|
||||
| Phase 17 P01 | 41 min | 4 tasks | 55 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -690,6 +699,28 @@ Recent decisions affecting current work:
|
||||
- [Phase 16.3]: ResolveBackend is Bearer-only (cookie-less JWT guard) — Plugin write routes have no CSRF; cookie auth must not drive them
|
||||
- [Phase 16.3]: 16.3-02: ApiTokensPanel + ApiTokenCreateDialog on /account; plaintext dialog-local only; shared ConfirmDialog for revoke and close-without-copy — UI-SPEC component split
|
||||
- [Phase 16.3]: 16.3-02: expired from API drives Expired chip; dates use CellValue local YYYY-MM-DD / YYYY-MM-DD HH:mm — Plan decisions; no new date format
|
||||
- [Phase 16.3]: Released summercms v0.1.21 after human resume release v0.1.21; Journal requires it; grzybyfunkcjonalne journal gitlink bumped; summercmsio not bumped
|
||||
- [Phase 16.3]: Journal API uses cabana.ResolveBackend with cover golem15.journal.*; ErrTokenScope maps to PHP Insufficient permissions
|
||||
- [Phase 16.3]: 16.3-04: full unit coverage + scripts/check-phase16.3.sh; postgres PASS on Docker host; nyquist_compliant true — Lean rule 3 phase-ending plan
|
||||
- [Phase 16.3]: Public Journal Show under optional-auth needs published_at in the past, not only published:true — TestJournalTokens fixture
|
||||
- [Phase 14.2]: storage.uploads.serve mounts attach.StaticHandlerPublic from surf (owner summercms.surf), resolving bucket and DB per request, with nosniff; default off
|
||||
- [Phase 14.2]: http.access_log logs one 'http request' record (method, pattern, status, duration_ms; path only when unmatched); default off
|
||||
- [Phase 14.2]: fonoteka console commands use commandDatabase/commandBucket: published handle, else open+publish; closers close what they opened
|
||||
- [Phase 14.2]: fonoteka:dev-seed allowed only for SUMMER_ENV local or dev; prints lines after commit; secret shown only on mint
|
||||
- [Phase 14.2]: dev-stack.sh runs Go processes with TMPDIR=tmp/dev-stack/go-tmp because fileblob renames uploads in from os.TempDir() and fails across devices when /tmp is a tmpfs (deployment fix deferred to Phase 20)
|
||||
- [Phase 14.2]: dev-stack.sh parses the env file as KEY=value data instead of sourcing it; pnpm dev gets only the NUXT_* values, not the SUMMER_* secrets
|
||||
- [Phase 14.2]: dev-stack.sh e2e requires a running stack and the guard before Playwright; e2e-verdict refuses skips, flaky, unexpected and zero-expected reports
|
||||
- [Phase 14.2]: Album model rules see nullable text columns as strings, so an empty format passes like PHP (14.2-03)
|
||||
- [Phase 14.2]: A 5xx written after the client cancelled the request is logged as 499 by the surf access log (14.2-03)
|
||||
- [Phase 14.2]: dev-seed creates seeded albums with artist rows and backfills them on rerun (14.2-03)
|
||||
- [Phase 14.2]: Item 11 PHP cross-check passed by classification: GD-undecodable fixture and missing PHP Centrifugo config, no contract difference (14.2-03)
|
||||
- [Phase 14.2]: The surf uploads route owns storage.uploads.public_path_prefix: BuildRouter fails when a plugin route sits at or under it
|
||||
- [Phase 14.2]: check-phase14.2.sh --contracts blocks on the phase's own contract test and only reports the whole pnpm test:contracts suite (missing-catalog.test.mjs predates the phase)
|
||||
- [Phase 14.2]: check-phase14.2.sh leaves TMPDIR to the caller: a TMPDIR inside a go.work tree makes internal/build scaffold tests edit that go.work
|
||||
- [Phase 17]: 17-01: menu item type registry is modules/signpost (party activates it before Boot); host URL overrides at menu_items.patterns (YAML only); list types use PatternFrom (one level) so a record type's host override reaches list children
|
||||
- [Phase 17]: 17-01: framework sanitizer modules/seaglass on bluemonday v1.0.27 (Journal policy without Blog Editor rules, plus tel:); D-10 Journal clause dropped, category content stays blogeditor
|
||||
- [Phase 17]: 17-01: dependent dropdown options for a repeater row are addressed as {field}=repeater.nested; sibling values reach DependentOptions as strings (nil when empty) on the route and the save recheck
|
||||
- [Phase 17]: 17-01: reorder saves write parent and sort columns via UpdateColumns (no model hooks); the partition is a belongsTo relation field of the form; stale sets answer 409 reorder_stale
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -751,6 +782,10 @@ Recent decisions affecting current work:
|
||||
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
|
||||
| 261010-iwu | Blog Editor live preview pane: toggleable, sandboxed iframe, embed thumbnail cards (16.2 follow-up) | 2026-10-10 | sm-journal-plugin df67939, c1f6a83, 2c78211 | [261010-iwu-blog-editor-live-preview-pane-toggleable](./quick/261010-iwu-blog-editor-live-preview-pane-toggleable/) |
|
||||
| 28 | Admin navbar View site link (admin origin root, new tab) | 2026-10-10 | 85fe541 | — |
|
||||
| 261011-joh | sieve: generic collection helpers module (Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set) | 2026-10-11 | 6933004, a0bf8ec | [261011-joh-sieve-generic-collection-helpers-module](./quick/261011-joh-sieve-generic-collection-helpers-module/) |
|
||||
| 30 | go test -short runs without Docker: TestMain flag parsing fix in 4 modules, cabana list fixture skip | 2026-10-11 | 7ef2e4f, c85cb64 | — |
|
||||
| 261011-kzi | Fix Phase 09 gaps: fonoteka phase09AdminRoutes inventory + per-guard cabana last_used_at throttle | 2026-10-11 | b45af8e | [261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-](./quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/) |
|
||||
| 261011-mag | fix check-phase10.sh hygiene gaps from 10-VERIFICATION.md | 2026-10-11 | 1541cd4 | [261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1](./quick/261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -762,6 +797,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-11T00:07:52.680Z
|
||||
Stopped at: Completed 16.3-02-PLAN.md
|
||||
Last session: 2026-10-12T13:38:04.804Z
|
||||
Stopped at: Completed 17-01-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Decision: bluemonday sanitizes the framework richeditor field
|
||||
|
||||
**Date:** 2026-10-11
|
||||
**Status:** Accepted (Phase 17 plan 01, D-10)
|
||||
**Relates to:** `.planning/notes/journal-html-sanitizing-bluemonday.md` (the Journal plugin's policy, which this one ports)
|
||||
|
||||
## Decision
|
||||
|
||||
The framework module `git.golem15.com/golem15/summercms` adds `github.com/microcosm-cc/bluemonday` **v1.0.27** to its `go.mod`. It is the same library and the same version `sm-journal-plugin` already uses (latest tag, 2024-07-04, confirmed with `go list -m -json github.com/microcosm-cc/bluemonday@latest`). `go.sum` is committed with it.
|
||||
|
||||
A new framework module, `seaglass` (glass the sea has worn smooth), owns the policy:
|
||||
|
||||
- `seaglass.Sanitize`, `seaglass.SanitizeReport` and `seaglass.Check` port Journal's `newHTMLPolicy` / `SanitizeHTMLReport` / `CheckHTML`: bluemonday's UGC policy with `RequireNoFollowOnLinks(false)`, the link scheme allowlist (http, https, mailto, tel and relative URLs) and Journal's extra attributes.
|
||||
- The four Blog Editor rules (`figure data-embed`, `figure data-id`, free-text `img alt`, `ol start`) are **not** ported: they describe one plugin's editor output, and the framework must not import or mirror a plugin. A plugin that needs more keeps its own policy, as Journal does.
|
||||
- The byte-stable comparison is kept: when the input and bluemonday's output tokenize (with `golang.org/x/net/html`, already a framework dependency) to the same tokens, the input is returned byte for byte and nothing is reported, so re-escaping alone never rewrites stored HTML. A repeated attribute always counts as a change.
|
||||
- `SanitizeReport` returns what was removed (element names, attribute names, comment count, a Changed flag), which cabana turns into `meta.notices` on the field.
|
||||
|
||||
cabana's core `type: richeditor` field (FAQ descriptions, repeater answers) sanitizes every submitted value through `seaglass.SanitizeReport` on create and update, top level and inside repeater rows. Settings and relation forms refuse the type at boot, so no save path bypasses the gate.
|
||||
|
||||
## Why not cabana's markdown gate
|
||||
|
||||
`cabana.RenderMarkdown` checks goldmark output with three regexes (unsafe tags, `on*=` attributes, dangerous schemes). It only works there because goldmark renders without unsafe HTML, so raw HTML never reaches the check. A richeditor stores raw HTML. The Journal decision note lists real bypasses of exactly that regex style: `<img src="x"onerror=…>`, `<svg/onload=…>`, `javascript:`, `javascript:`, `javascript:`, MathML `xlink:href`, `<base>`, `<meta http-equiv=refresh>`, `<form>`. A tokenizing sanitizer checks attributes and URL values the way a browser parses them. The project prefers a maintained library for security code over a hand-built gate (the Journal note also rejected a hand-built allowlist on `golang.org/x/net/html`).
|
||||
|
||||
## Deviation from D-10: Journal category content stays blogeditor
|
||||
|
||||
D-10 also said "Journal's category `content` switches from `textarea` to `richeditor`". Phase 16.2 already shipped the Journal Blog Editor (`type: blogeditor`) for category content and closed the `cabana-richeditor-field-type` todo (RESEARCH A1, PATTERNS note). Switching it would regress that editor. This clause is recorded as a resolved deviation: Journal category content stays `blogeditor`; the framework richeditor serves the FAQ description and the repeater answer only.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One sanitizer version across the workspace (framework and Journal both pin v1.0.27); a future bump changes both together.
|
||||
- Imported PHP HTML that the policy keeps whole round-trips byte for byte on its next admin save; HTML the policy changes is stored sanitized and the editor sees what was removed.
|
||||
- `modules/seaglass/README.md` names bluemonday under Dependencies; the root README lists the module.
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
phase: 09-backend-admin-authentication-and-schema-pipeline
|
||||
verified: 2026-10-05T15:08:52Z
|
||||
verified: 2026-10-11T13:38:34Z
|
||||
status: passed
|
||||
score: 5/5 roadmap success criteria verified (plan truths 54/54 verified, including 3 backstop truths with direct test evidence)
|
||||
score: 5/5 roadmap success criteria verified; plan truths 54/54
|
||||
covered_files:
|
||||
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-PLAN.md"
|
||||
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-SUMMARY.md"
|
||||
@@ -42,6 +42,7 @@ covered_files:
|
||||
- "modules/bouncer/registry.go"
|
||||
- "modules/bouncer/registry_test.go"
|
||||
- "modules/cabana/admin_openapi.go"
|
||||
- "modules/cabana/api_tokens.go"
|
||||
- "modules/cabana/auth.go"
|
||||
- "modules/cabana/auth_internal_test.go"
|
||||
- "modules/cabana/auth_test.go"
|
||||
@@ -53,6 +54,7 @@ covered_files:
|
||||
- "modules/cabana/crud.go"
|
||||
- "modules/cabana/crud_lifecycle_test.go"
|
||||
- "modules/cabana/crud_test.go"
|
||||
- "modules/cabana/field_type_upload.go"
|
||||
- "modules/cabana/filter_schema.go"
|
||||
- "modules/cabana/form_schema.go"
|
||||
- "modules/cabana/form_schema_test.go"
|
||||
@@ -79,6 +81,9 @@ covered_files:
|
||||
- "modules/cabana/settings.go"
|
||||
- "modules/cabana/testdata/list/all_columns.yaml"
|
||||
- "modules/cabana/testdata/list/all_filters.yaml"
|
||||
- "modules/cabana/token_guard.go"
|
||||
- "modules/cabana/token_guard_test.go"
|
||||
- "modules/cabana/token_scopes.go"
|
||||
- "modules/cabana/tx_context.go"
|
||||
- "modules/lagoon/backend_admin_migrations.go"
|
||||
- "modules/lagoon/backend_admin_migrations_test.go"
|
||||
@@ -89,46 +94,75 @@ covered_files:
|
||||
- "modules/phrasebook/translator.go"
|
||||
- "modules/surf/router.go"
|
||||
- "scripts/check-phase9.sh"
|
||||
covered_digest: "v2:sha256:c2f48f6ce04a5eed4788d745da45b1c1555a769df4feebfe88b9bbf671b634fb"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 after later phases (12.1, 12.2, 13, 14) added to the same modules/cabana, lagoon, pact and surf files Phase 9 already covered. Paths remain modules/ (Phase 10.2). scripts/check-phase9.sh --all passed at summercms.go HEAD 104a1c9 and fonoteka.go HEAD 93b8b75. fonoteka.go files stay outside the digest and are listed under Required Artifacts."
|
||||
covered_digest: "v2:sha256:e044b747d6e6be8c322be33286dc912e1d99b9fad727b0227877682f75a28ba1"
|
||||
covered_files_note: "Regenerated 2026-10-11 at summercms.go HEAD be82bcc (code at b45af8e) and fonoteka.go HEAD e074028. Added modules/cabana/api_tokens.go (stampThrottle, on the composite guard's token path) and token_guard_test.go (TestTokenLastUsedThrottlePerGuard). fonoteka.go files stay outside the digest (separate repo) and are listed under Required Artifacts."
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. As in the Phase 1/3/5/8/10 reports, the five ROADMAP success criteria are the contract."
|
||||
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. The five ROADMAP success criteria are the contract."
|
||||
re_verification:
|
||||
previous_status: stale
|
||||
previous_score: 5/5
|
||||
reason: "The 2026-10-01T21:17Z passed report went stale because Phases 12.1, 12.2, 13 and 14 edited 25 already-covered implementation files (+3294/-217) after HEAD ba1aaef. This is content-digest drift, not missing modules/ paths (those were fixed in 1a878b3). scripts/check-phase9.sh --all passed at HEAD 104a1c9; covered_digest recomputed to match current bytes."
|
||||
gaps_closed: []
|
||||
previous_status: gaps_found
|
||||
previous_score: "5/5 roadmap success criteria; plan truths 53/54"
|
||||
previous_verified: 2026-10-11T13:00:45Z
|
||||
closed_by: "quick 261011-kzi: fonoteka.go e074028 (test-only inventory update), summercms.go b45af8e (per-guard stampThrottle)"
|
||||
gaps_closed:
|
||||
- "09-12: Phase 9 fail-closed acceptance gate green. fonoteka phase09AdminRoutes now lists the 10 post-phase routes as protected; TestPhase09SecurityRoutes and TestPhase09SecurityMatrix PASS; scripts/check-phase9.sh --security and --all exit 0 (re-run by the verifier)."
|
||||
advisory_closed:
|
||||
- "TestBackendTokenPostgres order dependence: the process-global lastStamp map is gone (grep: no hits in modules/cabana). The 11-test run from the previous report plus TestTokenLastUsedDebounce and TestTokenLastUsedThrottlePerGuard gives 13/13 PASS on PostgreSQL, and the full Docker-backed go test -count=1 ./modules/cabana is ok (84.8s)."
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_items_resolved:
|
||||
- "Code-review triage: 09-REVIEW-DISPOSITION.md records CR-01 and WR-01..WR-19 as fixed (12 Info findings stay open, out of scope). Each fix is in the code at HEAD with a passing named test."
|
||||
- "WR-11 decision (user, 2026-10-01): index added. Migration 202610010001_backend_users_email_ci_unique creates backend_users_email_lower_unique on lower(email), refuses to run on case-duplicate emails and names the logins; rollback drops the index. TestBackendAdminEmailCaseInsensitiveUnique and TestBackendAdminEmailIndexRefusesCaseDuplicates PASS."
|
||||
- "WR-03 decision (user, 2026-10-01): single-record delete kept as Winter (allowed whenever a form exists). Code matches: operationDeclared 'delete' requires a form only; TestCRUDOperationsFollowDeclarations PASS."
|
||||
- "WR-17 decision (user, 2026-10-01): exact-code deny does not remove a wildcard grant, as Winter's getMergedPermissions. Code matches applyUserPermissions; TestBackendUserPermissionsOverrideRole PASS."
|
||||
- "Judgment-tier prohibitions (user, 2026-10-01): all 24 verdicts accepted; 09-03 #1 confirmed as superseded by Phase 10.1 D-09. 09-11 #1 (WR-02) and 09-12 #1 (WR-18) are not violated on code and test evidence."
|
||||
- "09-UAT.md: status complete, 3/3 pass (ba1aaef)."
|
||||
human_verified:
|
||||
- test: "Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422)"
|
||||
uat_row: 1
|
||||
recorded_at: "2026-10-01T21:13:02.000Z"
|
||||
- test: "Triage the 19 open code-review warnings (priority: WR-01, WR-02, WR-14, WR-17 for AUTH-08 Winter permission semantics)"
|
||||
uat_row: 2
|
||||
recorded_at: "2026-10-01T21:13:02.000Z"
|
||||
- test: "Review the 24 judgment-tier prohibitions in 09-VERIFICATION.md"
|
||||
uat_row: 3
|
||||
recorded_at: "2026-10-01T21:13:02.000Z"
|
||||
---
|
||||
|
||||
# Phase 9: Backend admin authentication and schema pipeline. Verification Report
|
||||
|
||||
**Phase Goal:** Backend admin users with roles are separate from frontend users and gate navigation and controller access; `fields.yaml`/`columns.yaml` drive a JSON form/list schema, including a first-class relation-manager schema replacing the one `partial` field.
|
||||
**Verified:** 2026-10-05T15:08:52Z (summercms.go HEAD 104a1c9, fonoteka.go HEAD 93b8b75)
|
||||
**Verified:** 2026-10-11T13:38:34Z (summercms.go HEAD be82bcc, code at b45af8e; fonoteka.go HEAD e074028)
|
||||
**Status:** passed
|
||||
**Re-verification:** Yes. The 2026-10-01T21:17Z passed report went stale because later phases edited 25 already-covered implementation files. `scripts/check-phase9.sh --all` passed at HEAD 104a1c9; `covered_digest` was recomputed to match current bytes. Human items remain the 2026-10-01 decisions (09-UAT.md complete 3/3).
|
||||
**Re-verification:** Yes, after gap closure. The 13:00Z run today found `gaps_found` (stale fonoteka route inventory). Quick task 261011-kzi closed it. This run re-checked the gap and the code changes from that task, and ran regression checks on everything else.
|
||||
|
||||
**MVP note:** ROADMAP marks this phase `mode: mvp`, but its goal is not a User Story. The PLAN files carry a valid one, which is used below. The five ROADMAP success criteria are the contract, and the plan `must_haves` are supporting evidence.
|
||||
|
||||
**Changes since the previous report that bear on Phase 9 (verified in code, not taken from 09-REVIEW-FIX.md):**
|
||||
## Summary of this re-verification
|
||||
|
||||
- **Permissions (WR-01, WR-17):** `cabana.Allows` (`contracts.go:130`) now passes when ANY required code is granted, and `granted` (line 147) matches wildcard requirements (`x.*`, `*x`) as Winter's `hasPermission` does. `BackendUsers.FindByID` overlays the user's own `backend_users.permissions` on the role grants (`applyUserPermissions`, `auth.go:77`): `1` grants, `-1`/`0` remove an exact code.
|
||||
- **Navigation (WR-02):** `Metadata` (`navigation.go:36`) drops a main item the principal may not open, whatever its children allow, and `openableTarget` (line 95) repoints an allowed parent to its first openable child. With Fonoteka's parent requirement `golem15.fonoteka.*`, a genres-only admin should now see the parent linked to `golem15.fonoteka.genres`, not albums (by code reading; the framework test pins this shape, no Fonoteka test asserts it).
|
||||
- **Writes (WR-03, WR-04, WR-05):** `operationDeclared` (`http.go:806`) refuses create/update/delete/bulk-delete the compiled YAML does not declare (403). Form `required` applies only in contexts that can supply it. `relationMutation` (line 469) refuses link/unlink missing from `view.toolbarButtons`.
|
||||
- **Auth (WR-10 to WR-14):** foreign `backend` guard fails boot (`bouncer.Registry.Owner`); ambiguous login identifiers answer the same opaque 401 (`findBackendLogin`, `auth.go:431`); dummy hash uses the configured bcrypt cost; `admin:*` read passwords from a prompt or stdin; logout runs outside the guard and revokes an expired-but-refreshable token via `bouncer.VerifyRefreshableClaimsAudience` (`refresh.go:60`).
|
||||
- **Schema/query (WR-06 to WR-09, WR-16):** relation default sort is the first sortable column; relation column order comes from an ordered decode, not indentation; list search uses `LOWER(CAST(col AS TEXT))` (`query.go:296`); the scaffold declares a required permission and a record source; reflection helpers see embedded structs and explicit `column:` tags (`model_fields.go`).
|
||||
- **Gate and transactions (WR-18, WR-19):** `check-phase9.sh` judges zero tests per package; hooks and scopes read the write transaction through `cabana.TxFromContext` (`tx_context.go:27`), used by Fonoteka's album and collection hooks.
|
||||
- **Fonoteka (WR-15):** an admin editor link leaves `granted_by` NULL.
|
||||
- **WR-11 index (2da8112, user decision):** migration `202610010001_backend_users_email_ci_unique` (`modules/lagoon/backend_admin_migrations.go`) checks for emails that differ only in case, fails naming the clashing logins, otherwise creates `backend_users_email_lower_unique ON backend_users (lower(email))`; rollback drops it. Registered through `BackendAdminMigrations` (`migrations.go:74`, plugin id `summercms.cabana`).
|
||||
The single blocker is closed, and I checked that by running the evidence, not by reading the quick-task SUMMARY:
|
||||
|
||||
- `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1` (fonoteka.go): `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS.
|
||||
- `scripts/check-phase9.sh --security`: "phase9 security passed", exit 0.
|
||||
- `scripts/check-phase9.sh --all`: self-test, security, postgres, openapi and evidence all pass, exit 0 (log below).
|
||||
- Assembled Phase 9 suites in fonoteka.go: 79 PASS, 0 FAIL, 0 SKIP (previous run: 78 PASS, 1 FAIL).
|
||||
|
||||
**Gap fix (fonoteka.go e074028).** The commit adds exactly 16 lines to `phase09AdminRoutes`: the 10 routes, each `public: false`, plus comments. The assertions in `TestPhase09SecurityRoutes` are unchanged. The test still fails on a missing route, a non-raw route, a protected route without `backend` middleware, a public route with it, and any unlisted admin route. I checked each of the 10 routes in `modules/cabana/http.go` `service.mount`. All are registered inside `r.GroupRaw(api, []string{"backend"}, …)`, which opens at line 264 and closes at line 387:
|
||||
|
||||
| Route | http.go line | Extra gate |
|
||||
|---|---|---|
|
||||
| `PUT /account` | 268 | `interactiveOnly`, `requireAjax` |
|
||||
| `POST /account/avatar` | 269 | `interactiveOnly`, `requireAjax` |
|
||||
| `DELETE /account/avatar` | 270 | `interactiveOnly`, `requireAjax` |
|
||||
| `GET /account/tokens` | 271 | `interactiveOnly` |
|
||||
| `POST /account/tokens` | 272 | `interactiveOnly`, `requireAjax` |
|
||||
| `GET /account/tokens/scopes` | 273 | `interactiveOnly` |
|
||||
| `DELETE /account/tokens/{id}` | 274 | `interactiveOnly`, `requireAjax` |
|
||||
| `GET /field-types` | 278 | `tokenRouteGate` (handler) |
|
||||
| `POST /markdown/preview` | 280 | `requireAjax`, `tokenRouteGate` (handler) |
|
||||
| `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | 386 | `requireAjax`; handler calls `protect` then `operationDeclared` (`field_type_upload.go:33,44`) |
|
||||
|
||||
None of the 10 routes is in the public `/auth`, `/lang` or `adminPrefix` groups.
|
||||
|
||||
**Throttle change (summercms.go b45af8e) does not weaken any Phase 9 must-have.** Since a3b502b (the HEAD of the previous report), the only code changes are `api_tokens.go`, `token_guard.go`, `http.go` and two test files (`git diff --stat a3b502b HEAD`).
|
||||
- `compositeGuard.AuthenticateCredential` (`token_guard.go:62-75`) is unchanged: a Bearer value with the `g15_` prefix goes to `authenticateToken`, and everything else goes to `g.jwt.Authenticate`. `g.jwt` is still `bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)` (`http.go:151,156`). The cookie-less resolver also uses `NewBackendJWTGuard` (`http.go:172-173`).
|
||||
- The only change on the token path is the debounce of the `last_used_at` write. It now happens after all authentication and scope decisions (`token_guard.go:120`) and does not affect the result. Token lookup, `subtle.ConstantTimeCompare`, `users.FindByID` (backend users only), `methodFilterScopes`, and `principal.Scope = limit` are unchanged.
|
||||
- Scope narrowing is untouched. `token_scopes.go` (`ownerHas`, line 237; `allowsUnderScope`, line 246) and `contracts.go` (`Allows`) have no diff. `TestAllowsScope`, `TestScopeCoversAll`, `TestScopeMatch`, `TestTokenRouteGates` and `TestInteractiveOnly` PASS.
|
||||
- Guard ownership (`guards.Owner("backend")`, `http.go:163-169`) is unchanged.
|
||||
- A nil `*stampThrottle` returns `due == true`, so a zero-value guard still stamps (the SQL UPDATE stays conditional) and cannot panic. The race run is clean.
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
@@ -136,12 +170,12 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
|
||||
|
||||
| Step | Expected | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS | VERIFIED |
|
||||
| Log in separately | Backend login by login or email returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `modules/cabana/auth.go`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestAdminTracerGenreList`, `TestPhase09GuardIsolation`: PASS | VERIFIED |
|
||||
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `modules/cabana/navigation.go` `Metadata`/`openableTarget`; `TestNavigationDropsDeniedParentAndRepointsTarget`, `TestAdminMetadataFiltering`: PASS | VERIFIED |
|
||||
| Open a controller | 403 without the controller permission, before any schema or SQL work | `modules/cabana/http.go` `protect` (line 780), then `operationDeclared` for writes; `TestPhase09PermissionMatrix`, `TestCRUDOperationsFollowDeclarations`, `TestAdminTracerPermissionBoundary`, `TestPhase09SecurityRoutes`: PASS | VERIFIED |
|
||||
| Work with schema-driven lists and forms | Form/list/relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; `TestPhase09AssembledAcceptance`: PASS on real PostgreSQL | VERIFIED |
|
||||
| Outcome: permission-gated, reusable, decoupled | Framework has no app names; admin identity never touches frontend users | `grep -niE "fonoteka|collection_editors|granted_by|golem15_"` on non-test `modules/cabana/*.go`: no hits. `BackendUsers.FindByID` reads only `backend_users` | VERIFIED |
|
||||
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS on PostgreSQL (re-run today) | VERIFIED |
|
||||
| Log in separately | Backend login returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `auth.go` `findBackendLogin`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestPhase09GuardIsolation` (via `check-phase9.sh --security`), Fonoteka `TestAdminTracerAuthBoundary`, `TestAdminTracerGenreList`: PASS | VERIFIED |
|
||||
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `navigation.go` `Metadata`/`openableTarget`/`canOpen`; Fonoteka `TestAdminMetadata*`: PASS | VERIFIED |
|
||||
| Open a controller | 403 without the controller permission, before any schema or SQL work | `http.go` `protect`, `operationDeclared`; `TestCRUDOperationsFollowDeclarations`, Fonoteka `TestPhase09SecurityMatrix`, `TestAdminTracerPermissionBoundary`: PASS | VERIFIED |
|
||||
| Work with schema-driven lists and forms | Form, list and relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; Fonoteka `TestPhase09AssembledAcceptance` (via `check-phase9.sh --postgres`, run twice inside `--all`): PASS | VERIFIED |
|
||||
| Outcome: permission-gated, reusable, decoupled | No app names in the framework; admin identity never touches frontend users | No new non-test framework code beyond the throttle; token principals still resolve only through `BackendUsers.FindByID` | VERIFIED |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
@@ -149,167 +183,165 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate Winter-shaped `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS in the full run). Separate `backend` guard with its own secret and required audience; cross-audience tokens fail both ways (`TestPhase09GuardIsolation`, `TestAdminTracerAuthBoundary`: PASS); a foreign guard fails boot (`TestActivateRefusesAForeignBackendGuard`: PASS). Grants = role JSON + `HasPermissions` role assignments + user-level overlay (`auth.go:39-77`; `TestBackendUserPermissionsOverrideRole`: PASS on PostgreSQL). Every controller, relation and CRUD route goes through `protect` (`http.go:780`), settings through `protectSetting` (line 387). Permission matching follows Winter's `hasAnyAccess` (`TestAllowsFollowsWinterHasAnyAccess`, 17 cases: PASS). Navigation and settings filter by the same `Allows`; denied parents are dropped and targets repointed (`TestNavigationDropsDeniedParentAndRepointsTarget`: PASS). Also `TestPhase09PermissionMatrix`, `TestPhase09SecurityMatrix`, `TestAdminMetadataFiltering`, `TestAdminMetadataRejectsFrontendPrincipal`: PASS. The previous WR-01/WR-02/WR-17 caveats are closed in code; WR-17's wildcard-deny behaviour was kept as Winter by user decision. |
|
||||
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | `modules/cabana/form_schema.go` decodes with goccy/go-yaml and `yaml.DisallowUnknownField()` (line 286), walks the AST to keep order, rejects unknown keys and types. The real Fonoteka YAML covers every listed item. `required` now honours `context` (`TestCRUDRequiredFollowsContext`: PASS). Tests: `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestAlbumsAdminForm`, `TestAlbumsAdminDropdowns`, `TestStylesAdminForm`, `TestCollectionsAdminForm`: PASS. |
|
||||
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | `modules/cabana/list_schema.go` column types `text`, `datetime`, `switch`; `ListColumn` carries `searchable`, `sortable`, `relation`, `select` (`schema_types.go:20-24`). Search/sort/filter resolve only through compiled allowlists with a PK tie-break; non-text searchable columns are cast to text (`query.go:296`; `TestListSearchNonTextColumns` on PostgreSQL: PASS). Tests: `TestListSchemaCompile`, `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestCollectionsAdminListCRUD`, `TestGenresAdminEdges`: PASS. WR-08 caveat closed. |
|
||||
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | `fonoteka.go/.../models/collection/fields.yaml:19` `editors: type: relation-manager`; no `partial` in the Fonoteka model YAML. `config_relation.yaml` has view/manage list columns, `toolbarButtons: link\|unlink`, `showSearch`. `modules/cabana/relation.go` serves schema, linked, candidates (`RelationExtendManageQuery` at line 473), link and unlink (`RelationBeforeLink` at line 671); link/unlink now require the panel's `toolbarButtons` (`TestRelationMutationsFollowToolbarButtons`: PASS); column order is indentation-independent (`TestRelationColumnOrderIsIndependentOfIndentation`: PASS); default sort is the first sortable column (`TestRelationDefaultSort`: PASS). Fonoteka: `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial` (legacy path-less partial fails boot), `TestRelationCandidateExclusions`: PASS. **Note:** Phase 10.1 D-09 lifted Phase 9's blanket `type: partial` boot error for a bare-name sanitized html/template partial (`compilePartialPath`, `form_schema.go:508`); the Collections editors tab is still a relation manager. WR-05, WR-07, WR-15 caveats closed. |
|
||||
| 5 | Admin CRUD endpoints expose listExtendQuery/formExtendQuery/formBeforeCreate/formBeforeUpdate/relationExtendManageQuery hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | Hook interfaces in `modules/pact/capabilities.go:331-394`, type-asserted in `modules/cabana/query.go:109`, `crud.go:445,534,581,597`, `relation.go:473`. Hooks receive the write transaction through `TxFromContext` (`TestHooksReceiveTheWriteTransaction`: PASS; Fonoteka `TestAlbumsAdminHooksUseTheWriteTransaction` with a one-connection pool: PASS). `CRUDService.BulkDelete` (`crud.go:187`) locks scoped rows in one transaction and deletes per row via `deleteRecord`, so hooks fire per record; it now requires `delete` in `toolbar.buttons`. `TestBulkDeleteDuplicates`, `Idempotent`, `Rollback`, `TestCRUDHooks`: PASS. Settings use the same `Localize`, writable projection, `lagoon.Fill` (`settings.go:149`) and `lagoon.Validate`; `TestAdminSettings*` on PostgreSQL: PASS. CR-01 stays fixed (`TestCRUDFillTypeIsValidation`: PASS). |
|
||||
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS on PostgreSQL today). Separate `backend` guard with its own secret and required audience, wrapped by the composite guard, which still hands every non-`g15_` request to `bouncer.NewBackendJWTGuard` (re-read after b45af8e). A frontend-audience token and a wrong-secret token get 401 on every Fonoteka admin path (`TestPhase09SecurityMatrix` PASS), and cross-audience fails both ways (`TestPhase09GuardIsolation` PASS in `--security`). A foreign `backend` guard still fails boot (ownership check unchanged). Grants = role + user overlay (`TestBackendUserPermissionsOverrideRole` PASS). Every controller route, including field upload, goes through `protect`. Personal API tokens narrow and never widen (`TestAllowsScope`, `TestBackendTokenGuard`, `TestTokenRouteGates`, `TestInteractiveOnly`, `TestResolveBackend`, `TestTokenTracerPostgres`, `TestBackendTokenPostgres`: PASS). |
|
||||
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | No change since the 13:00Z run (`form_schema.go` not in the a3b502b..HEAD diff). `TestCRUDRequiredFollowsContext` PASS today; full `./modules/cabana` package ok; Fonoteka `TestAlbumsAdmin*`, `TestStylesAdmin*`, `TestCollectionsAdmin*`: PASS. |
|
||||
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | No change since the 13:00Z run. `TestListSearchNonTextColumns` (PostgreSQL) PASS today; Fonoteka `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestGenresAdmin*`: PASS. |
|
||||
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | No change since the 13:00Z run. Fonoteka `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial`, `TestRelationCandidateExclusions`: PASS; full `./modules/cabana` package ok. |
|
||||
| 5 | Admin CRUD endpoints expose `listExtendQuery`/`formExtendQuery`/`formBeforeCreate`/`formBeforeUpdate`/`relationExtendManageQuery` hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | No change since the 13:00Z run. `TestBulkDelete*`, `TestCRUDHooks`, `TestHooksReceiveTheWriteTransaction`, `TestCRUDFillTypeIsValidation`: PASS on PostgreSQL today (27-test set). Fonoteka `TestAdminSettings*`, `TestAlbumsAdminHooksUseTheWriteTransaction`: PASS. |
|
||||
|
||||
**Score:** 5/5 ROADMAP truths verified (0 present-but-behavior-unverified).
|
||||
|
||||
### Plan must-have truths (supporting evidence)
|
||||
|
||||
There are 54 plan truths across 09-01 to 09-12, and each maps to a named test in its plan's `<verify>` block. I re-ran every named Fonoteka suite with `-v`: 79 top-level PASS, 0 SKIP, 0 FAIL. That equals the number of test functions with those prefixes in the package (79, now including `TestAlbumsAdminHooksUseTheWriteTransaction` from the WR-19 fix). The 20 tests added by the summercms.go fix commits were run by name and all pass.
|
||||
There are 54 plan truths across 09-01 to 09-12. Fonoteka's named Phase 9 suites at e074028 (`-run '^(TestPhase09.*|TestAdminSettings.*|TestAdminMetadata.*|TestCollectionsAdmin.*|TestAlbumsAdmin.*|TestGenresAdmin.*|TestArtistsAdmin.*|TestStylesAdmin.*|TestAdminTracer.*|TestAdminAuthLifecycleAssembled|TestRelationCandidateExclusions)$'`) gave 79 PASS, 0 FAIL, 0 SKIP.
|
||||
|
||||
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to the framework-owned `summercms.go/admin/openapi/admin.json`. The intent still holds: `scripts/check-admin-openapi.sh --check` and `scripts/check-phase9.sh --openapi` exit 0.
|
||||
| Truth | Status | Evidence |
|
||||
|---|---|---|
|
||||
| 09-12 #2: complete threat matrix executable, enforced by the fail-closed Phase 9 gate | ✓ VERIFIED (gap closed) | `check-phase9.sh --security` exit 0, `--all` exit 0. `TestPhase09SecurityRoutes` PASS with assertions unchanged; all 10 added routes carry `backend` middleware (checked in http.go, table above). |
|
||||
| All other 53 plan truths | ✓ VERIFIED | Named tests above and in the Spot-Checks table pass; `check-phase9.sh --all` (self-test, security, postgres, openapi, evidence) exit 0. |
|
||||
|
||||
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to `summercms.go/admin/openapi/admin.json`. `check-phase9.sh --openapi` passes inside `--all`.
|
||||
|
||||
Backstop (non-inferable) truths:
|
||||
|
||||
| Truth | Evidence | Status |
|
||||
|---|---|---|
|
||||
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestAdminTracerGenreList` (frontend user with the admin's email; frontend password rejected), `TestLoginAmbiguousIdentifier` (a cross-field collision now fails opaquely instead of taking the first match), `TestMissingUserHashUsesConfiguredCost`: PASS | VERIFIED (WR-11 closed; `lower(email)` unique index added, `TestBackendAdminEmailCaseInsensitiveUnique` PASS) |
|
||||
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate`: PASS | VERIFIED |
|
||||
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables, `Principal.Backend` flag, audience checks; `TestPhase09GuardIsolation`, `TestAdminMetadataRejectsFrontendPrincipal`, `TestVerifyRefreshableClaimsAudience` (refresh-window verification keeps the audience check): PASS | VERIFIED |
|
||||
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier` PASS today; Fonoteka `TestAdminTracerGenreList` (frontend user with the admin's email rejected) PASS | VERIFIED |
|
||||
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | Fonoteka `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate` PASS | VERIFIED |
|
||||
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables. Personal API tokens live in `backend_api_tokens`, resolve only through `BackendUsers.FindByID`, and the non-`g15_` path is the backend JWT guard. `TestPhase09GuardIsolation`, `TestBackendTokenGuard`, `TestBackendTokenPostgres` PASS | VERIFIED |
|
||||
|
||||
### Prohibitions (judgment tier, non-authoritative verdicts)
|
||||
|
||||
| Plan | Prohibition | Verdict |
|
||||
The 24 verdicts accepted in UAT row 3 still stand. b45af8e changes only when `last_used_at` is written, and e074028 is test-only, so neither touches a prohibition:
|
||||
|
||||
| Plan | Prohibition | Verdict at HEAD |
|
||||
|---|---|---|
|
||||
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (foreign `backend` guard now fails boot) |
|
||||
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every route; logout is public by design, CSRF-checked, and only revokes the presented token) |
|
||||
| 01 | Tracer must not be mock-only | not violated (testcontainers PostgreSQL, assembled router) |
|
||||
| 02 | No boot, web-wizard or env-seeded first admin | not violated (migration seeds roles only; `admin:create` only) |
|
||||
| 02 | No cookie session store and no merge with the frontend user model | not violated |
|
||||
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (`TestAdminAuthLogging`, `TestPhase09SecurityCoverage`; the `--password` deprecation warning never echoes the value) |
|
||||
| 03 | Form compiler must not accept `type: partial` | **superseded (accepted by the user 2026-10-01)**: Phase 10.1 D-09 lifted this for a supported partial contract (bare-name `path`, html/template, allowlisted node renderer, D-18 sanitizer). The legacy Winter path-less partial is still refused (`TestCollectionsAdminRejectsPartial`). |
|
||||
| 03 | Labels not deferred to the client or cached in the first request's locale | not violated (per-request `Localize`) |
|
||||
| 03 | Unknown keys, types or providers not silently ignored | not violated (`DisallowUnknownField`, `formFieldKeys`) |
|
||||
| 04 | YAML must not inject SQL or name an arbitrary method | not violated (`conditions:` rejected, finite `FilterScopes`; search cast is on an allowlisted, quoted column) |
|
||||
| 04 | Equal sort values must not make rows jump across pages | not violated (PK tie-break) |
|
||||
| 05 | No partially committed bulk operation | not violated (`TestBulkDeleteRollback`) |
|
||||
| 05 | Replay must not rerun destructive hooks | not violated (`TestBulkDeleteIdempotent`) |
|
||||
| 06 | No cross-collection or silently chosen duplicate user on albums | not violated (`TestAlbumsAdminAmbiguousEmail`, `CrossCollection`) |
|
||||
| 06 | Album form choices must not expose inactive or cross-collection users | not violated |
|
||||
| 07 | Artist parity not reached by silently omitting Winter keys | not violated |
|
||||
| 08 | No second Genre identity and no weakened permission | not violated (`TestGenresAdminTracerIdentity`, `DuplicateRegistration`) |
|
||||
| 09 | Style values not coerced between scalar types | not violated (`TestStylesAdminTypedOptions`) |
|
||||
| 10 | No PHP partial and no hardcoded Fonoteka pivot names in the framework | not violated (non-test `modules/cabana/*.go` grep: no hits) |
|
||||
| 10 | Owner, cross-collection or already-linked candidates not linkable by forgery | not violated (`ForgedPivot`, `CrossScope`, `Idempotent`; link now also requires the declared toolbar button) |
|
||||
| 11 | Navigation/settings metadata must not reveal existence, label or target of inaccessible entries | **not violated** (was qualified): a denied main item is dropped; an allowed parent never links to a controller the admin cannot open (`TestNavigationDropsDeniedParentAndRepointsTarget` pins the same parent `x.*` + genres-only shape; for Fonoteka this follows from code reading, since no Fonoteka test asserts a genres-only menu) |
|
||||
| 11 | Reading a missing singleton must not create a row | not violated (`TestAdminSettingsMissingRead`) |
|
||||
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | **not violated** (was qualified): `phase9_detect` refuses any package without a passing test; `--self-test` re-run prints "refuse: zero tests in a/cabana" for the planted case and passes |
|
||||
| 12 | High threats marked mitigated only with a named failing-when-broken test | not violated (`09-SECURITY-REVIEW.md`; `check-phase9.sh --security` re-run: "phase9 security passed") |
|
||||
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (composite guard wraps the backend JWT guard with the backend secret; tokens resolve to `backend_users` only) |
|
||||
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every controller and settings route, including field upload) |
|
||||
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (the throttle's failure log carries only `token_id` and the error; `logTokenAuth` unchanged) |
|
||||
| 03 | Form compiler must not accept `type: partial` | superseded (accepted 2026-10-01) by Phase 10.1 D-09; legacy path-less partial still refused |
|
||||
| 03 | Unknown keys, types or providers not silently ignored | not violated |
|
||||
| 11 | Navigation/settings metadata must not reveal inaccessible entries | not violated |
|
||||
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | not violated (`--all` self-test refuses planted skip/zero-test cases; real stages green) |
|
||||
| others | (see 2026-10-05 report) | unchanged |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|---|---|---|---|
|
||||
| `modules/lagoon/backend_admin_migrations.go` | backend_users/roles, system-role seed, case-insensitive unique email | ✓ VERIFIED | Explicit SQL up/down; `TestBackendAdmin*` (6 incl. `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) PASS |
|
||||
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user-level permission overlay | ✓ VERIFIED | `BackendUsers` reads only `backend_users` |
|
||||
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | prompt/stdin password, collision checks |
|
||||
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | Mounted from `modules/surf/router.go:522` via `cabana.Activate` |
|
||||
| `modules/cabana/form_schema.go`, `schema_types.go` | strict typed form compiler | ✓ VERIFIED | |
|
||||
| `modules/cabana/list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | list compiler, allowlisted query, column resolution | ✓ VERIFIED | |
|
||||
| `modules/cabana/crud.go`, `tx_context.go` | CRUD, projection, hooks, bulk delete, tx on context | ✓ VERIFIED | |
|
||||
| `modules/lagoon/fill.go` | allowlisted fill used by CRUD and settings | ✓ VERIFIED | |
|
||||
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | |
|
||||
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | |
|
||||
| `modules/bouncer/refresh.go`, `registry.go` | refresh-window verification for logout; guard ownership | ✓ VERIFIED | |
|
||||
| `scripts/check-phase9.sh` | fail-closed gate, per-package zero-test check | ✓ VERIFIED | `--self-test`, `--openapi`, `--security`: exit 0 |
|
||||
| `fonoteka.go/.../controllers/{albums,artists,collections,genres,styles}_admin_controller.go`, `controllers/request_db.go` | five controllers with permissions; hooks read the write tx | ✓ VERIFIED | |
|
||||
| `fonoteka.go/.../models/*/fields.yaml`, `columns.yaml`, `controllers/*/config_*.yaml` | Winter-shaped YAML | ✓ VERIFIED | `partial` replaced by `relation-manager` |
|
||||
| `fonoteka.go/.../admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | registerPermissions/Navigation/Settings ports | ✓ VERIFIED | Parent requires `golem15.fonoteka.*` |
|
||||
| `fonoteka.go/.../admin_phase09_e2e_test.go`, `admin_phase09_security_test.go` | assembled acceptance and route inventory | ✓ VERIFIED | Logout listed as public with reason |
|
||||
| `modules/lagoon/backend_admin_migrations.go` | backend users/roles, seed, case-insensitive email, metadata + api tokens | ✓ VERIFIED | `TestBackendAdmin*` PASS |
|
||||
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user overlay | ✓ VERIFIED | unchanged since the 13:00Z run |
|
||||
| `modules/cabana/token_guard.go`, `token_scopes.go`, `api_tokens.go` | composite guard (16.3) on the Phase 9 gate path | ✓ VERIFIED | Routing and narrow-only scope unchanged; throttle now per guard (b45af8e); 13 token tests PASS together |
|
||||
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | |
|
||||
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | b45af8e adds one line (`resolverGuard.stamps = guard.stamps`) |
|
||||
| `modules/cabana/form_schema.go`, `schema_types.go`, `list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | strict compilers, allowlisted query | ✓ VERIFIED | unchanged |
|
||||
| `modules/cabana/crud.go`, `tx_context.go`, `modules/lagoon/fill.go` | CRUD, hooks, bulk delete, tx on context | ✓ VERIFIED | unchanged |
|
||||
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | unchanged |
|
||||
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | unchanged |
|
||||
| `modules/bouncer/jwt.go`, `refresh.go`, `registry.go` | audience guard, refresh-window verification, ownership | ✓ VERIFIED | unchanged since 104a1c9 |
|
||||
| `modules/cabana/security_coverage_test.go` | framework route inventory | ✓ VERIFIED | `TestPhase09SecurityCoverage` PASS |
|
||||
| `scripts/check-phase9.sh` | fail-closed gate | ✓ VERIFIED | `--security` exit 0; `--all` exit 0 |
|
||||
| `fonoteka.go/.../admin_phase09_security_test.go` | assembled route inventory | ✓ VERIFIED | e074028 lists the 10 routes; `TestPhase09SecurityRoutes` PASS |
|
||||
| `fonoteka.go/.../admin_phase09_e2e_test.go` | assembled acceptance | ✓ VERIFIED | `TestPhase09AssembledAcceptance` PASS (twice, inside `--all`) |
|
||||
| `fonoteka.go/.../controllers/*_admin_controller.go`, model YAML, `admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | five controllers and registries | ✓ VERIFIED | fonoteka diff 7fc790d..e074028 touches only the inventory test |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|---|---|---|---|
|
||||
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` at line 522 | WIRED |
|
||||
| `modules/cabana/http.go` guard | `modules/bouncer/jwt.go` | `NewBackendJWTGuard` with backend audience; ownership via `Registry.Owner` | WIRED |
|
||||
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` then blacklist jti | WIRED |
|
||||
| `modules/cabana/http.go` handlers | compiled schemas | `s.reg.Get(id)`, `operationDeclared`, then list/form/relation | WIRED |
|
||||
| `modules/cabana/crud.go` | `lagoon.Fill`/`lagoon.Validate` | `save` transaction (line 290; Fill at 327, `FillTypeError` to 422) | WIRED |
|
||||
| `modules/cabana/crud.go` | plugin hooks | `TxFromContext` inside `lagoon.Transaction` | WIRED |
|
||||
| `modules/cabana/crud.go` bulk | model lifecycle hooks | per-row `deleteRecord` inside one transaction | WIRED |
|
||||
| `modules/cabana/settings.go` | form pipeline | `Localize`, `Fill` (line 149), `Validate` | WIRED |
|
||||
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` | WIRED |
|
||||
| `cabana` `backend` guard | `modules/bouncer/jwt.go` | `newCompositeGuard(NewBackendJWTGuard(secret, …))` (`http.go:151,156`); ownership via `Registry.Owner` | WIRED |
|
||||
| composite guard token path | `backend_users` | `users.FindByID(row.BackendUserID)` (`token_guard.go:101`) | WIRED |
|
||||
| composite guard token path | `backend_api_tokens.last_used_at` | `stampLastUsed(ctx, g.stamps, …)`; backend and resolver guards share one `stampThrottle` (`http.go:175`) | WIRED |
|
||||
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` | WIRED |
|
||||
| handlers | compiled schemas | `protect` then `s.reg.Get(id)`, `operationDeclared` | WIRED |
|
||||
| `crud.go` | `lagoon.Fill`/`Validate`, plugin hooks | `save` transaction, `TxFromContext` | WIRED |
|
||||
| `crud.go` bulk | lifecycle hooks | per-row `deleteRecord` | WIRED |
|
||||
| `settings.go` | form pipeline | `Localize`, `Fill`, `Validate` | WIRED |
|
||||
| `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED |
|
||||
| `internal/build/build.go` | `cabana.RuntimeCommands` | generated main | WIRED |
|
||||
| `scripts/check-phase9.sh` | framework + assembled security tests | named stages, zero-test detection | WIRED (all stages green) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|---|---|---|---|---|
|
||||
| List endpoint | `data` rows | GORM query on the registered model, scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
|
||||
| Navigation | entries | plugin `Navigation()` filtered by role + user-level grants from `backend_user_roles`/`backend_users` | Yes | ✓ FLOWING |
|
||||
| Settings GET | `data` | `golem15_fonoteka_settings` row or compiled defaults | Yes | ✓ FLOWING |
|
||||
| Relation linked/candidates | rows | pivot-joined user query with owner and linked users excluded | Yes | ✓ FLOWING |
|
||||
| List endpoint | `data` rows | GORM query scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
|
||||
| Navigation | entries | plugin `Navigation()` filtered by role + user grants (+ token scope) | Yes | ✓ FLOWING |
|
||||
| Settings GET | `data` | singleton row or compiled defaults | Yes | ✓ FLOWING |
|
||||
| Relation linked/candidates | rows | pivot-joined user query | Yes | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
All run by the verifier on 2026-10-11 at summercms.go b45af8e / fonoteka.go e074028.
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| Framework vet | `go vet ./...` (summercms.go) | exit 0, no output | ✓ PASS |
|
||||
| Framework regression (single full run) | `go test ./... -count=1` (summercms.go) | exit 0; 35 packages ok (plus packages with no test files), 0 FAIL | ✓ PASS |
|
||||
| App vet | `go vet $(go list -f '{{.Dir}}/...' -m)` (fonoteka.go, all workspace modules) | exit 0 | ✓ PASS |
|
||||
| App regression | `go test $(go list -f '{{.Dir}}/...' -m) -count=1` (fonoteka.go, root + user + fonoteka plugin modules) | exit 0; 13 packages ok, 0 FAIL | ✓ PASS |
|
||||
| Phase 9 framework tests, named | `go test ./modules/cabana -v -run '^(TestAllowsFollowsWinterHasAnyAccess\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestRelationMutationsFollowToolbarButtons\|TestBulkDelete(Duplicates\|Idempotent\|Rollback)\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestFormSchema(Compile\|Rejects)\|TestListSchemaCompile\|TestRelationCandidateExclusions\|TestPhase09PermissionMatrix\|TestAdminAuthLifecycle\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 16 PASS | ✓ PASS |
|
||||
| Review-fix tests, named | `go test ./modules/cabana ./modules/bouncer -v -run '^(TestActivateRefusesAForeignBackendGuard\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestAdminPasswordWithoutFlag\|TestBackendUserPermissionsOverrideRole\|TestCRUDOperationsFollowDeclarations\|TestCRUDRequiredFollowsContext\|TestFieldByColumnTagBeatsGoName\|TestHooksReceiveTheWriteTransaction\|TestListSearchNonTextColumns\|TestLoginAmbiguousIdentifier\|TestMissingUserHashUsesConfiguredCost\|TestModelHelpersLookThroughEmbeddedStructs\|TestRegistryOwner\|TestRelationColumnOrderIsIndependentOfIndentation\|TestRelationDefaultSort\|TestVerifyRefreshableClaimsAudience)$'` | 17 PASS (WR-09 scaffold assertions run inside `TestScaffoldAllArtifacts`, full run PASS) | ✓ PASS |
|
||||
| Guard isolation | `go test ./modules/bouncer -run '^TestPhase09GuardIsolation$' -v` | PASS | ✓ PASS |
|
||||
| Backend admin migrations incl. WR-11 index | `go test ./modules/lagoon -count=1 -v -run '^(TestBackendAdmin.*)$'` | 6 PASS (`Migration`, `Seed`, `Rollback`, `WinterRow`, `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) | ✓ PASS |
|
||||
| Assembled Phase 9 acceptance and controllers | `go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestPhase09.*\|TestAdminSettings.*\|TestAdminMetadata.*\|TestCollectionsAdmin.*\|TestAlbumsAdmin.*\|TestGenresAdmin.*\|TestArtistsAdmin.*\|TestStylesAdmin.*\|TestAdminTracer.*\|TestAdminAuthLifecycleAssembled)$'` (fonoteka.go) | exit 0; 79 PASS, 0 SKIP, 0 FAIL | ✓ PASS |
|
||||
| Framework vet + short suite | `go vet ./...`; `go test -short -count=1 ./...` | vet clean; 38 packages ok, none failing | ✓ PASS |
|
||||
| Token guard under the race detector | `go test -short -count=1 -race ./modules/cabana -run 'Token\|ResolveBackend\|Phase09\|Allows'` | ok | ✓ PASS |
|
||||
| 16.3 guard on the Phase 9 path (previously order-dependent) | `go test -count=1 ./modules/cabana -run '^(TestAllowsScope\|TestBackendTokenGuard\|TestBackendTokenPostgres\|TestInteractiveOnly\|TestResolveBackend\|TestScopeCoversAll\|TestScopeMatch\|TestTokenRouteGates\|TestTokenTracerPostgres\|TestTokenGatesSmoke\|TestAccountTokens\|TestTokenLastUsedThrottlePerGuard\|TestTokenLastUsedDebounce)$'` | 13 PASS; `TestBackendTokenPostgres` PASS after `TestAccountTokens` and `TestTokenTracerPostgres` | ✓ PASS |
|
||||
| Full cabana package (Docker) | `go test -count=1 ./modules/cabana` | ok (84.8s) | ✓ PASS |
|
||||
| Phase 9 framework tests (PostgreSQL) | `go test -count=1 ./modules/cabana ./modules/lagoon -run '^(TestLoginAmbiguousIdentifier\|TestHooksReceiveTheWriteTransaction\|TestCRUDRequiredFollowsContext\|TestCRUDOperationsFollowDeclarations\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestBulkDelete.*\|TestAdminPasswordWithoutFlag\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestBackendUserPermissionsOverrideRole\|TestListSearchNonTextColumns\|TestBackendAdmin.*\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 27 PASS | ✓ PASS |
|
||||
| Assembled route inventory + denial matrix | (fonoteka.go) `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1 -v` | `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS | ✓ PASS |
|
||||
| Assembled Phase 9 suites | (fonoteka.go) named suites (above) | 79 PASS, 0 FAIL, 0 SKIP | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase gate ran instead:
|
||||
No `scripts/*/tests/probe-*.sh` exists and no plan declares one. The phase gate ran instead:
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| `scripts/check-phase9.sh` | `--self-test` | planted cases refused ("skipped TestPhase09MigrationsFreshRollback", "zero tests", "failed TestPhase09ContractInventory", "zero tests in a/cabana"), then "phase9 self-test passed", exit 0 | PASS |
|
||||
| `scripts/check-phase9.sh` | `--openapi` | "phase9 openapi passed", exit 0 | PASS |
|
||||
| `scripts/check-phase9.sh` | `--security` | "phase9 security passed", exit 0 | PASS |
|
||||
| `scripts/check-admin-openapi.sh` | `--check` | exit 0 | PASS |
|
||||
| `scripts/check-phase9.sh` | `--all` | self-test refuses its 4 planted cases then "phase9 self-test passed"; "phase9 security passed"; "phase9 postgres passed"; "phase9 openapi passed"; "phase9 evidence files passed"; security/postgres/openapi again; "phase9 evidence passed"; exit 0 | PASS |
|
||||
|
||||
After `--all`, fonoteka.go's working tree showed only its three pre-existing submodule pointer changes. The openapi stage left no diff.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plans | Description | Status | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1 |
|
||||
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1; acceptance gate green |
|
||||
| ADMIN-01 | 09-03, 09-06..09-10, 09-12 | fields.yaml to JSON form schema | ✓ SATISFIED | Truth 2 |
|
||||
| ADMIN-02 | 09-01, 09-04, 09-06..09-10, 09-12 | columns.yaml to JSON list schema | ✓ SATISFIED | Truth 3 |
|
||||
| ADMIN-03 | 09-10, 09-12 | relation-manager replaces `partial` | ✓ SATISFIED | Truth 4 |
|
||||
| ADMIN-04 | 09-05..09-10, 09-12 | CRUD hooks and per-record bulk delete | ✓ SATISFIED | Truth 5 |
|
||||
| ADMIN-05 | 09-11, 09-12 | settings model bound through the same pipeline | ✓ SATISFIED | Truth 5 |
|
||||
|
||||
REQUIREMENTS.md maps exactly these six IDs to Phase 9, so there are no orphaned requirements.
|
||||
REQUIREMENTS.md maps exactly these six IDs to Phase 9; no orphaned requirements.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|---|---|---|---|---|
|
||||
| `internal/build/stubs/artifacts.tmpl` | 106 | `// TODO: return a pointer to the plugin's model` in the generated controller | ℹ️ Info | Deliberate scaffold placeholder (WR-09); generated text, not a debt marker in phase code. Until filled in, the scaffold answers 500 and boot refuses the undeclared permission |
|
||||
| `modules/cabana/settings.go` | 149 | no test writes a numeric settings field | ℹ️ Info | The `json.Number` conversion lives in the shared `lagoon.Fill`; settings Fill failures already answer 422 |
|
||||
| `internal/build/stubs/artifacts.tmpl` | — | `// TODO: return a pointer to the plugin's model` in generated scaffold | ℹ️ Info | Deliberate scaffold placeholder (WR-09) |
|
||||
| `modules/cabana/{account_tokens,account_tokens_test,token_postgres_smoke_test,token_routes_test,token_scopes_test}.go` | — | not gofmt-formatted (predates b45af8e, per the quick SUMMARY) | ℹ️ Info (16.3 scope) | Cosmetic. Not in the Phase 9 covered set; I did not re-check it |
|
||||
|
||||
No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits). The 12 Info review findings (IN-01..IN-12) remain `open` in the ledger and are out of the fix run's scope; none defeats a success criterion.
|
||||
The previous blocker (stale `admin_phase09_security_test.go` inventory) and the info item (`lastStamp` process-global map) are resolved. The five files touched by b45af8e are gofmt-clean, and they and the fonoteka inventory test have no `TBD`/`FIXME`/`XXX`.
|
||||
|
||||
### Advisory (New Scope, Unevidenced)
|
||||
|
||||
None. The previous advisory (`TestBackendTokenPostgres` order dependence) is closed, see `re_verification.advisory_closed`.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None open. The previous items are resolved by the user's recorded decisions on 2026-10-01 (09-REVIEW-FIX.md "Decisions", 09-UAT.md status complete, 3/3 pass, commit ba1aaef), and the code at HEAD matches each decision:
|
||||
None. All three 09-UAT.md rows (recorded 2026-10-01T21:13:02Z) were decisions and triage, not runtime observations. The code changed since this morning's report is the token `last_used_at` debounce and one fonoteka test inventory, and neither touches what those decisions rest on:
|
||||
|
||||
| Item | Decision | Code evidence |
|
||||
| UAT row | Rests on | Still holds at HEAD |
|
||||
|---|---|---|
|
||||
| WR-11 unique index on `lower(email)` | add it | migration `202610010001_backend_users_email_ci_unique`; `TestBackendAdminEmailCaseInsensitiveUnique`, `TestBackendAdminEmailIndexRefusesCaseDuplicates` PASS |
|
||||
| WR-03 single-record delete | keep as Winter (allowed whenever a form exists) | `operationDeclared` in `modules/cabana/http.go`; `TestCRUDOperationsFollowDeclarations` PASS |
|
||||
| WR-17 deny versus wildcard | keep as Winter (exact-code merge) | `applyUserPermissions` in `modules/cabana/auth.go`; `TestBackendUserPermissionsOverrideRole` PASS |
|
||||
| 24 judgment-tier prohibitions | verdicts accepted; 09-03 #1 superseded by Phase 10.1 D-09 | Prohibitions table above |
|
||||
| 1 CR-01 | Fill type errors answer 422 | `crud.go` unchanged; `TestCRUDFillTypeIsValidation` PASS today |
|
||||
| 2 WR-01..WR-19 triage (WR-03, WR-11, WR-17 decisions) | `operationDeclared` delete rule; `lower(email)` index; `applyUserPermissions` exact-code merge | `TestCRUDOperationsFollowDeclarations`, `TestBackendAdmin*`, `TestBackendUserPermissionsOverrideRole` PASS today; `auth.go`, `contracts.go` unchanged |
|
||||
| 3 24 prohibitions | see Prohibitions table | none newly violated |
|
||||
|
||||
They are recorded under `human_verified` and are not re-requested.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No ROADMAP success criterion failed, so there are no gaps. Separate admin authentication, permission gating of controllers, settings and navigation, the strict goccy/go-yaml form and list pipeline, the relation manager that replaces the Collections editors `partial`, the CRUD hooks, per-record bulk delete and the settings binding all exist under `modules/`, are wired, and pass their tests on real PostgreSQL at HEAD in both repositories. CR-01 and all 19 review warnings are fixed in code, and each fix has a passing named test.
|
||||
None remaining. The one blocker from the 13:00Z run, the stale app-side route inventory, was fixed by a test-only fonoteka.go commit (e074028). The verifier re-ran it and it is green. Each of the 10 routes is confirmed inside cabana's `backend` group, and the field upload route also goes through `protect`/`operationDeclared`. `check-phase9.sh --security` and `--all` exit 0.
|
||||
|
||||
The three policy choices from the fix run are decided (WR-11 index added and tested; WR-03 and WR-17 kept as Winter), the judgment-tier prohibition verdicts are accepted, and UAT is complete. No truth failed, no human item is open, so the phase is `passed`.
|
||||
The 16.3 advisory is also resolved. b45af8e replaces the process-global `lastStamp` map with a per-guard `stampThrottle`. It changes only when `last_used_at` is written, after authentication and scope decisions. The `g15_`/JWT routing, the backend JWT guard, guard ownership and narrow-only scopes are unchanged, and their tests pass, including on PostgreSQL and under `-race`.
|
||||
|
||||
A process note carries over from the last report, still not acted on: framework commits that add an admin route should also update fonoteka's `phase09AdminRoutes`, or run `check-phase9.sh --security` first. The root `go test ./...` does not run fonoteka.go tests.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-01T21:17:13Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
_Verified: 2026-10-11T13:38:34Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
status: complete
|
||||
status: testing
|
||||
phase: 10-admin-vue-spa
|
||||
source: [10-VERIFICATION.md]
|
||||
started: 2026-09-27T16:44:38Z
|
||||
updated: 2026-09-27T18:34:33Z
|
||||
updated: 2026-10-11T15:12:56.219Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
@@ -41,12 +41,32 @@ result: pass
|
||||
expected: Accept or reject the verifier's "not violated" verdicts.
|
||||
result: pass
|
||||
|
||||
### 8. Re-walk the Phase 10 admin flows in a real browser on the current build: run the fonoteka binary, open /plytadmin as a superuser and as a genres-only admin, then use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors), link and unlink a Collection editor, open Ustawienia, and glance at light and dark mode.
|
||||
expected: Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form's later widgets, partials and field types do not break the Phase 10 form; list state persisted in the URL and storage does not confuse paging or filtering.
|
||||
result: [pending]
|
||||
|
||||
### 9. Publish the sibling-repo test fixes the --go pass depends on: push sm-user-plugin 569768e, then commit the plugins/golem15/user pointer bump (cf5c6b9 -> 569768e) in fonoteka.go as a separate commit, and push fonoteka.go (03dad50, 7db5ef8 and the bump) when you choose.
|
||||
expected: A clean checkout of fonoteka.go at its committed pointer passes check-phase10.sh --go, as the working tree does now.
|
||||
result: [pending]
|
||||
|
||||
### 10. Carried: decide CR-01 (refresh ignores tokens_valid_after / is_activated)
|
||||
expected: Fix now or accept with override
|
||||
result: [pending]
|
||||
|
||||
### 11. Carried: Secure cookie on http://localhost (assumption A3)
|
||||
expected: Chrome and Firefox store summer_admin with default cookie_secure
|
||||
result: [pending]
|
||||
|
||||
### 12. Carried: review the 17 judgment-tier prohibitions
|
||||
expected: Accept or reject the verifier's non-authoritative verdicts
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 7
|
||||
total: 12
|
||||
passed: 7
|
||||
issues: 0
|
||||
pending: 0
|
||||
pending: 5
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
phase: 10-admin-vue-spa
|
||||
verified: 2026-10-05T20:22:38Z
|
||||
verified: 2026-10-11T15:11:15Z
|
||||
status: human_needed
|
||||
score: 4/4 roadmap success criteria verified by automated evidence (plan truths 46/46; 1 browser re-check of changed views pending)
|
||||
score: "4/4 roadmap success criteria hold; plan truths 46/46 (the --go gap is closed: check-phase10.sh --go exit 0 in the verifier's own run)"
|
||||
covered_files:
|
||||
- ".gitignore"
|
||||
- ".planning/phases/10-admin-vue-spa/10-01-PLAN.md"
|
||||
@@ -22,6 +22,7 @@ covered_files:
|
||||
- "admin/package.json"
|
||||
- "admin/src/App.vue"
|
||||
- "admin/src/api/client.ts"
|
||||
- "admin/src/api/files.ts"
|
||||
- "admin/src/api/schema.d.ts"
|
||||
- "admin/src/api/types.ts"
|
||||
- "admin/src/app/controllerRoutes.ts"
|
||||
@@ -32,6 +33,8 @@ covered_files:
|
||||
- "admin/src/app/runtime.ts"
|
||||
- "admin/src/app/theme.ts"
|
||||
- "admin/src/app/winterUrl.ts"
|
||||
- "admin/src/components/account/ApiTokenCreateDialog.vue"
|
||||
- "admin/src/components/account/ApiTokensPanel.vue"
|
||||
- "admin/src/components/form/FieldRenderer.vue"
|
||||
- "admin/src/components/form/FormErrorBanner.vue"
|
||||
- "admin/src/components/form/FormField.vue"
|
||||
@@ -40,6 +43,8 @@ covered_files:
|
||||
- "admin/src/components/form/control.ts"
|
||||
- "admin/src/components/form/fields/CheckboxField.vue"
|
||||
- "admin/src/components/form/fields/DropdownField.vue"
|
||||
- "admin/src/components/form/fields/FileCaptionModal.vue"
|
||||
- "admin/src/components/form/fields/MLField.vue"
|
||||
- "admin/src/components/form/fields/NumberField.vue"
|
||||
- "admin/src/components/form/fields/RelationField.vue"
|
||||
- "admin/src/components/form/fields/SwitchField.vue"
|
||||
@@ -47,6 +52,7 @@ covered_files:
|
||||
- "admin/src/components/form/fields/TextareaField.vue"
|
||||
- "admin/src/components/form/fields/UnsupportedField.vue"
|
||||
- "admin/src/components/form/formState.ts"
|
||||
- "admin/src/components/form/mlLocale.ts"
|
||||
- "admin/src/components/form/registry.ts"
|
||||
- "admin/src/components/list/CellValue.vue"
|
||||
- "admin/src/components/list/DataTable.vue"
|
||||
@@ -79,6 +85,8 @@ covered_files:
|
||||
- "admin/src/views/NotFoundView.vue"
|
||||
- "admin/src/views/SettingsFormView.vue"
|
||||
- "admin/src/views/SettingsIndexView.vue"
|
||||
- "admin/tests/account/ApiTokenCreateDialog.test.ts"
|
||||
- "admin/tests/account/ApiTokensPanel.test.ts"
|
||||
- "admin/tests/app/client.test.ts"
|
||||
- "admin/tests/app/controllerRoutes.test.ts"
|
||||
- "admin/tests/app/i18n.test.ts"
|
||||
@@ -100,14 +108,17 @@ covered_files:
|
||||
- "admin/tests/fixtures/widgets.relation-candidates.json"
|
||||
- "admin/tests/fixtures/widgets.relation-linked.json"
|
||||
- "admin/tests/fixtures/widgets.relation-schema.json"
|
||||
- "admin/tests/form/FileCaptionModal.test.ts"
|
||||
- "admin/tests/form/FormField.test.ts"
|
||||
- "admin/tests/form/FormGrid.test.ts"
|
||||
- "admin/tests/form/FormTabs.test.ts"
|
||||
- "admin/tests/form/FormView.test.ts"
|
||||
- "admin/tests/form/MLField.test.ts"
|
||||
- "admin/tests/form/RelationField.test.ts"
|
||||
- "admin/tests/form/Settings.test.ts"
|
||||
- "admin/tests/form/fields.test.ts"
|
||||
- "admin/tests/form/formState.test.ts"
|
||||
- "admin/tests/form/mlLocale.test.ts"
|
||||
- "admin/tests/form/registry.test.ts"
|
||||
- "admin/tests/helpers.ts"
|
||||
- "admin/tests/list/CellValue.test.ts"
|
||||
@@ -151,8 +162,8 @@ covered_files:
|
||||
- "internal/tools/swagger2openapi/main_test.go"
|
||||
- "modules/boardwalk/boardwalk.go"
|
||||
- "modules/boardwalk/boardwalk_test.go"
|
||||
- "modules/boardwalk/dist/assets/index-CqzF_Nki.css"
|
||||
- "modules/boardwalk/dist/assets/index-z_LzMU8D.js"
|
||||
- "modules/boardwalk/dist/assets/index-BeX-076N.css"
|
||||
- "modules/boardwalk/dist/assets/index-CXQZEJp2.js"
|
||||
- "modules/boardwalk/dist/index.html"
|
||||
- "modules/bouncer/cookie_guard_test.go"
|
||||
- "modules/bouncer/jwt.go"
|
||||
@@ -213,8 +224,7 @@ covered_files:
|
||||
- "scripts/check-admin-dist.sh"
|
||||
- "scripts/check-admin-openapi.sh"
|
||||
- "scripts/check-phase10.sh"
|
||||
covered_digest: "v2:sha256:778f7d89834605c7799f088401e3b1ef5edb4409f6899f322f67d165fa9e4847"
|
||||
covered_files_note: "Fingerprint refreshed 2026-10-05 at summercms.go HEAD e18885f. Content-hashed SPA bundles now modules/boardwalk/dist/assets/index-CqzF_Nki.css and index-z_LzMU8D.js. Later phases edited already-covered modules/ files (same digest-drift as Phase 9 Layer 2). Paths remain modules/. Status stays human_needed — this only clears the stale overlay so execute-phase is not re-invoked."
|
||||
covered_digest: "v2:sha256:dce85ba35408764f2ce0d7cc0f1297800a2a70d4c263b033a556c19bd3c6e604"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
mvp_mode_note: "ROADMAP marks Phase 10 mode: mvp, but the goal is not a User Story. Following the Phase 1/3/5/8 precedent, the four ROADMAP success criteria are the contract and User Flow Coverage is derived from them."
|
||||
@@ -223,63 +233,94 @@ decision_coverage:
|
||||
total: 28
|
||||
not_honored: []
|
||||
re_verification:
|
||||
previous_status: passed
|
||||
previous_score: "4/4 roadmap success criteria verified (plan truths 46/46 verified; A3 closed by human UAT)"
|
||||
previous_head: c7487f6
|
||||
reason: "The 2026-10-01T21:29Z report went stale because Vite rebuilt the two content-hashed SPA bundles (index-CfeX_snf.css / index-J-FCndLr.js → index-CqzF_Nki.css / index-z_LzMU8D.js) and later phases edited already-covered modules/ files. Paths were already under modules/. covered_digest recomputed at HEAD e18885f; status stays human_needed for the pending browser re-walk."
|
||||
previous_status: gaps_found
|
||||
previous_score: "4/4 roadmap success criteria hold; plan truths 45/46 (1 failed: the phase acceptance command check-phase10.sh --all is red at its --go stage, for causes outside Phase 10 code)"
|
||||
previous_head: fc7cfaf
|
||||
head: 77ca4b0
|
||||
reason: "Narrow re-verification of the one --go gap. summercms.go 77ca4b0 adds -skip '^TestBootstrapEndToEnd$' to the framework go test run. In the sibling repos, sm-user-plugin 569768e (local commit, not pushed, fonoteka.go pointer not bumped) and fonoteka.go 03dad50 and 7db5ef8 (local commits, fonoteka.go is 13 ahead of origin) bring the migration-history and parity tests up to date. No file under admin/src, modules/ or modules/boardwalk/dist changed since fc7cfaf."
|
||||
gaps_closed:
|
||||
- "Phase 10 review WR-01 (logout behind the guard did not expire a rejected cookie): logout is now mounted outside the guard and always clears the cookie (299d220). TestAdminLogoutRevokesExpiredRefreshableToken: PASS."
|
||||
- "scripts/check-phase10.sh --all, the phase acceptance command, passes: --go exit 0 (verifier run, 4m50s, 'phase10 go passed'); the other stages were run by the orchestrator this session and all exit 0"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verification:
|
||||
- test: "Re-walk the Phase 10 admin flows in a real browser on the current build: run the fonoteka binary, open /plytadmin as a superuser and as a genres-only admin, then use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors), link and unlink a Collection editor, open Ustawienia, and glance at light and dark mode."
|
||||
expected: "Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form now also shows the Phase 10.1 widgets and partials, and they do not break the Phase 10 form."
|
||||
why_human: "10-UAT.md was approved on 2026-09-27 against c7487f6. Since then Phase 10.1 changed ListView.vue, FormView.vue, ListToolbar.vue, FormField.vue, registry.ts, router.ts and main.css, and fonoteka's Albums controller now registers plugin widgets and partials. The Phase 9 review fixes changed server-side navigation (WR-01, WR-02, WR-17). Vitest and the Postgres acceptance test pass, but D-23 keeps the real browser out of the automated suite, and the browser checks for these views (10.1-UAT.md tests 1 and 4) are still pending. Closing 10.1-UAT tests 1 and 4 with Genres, Collections and Settings included also closes this item."
|
||||
expected: "Everything still behaves as approved in 10-UAT.md tests 2 to 5. The limited admin sees only fonoteka > Genres. Lists and forms render. The editor round trip works. The Albums form's later widgets, partials and field types do not break the Phase 10 form; list state persisted in the URL and storage does not confuse paging or filtering."
|
||||
why_human: "D-23 keeps the real browser out of the automated suite. Vitest (83 files, 1279 tests) and the Postgres acceptance test cover behavior, but not rendering in a browser."
|
||||
retest_reason: "10-UAT.md tests 2 to 5 passed on 2026-09-27 against c7487f6. Since then phases 10.1, 12.2, 14.2.1, 16.1, 16.2 and 16.3 changed ListView.vue, FormView.vue, FormField.vue, formState.ts, registry.ts, AppShell.vue, PluginRail.vue, UserMenu.vue, useAuth.ts and main.css, so those rows no longer cover the current code. 10.1-UAT.md is status: testing with all six rows pending; its tests 1 and 4 (Albums, Genres, Settings in a real browser) overlap this walk but do not cover it yet."
|
||||
- test: "Publish the sibling-repo test fixes the --go pass depends on: push sm-user-plugin 569768e, then commit the plugins/golem15/user pointer bump (cf5c6b9 -> 569768e) in fonoteka.go as a separate commit, and push fonoteka.go (03dad50, 7db5ef8 and the bump) when you choose."
|
||||
expected: "A clean checkout of fonoteka.go at its committed pointer passes check-phase10.sh --go, as the working tree does now."
|
||||
why_human: "Pushing needs the user's go-ahead (CLAUDE.md). The --go pass was proven against the go.work working tree. At fonoteka.go's committed pointer cf5c6b9, sm-user-plugin TestCreateOrganisationsMigration still fails as before, because the fix exists only in the local commit 569768e."
|
||||
- test: "Carried: decide CR-01 (refresh ignores tokens_valid_after / is_activated)"
|
||||
expected: "Fix now or accept with override"
|
||||
why_human: "Security-policy decision"
|
||||
resolution: "Fixed (be4a923, a13a121). TestAdminRefreshRevocation re-run this session: PASS. Approved in 10-UAT.md test 1. Still applies, still resolved."
|
||||
resolution: "Fixed (be4a923, a13a121), approved in 10-UAT.md test 1. No auth or refresh code changed since."
|
||||
- test: "Carried: Secure cookie on http://localhost (assumption A3)"
|
||||
expected: "Chrome and Firefox store summer_admin with default cookie_secure"
|
||||
why_human: "Browser cookie policy, non-inferable"
|
||||
resolution: "Approved in 10-UAT.md test 6. The cookie attributes (cabana auth.go) are unchanged in substance since then. Still resolved."
|
||||
resolution: "Approved in 10-UAT.md test 6. No auth.go or cookie change since."
|
||||
- test: "Carried: review the 17 judgment-tier prohibitions"
|
||||
expected: "Accept or reject the verifier's non-authoritative verdicts"
|
||||
why_human: "Judgment-tier prohibitions need human resolution"
|
||||
resolution: "Accepted in 10-UAT.md test 7. Re-checked against HEAD in this report and all are still not violated, so the acceptance carries over."
|
||||
resolution: "Accepted in 10-UAT.md test 7. The --hygiene stage, which enforces the mechanical ones, is still green."
|
||||
human_verified:
|
||||
- item: "CR-01 decision"
|
||||
uat: "10-UAT.md test 1 (pass)"
|
||||
- item: "A3 Secure cookie on http://localhost"
|
||||
uat: "10-UAT.md test 6 (pass)"
|
||||
- item: "17 judgment-tier prohibitions"
|
||||
uat: "10-UAT.md test 7 (pass)"
|
||||
---
|
||||
|
||||
# Phase 10: Admin Vue SPA Verification Report
|
||||
|
||||
**Phase Goal:** A minimal Vue 3 + TypeScript admin SPA renders login, permission-gated navigation, lists, forms and the relation manager for Albums, Artists, Collections, Genres and Styles, typed from the generated OpenAPI document.
|
||||
**Verified:** 2026-10-01T21:29:59Z (summercms.go HEAD f2f2279, fonoteka.go HEAD e62f4fc)
|
||||
**Verified:** 2026-10-11T15:11:15Z (summercms.go HEAD 77ca4b0; fonoteka.go HEAD 7db5ef8 with plugins/golem15/user checked out at 569768e, which is not the recorded pointer cf5c6b9)
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes. The previous report (c7487f6, passed) went stale. Phase 10.2 moved its 59 framework paths under `modules/`, and later phases changed covered code.
|
||||
**Re-verification:** Yes, narrow. It re-checks only the one gap left by the fc7cfaf report (`check-phase10.sh --all` red at `--go`) and carries the other truths forward, because no Phase 10 code changed.
|
||||
|
||||
**MVP note:** ROADMAP marks this phase `mode: mvp`, but the goal is not a User Story. Following the precedent of Phases 1, 3, 5 and 8, the four ROADMAP success criteria are the contract and plan `must_haves` are supporting evidence.
|
||||
## What changed since fc7cfaf
|
||||
|
||||
## What changed since the previous verification
|
||||
|
||||
| Source | Change to covered code | Effect on this report |
|
||||
|---|---|---|
|
||||
| Phase 10.2 (5e50b16, 57e7b56) | Framework packages moved to `modules/<pkg>`. Moves only, plus retargeted fixtures. | 59 covered paths re-mapped. No behavior change. |
|
||||
| Phase 10.1 (f928194..5bbb0ad) | Runtime extension point. The SPA gains `WidgetField`, `PartialField`, `PartialHost` with an allowlisted node renderer, `pluginAssets.ts`, toolbar actions in `ListToolbar.vue`, and plugin CSS scoped per controller in `router.ts`. `ListView.vue`, `FormView.vue`, `FormField.vue` and `registry.ts` were extended. cabana gains action and asset routes. The OpenAPI document and `schema.d.ts` were regenerated (+516 / +325 lines, additive). The dist was rebuilt. | Phase 10 tests still pass, together with the new ones (Vitest went from 441 to 681). The drift gates are clean. The changed views need a browser re-check, so a human item is open. |
|
||||
| Phase 11 (f7b6b0c, 93c735b, 5382947, 61da4d1) | cabana writes are commit-safe, transaction gates fail closed, the jwt.auth 401 gets a cache header, and the Phase 10 gate accepts the Phase 12 pending goldens | `go test` and the gate stages pass |
|
||||
| Phase 9 review fixes (b4b8b5d..4ae272e, 2da8112) | WR-01 wildcard permissions, WR-02 drops a denied main menu item and repoints its target, WR-17 merges an admin's own permissions over the role's, WR-03 and WR-05 refuse writes and relation link/unlink that are not declared, WR-14 mounts logout outside the guard and always clears the cookie, WR-11 case-insensitive unique emails, WR-19 TxFromContext | Re-checked against the SPA's contract: the assembled acceptance test (exact nav sets, CRUD, link/unlink, logout) and the named cabana tests pass |
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
Derived user story: *As a Płytarium admin, I want to log in to /plytadmin, see only what my role permits, and manage Albums, Artists, Collections, Genres, Styles and Collection editors, so that the catalogue can be administered without the PHP backend.*
|
||||
|
||||
| Step | Expected | Evidence (this session) | Status |
|
||||
| Repo | Commit | Files | Effect |
|
||||
|---|---|---|---|
|
||||
| Open /plytadmin | Embedded SPA served with base /plytadmin | `modules/boardwalk/boardwalk.go`, `TestPhase10TracerSPA` PASS, `check-admin-dist.sh` "matches a fresh build" | VERIFIED |
|
||||
| Log in | Cookie session, no token in body | `modules/cabana/auth.go` login, `useAuth.login`, `LoginView.test.ts` (Vitest PASS), `TestPhase10Coverage` PASS | VERIFIED |
|
||||
| See permitted navigation | Limited admin sees Genres only | `TestPhase10AssembledAcceptance` SC-1 PASS (exact nav sets) and `TestNavigationDropsDeniedParentAndRepointsTarget` PASS | VERIFIED (automated); browser re-check pending |
|
||||
| Use five lists and forms | Schema-driven list and form, create, update | `TestPhase10AssembledAcceptance` SC-2 and `TestPhase10Controllers` PASS, ListView/FormView Vitest PASS | VERIFIED (automated); browser re-check pending |
|
||||
| Link and unlink an editor | Search candidates, link, unlink | `TestPhase10AssembledAcceptance` SC-3 PASS, `relation.smoke.test.ts` PASS | VERIFIED (automated); browser re-check pending |
|
||||
| Stay signed in / be signed out | Refresh keeps an active session; reset, deactivation or deletion ends it | `TestAdminRefreshRevocation` PASS | VERIFIED |
|
||||
| Log out | Cookie expired, old cookie 401, works with an expired access token | Acceptance test (logout then /auth/me 401) and `TestAdminLogoutRevokesExpiredRefreshableToken` PASS | VERIFIED |
|
||||
| summercms.go | 77ca4b0 | `scripts/check-phase10.sh` (+2/-1) | `run_go` runs the framework suite with `-skip '^TestBootstrapEndToEnd$'` and a comment naming the opt-in guard (SUMMERCMS_BOOTSTRAP_CHECK=1, 14.3-02) |
|
||||
| sm-user-plugin (local only, ahead 1) | 569768e | `updates/organisations_test.go`, `user_groups_test.go`, `user_session_test.go` | `TestCreateOrganisationsMigration` expects `202610050001_create_oauth_identities`; five rollback loops count one more migration |
|
||||
| fonoteka.go (local, ahead 13) | 03dad50 | `parity/migrate_test.go`, `parity/schema_diff_test.go` | Expects 11 user migrations (was 10); allows the `backend_api_tokens` table in the schema diff |
|
||||
| fonoteka.go | 7db5ef8 | `parity/oauth_flow_test.go` | `TestOAuthFlows` writes `mcp-lifecycle.vars` under `$PARITY_ROOT` or `t.TempDir()` instead of a fixed `/tmp/summercms-parity/...` path |
|
||||
|
||||
`git diff fc7cfaf HEAD` in summercms.go touches only `scripts/check-phase10.sh`. No file changed under `admin/src`, `modules/` or `modules/boardwalk/dist`.
|
||||
|
||||
## Gap closure: `check-phase10.sh --all` at `--go`
|
||||
|
||||
### Independent run
|
||||
|
||||
`bash scripts/check-phase10.sh --go` (verifier's own process): `phase10 go passed`, **exit 0**, 4m50s. The orchestrator separately reports `--self-test`, `--security`, `--postgres`, `--openapi`, `--dist`, `--hygiene` and `--evidence` all exit 0 this session, plus typecheck and Vitest (83 files, 1279 tests). Only `npm ci` inside `--spa` was skipped, as before.
|
||||
|
||||
### Do the fixes weaken the gate? No.
|
||||
|
||||
**77ca4b0 (framework skip).**
|
||||
- The `-skip` pattern is anchored (`^...$`) and matches exactly one test in the repo: `modules/sunrise/sunrise_test.go:242`. Its body calls `t.Skip` unless `SUMMERCMS_BOOTSTRAP_CHECK=1`, so under this gate it never asserted anything.
|
||||
- That test still runs for real in `scripts/check-phase14.3.sh:484` (`SUMMERCMS_BOOTSTRAP_CHECK=1 go test ./modules/sunrise/ -run '^TestBootstrapEndToEnd$'`).
|
||||
- `phase10_detect` is unchanged. Any other skip in either repo is still refused, and `--self-test` exits 0.
|
||||
- `KNOWN_APP_FAILURES=""` and `APP_PENDING_SKIPS=""` are unchanged, so the app run allows no failures and no skips.
|
||||
- The fix takes the `-skip` option the previous report offered. It adds no self-test case because no skip event reaches the detector.
|
||||
- Residual (ℹ️): a future top-level test with the same name in another framework package would also be skipped silently.
|
||||
|
||||
**569768e (sm-user-plugin).**
|
||||
- The plugin defines exactly 11 migration IDs. The new ID, `202610050001_create_oauth_identities`, is the 11th and the last in order.
|
||||
- The `want` list gains only that ID, and the five `RollbackLast` loops each go up by one to account for it.
|
||||
- No assertion was removed or loosened. The tests still require exact history equality and roll back to the same target migration.
|
||||
|
||||
**03dad50 (fonoteka parity).**
|
||||
- The count change from 10 to 11 matches the plugin's real migration count.
|
||||
- `backend_api_tokens` is a real Go-only framework table from 16.3-01 (`modules/cabana`, `modules/lagoon/backend_admin_migrations_test.go`). The allowlist names it explicitly with a reason, the same way it handles `backend_jwt_blacklist`.
|
||||
|
||||
**7db5ef8 (TestOAuthFlows vars path).**
|
||||
- Only where the vars store is written changed. The replay and every assertion are the same.
|
||||
- I found no reader of `mcp-lifecycle.vars` anywhere in fonoteka.go. The new comment says "php_parity.sh reads these vars from PARITY_ROOT", which overstates things (php_parity.sh only defines `PARITY_ROOT`), but it does not affect behavior. ℹ️ Info.
|
||||
|
||||
### Caveat: the pass depends on uncommitted, unpushed sibling state
|
||||
|
||||
The `--go` pass was proven against the go.work working tree, where `plugins/golem15/user` is checked out at 569768e. fonoteka.go's recorded pointer is still cf5c6b9. At that commit, `TestCreateOrganisationsMigration` fails exactly as in the previous report. Pushing 569768e and bumping the pointer needs the user's go-ahead, so this is an escalation item (human_verification #2), not a code gap. The previous report accepted the same arrangement, with an uncommitted pointer at 7a6fa0e.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
@@ -287,148 +328,109 @@ Derived user story: *As a Płytarium admin, I want to log in to /plytadmin, see
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | An admin logs in through the SPA and sees only the navigation items their permissions allow. | ✓ VERIFIED | Server: `TestPhase10AssembledAcceptance` (fonoteka, Postgres) asserts the limited admin's nav is exactly `fonoteka:[genres]`, the developer's is `albums,collections,genres,styles,artists`, the limited admin gets 403 on albums and an empty settings list. It passes at HEAD with the Phase 9 WR-01/WR-02/WR-17 permission changes in place. `TestNavigationDropsDeniedParentAndRepointsTarget`: PASS. SPA: `useNavigation.ts`, `PluginRail.vue` and `SectionPanel.vue` are unchanged since c7487f6. Their Vitest tests pass. |
|
||||
| 2 | Each of the five controllers renders a working list and form generated from its JSON schema. | ✓ VERIFIED | Server: the SC-2 loop and `TestPhase10Controllers` (fields and columns equal the tracked YAML) pass. `TestCRUDOperationsFollowDeclarations` (Phase 9 WR-03) passes, and SC-2's create and update still succeed under it. SPA: `ListView.vue` still loads `/schema/list` and the list, and `FormView.vue` still loads `/schema/form` and the record and then POSTs or PUTs. 10.1 adds toolbar actions, widgets, partials and plugin CSS on top. `ListView.test.ts` (+171 lines) and `FormView.test.ts` (+144) pass with the original Phase 10 cases intact. Vitest: 53 files, 681 passed. Browser confirmation of the changed views is the open human item. |
|
||||
| 3 | The Collections form's relation manager lets an admin search, link and unlink an editor. | ✓ VERIFIED | Server: the SC-3 block covers candidates with the owner excluded, link, list, the linked user dropping out of the candidates, unlink, and an empty list afterwards. It passes with Phase 9 WR-05 (undeclared link/unlink refused) in place. SPA: `RelationManager.vue` and `RelationPickerModal.vue` are unchanged since c7487f6, and their tests and `relation.smoke.test.ts` pass. |
|
||||
| 4 | API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type. | ✓ VERIFIED | `client.ts` is `createClient<paths>` over the generated `schema.d.ts`. `admin/src` has no other `fetch(`, and `pluginAssets.ts` loads same-origin assets through elements, not a fetch. `types.ts` (+12 lines for the 10.1 types) still contains only aliases onto `components['schemas']` and `paths[...]`. `check-admin-openapi.sh --check`: exit 0. `check-phase10.sh --hygiene`: passed. |
|
||||
| 1 | An admin logs in through the SPA and sees only the navigation items their permissions allow. | ✓ VERIFIED | Carried from fc7cfaf (no admin/src or Go change). `--security`/`--postgres`/`--evidence` exit 0 this session (orchestrator); `--go` exit 0 (verifier). |
|
||||
| 2 | Each of the five controllers renders a working list and form generated from its JSON schema. | ✓ VERIFIED | Carried: `TestPhase10Controllers` and the assembled acceptance SC-2 block are in the green `--postgres` stage. The browser re-check stays open as human item #1. |
|
||||
| 3 | The Collections form's relation manager lets an admin search, link and unlink an editor. | ✓ VERIFIED | Carried: `TestPhase10AlbumRelations`, `TestPhase10RelationOptions`, `TestPhase10RelationSave` and the SC-3 block are in green stages. |
|
||||
| 4 | API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type. | ✓ VERIFIED | Carried: `--hygiene` and `--openapi` exit 0; the files.ts exemption is unchanged since it was judged narrow in fc7cfaf. |
|
||||
|
||||
**Score:** 4/4 ROADMAP truths verified (0 present-but-behavior-unverified).
|
||||
**Score:** 4/4 ROADMAP truths verified. Plan truths 46/46 (0 present, behavior-unverified).
|
||||
|
||||
### Plan must-have truths (supporting evidence)
|
||||
### Plan must-have truths (re-checked rows)
|
||||
|
||||
There are 46 plan truths across 10-01..10-05. The earlier runs verified all of them, with A3 human-observed. Their evidence is the named tests and gates, and all of those were re-run this session as part of `go test ./...` (summercms.go), the fonoteka Phase 10 acceptance tests, Vitest, the dist and OpenAPI drift gates, and the gate stages `--self-test`, `--hygiene`, `--security` and `--evidence`. All of them pass. The renamed packages carry the same tests: `modules/cabana/phase10_*_test.go`, `modules/phrasebook/phase10_test.go`, `modules/surf/admin_prefix_test.go` and the others in covered_files.
|
||||
|
||||
Backstop (non-inferable) truths:
|
||||
|
||||
| Truth | Evidence | Status |
|
||||
| Truth (10-05) | Status | Evidence |
|
||||
|---|---|---|
|
||||
| A1 default prefix /backend; fonoteka /plytadmin | `modules/cabana/prefix.go` `DefaultAdminPrefix`, `TestPhase10Prefix` in the passing cabana run | VERIFIED |
|
||||
| A3 Secure cookie accepted on http://localhost | 10-UAT.md test 6 (human-observed). The cookie attributes are unchanged in substance. | VERIFIED (human-observed) |
|
||||
| A10 30 s blacklist grace plus single-flight refresh | `modules/cabana/auth.go` reads `admin.jwt.blacklist_grace`, fonoteka `config/admin.yaml`, `client.test.ts` (Vitest PASS) | VERIFIED |
|
||||
| A8 pivot sort_order = array index | fonoteka `admin_phase10_relations_test.go` (package passes) | VERIFIED |
|
||||
| Required relation is a schema hint only | `TestPhase10RelationSave` PASS | VERIFIED |
|
||||
| A6 dark mode follows the system only | `theme.ts`, `theme.test.ts` PASS | VERIFIED |
|
||||
| SC-4 mechanical enforcement | `check-phase10.sh --hygiene` PASS | VERIFIED |
|
||||
| Every Phase 10 Go change has named tests, and both repositories pass go vet ./... and go test ./... | ✓ VERIFIED (was ✗ FAILED) | `check-phase10.sh --go` exit 0 in the verifier's run. The framework excludes only the opt-in sunrise test, which `check-phase14.3.sh` runs for real; the app run has no allowances. Working-tree caveat above. |
|
||||
| `--all` exits non-zero on any failing stage, zero-test run, skip, drift, hygiene violation or evidence gap; `--self-test` proves each detector fails closed | ✓ VERIFIED | `--self-test` exit 0 (orchestrator). `phase10_detect` and the allowance variables are unchanged. |
|
||||
| `scripts/check-phase10.sh --all` passes at HEAD | ✓ VERIFIED | Every stage exits 0 this session: `--go` in the verifier's run, the rest in the orchestrator's. `--spa` used its typecheck and Vitest equivalent without `npm ci`. |
|
||||
|
||||
The other 43 plan truths are carried from fc7cfaf. Their code is unchanged, and the stages that cover them passed this session.
|
||||
|
||||
### Prohibitions (judgment tier)
|
||||
|
||||
All 17 verdicts were accepted in 10-UAT.md test 7. I re-checked them against the code that changed since:
|
||||
|
||||
- No Płytarium or fonoteka names appear in `admin/src`, `admin/tests`, `admin/openapi` or `modules/boardwalk/dist`. A grep came back empty, and the hygiene gate passes.
|
||||
- `admin/src` has no `v-html` or `innerHTML`. The 10.1 partials are built from an allowlisted node tree with `h()`, and text is always text (`partialNodes.ts`). Plugin labels and comments are still rendered as text.
|
||||
- Plugin JS and CSS come from cabana's same-origin asset route, so nothing loads from a foreign origin.
|
||||
- The cookie login and refresh paths still never return the JWT in a body.
|
||||
- Phase 9 WR-03 and WR-05 tighten "relation save never writes a protected FK or an out-of-scope id". They do not weaken it.
|
||||
|
||||
All 17 remain not violated, and the human acceptance carries over.
|
||||
Unchanged. All 17 were accepted in 10-UAT.md test 7, and the mechanical subset is still enforced by the green `--hygiene` stage. None is violated.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|---|---|---|
|
||||
| `admin/` SPA (views, components, state, api, app) | ✓ VERIFIED | It exists, has substance and is wired, and it is embedded via `modules/boardwalk/dist`, which matches a fresh build |
|
||||
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `admin/src/api/types.ts` | ✓ VERIFIED | No drift from generation |
|
||||
| `modules/boardwalk/boardwalk.go` + `dist/` | ✓ VERIFIED | `TestPhase10TracerSPA` PASS |
|
||||
| `modules/cabana/{auth,http,crud,relation,relation_field,form_schema,list_schema,filter_schema,schema_types,registry,contracts,csrf,lang,messages,prefix,admin_openapi}.go` | ✓ VERIFIED | Moved under modules/ and changed by 10.1, 11 and the Phase 9 fixes. The cabana package passes. |
|
||||
| `modules/bouncer/{jwt,refresh}.go` | ✓ VERIFIED | `RefreshAudienceFor` still wired from `modules/cabana/auth.go`. The new WR-14 logout helper is additive. |
|
||||
| `modules/pact/capabilities.go`, `modules/phrasebook/*`, `modules/surf/router.go` | ✓ VERIFIED | Packages pass |
|
||||
| `scripts/check-phase10.sh`, `check-admin-dist.sh`, `check-admin-openapi.sh` | ✓ VERIFIED | Retargeted to `modules/` paths, and every stage run passes |
|
||||
| `../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_*_test.go` | ✓ VERIFIED | At e62f4fc: Acceptance, Tracer and Controllers PASS |
|
||||
| `admin/` SPA, `admin/openapi`, generated types | ✓ VERIFIED | Unchanged since fc7cfaf; `--dist`, `--openapi`, `--hygiene` exit 0 |
|
||||
| `modules/cabana`, `bouncer`, `surf`, `boardwalk`, `phrasebook` Phase 10 code | ✓ VERIFIED | Unchanged; `--security`, `--postgres`, `--go` exit 0 |
|
||||
| `scripts/check-phase10.sh` | ✓ VERIFIED (was ⚠️ PARTIAL) | All stages exit 0; the 77ca4b0 change is narrow (see above) |
|
||||
| fonoteka `admin_phase10_*_test.go` | ✓ VERIFIED | Pass within the `--go` app run and `--postgres` |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|---|---|---|---|
|
||||
| `admin/src/api/client.ts` | generated `schema.d.ts` | `createClient<paths>`, the only `fetch(` in `admin/src` | WIRED |
|
||||
| `admin/src/state/useAuth.ts` logout | `POST /auth/logout` | `modules/cabana/http.go:205`, now mounted outside the guard with `requireAjax`. The client sets `X-Requested-With` on every request (`client.ts:71`). | WIRED |
|
||||
| `modules/cabana/auth.go` refresh | `bouncer.RefreshAudienceFor` → the guard's `UserProvider` | `s.users` (`lazyBackendUsers`) | WIRED |
|
||||
| `admin/src/views/ListView.vue` / `FormView.vue` | `/schema/list`, `/schema/form`, records | client calls (10.1 additions on top) | WIRED |
|
||||
| `RelationManager.vue` | `/relations/{name}`, `/candidates` | client calls | WIRED |
|
||||
| `modules/boardwalk` | `admin` build | `go:embed dist`, drift gate | WIRED |
|
||||
| `scripts/check-phase10.sh` run_go | `go test -json` in both repos | `phase10_detect`, with no app allowances and one anchored framework `-skip` | WIRED |
|
||||
| `check-phase10.sh` `-skip '^TestBootstrapEndToEnd$'` | `check-phase14.3.sh --bootstrap` | the skipped test runs for real in its own phase gate | WIRED |
|
||||
| All other links (client.ts to schema.d.ts, files.ts to aliases and client, views to endpoints, boardwalk to dist) | | | WIRED (unchanged) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
These are unchanged in structure. Every flow is ✓ FLOWING:
|
||||
Unchanged. Every flow is FLOWING.
|
||||
|
||||
- navigation comes from `GET /navigation`, filtered on the server
|
||||
- list rows and columns come from the list endpoint and `/schema/list`
|
||||
- form fields and the record come from `/schema/form` and `GET /{id}`
|
||||
- the relation manager's linked rows and candidates come from `/relations/{name}` and `/candidates`
|
||||
- strings come from `GET /lang`
|
||||
|
||||
### Behavioral Spot-Checks (run this session)
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| Vet | `go vet ./...` (summercms.go) | exit 0 | ✓ PASS |
|
||||
| Full Go suite | `go test -count=1 ./...` (summercms.go), run once | Most packages ok. Eight packages failed only because testcontainers Postgres did not start within the timeout (`context deadline exceeded`, and one `internal/dev` watch timeout) while Vitest and other suites shared the machine: docs/examples/blog, internal/dev, beachcomber, bouncer, cabana, conga, lagoon/attach, lighthouse. | see re-run |
|
||||
| Re-run of those 8 packages serially | `go test -count=1 -p 1 <8 pkgs>` | all 8 ok, exit 0 | ✓ PASS |
|
||||
| Logout, nav, refresh, CRUD gating, Phase 10 coverage | `go test -count=1 -run '^(TestAdminLogoutRevokesExpiredRefreshableToken\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestAdminRefreshRevocation\|TestPhase10Coverage\|TestCRUDOperationsFollowDeclarations)$' ./modules/cabana/` | all PASS | ✓ PASS |
|
||||
| SC-1..SC-3 assembled on Postgres plus tracer and controllers | `go test -count=1 -run '^(TestPhase10AssembledAcceptance\|TestPhase10TracerSPA\|TestPhase10Controllers)$' ./plugins/golem15/fonoteka/` (fonoteka.go) | 3 PASS, ok 6.6s | ✓ PASS |
|
||||
| SPA unit/component suite | `npx vitest run` (admin) | 53 files, 681 passed | ✓ PASS |
|
||||
| Embedded dist drift (incl. vue-tsc) | `scripts/check-admin-dist.sh` | "modules/boardwalk/dist matches a fresh build" | ✓ PASS |
|
||||
| OpenAPI and types drift | `scripts/check-admin-openapi.sh --check` | exit 0 | ✓ PASS |
|
||||
| Phase gate stages | `scripts/check-phase10.sh --self-test / --hygiene / --security / --evidence` | each exit 0 | ✓ PASS |
|
||||
| Go stage | `bash scripts/check-phase10.sh --go` (verifier) | `phase10 go passed`, exit 0, 4m50s | ✓ PASS |
|
||||
| `-skip` target is unique | `grep -rn "func TestBootstrapEndToEnd"` | one hit, `modules/sunrise/sunrise_test.go:242` (env-gated `t.Skip`) | ✓ PASS |
|
||||
| Skipped test still runs elsewhere | `grep` in `scripts/check-phase14.3.sh` | line 484 runs it with `SUMMERCMS_BOOTSTRAP_CHECK=1` | ✓ PASS |
|
||||
| App allowances empty | `grep` for `KNOWN_APP_FAILURES` and `APP_PENDING_SKIPS` | both `""` | ✓ PASS |
|
||||
| User plugin migration count | migration ID grep in `plugins/golem15/user/updates` | 11 unique IDs, `202610050001_create_oauth_identities` last | ✓ PASS |
|
||||
| Other stages | orchestrator run: `--self-test`, `--security`, `--postgres`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, typecheck, Vitest | all exit 0; 83 files, 1279 tests | ✓ PASS (reported) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `probe-*.sh` scripts are declared or present. `check-phase10.sh` is the phase gate. Its stages were run as listed above. Its `--go`, `--postgres`, `--spa`, `--openapi` and `--dist` stages were covered directly by the full Go run with the serial re-run, the fonoteka tests, Vitest and the two drift scripts.
|
||||
No `probe-*.sh` is declared or present. `check-phase10.sh` is the phase gate.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| ADMIN-06 | 10-01..10-05 | Minimal Vue 3 + TS SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers using generated types | ✓ SATISFIED (automated); browser re-check pending | Truths 1-4 |
|
||||
| ADMIN-06 | 10-01..10-05 | Minimal Vue 3 + TS SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers using generated types | ✓ SATISFIED; browser re-walk pending | Truths 1-4; acceptance command green |
|
||||
|
||||
Orphaned requirements: none.
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
All 28 trackable CONTEXT.md decisions are honored (no decision was reverted by the later phases).
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Pattern | Severity | Impact |
|
||||
|---|---|---|---|
|
||||
| `modules/cabana/auth.go`, `http.go` | Phase 10 review WR-01: logout behind the guard | resolved | Fixed by Phase 9 WR-14 (299d220) |
|
||||
| `modules/bouncer/refresh.go`, fonoteka `golem15/user` | WR-08: the site-user refresh ignores tokens_valid_after | ⚠️ Warning | Outside the Phase 10 contract. Core user plugin contract kept. |
|
||||
| `modules/cabana/relation_field.go`, `crud.go` | WR-02 and WR-03 (Phase 10 review) | ⚠️ Warning | Phase 9 WR-03 and WR-05 now refuse undeclared writes and link/unlink. Not re-triaged here. |
|
||||
| `modules/pact/capabilities.go` | WR-04: `FilterOptions(scope)` has no ctx or db | ⚠️ Warning | Open |
|
||||
| `admin/src/views/*.vue` | WR-05: loaders without try/catch | ⚠️ Warning | Open |
|
||||
| `ListView.vue`, `RelationManager.vue` | WR-06: no page clamp after delete or unlink | ⚠️ Warning | Open |
|
||||
| `modules/bouncer/refresh.go` | WR-07: the refresh window slides with no absolute cap | ⚠️ Warning | Residual risk in T-10-05 |
|
||||
| fonoteka.go `plugins/golem15/user` pointer | The fix lives in an unpushed local commit; the recorded pointer cf5c6b9 still fails `TestCreateOrganisationsMigration` | ⚠️ Warning | A clean checkout of fonoteka.go fails `--go` until 569768e is pushed and the pointer is bumped (human item #2) |
|
||||
| `scripts/check-phase10.sh:293` | `-skip` matches by test name across all framework packages | ℹ️ Info | A future same-named test elsewhere would be skipped silently; today the name is unique |
|
||||
| fonoteka.go `parity/oauth_flow_test.go:288` | Comment says php_parity.sh reads the vars; nothing reads `mcp-lifecycle.vars` | ℹ️ Info | Misleading comment only |
|
||||
| Phase 10 review WR-04..WR-08 | carried | ⚠️ Warning | Unchanged |
|
||||
|
||||
None of the non-planning covered files contains an unreferenced `TBD`, `FIXME` or `XXX` marker (grep came back empty).
|
||||
|
||||
### Other observations (Info)
|
||||
|
||||
- Phase 10.1's own UAT (10.1-UAT.md) is still `status: testing` with 6 items pending. Its tests 1 and 4 overlap the open Phase 10 human item.
|
||||
- The summercms.go working tree has an uncommitted change to `.planning/phases/10.2-.../10.2-VERIFICATION.md` that this verifier did not make (another run in progress), plus the untracked `SummerCMS landing page.zip`. Neither is Phase 10 code.
|
||||
- The first full `go test` run was disturbed by machine load: Postgres containers timed out at startup. A fail-closed reading needs the serial re-run. If the gate is run in CI, run it on an otherwise idle machine.
|
||||
There are no `TBD`, `FIXME` or `XXX` markers in the changed lines.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
### 1. Browser re-walk of the Phase 10 flows on the current build
|
||||
|
||||
**Test:** Run the fonoteka binary and open /plytadmin. Log in as a superuser and then as a genres-only admin. For each of the five controllers, use the list and form (search, sort, filter, paging, save, 422 field errors). Link and unlink a Collection editor. Open Ustawienia. Glance at light and dark mode.
|
||||
**Expected:** Everything still behaves as approved in 10-UAT.md tests 2 to 5. The Albums form's new 10.1 widgets and partials do not break the Phase 10 form.
|
||||
**Why human:** Phase 10.1 changed the very views the 2026-09-27 approval covered, and D-23 keeps browser e2e out of the automated suite. Closing 10.1-UAT tests 1 and 4 with Genres, Collections and Settings included also closes this item.
|
||||
**Test:** Run the fonoteka binary and open /plytadmin. Log in as a superuser, then as a genres-only admin. Use the list and form of each of the five controllers (search, sort, filter, paging, save, 422 field errors). Link and unlink a Collection editor. Open Ustawienia. Glance at light and dark mode.
|
||||
**Expected:** Everything behaves as approved in 10-UAT.md tests 2 to 5. Later widgets, field types and persisted list state do not break the Phase 10 views.
|
||||
**Why human:** D-23 keeps the browser out of the suite.
|
||||
**UAT coverage:** Not covered by an existing passing row. 10-UAT.md is complete with all 7 rows pass, but rows 2 to 5 were recorded against c7487f6 (2026-09-27), and later phases have since changed the views they cover (retest_reason in the frontmatter). 10.1-UAT.md is status: testing with 0 of 6 rows passed. Its tests 1 and 4 overlap this walk; passing them with Genres, Collections and Settings included would also close this item.
|
||||
|
||||
The carried items (CR-01, A3, the 17 prohibitions) remain resolved, with evidence re-run this session.
|
||||
### 2. Publish the sibling-repo test fixes
|
||||
|
||||
**Test:** Push sm-user-plugin 569768e. Commit the `plugins/golem15/user` pointer bump (cf5c6b9 to 569768e) in fonoteka.go as a separate commit, then push fonoteka.go when you choose.
|
||||
**Expected:** A clean fonoteka.go checkout passes `check-phase10.sh --go`.
|
||||
**Why human:** Pushing needs the user's go-ahead. The verified pass relies on the working-tree submodule state.
|
||||
|
||||
Carried items CR-01, A3 and the 17 prohibitions stay resolved by 10-UAT.md tests 1, 6 and 7 (code unchanged).
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
There are no gaps. All four ROADMAP success criteria hold at HEAD on automated evidence re-run this session:
|
||||
The one remaining gap from fc7cfaf is closed. `check-phase10.sh --go` passes in the verifier's own run, and every other stage passed this session. None of the four fixes weakens the gate:
|
||||
- The framework `-skip` is anchored to a single opt-in test that `check-phase14.3.sh` runs for real.
|
||||
- The app run still allows no failures and no skips.
|
||||
- The migration-history and parity tests were updated to match the real 11 migrations and a real 16.3 table, with no assertion loosened.
|
||||
|
||||
- `go vet` is clean
|
||||
- every Go package passes, with eight container-startup timeouts cleared by a serial re-run
|
||||
- the fonoteka Postgres acceptance test passes
|
||||
- Vitest passes, 681 of 681
|
||||
- the dist and OpenAPI drift gates and four gate stages pass
|
||||
|
||||
The status is human_needed, not passed, because the human browser approvals predate the Phase 10.1 changes to ListView, FormView, the toolbar, the router and the styles. Those approvals cannot be carried over to the changed views.
|
||||
No code gaps remain. Status is human_needed for two items: the browser re-walk, which no passing UAT row covers for the current code, and publishing the sibling-repo fixes so a clean checkout matches the verified working tree.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-01T21:29:59Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
_Verified: 2026-10-11T15:11:15Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 01
|
||||
subsystem: infra
|
||||
tags: [dev-seed, surf, uploads, access-log, slog, console, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 14.1
|
||||
provides: fonoteka plugin console commands, classes write services, parity corpus 175/175
|
||||
provides:
|
||||
- fonoteka:dev-seed (local/dev only), idempotent D-02 seed shape with generated PNG covers
|
||||
- console.DevEnvironment and console.DevSeedCommand
|
||||
- fonoteka plugin commands open and publish their own database (G1)
|
||||
- GET /_fonoteka/api/v1/_dev/backend marker, local/dev only, outside the parity manifest
|
||||
- framework config key storage.uploads.serve (uploads route on attach.StaticHandlerPublic, nosniff)
|
||||
- framework config key http.access_log (one redacted "http request" record per request)
|
||||
affects: [14.2-02 start script, 14.2-03 e2e specs, 14.2-04 unit tests, phase 20 cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 15212
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 3408742fb0e64a57ac3dcaad5a4b31001cffc849
|
||||
plan_head_after: 2d68eab3c0f82b55bf4c7e6f56c46f98b9c124f6
|
||||
fonoteka_head_before: df7876bfa9933fa68d28a2ff3f80b1c33453398c
|
||||
fonoteka_head_after: 464ba475eff0f5282e4c68da53b9eab4b3b688e7
|
||||
fonoteka_commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Console command database: published handle, else lagoon.OpenFromApp + lagoon.Publish (OnDatabase hooks run) with a closer"
|
||||
- "Framework opt-in routes resolve published services per request so route:list and boot need no database"
|
||||
- "Request-scoped pointer slot in the context carries the matched route pattern to an outer middleware"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- .planning/todos/pending/user-plugin-commands-open-database.md
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
modified:
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
|
||||
|
||||
key-decisions:
|
||||
- "commandDatabase/commandBucket closers close the handles they opened, so a later command in the same process must not reuse that app; TestDevStackSmoke models seed and serve as separate fresh processes, like the start script"
|
||||
- "The uploads route is owned by plugin id summercms.surf, prefix falls back to /storage/uploads (attach default); a plugin route with the same pattern fails boot as a duplicate"
|
||||
- "Seed output is printed only after the transaction commits, so a failed run prints no created lines"
|
||||
- "With DEV_SEED_API_TOKEN set the seed never prints a secret; an existing row with that hash is reassigned to the seed user and name and un-revoked (api token: updated)"
|
||||
- "An existing rating on the first album is left as it is (keyed by album_id, user_id), even when its value differs from 4"
|
||||
- "Seeded albums are created with name, artist_display, year and format LP only (fields the PHP create form accepts)"
|
||||
|
||||
patterns-established:
|
||||
- "Seed lines: '<entity>: created|updated|unchanged', cover lines append the public URL, token line shows a minted secret only on creation"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "fonoteka:dev-seed seeds user, owned collection as context, three albums with PNG covers, wishlist entry, rating, API token and flips search_use_typesense last, in a fresh process with no published database"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Seed is idempotent (rerun prints unchanged, adds no row in users, collections, albums, system_files, ratings, user_api_tokens) and refuses production, development and staging before opening the database"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "storage.uploads.serve serves a seeded cover from the assembled router: 200 image/png with X-Content-Type-Options: nosniff"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go run ./cmd/summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Marker GET /_fonoteka/api/v1/_dev/backend answers 200 with exactly backend/app/env and Cache-Control: no-store in local; parity corpus unchanged"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go#TestDevStackSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../fonoteka.go test ./parity -run TestParityCorpus"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "fonoteka:reindex, fonoteka:oauth-client and fonoteka:prune-notifications use commandDatabase; existing console tests unchanged and passing"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/console#TestReindexCommand,TestPruneNotifications,TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "http.access_log logs one 'http request' record with method, pattern, status, duration_ms; path without query only for unmatched requests; nothing when off"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/surf/accesslog_test.go#TestAccessLogSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 16min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 01: Go-side local stack glue Summary
|
||||
|
||||
**A fresh fonoteka process can now seed itself with `fonoteka:dev-seed` (local/dev only, idempotent, PNG covers through StoreAlbumPhoto). The framework serves those covers under the WinterCMS upload URL through the opt-in `storage.uploads.serve` route, the plugin answers a local-only backend marker, the fonoteka commands open their own database, and surf has an opt-in redacted request log (`http.access_log`).**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 16 min
|
||||
- **Started:** 2026-10-11T15:31:28Z
|
||||
- **Completed:** 2026-10-11T15:47:12Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 18 (8 created, 10 modified) across summercms.go and fonoteka.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `fonoteka:dev-seed`: refuses outside `SUMMER_ENV` local/dev before opening anything, then seeds in one `lagoon.Transaction` inside `lighthouse.WithoutBroadcasting[models.Album]`: the user (E2E_EMAIL/E2E_PASSWORD, default demo@example.com/changeme), the owned collection as context, Kind of Blue, Blue Train and Mingus Ah Um with 64x64 PNG covers, the wishlist with A Love Supreme, a rating of 4, the `dev-seed-mcp` token, and `search_use_typesense` flipped last.
|
||||
- `console.commandDatabase` / `commandBucket` (G1): reuse the published handle, else open and publish it. Used by the seed, `fonoteka:reindex`, `fonoteka:oauth-client` and `fonoteka:prune-notifications`.
|
||||
- `GET /_fonoteka/api/v1/_dev/backend` (D-09/D-10): `{"backend":"summercms","app":"fonoteka","env":"local"}`, `Cache-Control: no-store`, `throttle:60,1`, registered only in local/dev, not in the manifest.
|
||||
- Framework `storage.uploads.serve` (D-19): `GET {public_path_prefix}/{path...}` on `attach.StaticHandlerPublic` with nosniff, documented in the surf README, docs/services/storage.md and docs/setup/configuration.md.
|
||||
- Framework `http.access_log` (D-12): one Info `http request` record per request, documented in the surf README, docs/services/logging.md (new "Request log" section) and docs/setup/configuration.md.
|
||||
- Planning todo for the same database gap in the shared sm-user-plugin (not modified).
|
||||
|
||||
## Seed output (TestDevStackSmoke)
|
||||
|
||||
First run:
|
||||
|
||||
```
|
||||
user: created
|
||||
collection: created
|
||||
album Kind of Blue: created
|
||||
cover Kind of Blue: created /storage/app/uploads/public/25d/b37/451/25db37451a4705c659ecfb.png
|
||||
album Blue Train: created
|
||||
cover Blue Train: created /storage/app/uploads/public/16f/a0b/4b0/16fa0b4b09666aeca51b6c.png
|
||||
album Mingus Ah Um: created
|
||||
cover Mingus Ah Um: created /storage/app/uploads/public/940/b2b/d6a/940b2bd6a1cb2b3d7d4ba4.png
|
||||
wishlist: created
|
||||
wishlist album A Love Supreme: created
|
||||
rating Kind of Blue: created
|
||||
api token: created inv_<secret> (shown once)
|
||||
search: created
|
||||
```
|
||||
|
||||
The second run prints the same 13 lines with `unchanged`; the cover lines keep the same URLs and the token line reads `api token: unchanged (secret not shown)`. A third run with a different `E2E_PASSWORD` prints `user: updated` and 12 `unchanged` lines.
|
||||
|
||||
Parity: `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): seeded cover served end to end**
|
||||
- summercms.go `0b6d757` feat(14.2-01): opt-in storage.uploads.serve route for public uploads
|
||||
- fonoteka.go `89b703a` feat(14.2-01): fonoteka:dev-seed with a served cover in a fresh process
|
||||
- Tracer gate: verify re-run passed (automated only, end-of-phase mode), then expanded.
|
||||
2. **Task 2: full seed shape, marker, commands open their database**
|
||||
- fonoteka.go `464ba47` feat(14.2-01): full dev-seed shape, local backend marker, commands open their database
|
||||
- summercms.go `ad1fa5a` docs(14.2-01): todo for sm-user-plugin commands that need a published database
|
||||
3. **Task 3: opt-in request log**
|
||||
- summercms.go `2d68eab` feat(14.2-01): opt-in http.access_log request log in surf
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/surf/uploads.go`: `mountUploads`, per-request bucket/DB lookup, nosniff
|
||||
- `modules/surf/accesslog.go`: `accessLog`, `recordPattern`, `statusRecorder` (Flush/Unwrap kept)
|
||||
- `modules/surf/accesslog_test.go`: `TestAccessLogSmoke` (acme.blog fixture)
|
||||
- `modules/surf/router.go`: `mountUploads` after the plugin route loop; access log wrap in `compile`; `recordPattern` outermost in `wrap`
|
||||
- `modules/surf/README.md`, `docs/services/storage.md`, `docs/services/logging.md`, `docs/setup/configuration.md`: both keys documented
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/console/{database,dev_seed}.go`: helpers and the seed
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/console/{reindex,oauth_client,prune_notifications}.go`: `commandDatabase`
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/{dev_marker,routes,plugin}.go`: marker and command registration
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go`: `TestDevStackSmoke`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. The main one is that the command closers close what they opened. A process that ran the seed therefore must not serve from the same app, so the smoke test models migrate, seed and serve as three fresh processes, which is what the plan 02 start script does.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 2 - Missing coverage] Refusal and password-change checks added to TestDevStackSmoke.** The plan put the production-refusal test in plan 04, but threat T-14.2-01 cites TestDevStackSmoke for this plan. The smoke test now checks that an unset SUMMER_ENV, `development` and `staging` are refused and that no database gets published. It also checks that a changed `E2E_PASSWORD` prints `user: updated`. Files: dev_stack_smoke_test.go. Commit: 464ba47.
|
||||
2. **[Rule 1 - Test isolation] The smoke test restores `search_use_typesense`.** The seed flips the shared container's settings row, so `keepSearchSetting` makes sure row 1 exists with the flag off and restores its earlier state when the test ends. Without it, later tests in the package would run with search on. Commit: 464ba47.
|
||||
3. **Mid-plan in-progress SUMMARY skipped.** The context was well under half after Task 2, so Task 3 ran in the same executor and only this final SUMMARY was written. No committed artifact is affected.
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 missing coverage, 1 test isolation), 1 procedural. **Impact:** no scope change. The production code follows the plan.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None. Pre-existing gofmt drift in `fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go` was left alone (out of scope).
|
||||
|
||||
## Verification
|
||||
|
||||
- summercms.go: `go vet ./...` clean, `go test -short ./...` and full `go test ./...` (Docker) green (38 packages), `TestDocsTree` and `docs:build --check` print no problems.
|
||||
- fonoteka.go: `go vet ./...` clean, `go test ./plugins/golem15/fonoteka/... -count=1` green (10 packages), `TestParityCorpus` 175/175/0.
|
||||
- `git -C ../fonoteka.go status --porcelain` shows only the existing submodule pointer drift (feedback, golem, user), with nothing staged. `plugins/golem15/user` has a clean worktree. `_dev/backend` does not appear in `parity/manifest.yaml`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 02's start script can run `migrate`, `fonoteka:dev-seed`, `fonoteka:reindex` and `serve --addr 127.0.0.1:8423` with `SUMMER_ENV=local`, `SUMMER_STORAGE__UPLOADS__SERVE=true` and `SUMMER_HTTP__ACCESS_LOG=true`, and can probe the marker. `/storage/app/uploads/public/` should be added to `fonoteka.go/.gitignore` (G11, plan 02 scope) before seeding with the real config.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 8 created files are present. Commits 0b6d757, ad1fa5a and 2d68eab exist in summercms.go, and 89b703a and 464ba47 exist in fonoteka.go.
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 02
|
||||
subsystem: infra
|
||||
tags: [dev-stack, bash, nuxt, playwright, centrifugo, typesense, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 14.2-01
|
||||
provides: fonoteka:dev-seed, the local backend marker, storage.uploads.serve, http.access_log
|
||||
provides:
|
||||
- fonoteka.go/scripts/dev-stack.sh with up [--detach], down, status, check, guard, exec, e2e [--report-only], e2e-verdict, help
|
||||
- fonoteka.go/.env.dev.example (placeholders only) and .gitignore entries for /.env.dev and /storage/app/uploads/public/
|
||||
- app config defaults storage.uploads.serve false and access_log false
|
||||
- README section "Running locally with the Nuxt app"
|
||||
affects: [14.2-03 e2e specs via dev-stack.sh e2e, 14.2-04 gate (--guard, --self-test, --e2e), phase 20 cutover (fileblob TMPDIR)]
|
||||
|
||||
actuals:
|
||||
tokens: 10960
|
||||
tasks: 4
|
||||
commits: 3
|
||||
plan_head_before: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
|
||||
plan_head_after: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
|
||||
fonoteka_head_before: 464ba475eff0f5282e4c68da53b9eab4b3b688e7
|
||||
fonoteka_head_after: b5363ea99e12b50dc6238438dd3aa60288f08c1e
|
||||
fonoteka_commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Env file parsed as KEY=value data (no source), values never printed; missing keys named only"
|
||||
- "Each child runs under setsid as its own process group, recorded in tmp/dev-stack/pids; EXIT trap stops what this run started"
|
||||
- "Centrifugo API key on curl's stdin (-H @-), never argv; probes are read-only (info, history)"
|
||||
- "flock on tmp/dev-stack/up.lock serialises up; launched children close the lock fd"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- ../fonoteka.go/.env.dev.example
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
|
||||
modified:
|
||||
- ../fonoteka.go/.gitignore
|
||||
- ../fonoteka.go/config/storage.yaml
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/README.md
|
||||
|
||||
key-decisions:
|
||||
- "The Go processes run with TMPDIR=tmp/dev-stack/go-tmp: gocloud fileblob renames uploads in from os.TempDir(), which fails across devices when /tmp is a tmpfs (deferred to Phase 20 for deployments)"
|
||||
- "The env file is parsed as data instead of sourced, so nothing in it runs as shell code"
|
||||
- "pnpm dev gets the NUXT_* values on its process only; SUMMER_* secrets and DEV_SEED_API_TOKEN are removed from its environment"
|
||||
- "up refuses up front when the Nuxt checkout's .nuxt, node_modules/.cache or node_modules/.vite are not writable by the current user"
|
||||
- "e2e requires a running stack (marker through :3119) and passes the guard before Playwright; it never lets Playwright start its own Nuxt"
|
||||
- "e2e-verdict also refuses a report it cannot parse or that is missing"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "up --detach on the real machine: preflight, build, migrate, dev-seed, reindex, Go on 127.0.0.1:8423, guard, Nuxt on :3119, probe (marker, 401, seeded cover image/png through Nuxt); down stops both"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 2 automated verify (up --detach; marker jq through :3119; albums 401; down)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "guard refuses :8422, plytarium.com, unset internal origin, mismatched origins, an origin other than the Go address, a non-loopback DEV_STACK_GO_ADDR, no marker, and a fake PHP answering X-Powered-By: PHP"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 automated verify 1 plus manual planted cases (fake PHP on 127.0.0.1:8431)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "e2e-verdict refuses skipped, zero-expected, unparsable and missing reports and accepts a clean one"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 automated verify 1 plus planted reports"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "check exits 0 with warn lines only: dev_stack_absent_ns reported missing, user not reported; on the real config collection and wishlist reported missing and the origin warning printed"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 automated verify 2"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Lifecycle: second up --detach prints already up; status 0 when up; a port held by another process is refused; a concurrent up is refused; foreground up stops both groups and removes pids on TERM; down twice is a no-op the second time"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "manual runs during Task 3"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "README section documents run, Centrifugo operator step with the exact namespace entries, subcommands, logs and MCP"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 4 automated verify and acceptance greps"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 27min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 02: Local stack start script Summary
|
||||
|
||||
**`fonoteka.go/scripts/dev-stack.sh up` takes a configured machine to Go on 127.0.0.1:8423 and the unchanged Nuxt app on :3119 on host services. Before Nuxt starts, it refuses any backend origin that is not this Go binary. It proves the Nuxt proxy reaches Go with the marker, a 401 and a seeded cover. It also offers `guard`, `check`, `status`, `exec`, `e2e` and `e2e-verdict` for plans 03 and 04.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 27 min of execution, after the Task 1 env checkpoint and the Nuxt write-access checkpoint
|
||||
- **Completed:** 2026-10-11T17:07Z
|
||||
- **Tasks:** 4 (1 human-action, 3 automated)
|
||||
- **Files:** 6 in fonoteka.go (2 created, 4 modified), 1 planning note in summercms.go
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **`up [--detach]`:** runs the steps in the D-05 order and only on host services. It never uses docker compose and never starts a second Centrifugo. Output goes to `tmp/dev-stack/{go,nuxt}.log` with `[go] ` and `[nuxt] ` prefixes. In the foreground, Ctrl-C, TERM or any failure stops both process groups.
|
||||
- **`guard` (D-09):** applies the origin rules before any network call, then checks the marker and refuses an `X-Powered-By: PHP` header.
|
||||
- **`check` (D-06, D-18, D-23):**
|
||||
- Postgres (loopback only), Typesense and Centrifugo `info` must answer, and Centrifugo must accept the key.
|
||||
- The WebSocket origin probe and the read-only `history` namespace probe print only `warn:` lines.
|
||||
- On this machine it reports the 3119 origin and the `collection` and `wishlist` namespaces as missing. That is the plan 03 operator step.
|
||||
- **`exec` and `e2e`:** `exec` exports the E2E and NUXT values with `CI` unset. `e2e` writes a JSON report to `tmp/dev-stack/e2e-<ts>.json`, and `e2e-verdict` fails the run on any skip, flaky, unexpected or zero-expected result.
|
||||
- **Template, ignores and config:** `.env.dev.example` holds placeholders only. `.gitignore` ignores `/.env.dev` and `/storage/app/uploads/public/`. The app config now has `serve: false` and `access_log: false` defaults.
|
||||
- **README:** a "Running locally with the Nuxt app" section.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1:** the developer created the env file and database and started Typesense (checkpoint, no commit).
|
||||
2. **Task 2 (tracer):** fonoteka.go `f74b00c` feat(14.2-02): dev-stack.sh up brings Go and Nuxt up on host services. Tracer gate: I re-ran the full verify end to end, it passed, then I expanded.
|
||||
3. **Task 3:** fonoteka.go `3029a4c` feat(14.2-02): dev-stack.sh guard, status, check, exec and e2e fail closed
|
||||
4. **Task 4:** fonoteka.go `b5363ea` docs(14.2-02): running fonoteka locally with the Nuxt app
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 3 - Blocking] Cover writes failed across devices.** `fonoteka:dev-seed` failed with `rename /tmp/... storage/app/uploads/public/...: invalid cross-device link`. The cause: `/tmp` is a tmpfs, and gocloud fileblob stages each write in `os.TempDir()`. Fix: every Go process the script starts runs with `TMPDIR=$APP/tmp/dev-stack/go-tmp`. The deployment side is logged in `deferred-items.md` for Phase 20. Commit f74b00c.
|
||||
2. **[Rule 1 - Bug] The WebSocket origin probe got no answer.** My first `Sec-WebSocket-Key` did not decode to 16 bytes, so Centrifugo closed every request without a response. With a valid key it answers 101 for an allowed origin and 403 for a rejected one. Commit f74b00c.
|
||||
3. **[Rule 1 - Bug] Foreground `up` stopped right after starting.** A bare `wait -n` also returned for earlier process substitutions. It now waits on the launched process groups only. Commit 3029a4c.
|
||||
4. **[Rule 2 - Security] The env file is parsed, not sourced.** `set -a; source` would run shell code from the file. The plan named it, but a data parser has the same effect without that risk. `pg_isready` gets host and port only, so the DSN password never reaches argv. The `pnpm dev` process does not inherit the SUMMER_* secrets. Commit f74b00c.
|
||||
5. **[Rule 2 - Correctness] `up` checks first that it can write to the Nuxt checkout.** It refuses with the directory names instead of failing inside `pnpm dev`. Commit f74b00c.
|
||||
6. **[Rule 2 - Concurrency] `up` takes a `flock`.** The plan relied on the port checks to refuse a second concurrent `up`. Ports are bound only after build and seed, so a second run could have wiped the first run's pid file. The lock refuses it at once. Commit 3029a4c.
|
||||
|
||||
### Checkpoints
|
||||
|
||||
- **Nuxt checkout write access (human action).** The checkout was owned by `jin` and not writable by `golem`, so `pnpm dev` could not write `.nuxt`. The user fixed the access, and the orchestrator confirmed it ("nuxt writable"). Writability is now documented as a README prerequisite.
|
||||
|
||||
**Total deviations:** 6 auto-fixed (2 bugs, 1 blocking, 3 missing critical). **Impact:** no scope change. All interfaces are as planned.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Playwright has no browsers for user `golem`.** Chromium headless shell 1223 is missing from `~/.cache/ms-playwright`. `dev-stack.sh e2e --report-only smoke.spec.ts` ran the whole path: the report was written and the counts printed. Every test failed at browser launch. Plan 03 needs `pnpm exec playwright install chromium`, which downloads the browser, so a person should approve it at plan 03's checkpoint. It is in the README prerequisites.
|
||||
- **`fonoteka:reindex` prints `collection_id:=0 document count is zero`.** This is an info line from the existing command, and the reindex itself succeeds. Plan 03's search spec will show whether seeded albums are indexed.
|
||||
- **GORM `record not found` noise.** The seed's first collection lookup logs it in color on stderr. It is cosmetic and logged in `deferred-items.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n` and `shellcheck` are clean. All acceptance greps of Tasks 2-4 pass.
|
||||
- The Task 2 verify passes against the real services: `up --detach`, then the marker JSON and the albums 401 through :3119, then `down`.
|
||||
- Both Task 3 verifies pass.
|
||||
- The Task 4 verify passes.
|
||||
- `go -C ../fonoteka.go vet ./...` is clean. `go test ./plugins/golem15/fonoteka/... -count=1` passes, and so does `go test .` for the app root, which covers the config files.
|
||||
- `git -C ../fonoteka.go status --porcelain` shows only the existing submodule pointer drift (feedback, golem, user). The seeded covers, `tmp/` and `.env.dev` are ignored.
|
||||
- The vue-fonoteka-app working tree is clean (`git status`). The only things written there are Nuxt and Playwright build and test outputs, which are git-ignored.
|
||||
|
||||
## Left running
|
||||
|
||||
Nothing. Go and Nuxt are stopped, and ports 8423 and 3119 are free. Seed data stays in `fonoteka_go_dev` and the covers stay under the git-ignored `storage/app/uploads/public/`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- **Plan 03:** run specs with `scripts/dev-stack.sh up --detach`, then `scripts/dev-stack.sh e2e <specs>`. Before that, the operator step for Centrifugo origins and namespaces and the Playwright browser install are needed.
|
||||
- **Plan 04:** `--guard` can feed planted env files through `DEV_STACK_ENV_FILE`, and `--self-test` can feed planted reports to `e2e-verdict`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
scripts/dev-stack.sh, .env.dev.example and deferred-items.md are present; commits f74b00c, 3029a4c and b5363ea exist in fonoteka.go (3 commits since 464ba47).
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 03
|
||||
subsystem: testing
|
||||
tags: [playwright, e2e, nuxt, centrifugo, dev-seed, surf, access-log, fonoteka, uat]
|
||||
|
||||
requires:
|
||||
- phase: 14.2-01
|
||||
provides: fonoteka:dev-seed, storage.uploads.serve, http.access_log, the local backend marker
|
||||
- phase: 14.2-02
|
||||
provides: dev-stack.sh up/down/check/e2e/e2e-verdict
|
||||
provides:
|
||||
- vue-fonoteka-app E2E_BACKEND=go seed switch (runTinker.ts goMode/goDir, auth.setup.ts Go seed)
|
||||
- backend-agnostic specs album-edit-rate-cover, album-search, wishlist and the rewritten realtime spec
|
||||
- repaired smoke, login and album-create specs; tests/e2e/helpers/fonotekaApi.ts
|
||||
- Go fixes found by the specs and the manual session (empty album format, seeded artist rows, cancelled request logged as 499)
|
||||
- 14.2-UAT.md with the D-13 session and the D-11 PHP cross-check
|
||||
affects: [14.2-04 unit tests and gate, phase 20 cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 11639
|
||||
tasks: 4
|
||||
commits: 4 # summercms.go: rev-list plan_head_before..plan_head_after = 5, minus 48286b6 docs(17) from another session
|
||||
plan_head_before: 05442174b67f1687d07530ef90b4ac6998bf7a50
|
||||
plan_head_after: a2d19360d290d81339f0c0f519585bcb337472b4
|
||||
fonoteka_head_before: b5363ea99e12b50dc6238438dd3aa60288f08c1e
|
||||
fonoteka_commits: 3
|
||||
vue_fonoteka_app_head_before: 984031f646784f640930a25eb7f25b3d268ac4d5
|
||||
vue_fonoteka_app_commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Specs self-seed through page.request on the Nuxt origin with the Bearer from the auth_token cookie, Date.now() names, cleanup in finally"
|
||||
- "Realtime is asserted on Centrifugo JSON protocol frames (connect reply, subscribe reply by id, push), the same for both backends"
|
||||
- "Go-mode switches read process.env at call time so contract tests can toggle them"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md
|
||||
modified:
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/logging.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "The album model rules see nullable text columns as strings, so an empty format passes like PHP, which skips non-implicit rules for an empty string"
|
||||
- "A 5xx written after the client cancelled the request is logged as 499 by the surf access log (framework), not remapped per plugin"
|
||||
- "dev-seed creates albums with their artist rows and backfills seeded albums that have none (album: updated)"
|
||||
- "dev-stack.sh e2e exports TMPDIR=tmp/dev-stack/go-tmp so the Playwright seed can write covers on a tmpfs /tmp machine"
|
||||
- "Item 11 (PHP cross-check) passes by classification: the two PHP failures are a GD-undecodable test fixture and a missing PHP Centrifugo config, with no contract difference"
|
||||
|
||||
patterns-established:
|
||||
- "UAT findings that match PHP are recorded with the PHP source reference instead of being changed in Go"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "E2E_BACKEND=go seed switch: Go mode runs fonoteka:dev-seed and fails closed, runTinker throws, the PHP path is byte-identical (additions only)"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "dev-stack.sh e2e auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts i18n.spec.ts public-share.spec.ts"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "git diff HEAD~1 -- tests/e2e/helpers/runTinker.ts | grep -c '^-[^-]' = 0 at 87d6939"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "D-15 Go gate subset (10 spec files) green with zero skips"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "dev-stack.sh e2e (10 files): expected=17 unexpected=0 skipped=0 flaky=0, rerun after the session fixes"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Edit, rate, cover upload (cover served from /storage/app/uploads/public/ with naturalWidth > 0), search and wishlist add/remove specs, self-seeding"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "album-edit-rate-cover.spec.ts, album-search.spec.ts, wishlist.spec.ts on Go"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Realtime spec on Centrifugo frames: connect reply, error-free collection: subscribe, push within 15 s"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: e2e
|
||||
ref: "realtime.spec.ts on Go with E2E_CENTRIFUGO=1"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Go fixes: empty wishlist format 201, seeded artist rows, cancelled request logged as 499"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "classes#TestSaveAlbumEmptyFormatPassesModelRules, fonoteka#TestDevStackSmoke (artist backfill), surf#TestAccessLogClientClosedRequest"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Manual D-13 session on the Go stack and D-11 PHP cross-check recorded in 14.2-UAT.md"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: ".planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-UAT.md"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
|
||||
duration: about 4h including the manual session
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 03: vue-fonoteka-app proven on the Go backend Summary
|
||||
|
||||
**The vue-fonoteka-app Playwright suite now seeds the Go stack through `fonoteka:dev-seed` (`E2E_BACKEND=go`). Every core flow has a self-seeding spec: edit, rate, cover upload, search, wishlist and realtime over Centrifugo frames. The D-15 subset passes on Go with zero skips. The developer's manual session and the PHP cross-check are on record in 14.2-UAT.md, and the four Go defects they and the specs surfaced are fixed.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 4 hours of wall time, including the operator step, the manual session and the PHP run.
|
||||
- **Completed:** 2026-10-11
|
||||
- **Tasks:** 4 (2 automated, 1 operator checkpoint, 1 manual checkpoint)
|
||||
- **Repos:** vue-fonoteka-app (3 commits), fonoteka.go (3), summercms.go (4 before this summary)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-14 seed switch.**
|
||||
- `runTinker.ts` gains `goMode()` and `goDir()`. In Go mode, `backendAvailable()` checks `E2E_GO_BIN`, `runTinker()` throws, and `seedE2EFixtures()` runs `$E2E_GO_BIN fonoteka:dev-seed` and throws on any failure.
|
||||
- `auth.setup.ts` seeds before login only when `E2E_BACKEND=go`. The PHP lines are unchanged: the change only adds lines.
|
||||
- **D-16 and D-21 specs.**
|
||||
- `album-edit-rate-cover`, `album-search` and `wishlist` are new, and `realtime` is a full rewrite without the removed `realtime-*` test IDs.
|
||||
- The four specs share `helpers/fonotekaApi.ts`.
|
||||
- **Go fixes:**
|
||||
- The wishlist form's `"format":""` now gets a 201, as on PHP.
|
||||
- `dev-seed` attaches artist rows, and a rerun backfills them on albums seeded earlier.
|
||||
- A cancelled request is logged as 499 instead of 500 (surf access log).
|
||||
- `dev-stack.sh e2e` keeps the seed's temporary files on the app's device.
|
||||
- **Records:**
|
||||
- 14.2-UAT.md has all 11 D-13 items. Items 7, 9 and 10 carry analysed findings, and item 11 is the PHP cross-check.
|
||||
- `deferred-items.md` lists the follow-ups.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): seed switch, existing core specs green on Go**
|
||||
- vue-fonoteka-app `e4f2a9c`: fix(14.2-03): repair stale smoke, login and album-create e2e specs
|
||||
- vue-fonoteka-app `87d6939`: feat(14.2-03): E2E_BACKEND=go seeds through fonoteka:dev-seed
|
||||
- Tracer gate: the verify ran green end to end (12/0/0/0) before expanding.
|
||||
2. **Task 2: Centrifugo origins and namespaces (operator)**
|
||||
- The operator did it before resume. `dev-stack.sh check` exits 0 and prints no origin or namespace warning.
|
||||
3. **Task 3: specs for edit, rate, cover, search, wishlist and realtime**
|
||||
- vue-fonoteka-app `c1f323a`: test(14.2-03): backend-agnostic specs for edit, rate, cover, search, wishlist and realtime
|
||||
- fonoteka.go `7675d64`: fix(14.2-03): an empty album format passes the model rules like PHP
|
||||
- summercms.go `66dbd5a`: docs(14.2-03): UAT record with the deferred PHP cross-check
|
||||
- The PHP cross-check was deferred to Task 4 item 11. Executor runs on 2026-10-11 had no PHP E2E credentials, and the run was never reported as passed.
|
||||
4. **Task 4: manual session, follow-up fixes and PHP cross-check**
|
||||
- fonoteka.go `d31e00a`: fix(14.2-03): dev-seed attaches artist rows to the seeded albums
|
||||
- fonoteka.go `807dfdb`: fix(14.2-03): dev-stack.sh e2e keeps the seed's temporary files on the app device
|
||||
- summercms.go `4660f95`: fix(14.2-03): access log records a cancelled request's 5xx as 499
|
||||
- summercms.go `b9de061`: docs(14.2-03): record the manual session, its findings and fixes in UAT
|
||||
- summercms.go `a2d1936`: docs(14.2-03): PHP cross-check and re-verification recorded in UAT
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 3 - Blocking] Three existing gate specs were stale against the current app, on any backend.**
|
||||
- `smoke` ran with the logged-in storage state, which shows the dashboard on `/`, and expected "Plytarium" where the page says "Płytarium".
|
||||
- `login` used `/login`, which now serves Polish labels, and clicked before hydration.
|
||||
- `fonoteka-item` did not fill the now-required artist and format, and its toast race could not succeed.
|
||||
- Fixed in the specs only; nothing under `app/` changed. Commit `e4f2a9c`.
|
||||
2. **[Rule 2 - Missing helper] `tests/e2e/helpers/fonotekaApi.ts` added.** It holds the Bearer, create and delete helpers shared by the specs. The file was not in the plan's file list. Commit `e4f2a9c`.
|
||||
3. **[Rule 1 - Bug, D-11 Go fix] Wishlist create with `"format":""` answered the 500 page.**
|
||||
- Cause: the model-rule check saw a `*string` pointing to "", not an empty value, so the `in:` rule failed. PHP skips non-implicit rules for an empty string.
|
||||
- Fixed in fonoteka.go `7675d64`, with a DB test. The framework-level gap is logged in `deferred-items.md`.
|
||||
4. **[Rule 1 - Bug, session finding A] The artist field in the edit form was empty for seeded albums.** The seed set only `artist_display`. Fixed in fonoteka.go `d31e00a`, and the developer re-verified it.
|
||||
5. **[Rule 1 - Bug, session finding D] A cancelled request was logged as a 500.**
|
||||
- Fixed in the framework (summercms.go `4660f95`), with docs.
|
||||
- The fix belongs in the framework because the status is visible only in the access log, so a per-plugin remap would not reach it.
|
||||
6. **[Rule 3 - Blocking] The Playwright-run seed failed with `invalid cross-device link`.** A renamed seeded album made the seed write a new cover with the default `/tmp`. Fixed in fonoteka.go `807dfdb`.
|
||||
7. **[Procedural] The PHP cross-check was not a clean run.**
|
||||
- Item 11 on PHP gave `expected=4 unexpected=2`. The failures were a GD-undecodable `PNG_1PX` fixture and the PHP env missing a Centrifugo config.
|
||||
- The developer accepted it as a pass by classification: no contract difference was found. Both issues are in `deferred-items.md`.
|
||||
|
||||
### Findings recorded as matching PHP (no change)
|
||||
|
||||
- **Item 7:** a new photo is appended and the first photo stays the main cover. Winter's `Sortable` sets `sort_order` to the id, and Go does the same.
|
||||
- **Item 9:** a same-user edit in a second tab does not refresh the UI, because the frontend's self-echo guard skips it. The Go push payload has the same shape as PHP's.
|
||||
- **Item 3:** `artist_display` is not rewritten after an artist change. PHP fills it only when it is empty.
|
||||
|
||||
**Total deviations:** 6 auto-fixed (3 bugs, 2 blocking, 1 missing helper), 1 procedural. **Impact:** no `app/` change. The Go and framework fixes are parity corrections.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `pnpm test:contracts` fails 1 of 279 on vue-fonoteka-app HEAD from before this plan: `missing-catalog.test.mjs` finds the word "any" in an `app/` type comment from `984031f`. It is outside D-20 scope and logged.
|
||||
- `go.log` is appended across `up` runs. The pre-session and session logs are kept as `go.log.pre-uat-20261011` and `go.log.uat-session-20261011`.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Go gate subset (D-15):** `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed` after all fixes. go.log shows 0 5xx afterwards.
|
||||
- **Go tests:**
|
||||
- fonoteka.go: `go vet ./...` is clean; the fonoteka plugin, classes, console, parity and app-root tests pass.
|
||||
- summercms.go: `go vet ./...` is clean, `go test -short ./...` passes, and so do `TestDocsTree` and `docs:build --check`.
|
||||
- **vue-fonoteka-app:** `git diff --name-only 984031f..HEAD -- app` is empty.
|
||||
|
||||
## Left running
|
||||
|
||||
The Go and Nuxt dev stack (`dev-stack.sh up --detach`) was left running for the developer at the last executor step. The PHP run stopped it and started it again as the coordinator described.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 04 can pin the Go seed switch with a contract test (the switch is read at call time) and use `dev-stack.sh` `--guard`, `--self-test` and `--e2e`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
The created spec files, the helper and 14.2-UAT.md exist. Commits:
|
||||
- vue-fonoteka-app: e4f2a9c, 87d6939, c1f323a
|
||||
- fonoteka.go: 7675d64, d31e00a, 807dfdb
|
||||
- summercms.go: 66dbd5a, 4660f95, b9de061, a2d1936
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 04
|
||||
subsystem: testing
|
||||
tags: [unit-tests, gate, dev-seed, surf, uploads, access-log, playwright, contracts, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 14.2-01
|
||||
provides: fonoteka:dev-seed, commandDatabase, the local marker, storage.uploads.serve, http.access_log
|
||||
- phase: 14.2-02
|
||||
provides: dev-stack.sh guard, e2e, e2e-verdict, status, up --detach
|
||||
- phase: 14.2-03
|
||||
provides: the E2E_BACKEND=go switch in runTinker.ts and the Go-gate spec set
|
||||
provides:
|
||||
- scripts/check-phase14.2.sh (--self-test --go --docs --parity --contracts --guard --e2e --all)
|
||||
- console tests TestDevSeedRefusesProduction, TestDevSeedShape, TestDevSeedIdempotent
|
||||
- plugin tests TestDevSeedOpensDatabase, TestReindexOpensDatabase, TestCommandsOpenDatabase, TestDevMarkerRoute
|
||||
- surf tests TestUploadsMount, TestAccessLog
|
||||
- vue-fonoteka-app tests/contracts/e2e-backend-switch.test.mjs
|
||||
- surf: the uploads route owns its prefix (a plugin route at or under it fails BuildRouter)
|
||||
affects: [/msd-verify-work 14.2, /msd-validate-phase 14.2, phase 20 cutover]
|
||||
|
||||
actuals:
|
||||
tokens: 20256
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: dbdf15da76098beb315a61fa4719e05ab1ca13f2
|
||||
plan_head_after: 78d72418cb731263572b75294846841a23a853d3
|
||||
fonoteka_head_before: 807dfdbfcc6e323d327c29295d23c76887e4dc89
|
||||
fonoteka_head_after: 49af991a8f3a9b5c75af29a0734d2d583b83449a
|
||||
fonoteka_commits: 2
|
||||
vue_fonoteka_app_head_before: c1f323a0ff3a51e1ca6f5db80581a0db0d644d6a
|
||||
vue_fonoteka_app_head_after: 6d60e42022e4252cbe0d73b7fb5196eb000d093b
|
||||
vue_fonoteka_app_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fresh-process command tests: party.Activate on an app with only database.dsn, run the plugin's own Command, assert the published handle carries the OnDatabase GORM callback"
|
||||
- "Gate guard cases run dev-stack.sh in env -i with planted guard-*.conf files, require a case-specific refusal reason, and keep a passing SummerCMS control case"
|
||||
- "Report-only suites are printed with their counts next to a blocking, phase-scoped run"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase14.2.sh
|
||||
- modules/surf/uploads_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
|
||||
modified:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/deferred-items.md
|
||||
|
||||
key-decisions:
|
||||
- "The uploads route owns storage.uploads.public_path_prefix: BuildRouter fails when a plugin route sits at or under it (the plan's truth; before, only an identical pattern failed, and only at Assemble)"
|
||||
- "--contracts blocks on the phase's own contract test and reports the whole pnpm test:contracts suite, because missing-catalog.test.mjs fails on vue-fonoteka-app HEAD from before this phase"
|
||||
- "The gate leaves TMPDIR to the caller: a TMPDIR inside fonoteka.go made internal/build scaffold tests edit fonoteka.go/go.work and made go test ./... there pick up build directories"
|
||||
- "--go also runs go test ./... in the fonoteka plugin module, because ./... at the fonoteka.go root covers only the root module"
|
||||
- "The go.log scan reads the whole log, not only lines from this run, so a 5xx left from an earlier session also fails the gate until the log is read and rotated"
|
||||
|
||||
requirements-completed: [QA-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Seed refusal: unset SUMMER_ENV, production, development, testing and staging refused before anything is opened; local and dev pass the gate; DevEnvironment agrees"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go#TestDevSeedRefusesProduction"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Seed shape, demo@example.com/changeme fallback, DEV_SEED_API_TOKEN prefix refusal and hash storage; idempotent rerun and password-only update"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go#TestDevSeedShape,TestDevSeedIdempotent"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "dev-seed, fonoteka:reindex, fonoteka:oauth-client and fonoteka:prune-notifications open and publish their own database with the OnDatabase hooks"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go#TestDevSeedOpensDatabase,TestReindexOpensDatabase,TestCommandsOpenDatabase"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Marker route only in local/dev, no auth middleware, exact body and no-store; absent and 404 in production, development and testing; not in parity/manifest.yaml"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go#TestDevMarkerRoute"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Uploads mount (off, on, nosniff, HEAD, private/missing/traversal 404, unpublished bucket or database, custom prefix, prefix ownership) and access log (pattern/status, unmatched path, locale prefix, integer duration, no secrets, flush)"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/surf/uploads_test.go#TestUploadsMount"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/surf/accesslog_test.go#TestAccessLog"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "E2E_BACKEND=go switch contract: argv, cwd, fail-closed exits, runTinker throws, PHP path unchanged, auth.setup.ts seeds only in Go mode"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "check-phase14.2.sh --all: self-test, go, docs, parity 175/175, contracts, guard refusals, Go-gate Playwright 17/0/0/0 and go.log scan; last line Phase 14.2 gate passed"
|
||||
requirement: QA-05
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase14.2.sh --all"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 60min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 Plan 04: Unit tests and the fail-closed phase gate Summary
|
||||
|
||||
**Every Go piece the phase added now fails a named test when it regresses: the seed's environment gate, shape and idempotency, the commands' own database, the local marker, the uploads mount and the access log. A Nuxt contract pins the `E2E_BACKEND=go` switch. `scripts/check-phase14.2.sh --all` runs all of it plus parity, the guard refusals and the Go-gate Playwright subset, and ends with `Phase 14.2 gate passed`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 60 min (2026-10-11T21:06Z to 22:06Z)
|
||||
- **Tasks:** 3 (tracer, auto TDD, auto)
|
||||
- **Repos:** summercms.go (3 code commits), fonoteka.go (2), vue-fonoteka-app (1)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Seed tests (console package, shared testcontainer):**
|
||||
- `TestDevSeedRefusesProduction` needs no database: the refused environments return `refused in environment`, print nothing and publish no handle. `local` and `dev` get past the gate and fail on a closed port.
|
||||
- `TestDevSeedShape` checks the user flags and bcrypt hash, the context collection, three albums with one public PNG cover each whose blob is in the mem bucket, the wishlist, the rating, the token hash against the printed `inv_` secret and `search_use_typesense`. It also covers the `demo@example.com` / `changeme` fallback and `DEV_SEED_API_TOKEN` values without the prefix (`tok_…`, bare `inv_`, `INV_…`): each is refused with no row in any of nine tables. A value with the prefix is stored by its hash and never printed.
|
||||
- `TestDevSeedIdempotent`: the rerun prints the same entities, all `unchanged`, with no secret and no new row. A new `E2E_PASSWORD` prints only `user: updated`.
|
||||
- **Own-database tests (plugin root):** the four commands are taken from the plugin's own `Commands()` and run in fresh processes. Each publishes a `*gorm.DB` carrying the `fonoteka:album_artist_resolver` callback, which proves the `OnDatabase` hooks ran. None prints `database is not configured`. `TestDevMarkerRoute` covers local, dev, unset, production, development and testing, and reads `parity/manifest.yaml`.
|
||||
- **Framework tests (surf):**
|
||||
- `TestUploadsMount` uses its own Postgres container with `system_files` and a mem bucket.
|
||||
- `TestAccessLog` runs next to `TestAccessLogSmoke` with the `acme.blog` fixture and the in-package path locale resolver.
|
||||
- **Gate script:** every stage named in the plan. Self-test plants detector events, Playwright reports, Go logs and TAP summaries. The guard stage plants six refusal cases plus a passing control case.
|
||||
|
||||
## Gate result
|
||||
|
||||
```
|
||||
PASS self-test
|
||||
PASS go
|
||||
PASS docs
|
||||
PASS parity (recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0)
|
||||
PASS contracts (blocking: e2e-backend-switch 9/9; report only: pnpm test:contracts 287/288)
|
||||
PASS guard (php-port, internal-unset, mismatch, production, fake-php, other-marker refused; control passed)
|
||||
PASS e2e (Go gate expected=17 unexpected=0 skipped=0 flaky=0; D-15 report only expected=19 unexpected=22)
|
||||
Phase 14.2 gate passed
|
||||
```
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): the gate runs the seed refusal through the detector**
|
||||
- fonoteka.go `916d9cf` test(14.2-04): dev-seed refuses every environment but local and dev before opening anything
|
||||
- summercms.go `46eee17` test(14.2-04): check-phase14.2.sh runs the seed refusal through the skip-intolerant detector
|
||||
- Tracer gate: the verify passed, a re-run of `--all` passed (end-of-phase mode, automated verify), then I expanded.
|
||||
2. **Task 2: unit tests for the seed, the commands, the marker, uploads and the access log**
|
||||
- fonoteka.go `49af991` test(14.2-04): dev-seed shape and idempotency, own-database commands, local marker
|
||||
- summercms.go `2c5c058` test(14.2-04): uploads mount and access log unit tests; the uploads route owns its prefix
|
||||
3. **Task 3: Nuxt contract and the remaining gate stages**
|
||||
- vue-fonoteka-app `6d60e42` test(14.2-04): contract for the E2E_BACKEND=go seed switch
|
||||
- summercms.go `78d7241` test(14.2-04): check-phase14.2.sh docs, parity, contracts, guard and e2e stages
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
1. **[Rule 1 - Bug] A plugin route under the uploads prefix did not fail BuildRouter.**
|
||||
- **Found during:** Task 2, by `TestUploadsMount` (written first, failing).
|
||||
- **Issue:** only an identical `GET {prefix}/{path...}` pattern failed, and only later at `Assemble` (duplicate route). A plugin route like `/storage/uploads/avatars/{id}` was accepted and took over part of the uploads URL space.
|
||||
- **Fix:** `mountUploads` refuses any route at or under the prefix, so `BuildRouter` returns the error, as `checkAdminPrefix` does for the admin prefix. A sibling path (`/storage/uploadsx/…`) and the key off are unaffected. The surf README and docs/services/storage.md now say this. The real fonoteka binary still boots with the key on (`dev-stack.sh up` after the change).
|
||||
- **Files:** modules/surf/uploads.go, modules/surf/README.md, docs/services/storage.md. **Commit:** 2c5c058.
|
||||
2. **[Rule 3 - Blocking] `--contracts` scoped to the phase's own contract test.** The whole `pnpm test:contracts` suite fails 1 of 288 on `missing-catalog.test.mjs`, which predates this phase (deferred-items.md, plan 03). Following the coordinator's instruction, the blocking run is `node --test tests/contracts/e2e-backend-switch.test.mjs` with a strict TAP check: tests > 0, all pass, and no fail, skip, todo or cancel. The whole suite still runs, and its counts and the failing test name are printed, but it does not block. **Commit:** 78d7241.
|
||||
3. **[Rule 3 - Blocking] TMPDIR not forced into the fonoteka.go tree.**
|
||||
- **Issue:** with `TMPDIR=fonoteka.go/tmp/dev-stack/go-tmp` (the coordinator's instruction), the framework's `internal/build` scaffold tests and `TestWatchHelloWorkspaceRebuildLatency` failed. One scaffold test added a `use ./tmp/dev-stack/go-tmp/TestScaffoldCoreArtifacts…` line to `fonoteka.go/go.work`; I reverted it with `git checkout -- go.work` before any commit. A concurrent `go test ./...` in fonoteka.go also listed the build directories as packages.
|
||||
- **Fix:** the gate leaves TMPDIR to the caller (`PHASE142_TMPDIR` overrides). The Go tests do not need the on-device TMPDIR: their buckets live in `t.TempDir()`. `dev-stack.sh` already sets its own TMPDIR for the Go processes and for Playwright. Logged in deferred-items.md.
|
||||
4. **[Rule 2 - Missing coverage] `--go` tests the fonoteka plugin module too.** `go -C fonoteka.go test ./...` covers only the root module (`app`, `parity`, `scripts`). The plugin is a separate workspace module, so the stage also runs `go vet` and `go test ./...` in `plugins/golem15/fonoteka`.
|
||||
5. **[Rule 2 - Stronger evidence] Guard cases check the refusal reason, and a control case must pass.** Each planted case must exit non-zero and print a `refuse:` line naming its own reason (port 8422, unset, differ, not loopback, X-Powered-By: PHP, the summercms marker). A fake SummerCMS marker must pass the same guard. All cases run in `env -i`, so the developer's shell cannot change them.
|
||||
6. **[Plan text vs. behaviour] Raw `..` traversal is a ServeMux redirect, not a 404.** ServeMux answers a raw dot-dot path with a 307 to the cleaned path before any handler runs. The test therefore follows the redirect, requires a `Location` without `..`, and requires that the cleaned path is refused (404) with no blob bytes. Encoded traversal (`%2f`, `%2e%2e`, `%5c`) reaches the handler and gets a 404 with nosniff.
|
||||
|
||||
### Acceptance criterion not met as written
|
||||
|
||||
- **`grep -c 't.Skip' modules/surf/uploads_test.go` prints 1, not 0.**
|
||||
- The Postgres helper `uploadsGorm` calls `t.Skip` only under `-short`. The project's CLAUDE.md requires Docker-backed tests to skip under `-short`, and CLAUDE.md overrides the plan.
|
||||
- An unavailable Docker daemon is still a `t.Fatalf`, never a skip.
|
||||
- The gate never runs `-short`, and its detector refuses any skipped test or subtest (planted case `subtest-skip`).
|
||||
- `dev_stack_test.go` and `dev_marker_test.go` contain no `t.Skip`. They reach the existing `bootDB` helper, which skips under `-short` only.
|
||||
|
||||
**Total deviations:** 5 auto-fixed (1 bug, 2 blocking, 2 coverage), 1 plan-text clarification, 1 acceptance grep deviation driven by CLAUDE.md. **Impact:** one framework behaviour change (uploads prefix ownership), documented. No other production code changed.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Vite cold start after a fresh `up`.** The first `--all` run came right after I restarted the stack, so the binary would include this plan's code. Its `--e2e` stage failed: `auth.setup.ts` got Nuxt's 500 page "Failed to fetch dynamically imported module …/layouts/public.vue", so 16 tests were skipped and `e2e-verdict` refused the run, as designed. go.log showed no 5xx. On the warm server the same specs passed 17/0/0/0, and the final `--all` run passed. The gate does not retry. Logged in deferred-items.md with options.
|
||||
- **D-15 report-only specs:** expected=19 unexpected=22 on Go. They are reported, not gated, per D-15. go.log had no 5xx and no `album search engine failed` after them.
|
||||
- **Pre-existing:** gofmt drift in `fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go`, out of scope.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash scripts/check-phase14.2.sh --all`: exit 0. Last line: `Phase 14.2 gate passed`.
|
||||
- `--go` covers `go vet ./...` and `go test ./... -count=1` in summercms.go (Docker), in the fonoteka.go root module and in the fonoteka plugin module. The named tests passed through the detector: console (4), plugin (5) and surf (3).
|
||||
- `shellcheck scripts/check-phase14.2.sh` is clean, and `bash -n` passes on it and on dev-stack.sh.
|
||||
- Plan acceptance greps pass, except the `t.Skip` grep noted above.
|
||||
- `git status` after the run:
|
||||
- fonoteka.go: only the other sessions' submodule pointers (feedback, golem, user). `go.work` is unchanged.
|
||||
- vue-fonoteka-app: clean.
|
||||
- summercms.go: only `.planning/state.json` and `.msd/` (other sessions), plus this plan's planning docs.
|
||||
|
||||
## Left running
|
||||
|
||||
The Go and Nuxt dev stack is up (`dev-stack.sh status`: Go on 127.0.0.1:8423, Nuxt on :3119). I restarted it once with `down` + `up --detach` so the binary includes this plan's framework change, and left it running.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
`/msd-verify-work 14.2` can use `bash scripts/check-phase14.2.sh --all`. The services must be up and the Nuxt server warm (load one page, or run `--e2e` twice after a fresh `up`). `14.2-VALIDATION.md` statuses are left for `/msd-validate-phase 14.2`, as the plan says.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files exist. Commits: summercms.go 46eee17, 2c5c058, 78d7241. fonoteka.go 916d9cf, 49af991. vue-fonoteka-app 6d60e42.
|
||||
@@ -18,6 +18,7 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
|
||||
### Local data
|
||||
- **D-01:** Local data comes from `fonoteka migrate` followed by a new seed command. The Phase 20 `import:winter` runner is not pulled forward, and PHP data is not imported in this phase.
|
||||
- **D-02:** The seed is an application command, `fonoteka dev:seed` (final name at the planner's discretion), in the fonoteka plugin, not a framework seeder hook. It refuses to run unless the app environment is local/dev, and it is idempotent: a second run changes nothing. It creates:
|
||||
- *Amended 2026-10-12 (code review WR-05, accepted by the developer):* the seed, the backend marker route and `dev-stack.sh` accept `SUMMER_ENV=local` only; `dev` is refused because it is a common name for shared servers. The seed also refuses to modify an existing user it did not create (opt-in `DEV_SEED_RESET_EXISTING=1`) and a token row owned by another user.
|
||||
- a verified user;
|
||||
- an owned collection;
|
||||
- a few albums;
|
||||
@@ -46,6 +47,7 @@ Repos: `fonoteka.go` (seed command, start script, dev env template, any Go fixes
|
||||
- **D-10:** The marker is a route only the Go binary serves (an existing framework health/readiness route, or the smallest new one) that stays **outside the parity manifest**. No manifest route's response changes, and no new header is added to API responses.
|
||||
- **D-11:** `vue-fonoteka-app` may receive **small non-breaking changes** (user's decision). The constraint: the app must still run without issues against the PHP backend. Gaps found during the session are fixed in Go first, with a parity case added where a recorded PHP response exists. An app change is acceptable only when it leaves PHP behavior unchanged (for example an env-switched test helper, a new backend-agnostic spec, or docs and `.env.example` notes). Commits in `vue-fonoteka-app` go to its own repo, which is a submodule of the PHP `fonoteka` repo.
|
||||
- **D-12:** Failures show up through the Go request log, which is redacted by sunscreen and records each request's path and status, and through Nuxt's normal behavior. The script tees both processes into labeled log files, and the run docs say to watch them for 4xx/5xx and 404 responses. No 404 tally or dev-only error UI is added.
|
||||
- *Amended 2026-10-12 (code review WR-01, accepted by the developer):* the Go request log never records the URL path, not even for unmatched requests, because share and invitation tokens travel in paths. It records method, matched pattern, status and duration. An unported route shows as `status=404 pattern=""`; take the path from the browser network tab.
|
||||
|
||||
### Flow proof
|
||||
- **D-13:** The flows are proven twice:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 14.2
|
||||
review: 14.2-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Access log writes share and invitation tokens for 405 and 404 responses on token routes"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "499 remap hides real server errors whenever the client has gone away"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "`storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "`fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Minted API token secret persisted to a group/world-readable file and echoed into Playwright output"
|
||||
- id: WR-07
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Phase gate `--e2e` can verify a stale binary"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "go.log 5xx scan passes when the access log is off"
|
||||
- id: WR-09
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Realtime spec accepts any publication on the channel, including another spec's"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Env file parser exports arbitrary keys, so \"never executed\" is not quite true"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Postgres loopback check can be bypassed by `hostaddr` or a query `host`"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`down` signals recorded process groups without checking they are still this stack's"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Seed's token \"unchanged\" check ignores expiry and scopes"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "`marker_ok` does not check the app name"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "statusRecorder records a 1xx informational status as the final status"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Gate header overstates what the whole-suite Go runs check"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Hard-coded absolute developer path in a framework script"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Search spec relies on the \"other\" album appearing on the first page after clearing"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`"
|
||||
open: 11
|
||||
total: 20
|
||||
recorded: 2026-10-12T10:23:19.844Z
|
||||
---
|
||||
|
||||
# Phase 14.2: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-01 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-02 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-03 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-04 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-05 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-06 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-07 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-08 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| WR-09 | warning | fixed | 14.2-REVIEW-FIX.md |
|
||||
| IN-01 | info | open | - |
|
||||
| IN-02 | info | open | - |
|
||||
| IN-03 | info | open | - |
|
||||
| IN-04 | info | open | - |
|
||||
| IN-05 | info | open | - |
|
||||
| IN-06 | info | open | - |
|
||||
| IN-07 | info | open | - |
|
||||
| IN-08 | info | open | - |
|
||||
| IN-09 | info | open | - |
|
||||
| IN-10 | info | open | - |
|
||||
| IN-11 | info | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
fixed_at: 2026-10-12T10:20:00Z
|
||||
review_path: .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 9
|
||||
fixed: 9
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 14.2: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-12T10:20:00Z
|
||||
**Source review:** .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 9 (WR-01 to WR-09; Info findings were out of scope)
|
||||
- Fixed: 9
|
||||
- Skipped: 0
|
||||
|
||||
Commits went to the repository that owns each file. Repositories: `summercms.go` (framework), `fonoteka.go` (app), `vue-fonoteka-app`. `workflow.use_worktrees` is `false`, so every edit and commit was made in the main checkouts.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
|
||||
|
||||
**Files modified:** `modules/surf/accesslog.go`, `modules/surf/accesslog_test.go`, `modules/surf/README.md`, `docs/services/logging.md`; fonoteka.go `README.md`
|
||||
**Commits:** summercms.go `fbcfaaf`; fonoteka.go `560bb3e`
|
||||
**Applied fix:** The request log no longer logs the URL path, not even for an unmatched request. It records `method`, `pattern` (empty when unmatched), `status` and `duration_ms`. New test cases: `POST /blog/posts/<secret>` (405) and `/blog/posts/<secret>/` (404). Both assert that the secret and the path are absent. The docs and the fonoteka README say that the Go log never shows a path.
|
||||
|
||||
### WR-02: 499 remap hides real server errors whenever the client has gone away
|
||||
|
||||
**Files modified:** `modules/surf/accesslog.go`, `modules/surf/router.go`, `modules/surf/accesslog_test.go`, `modules/surf/README.md`, `docs/services/logging.md`, `scripts/check-phase14.2.sh`
|
||||
**Commit:** summercms.go `c30145e`
|
||||
**Status:** fixed: requires human verification (logic change)
|
||||
**Applied fix:** A 5xx is logged as 499 only when both of these hold:
|
||||
- The request context had already ended with `context.Canceled` when the status was written. `statusRecorder` checks this at the first `WriteHeader`/`Write`.
|
||||
- The handler did not panic. `recoverJSON` and `recoverBare` now mark the access entry through `markPanicked`.
|
||||
|
||||
A 5xx written before the client went away keeps its real status. A recovered panic is always 500. Every record of a request whose client went away carries `client_gone=true`. The handler's own error is not visible to the log. The heuristic "cancelled before the status was written" therefore stands in for "the error is the cancellation". A real error that happens after cancellation and does not panic would still log 499. That case needs a cancelled context, so it is narrow.
|
||||
|
||||
Tests cover seven cases:
|
||||
- cancel, then 5xx
|
||||
- live 5xx
|
||||
- 5xx, then cancel
|
||||
- 200, then cancel
|
||||
- `recoverJSON` 5xx after cancel
|
||||
- panic after cancel, through `recoverJSON`
|
||||
- panic after cancel, through `recoverBare`
|
||||
|
||||
A gate self-test plants `status=500 ... client_gone=true` and checks that `scan_go_log` refuses it.
|
||||
|
||||
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
|
||||
|
||||
**Files modified:** `modules/surf/uploads.go`, `modules/surf/uploads_test.go`, `modules/surf/README.md`, `docs/services/storage.md`, `docs/setup/configuration.md`
|
||||
**Commit:** summercms.go `41a03b9`
|
||||
**Applied fix:** Every uploads response now carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`. A successful response whose type is not a raster image (jpeg, png, gif, webp, avif) gets `Content-Disposition: attachment`, on GET and HEAD. That covers HTML, SVG, PDF, text and a missing type. Error responses get no disposition. The disposition is decided when the status is written, after `attach.StaticHandlerPublic` set the type.
|
||||
|
||||
Tests:
|
||||
- `TestUploadsResponseHeaders` (no Docker) runs a type matrix on GET and HEAD.
|
||||
- `TestUploadsMount` (Docker) now stores a public `text/html` blob and asserts it is served as an attachment, while the png stays inline.
|
||||
|
||||
Checked live: a seeded `.jpg` cover is served through Nuxt as `200 image/jpeg` with the CSP and no disposition. The docs' "nosniff prevents HTML" claim is corrected.
|
||||
|
||||
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
|
||||
|
||||
**Files modified:** `modules/surf/uploads.go`, `modules/surf/uploads_test.go`, `modules/surf/README.md`, `docs/services/storage.md`
|
||||
**Commit:** summercms.go `01d2548`
|
||||
**Applied fix:** A new function, `uploadsPrefix`, validates the prefix when `storage.uploads.serve` is on. It refuses:
|
||||
- a missing leading slash
|
||||
- a scheme or host
|
||||
- `//`
|
||||
- `{` or `}`
|
||||
- `?` or `#`
|
||||
- whitespace or a backslash
|
||||
- an empty, `.` or `..` segment
|
||||
- a bare `/`
|
||||
|
||||
The error names `storage.uploads.public_path_prefix` and says "absolute URL path". Test cases include `storage/uploads`, `storage/app/uploads/public`, `https://cdn.example.com/uploads` and `//cdn...`. Valid prefixes with a trailing slash or surrounding spaces pass. A CDN prefix also passes while `serve` is off.
|
||||
|
||||
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
|
||||
|
||||
**Files modified:** fonoteka.go `plugins/golem15/fonoteka/console/dev_seed.go`, `console/dev_seed_test.go`, `dev_marker.go`, `dev_marker_test.go`, `plugin.go`, `scripts/dev-stack.sh`, `.env.dev.example`, `README.md`; summercms.go `scripts/check-phase14.2.sh`
|
||||
**Commits:** fonoteka.go `389a059`; summercms.go `8b835f3`
|
||||
**Status:** fixed: requires human verification (behaviour change: `dev` is no longer accepted)
|
||||
**Applied fix:**
|
||||
- **Existing user:** a user with `E2E_EMAIL` and no `dev-seed-mcp` API token row (live or revoked) is one the seed did not create. The seed now refuses it before writing anything, unless `DEV_SEED_RESET_EXISTING=1`. The seed creates the user and the token in one transaction, so the token reliably marks seeded users. dev-stack.sh does not set the opt-in. The local demo user already carries the marker, so `up` reported `user: unchanged`.
|
||||
- **Fixed token:** a `DEV_SEED_API_TOKEN` row owned by another user is refused and never reassigned. The error names the token id, never the secret. The update path no longer writes `user_id`.
|
||||
- **Environment:** `console.DevEnvironment` accepts `local` only. The seed, the marker route (which shares it) and dev-stack.sh `load_env` all refuse `dev`. It is documented as a common name for a shared server. The running stack was already `env: local`.
|
||||
- **Tests:** the new `TestDevSeedRefusesForeignRows` covers a foreign user (opt-in values "", 0, yes, true are refused; password and must_change_password are untouched; the counts do not change; `=1` takes the user over and later runs need no opt-in) and a token row owned by another user. `TestDevSeedRefusesProduction` and `TestDevMarkerRoute` now expect `dev` to be refused. The gate's `CONSOLE_REQUIRE` lists the new test.
|
||||
|
||||
The reviewer also suggested a seed-side check that the DSN host is loopback. It was not added: dev-stack.sh already enforces it, and the guidance for this fix did not require it.
|
||||
|
||||
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
|
||||
|
||||
**Files modified:** fonoteka.go `scripts/dev-stack.sh`, `README.md`; vue-fonoteka-app `tests/e2e/helpers/runTinker.ts`, `tests/contracts/e2e-backend-switch.test.mjs`; summercms.go `scripts/check-phase14.2.sh`
|
||||
**Commits:** fonoteka.go `a5527e9`; vue-fonoteka-app `70eba9e`; summercms.go `bb5c2d3`
|
||||
**Applied fix:**
|
||||
- **dev-stack.sh:** `seed()` removes `seed.out`, then writes the seed output to it under `umask 077` (mode 600; the old file was 664 and `tee` would have kept that mode). The terminal gets the output through `redact_seed`, which replaces `created <secret> (shown once)`. A new `redact-seed` subcommand exposes the filter.
|
||||
- **runTinker.ts:** the seed runs with `stdio: ['ignore', 'pipe', 'inherit']`, and stdout is echoed through the exported `redactSeedOutput`. The secret is also scrubbed from the thrown error's stdout and message.
|
||||
- **Tests:** the contract test plants a secret in a fake seed and asserts it never reaches stdout or stderr (it failed before the fix). The gate self-test pipes a planted token line through `redact-seed`, and `--e2e` refuses unless `seed.out` has mode 600. After the restart it was 600.
|
||||
|
||||
### WR-07: Phase gate `--e2e` can verify a stale binary
|
||||
|
||||
**Files modified:** fonoteka.go `scripts/dev-stack.sh`, `README.md`; summercms.go `scripts/check-phase14.2.sh`
|
||||
**Commits:** fonoteka.go `45ee468`; summercms.go `3159bc1`
|
||||
**Applied fix:**
|
||||
- **Build stamp:** `up` creates `build.stamp.pending` before `summer build`. Once Go answers, it moves the file to `tmp/dev-stack/build.stamp`, so the stamp's mtime is the moment the build started. `down` removes the stamp.
|
||||
- **`fresh` subcommand:** refuses a missing stamp, or any file of the app or framework checkout newer than the stamp. It ignores .git, node_modules, testdata, .planning, .claude, .msd, tmp, storage, bin, parity, the framework's admin, docs and scripts, `*.md`, `*_test.go` and `*.golden`.
|
||||
- **Already-up stack:** `up` on a stack that is already running warns when it is stale.
|
||||
- **Gate:** `--e2e` restarts a running stack that `fresh` refuses (`down`, then `up --detach`, left running) and requires `fresh` to pass after `up`. The self-test plants an app and framework tree with five cases: clean, ignored files, a stale framework source, a stale app source and no stamp.
|
||||
|
||||
### WR-08: go.log 5xx scan passes when the access log is off
|
||||
|
||||
**Files modified:** summercms.go `scripts/check-phase14.2.sh`; fonoteka.go `scripts/dev-stack.sh`, `README.md`
|
||||
**Commits:** summercms.go `1fb3fb9`, `f70a0a5`, `a082a05`; fonoteka.go `261d9bc`
|
||||
**Applied fix:**
|
||||
- **Scan:** `scan_go_log FILE OFFSET` still refuses a 5xx or a search-failure line anywhere in the file. It now also refuses unless the part written during this run has an `http request` record. `--e2e` takes the offset after `up`/`fresh`.
|
||||
- **dev-stack.sh:** `check_keys` defaults `SUMMER_HTTP__ACCESS_LOG` and `SUMMER_STORAGE__UPLOADS__SERVE` to `true` when they are unset, and refuses any other value.
|
||||
- **Self-tests:** no records, an empty log, records only before the offset, records after the offset, a long log, and planted env files with the request log or the uploads route off.
|
||||
- **Follow-up `a082a05`:** the first version (`tail | grep -q` under `pipefail`) wrongly refused a long go.log that did have records: grep stopped at the first match and tail died of SIGPIPE. The gate run found it. The check now counts matches, and a 20000-line self-test case covers it.
|
||||
|
||||
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
|
||||
|
||||
**Files modified:** vue-fonoteka-app `tests/e2e/realtime.spec.ts`
|
||||
**Commit:** vue-fonoteka-app `4d8d525`
|
||||
**Status:** fixed: requires human verification (test logic)
|
||||
**Applied fix:** The spec takes a snapshot of `received.length` before `createAlbum`. The assertion then accepts only later frames on the channel whose `push.pub.data.payload.id` equals the created album's id. Both backends publish the `{event, payload, timestamp}` envelope (UAT Finding C; Go `AlbumBroadcastPayload`). It passed in the gate run. It was not shown failing before the fix, because the false pass depends on timing across parallel specs.
|
||||
|
||||
## Verification
|
||||
|
||||
Gates ran in the main checkouts of all three repositories (no worktree), with Go on 127.0.0.1:8423 and Nuxt on :3119.
|
||||
|
||||
- After the Go changes, the stack was restarted with `scripts/dev-stack.sh down` and then `up --detach` in fonoteka.go. The seed showed every entity `unchanged` and the token line `unchanged (secret not shown)`. The new `build.stamp` was written and `fresh` passed. Nuxt was warmed up with requests to /en, /en/albums, /en/login and /pl.
|
||||
- **First `--all`:** every stage up to guard passed, then e2e failed. `auth.setup` timed out waiting to leave /login: the backend answered `POST /login` 200 and the post-login API calls 200, so this was Nuxt compiling the first post-login page cold, not a code failure. `--e2e` alone then passed with 17 expected and nothing unexpected, skipped or flaky.
|
||||
- **Second `--all`:** e2e failed on the WR-08 SIGPIPE bug described above. It was fixed in `a082a05`.
|
||||
- **Final `bash scripts/check-phase14.2.sh --all`:** every stage passed, from self-test, go (vet and full `go test ./...` with Docker in summercms.go, the fonoteka.go root and the fonoteka plugin) and docs, through parity (175/175), contracts (10/10 blocking) and guard, to e2e (17/0/0/0).
|
||||
- **Report-only results:** specs 19 expected and 22 unexpected; `pnpm test:contracts` fails 1 of 289. Both are the same as before the fixes (earlier reports `e2e-20261011T213918Z`/`T215650Z`; `missing-catalog` is in deferred-items).
|
||||
|
||||
Last gate line: `Phase 14.2 gate passed`
|
||||
|
||||
The stack is still running (`dev-stack.sh status`: marker on Go and through :3119).
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-12T10:20:00Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
reviewed: 2026-10-11T22:16:58Z
|
||||
depth: standard
|
||||
files_reviewed: 43
|
||||
files_reviewed_list:
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/uploads_test.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- scripts/check-phase14.2.sh
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
|
||||
- ../fonoteka.go/scripts/dev-stack.sh
|
||||
- ../fonoteka.go/.env.dev.example
|
||||
- ../fonoteka.go/.gitignore
|
||||
- ../fonoteka.go/config/storage.yaml
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/README.md
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
|
||||
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 9
|
||||
info: 11
|
||||
total: 20
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 14.2: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-11T22:16:58Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 43 (three repositories)
|
||||
**Status:** issues_found
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed the framework additions in summercms.go (the `storage.uploads.serve` route, the `http.access_log` request log, the router wiring, docs and the phase gate script), the fonoteka.go glue (`fonoteka:dev-seed`, `commandDatabase`/`commandBucket`, the local-only marker route, `dev-stack.sh`, env template, config and README) and the vue-fonoteka-app e2e changes (Go seed switch, new and repaired specs, contract test).
|
||||
|
||||
The four security properties the phase cares about hold in their main form:
|
||||
- The marker route and the seed both depend on `console.DevEnvironment`. That function accepts only `local`/`dev`, and an unset `SUMMER_ENV` resolves to `production`. Tests cover the refusal cases.
|
||||
- `attach.parsePublicBlobPath` and the `is_public` lookup block path traversal and private files on the uploads route. I checked this against `lagoon/attach/static.go` and `file.go`.
|
||||
- A matched route never logs its query string, headers or raw path.
|
||||
|
||||
I found no BLOCKER. I did find nine WARNINGs:
|
||||
- The access log's "no tokens in paths" promise breaks for 405 and 404 responses.
|
||||
- The 499 remap can hide real 5xx responses from the gate.
|
||||
- The uploads route serves a stored `text/html` content type on the app origin.
|
||||
- `public_path_prefix` is not validated: a prefix that is not a path fails to mount, either loudly at boot or silently as 404s.
|
||||
- The seed can take over existing accounts and tokens.
|
||||
- The minted API token secret is written to a group/world-readable file.
|
||||
- The gate can test a stale binary, and its 5xx log scan passes when the access log is off.
|
||||
- The realtime spec can pass on another spec's publication.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
|
||||
|
||||
**File:** `modules/surf/accesslog.go:70-72` (docs: `docs/services/logging.md` "Request log", `modules/surf/README.md`)
|
||||
**Issue:** `path` is added whenever `entry.pattern == ""`. For a path that a token route matches but with another method, ServeMux answers 405 before any route handler runs, so `recordPattern` never fires. I verified this: `POST /public/secret-token` against `GET /public/{token}` returns 405. The same happens for a trailing-slash variant (`/public/<token>/`, 404) and for an OPTIONS request to a non-CORS path. fonoteka has bearer-secret paths of exactly this kind:
|
||||
- `GET /_fonoteka/api/v1/public/{token}` and `public-wishlist/{token}`
|
||||
- `GET /_fonoteka/api/v1/invitations/{token}`
|
||||
- `POST /invitations/{token}/accept`, where a GET answers 405
|
||||
|
||||
So `POST /_fonoteka/api/v1/public/<share-token>` puts the share token into the log. The docs say the opposite: "the raw path of a matched route [is never logged] because tokens often travel in paths". Sunscreen redacts by key and pattern, not by a `path` value.
|
||||
**Fix:** Log a path only when it cannot carry a route parameter, or redact the path segments. For example, log `path` only for a ServeMux 404 where no registered pattern matches any method, or replace every segment longer than N characters or matching a token shape with `{redacted}`:
|
||||
```go
|
||||
if entry.pattern == "" {
|
||||
attrs = append(attrs, slog.String("path", redactPathSegments(req.URL.Path)))
|
||||
}
|
||||
// redactPathSegments replaces segments that look like identifiers/tokens
|
||||
// (len >= 16, or [A-Za-z0-9_-]{8,} with a digit) with "{redacted}".
|
||||
```
|
||||
Add a test case with `POST /blog/posts/<secret>` (405) asserting the secret is absent. Fix the docs sentence in the same commit.
|
||||
|
||||
### WR-02: 499 remap hides real server errors whenever the client has gone away
|
||||
|
||||
**File:** `modules/surf/accesslog.go:61-63`
|
||||
**Issue:** Every status >= 500 becomes 499 once the request context is cancelled, whatever caused the 5xx. A handler that panics, or that fails on a real DB or engine error, after the browser navigated away or Playwright closed the page, is logged as 499. That happens often: Nuxt aborts in-flight fetches on route change, and Playwright closes pages at the end of each test. `scripts/check-phase14.2.sh` `scan_go_log` (line 195) explicitly treats 499 as clean, so these real failures pass the gate. The comment's premise ("the error came from the cancellation") is assumed, not checked.
|
||||
**Fix:** Keep the real status and add a separate attribute, so a grep for 5xx still finds every error and cancelled ones can be filtered on purpose:
|
||||
```go
|
||||
attrs = append(attrs, slog.Int("status", status))
|
||||
if errors.Is(req.Context().Err(), context.Canceled) {
|
||||
attrs = append(attrs, slog.Bool("client_gone", true))
|
||||
}
|
||||
```
|
||||
If 499 is kept for nginx parity, apply it only when nothing was written, or record the original status as `upstream_status`, and have `scan_go_log` refuse `status=499` records that carry an upstream 5xx.
|
||||
|
||||
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
|
||||
|
||||
**File:** `modules/surf/uploads.go:57-71` (behaviour in `modules/lagoon/attach/static.go:72-76`); docs claim in `docs/services/storage.md` "Serving files"
|
||||
**Issue:** The route answers with the blob's stored `Content-Type`. `attach.Store` stores `http.DetectContentType` of the bytes, so a `.txt` upload (in `DefaultFileExtensions`) whose body starts with `<html>` is stored as `text/html`. A cabana file field in non-image mode on a public relation therefore yields a public blob served as `text/html` on the application origin. `X-Content-Type-Options: nosniff` does not help when the declared type is already `text/html`, so the docs sentence "a browser does not sniff a stored file into HTML or script" overstates the protection. The Nuxt app keeps `auth_token` in a JS-readable cookie on that origin. nginx in the PHP setup served by extension (`.txt` as `text/plain`), so this is also a behaviour difference. D-19 says Phase 20 reuses this route.
|
||||
**Fix:** In `uploadsHandler`, add a sandboxing CSP and force download for anything outside an inline-safe allowlist:
|
||||
```go
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; sandbox")
|
||||
// after the type is known (wrap the ResponseWriter or move into attach):
|
||||
// if !inlineSafe(ct) { w.Header().Set("Content-Disposition", "attachment") }
|
||||
```
|
||||
`inlineSafe` should cover image/jpeg, png, gif, webp, audio/*, video/* and application/pdf. Correct the docs sentence.
|
||||
|
||||
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
|
||||
|
||||
**File:** `modules/surf/uploads.go:38-51`
|
||||
**Issue:** The prefix is only trimmed. `joinPath` does not add a leading slash, so ServeMux treats whatever comes before the first `/` as a host pattern. I checked both cases:
|
||||
- `storage/app/uploads/public` (no leading slash, a plausible WinterCMS-style value) registers `GET storage/app/uploads/public/{path...}` with host `storage`. It never matches, so every cover silently 404s.
|
||||
- An absolute CDN URL (`https://cdn.example.com/uploads`) panics in ServeMux. `handleRoute` reports this as a misleading "route conflict" error.
|
||||
|
||||
A prefix containing `{` or `}` would also be parsed as a wildcard. The ownership check at line 43 compares against the same unnormalized prefix.
|
||||
**Fix:** Validate when mounting and fail boot with a clear error:
|
||||
```go
|
||||
if !strings.HasPrefix(prefix, "/") || strings.ContainsAny(prefix, "{}") || strings.Contains(prefix, "//") {
|
||||
return fmt.Errorf("surf: storage.uploads.serve needs storage.uploads.public_path_prefix to be an absolute path such as /storage/uploads, got %q", prefix)
|
||||
}
|
||||
```
|
||||
Add test cases for `storage/uploads` and `https://cdn/x`.
|
||||
|
||||
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:72-78, 329-386, 258-280`
|
||||
**Issue:** The only safeguard is `SUMMER_ENV` (`local` or `dev`), which can also come from a `.env` file next to the config. Inside that gate the seed changes existing accounts without saying so:
|
||||
- **Existing user:** any user whose email equals `E2E_EMAIL` gets their password reset to `E2E_PASSWORD`, with the default `changeme`. The seed also activates them and clears `must_change_password`.
|
||||
- **Existing token:** with `DEV_SEED_API_TOKEN` set, an existing `user_api_tokens` row with that hash, owned by any user, is reassigned to the seed user and un-revoked. The `unchanged` check ignores `scopes`.
|
||||
- **The `dev` environment:** `dev` is a common name for shared, network-reachable servers. On such a server the seed creates `demo@example.com` / `changeme` with a `read write ai` API token.
|
||||
|
||||
Phase 20 imports production data, and a local copy of it plus this seed is the likely way a real account gets overwritten.
|
||||
**Fix:** Add a second, data-based guard before opening the transaction. Refuse when the DSN host is not loopback (the same rule dev-stack.sh applies), and refuse to modify an existing user that the seed did not create unless `--force` is passed. One way is to mark seeded users, for example by checking for the `dev-seed-mcp` token. Never reassign a token row owned by another user: refuse instead. Consider dropping `dev` from the allowed set or documenting it as local-only.
|
||||
|
||||
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:334`; `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:309`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:129-133`
|
||||
**Issue:** On creation the seed prints `api token: created inv_<secret> (shown once)`. `seed()` tees all seed output to `tmp/dev-stack/seed.out`, which is mode 664 in a 775 directory on this machine. This host has several users (`jin` owns the Nuxt checkout, `golem` runs the stack), so any local user can read a `read write ai` token and use it against `127.0.0.1:8423`. With the `ai` scope that spends the developer's configured AI keys. `seedE2EFixtures` runs the seed with `stdio: 'inherit'`, so a first-run secret also lands in Playwright and CI output. The README says the script "never prints a value".
|
||||
**Fix:** Create the file with a restrictive umask, and filter the secret before writing it:
|
||||
```bash
|
||||
( umask 077; cd "$APP" && TMPDIR="$GO_TMP" bin/fonoteka fonoteka:dev-seed 2>&1 | tee "$RUN/seed.out" )
|
||||
```
|
||||
In runTinker.ts, use `stdio: ['ignore', 'pipe', 'inherit']` and drop lines matching `/api token: created/` before echoing. Alternatively, have the seed write the secret only to the terminal, for example behind a `--show-token` flag.
|
||||
|
||||
### WR-07: Phase gate `--e2e` can verify a stale binary
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:513-534` (with `../fonoteka.go/scripts/dev-stack.sh:538-546`)
|
||||
**Issue:** When `dev-stack.sh status` succeeds, the gate reuses the running stack. `up --detach` then prints `already up` and returns without `summer build`, migrate or seed. The Playwright and go.log stages then exercise whatever binary was started earlier, possibly before the commits under review. Plan 04's SUMMARY admits the stack had to be restarted by hand so the binary would include the plan's framework change. A gate that can pass on old code does not fail closed.
|
||||
**Fix:** Pick one of these:
|
||||
- Always `down` and `up --detach` in `--e2e`.
|
||||
- Record the git HEADs of summercms.go and fonoteka.go (and `bin/fonoteka` mtime or `go version -m`) in `tmp/dev-stack/build.id` at `up` time, and have `--e2e` refuse when they differ from the current HEADs.
|
||||
|
||||
### WR-08: go.log 5xx scan passes when the access log is off
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:189-201`
|
||||
**Issue:** `scan_go_log` refuses only when it finds `status=5xx` or the search-failure line. If the developer's `.env.dev` lacks `SUMMER_HTTP__ACCESS_LOG=true`, there are no `http request` records at all, and the scan passes on an empty signal. `config/http.yaml` defaults the key to `false`, and dev-stack.sh does not require it (it is not in `REQUIRED_KEYS`).
|
||||
**Fix:** Require evidence that the log was produced by this run:
|
||||
```bash
|
||||
grep -q 'msg="http request"' "$file" || { echo "refuse: $file has no http request records; set SUMMER_HTTP__ACCESS_LOG=true" >&2; return 1; }
|
||||
```
|
||||
Also add `SUMMER_HTTP__ACCESS_LOG` (and `SUMMER_STORAGE__UPLOADS__SERVE`) to `REQUIRED_KEYS`, or export them in `start_go`.
|
||||
|
||||
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts:114-119`
|
||||
**Issue:** The live assertion is `received.some(m => m.push?.channel === channel && m.push.pub !== undefined)`. It is evaluated over all frames received since the socket opened, before and after `createAlbum`. Other gate specs create albums in the same user's collection (`album-search` creates two, `album-edit-rate-cover` and `fonoteka-item` one each). If they run in parallel workers, or a previous push is still buffered, the test passes even when the album this spec created publishes nothing. The test can therefore pass without proving that this spec's write published anything.
|
||||
**Fix:** Snapshot the frame count before the write and match the created album:
|
||||
```ts
|
||||
const before = received.length
|
||||
const id = await createAlbum(page, `E2E Realtime ${Date.now()}`)
|
||||
await expect.poll(() => received.slice(before).some(m =>
|
||||
m.push?.channel === channel && JSON.stringify(m.push.pub ?? {}).includes(`"id":${id}`)), ...).toBe(true)
|
||||
```
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Env file parser exports arbitrary keys, so "never executed" is not quite true
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:129-150`
|
||||
**Issue:** Any `KEY=value` is exported, including `BASH_ENV`, `LD_PRELOAD`, `PATH` and `NODE_OPTIONS`. The `setsid bash -c` children source `$BASH_ENV`, so an env file line can still run code, which contradicts the comment and the README.
|
||||
**Fix:** Export only keys matching `^(SUMMER_|NUXT_|E2E_|DEV_STACK_|DEV_SEED_)` and refuse others by name.
|
||||
|
||||
### IN-02: Postgres loopback check can be bypassed by `hostaddr` or a query `host`
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:215-241`
|
||||
**Issue:** For URI DSNs, a query `host=` is used only when the netloc host is empty, but pgx lets query parameters override the netloc. `postgres://u:p@localhost/db?host=prod.example` therefore passes the check and connects remotely. `hostaddr=` is ignored in both URI and key=value forms.
|
||||
**Fix:** Let the query `host` override, check `hostaddr` as well, and refuse multi-host values.
|
||||
|
||||
### IN-03: `down` signals recorded process groups without checking they are still this stack's
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:582-592`
|
||||
**Issue:** After a crash or reboot, a recycled pgid in `tmp/dev-stack/pids` makes `down` send TERM and then KILL to an unrelated process group.
|
||||
**Fix:** Record the start time or command line with the pgid (`ps -o lstart= -p`), and compare it before killing.
|
||||
|
||||
### IN-04: Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:168-197, 258-261`
|
||||
**Issue:** `StoreAlbumPhoto` writes blobs before rows (see `attach.Store`'s contract). A bad `DEV_SEED_API_TOKEN` rolls back the transaction after three covers are written, and the files stay under `storage/app/uploads/public`. The same happens for any later failure.
|
||||
**Fix:** Validate `DEV_SEED_API_TOKEN` and the token prefix before `lagoon.Transaction`. On rollback, delete the keys the run stored.
|
||||
|
||||
### IN-05: Seed's token "unchanged" check ignores expiry and scopes
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:292-300`
|
||||
**Issue:** An expired or narrowed `dev-seed-mcp` token counts as present, so no usable token is minted and no secret is shown.
|
||||
**Fix:** Add `(expires_at IS NULL OR expires_at > now())` to the query, and compare scopes.
|
||||
|
||||
### IN-06: `marker_ok` does not check the app name
|
||||
|
||||
**File:** `../fonoteka.go/scripts/dev-stack.sh:369`
|
||||
**Issue:** Any SummerCMS app (for example another `sm-*-app` on the same port) passes `marker_ok` and `guard_marker` (`.backend == "summercms"` only).
|
||||
**Fix:** Check `.backend == "summercms" and .app == "fonoteka"`.
|
||||
|
||||
### IN-07: statusRecorder records a 1xx informational status as the final status
|
||||
|
||||
**File:** `modules/surf/accesslog.go:106-111`
|
||||
**Issue:** A handler that sends `103 Early Hints` and then 200 is logged with status 103.
|
||||
**Fix:** Record only when `status >= 200` (or overwrite while `w.status < 200`).
|
||||
|
||||
### IN-08: Gate header overstates what the whole-suite Go runs check
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:3-5, 331-348`
|
||||
**Issue:** The header says every stage fails on a go test run that "skips". The `go test ./...` runs in all three modules do not go through `detect`; only the named tests do.
|
||||
**Fix:** Reword the header, or run the whole suites through `go_json` with an allowlist for intended skips.
|
||||
|
||||
### IN-09: Hard-coded absolute developer path in a framework script
|
||||
|
||||
**File:** `scripts/check-phase14.2.sh:51`
|
||||
**Issue:** `PHASE142_NUXT` defaults to `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app`, which ties the framework repo to one machine layout. dev-stack.sh already requires `DEV_STACK_NUXT_DIR` from the env file.
|
||||
**Fix:** Default to `$DEV_STACK_NUXT_DIR`, or refuse when `PHASE142_NUXT` is unset.
|
||||
|
||||
### IN-10: Search spec relies on the "other" album appearing on the first page after clearing
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts:46-47`
|
||||
**Issue:** On a collection that has grown (left-over albums from failed runs, real data in the seeded user's collection), the freshly created `other` album may fall off the first page, depending on the list's sort and page size. The spec then becomes order-dependent.
|
||||
**Fix:** Assert that the list is unfiltered another way (for example, the result count grows back), or search for `E2E Other ${stamp}` in a second step.
|
||||
|
||||
### IN-11: Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`
|
||||
|
||||
**File:** `../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go:106-111`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:48-51`
|
||||
**Issue:**
|
||||
- **Test cleanup:** `TestDevSeedOpensDatabase` deletes `users` with the errors ignored. A foreign-key refusal leaves the seeded collections, albums and tokens in the shared container.
|
||||
- **`goDir`:** `path.resolve(path.dirname(bin), '..')` resolves a relative `E2E_GO_BIN` against the Nuxt checkout's working directory, not the app's.
|
||||
|
||||
**Fix:** Check the cleanup `Exec(...).Error` with `t.Errorf`, and delete dependents first. Resolve `E2E_GO_BIN` to an absolute path, or refuse a relative one.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-11T22:16:58Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
plan: 03
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 14.2 UAT: vue-fonoteka-app on the local Go backend
|
||||
|
||||
This file records the D-13 manual session (plan 03, Task 4) and the D-11 PHP cross-check of the plan 03 specs.
|
||||
|
||||
## Automated evidence (plan 03, Tasks 1 and 3)
|
||||
|
||||
- Go gate subset (D-15) through `fonoteka.go/scripts/dev-stack.sh e2e`. Specs: `auth.setup.ts`, `login.spec.ts`, `smoke.spec.ts`, `fonoteka-item.spec.ts`, `public-share.spec.ts`, `realtime.spec.ts`, `i18n.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`. Result: `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed`.
|
||||
- The realtime live test passed against the shared Centrifugo after the D-18/D-23 operator step: a connect reply, an error-free `collection:` subscribe and a push on that channel after an API album create.
|
||||
- Commits tested: vue-fonoteka-app `c1f323a`; fonoteka.go `7675d64`.
|
||||
- Re-run after the session fixes (2026-10-11): the same 10 spec files give `expected=17 unexpected=0 skipped=0 flaky=0`, `e2e-verdict: passed`. Tested with fonoteka.go `807dfdb`, summercms.go `4660f95`, vue-fonoteka-app `c1f323a`. Afterwards `grep -cE 'status=5[0-9]{2}' tmp/dev-stack/go.log` printed 0.
|
||||
|
||||
## PHP cross-check (Task 3): deferred
|
||||
|
||||
The plan 03 specs could not be checked against PHP automatically on 2026-10-11.
|
||||
|
||||
- **What PHP did:** PHP started. `php artisan serve --host=127.0.0.1 --port=8422` in `/media/nvme/dev/golem15/fonoteka` answered the probe `GET http://127.0.0.1:8422/_fonoteka/api/v1/albums` with `HTTP/1.1 401 Unauthorized` and `X-Powered-By: PHP/8.5.10`, so the verify took branch A.
|
||||
- **Why the run did not count:** no PHP E2E credentials are available to the executor. `E2E_EMAIL` and `E2E_PASSWORD` were still unset after Playwright loaded `tests/e2e/.env.local` and `tests/e2e/.env`. `auth.setup.ts` therefore wrote an empty storage state, and the five specs skipped.
|
||||
- Report: `expected=1 unexpected=0 skipped=5 flaky=0`.
|
||||
- `e2e-verdict` refused it. It is **not** a PHP pass.
|
||||
- **Why the executor did not seed:** the Go seed user `demo@example.com` / `changeme` does not exist on PHP (`POST /_user/api/v1/login` answered 401). The executor did not create a user in the developer's PHP database. Seeding with `seedE2EFixtures()` in PHP mode runs `firstOrNew` on the e-mail, so it can reset the password of an existing user.
|
||||
|
||||
**Manual PHP check (Task 4, item 11).** Run it with the credentials of a PHP user whose collection may receive temporary `E2E …` albums. Every spec deletes what it creates.
|
||||
|
||||
```sh
|
||||
# Go stack stopped first: bash ../fonoteka.go/scripts/dev-stack.sh down
|
||||
cd /media/nvme/dev/golem15/fonoteka && php artisan serve --host=127.0.0.1 --port=8422 &
|
||||
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go
|
||||
R=/tmp/php-e2e-14.2.json; rm -f "$R"
|
||||
env -u CI -u E2E_BACKEND E2E_EMAIL='<php user>' E2E_PASSWORD='<password>' \
|
||||
E2E_FRONTEND_URL=http://localhost:3119 E2E_BACKEND_URL=http://localhost:8422 E2E_CENTRIFUGO=1 \
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME="$R" pnpm --dir /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app exec playwright test \
|
||||
auth.setup.ts realtime.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts \
|
||||
--project=chromium --reporter=json > /dev/null
|
||||
bash ../fonoteka.go/scripts/dev-stack.sh e2e-verdict "$R" # must print e2e-verdict: passed
|
||||
```
|
||||
|
||||
Item 11 passes only on a run with no skipped, unexpected or flaky test. Until then the plan stays open (D-11). The run was done at Task 4; see below.
|
||||
|
||||
## Manual session (Task 4, D-13)
|
||||
|
||||
Session by the developer on 2026-10-11 (about 20:10 to 20:20 local time) against the Go stack. Commits tested: fonoteka.go `7675d64`, summercms.go `66dbd5a` (framework), vue-fonoteka-app `c1f323a`. The session's Go log is kept as `fonoteka.go/tmp/dev-stack/go.log.uat-session-20261011`.
|
||||
|
||||
| # | Item | Result | Notes |
|
||||
|---|------|--------|-------|
|
||||
| 1 | Login lands outside /login | pass | |
|
||||
| 2 | Collections: "Moja kolekcja" lists Kind of Blue, Blue Train, Mingus Ah Um with covers | pass | |
|
||||
| 3 | Album detail page opens | pass | After an artist change the detail page keeps the old `artist_display`. Same as PHP: `Album.php` (around line 397) fills `artist_display` only when it is empty. See finding E. |
|
||||
| 4 | Search "Mingus" finds the album; no `album search engine failed` in go.log | pass | search_fail = 0 |
|
||||
| 5 | Edit name persists after reload | pass | Finding A below (empty artist field): fixed in fonoteka.go `d31e00a`. The developer re-verified it: Blue Train shows John Coltrane. |
|
||||
| 6 | Rating persists after reload | pass | |
|
||||
| 7 | Cover upload shows on the page | pass (re-verified) | Finding B: a new photo is appended and the first photo stays the main cover, as on PHP. The developer re-verified it. |
|
||||
| 8 | Wishlist add on /wishlist/new, then remove | pass | Also marked an existing entry purchased. |
|
||||
| 9 | Realtime: `collection:` subscribe without error; push frame after an edit in a second tab | pass (matches PHP) | See finding C. The subscribe reply had no error and the push arrived. |
|
||||
| 10 | No 5xx in go.log; no `X-Powered-By: PHP` through :3119 | pass after fix | The session failed it; see finding D. After the fix, go.log had 0 5xx lines. No PHP header; search_fail = 0. |
|
||||
| 11 | PHP cross-check | pass (by classification) | PHP: `expected=4 unexpected=2 skipped=0 flaky=0`; Go: all passed on the same specs. Both PHP failures come from the environment or the test fixture, not from a contract difference. See "PHP cross-check (Task 4, item 11)" below. |
|
||||
|
||||
### Finding A: the artist field in the edit form was empty (fixed in Go)
|
||||
|
||||
- **Symptom:** the collection view showed "John Coltrane" for Blue Train, but the Artist field of the edit form was empty.
|
||||
- **Cause:** `fonoteka:dev-seed` set only `artist_display` and attached no artist rows, so `GET /albums/3` answered `"artists":[]`. The edit form fills the field from `album.artists` (`utils/album-draft.ts`).
|
||||
- **Fix:** fonoteka.go `d31e00a`. The seed creates albums with `artists: [<name>]` through `classes.CreateAlbum`, the album form's path. A rerun attaches the artist to a seeded album that has no artist rows and reports `album …: updated`. `TestDevStackSmoke` covers both cases.
|
||||
- **After the fix:** the restart printed `album Kind of Blue: updated`, `album Blue Train: updated` and `wishlist album A Love Supreme: updated`. `GET /albums/3` now answers `artists: ["John Coltrane"]`. Mingus Ah Um already had the artist set during the session.
|
||||
- **Other write paths:** every non-seed album create goes through `classes.CreateAlbum`, the port of `AlbumWriteService::create`. That covers the album form, the wishlist form, bulk and CSV import. The create rules require `artists` (`required|array|min:1`, also per bulk row), as in PHP. The seed was the only path without artist rows.
|
||||
|
||||
### Finding B: a new cover does not become the main cover (matches PHP, not a Go defect)
|
||||
|
||||
- **Frontend:** both the edit gallery (`PhotoUploader.vue` `editHeroSrc`) and the detail page (`AlbumCoverGallery.vue` `hero`) show `photos[0]` as the main cover.
|
||||
- **PHP:** `Album::$attachMany['photos']` is ordered by `sort_order`, and Winter's `Sortable` trait sets `sort_order` to the new file id on create (`vendor/winter/storm/src/Database/Traits/Sortable.php`). A new photo is therefore appended, and the oldest photo stays the main cover.
|
||||
- **Go:** `classes.AttachAlbumPhoto` sets `sort_order = id`, and the DTO orders by `sort_order, id`.
|
||||
- **Reproduction on Go** (album with photo A, then photo B uploaded through the edit form):
|
||||
- API order is `[A, B]`.
|
||||
- In the edit page right after the upload, the hero is A and the strip is `[A, B]`.
|
||||
- After F5, the hero is still A.
|
||||
- The upload answer has the PHP shape: `201 {"data":{"id","url","thumb_url"}}`.
|
||||
- **The developer's session:** the log has two photo POSTs at 20:15:10 and 20:15:16, then `DELETE /albums/{id}/photos/{fileId}` at 20:15:23. The access log does not record the album id. The main cover changes only when `photos[0]` changes, for example when the older photo is deleted. That fits a new photo that showed as the main cover only later.
|
||||
- **Re-verify:** item 7 means "the uploaded photo shows on the page", and the photo appears in the strip at once. A new photo replacing the main cover is not PHP behaviour.
|
||||
|
||||
### Finding C: realtime push arrives, no UI refresh for the same user (frontend design, same on PHP)
|
||||
|
||||
- **The Go push frame on `collection:2`:** `{"event":"updated.fonoteka.album","payload":{"id","collection_id","action","actor":{"user_id":2,"name":"Demo"},"timestamp","album"},"timestamp"}`.
|
||||
- **The PHP publication:** the same keys at both levels. `CentrifugoClient::publish` sends `data: {event, payload, timestamp}`, and the payload comes from `Album::getBroadcastPayload` (`id`, `collection_id`, `action`, `actor`, `timestamp`, `album`).
|
||||
- **The frontend's self-echo guard:** `useRealtimeSync.ts` skips the refresh when `payload.actor.user_id === auth.user.id`. Both tabs were logged in as the same user, so the second tab's edit is the first tab's own echo, and it is suppressed on purpose. Against PHP it behaves the same.
|
||||
- **Re-verify:** for a UI refresh, edit from a different user who shares the collection (a household member).
|
||||
|
||||
### Finding D: a cancelled request was logged as a 500 (fixed in the framework)
|
||||
|
||||
- **What was logged:** `GET /_fonoteka/api/v1/genres status=500` at 20:18:34. The client cancelled the request, `classes/active_collection.go:204` failed with `context canceled`, and the handler wrote a 500 that no client received. PHP keeps running after a client disconnect and does not log a server error.
|
||||
- **Fix:** summercms.go `4660f95`. The surf access log records a 5xx written after the request context ended with `context.Canceled` as 499 (client closed request). A real 5xx on a live request keeps its status.
|
||||
- **Tests and docs:** `TestAccessLogClientClosedRequest` covers both cases. The surf README and `docs/services/logging.md` are updated.
|
||||
- **Left as is:** the GORM logger still prints the `context canceled` line in colour; that is the known GORM log noise in `deferred-items.md`.
|
||||
|
||||
### Also fixed during the session follow-up
|
||||
|
||||
- fonoteka.go `807dfdb`: `dev-stack.sh e2e` exports `TMPDIR=tmp/dev-stack/go-tmp`. The session renamed the seeded "Kind of Blue" to "Kind of Blue!", so the next `fonoteka:dev-seed` run in `auth.setup.ts` created a new "Kind of Blue" album and had to write a new cover. It and failed with `invalid cross-device link`. This is the deferred file-bucket issue, now covered on the e2e path too.
|
||||
|
||||
### For the developer to re-verify
|
||||
|
||||
- **Artist field:** open Edit on Blue Train; the field shows John Coltrane.
|
||||
- **Item 7:** upload a photo to an album that has a cover. It appears in the photo strip right away, and the main cover stays the first photo. To make it the main cover, delete the old one.
|
||||
- **Item 9 (optional):** an edit by another household member refreshes the open page.
|
||||
- **Item 11:** the PHP run described in the deferred section.
|
||||
|
||||
### Finding E: `artist_display` stays the same after an artist change (same as PHP)
|
||||
|
||||
- **What happens:** changing an album's artists does not rewrite `artist_display`.
|
||||
- **PHP:** the same. `Album.php` (around line 397) fills `artist_display` only when it is empty.
|
||||
- **Go:** the same. `syncAlbumArtistsFromInput` keeps a non-empty `artist_display` that the request sends; the edit form sends it. It derives the value only when the request omits it or leaves it empty.
|
||||
- No change; the item passes.
|
||||
|
||||
## PHP cross-check (Task 4, item 11)
|
||||
|
||||
The developer ran it on 2026-10-11, together with the coordinator.
|
||||
|
||||
- **Setup:**
|
||||
- Go stack stopped during the run.
|
||||
- PHP through `php artisan serve` on :8422; Nuxt on :3119 pointed at it.
|
||||
- A throwaway PHP user `e2e-go-check@example.com`, created for the run and deleted afterwards (`deleted=1 remaining=0`).
|
||||
- **Specs:** `auth.setup.ts`, `realtime.spec.ts`, `album-edit-rate-cover.spec.ts`, `album-search.spec.ts`, `wishlist.spec.ts`.
|
||||
- **PHP result:** `expected=4 unexpected=2 skipped=0 flaky=0`.
|
||||
- **Go result on the same specs:** all passed.
|
||||
|
||||
The two PHP failures:
|
||||
|
||||
1. **`album-edit-rate-cover`: a fixture artefact on PHP.**
|
||||
- PHP answered `POST /albums/{id}/photos` with a 500. `system.log` shows `TypeError: imagealphablending(): Argument #1 must be of type GdImage, false given` at winter/storm `Database/Attach/Resizer.php:139`.
|
||||
- GD cannot decode the spec's hand-built 1x1 PNG fixture (`PNG_1PX`). GD itself supports PNG, JPEG, WebP and AVIF, and a real photo uploaded through PHP works (manual item 7).
|
||||
- Go accepts the fixture. This is not a Go divergence.
|
||||
2. **`realtime` live: an environment gap on PHP.**
|
||||
- No Centrifugo socket opened within 15 s. The PHP log says `Centrifugo: Skipping publish (not configured)`: the local PHP env has no Centrifugo config.
|
||||
- The realtime baseline test passed on PHP. This is not a Go divergence.
|
||||
|
||||
**Verdict:** item 11 passes. No contract difference between PHP and Go was found. The strict D-11 bar (a clean PHP run) was not met, because the two failures above come from the fixture and from the PHP environment. The developer accepted the classification, and both issues are in `deferred-items.md`.
|
||||
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
|
||||
verified: 2026-10-12T10:41:00Z
|
||||
status: passed
|
||||
score: 54/54 must-haves verified (5 by override or human verification)
|
||||
covered_files:
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-01-SUMMARY.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-02-SUMMARY.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-03-SUMMARY.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-PLAN.md
|
||||
- .planning/phases/14.2-local-fonoteka-frontend-on-local-summercms-backend/14.2-04-SUMMARY.md
|
||||
- docs/services/logging.md
|
||||
- docs/services/storage.md
|
||||
- docs/setup/configuration.md
|
||||
- modules/surf/README.md
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/uploads_test.go
|
||||
- scripts/check-phase14.2.sh
|
||||
covered_digest: "v2:sha256:458b8aba274a449e0c7fec7aa5776f833f9e2dc54b4b2686e64b625d0e59960c"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 3
|
||||
overrides:
|
||||
- must_have: "Per D-11, D-16 and D-21, the new and rewritten specs also pass against the PHP backend on :8422 with the app's own PHP-targeted .env"
|
||||
reason: "The PHP run (UAT item 11) gave expected=4 unexpected=2. Both failures were classified as non-contract: the PNG_1PX fixture cannot be decoded by PHP GD (Resizer.php:139), and the local PHP env has no Centrifugo config. Go passed the same specs. Both follow-ups are in deferred-items.md."
|
||||
accepted_by: "developer (14.2-UAT.md item 11, 'The developer accepted the classification')"
|
||||
accepted_at: "2026-10-11T21:04:28Z"
|
||||
- must_have: "TestDevSeedRefusesProduction proves local and dev pass the gate; TestDevMarkerRoute answers 200 for local and dev (D-02; 14.2-01 truths 3 and 7, 14.2-04 truths 2 and 5)"
|
||||
reason: "WR-05: dev is a common name for a shared server; seed, marker and dev-stack.sh accept local only. D-02 amended in 14.2-CONTEXT.md."
|
||||
accepted_by: "developer (orchestrator question after verification)"
|
||||
accepted_at: "2026-10-12T11:01:43Z"
|
||||
- must_have: "http.access_log adds path without its query string only for unmatched requests (D-12; 14.2-01 truth 9, 14.2-04 truth 7)"
|
||||
reason: "WR-01: share and invitation tokens travel in paths; the log records method, pattern, status and duration only. D-12 amended in 14.2-CONTEXT.md."
|
||||
accepted_by: "developer (orchestrator question after verification)"
|
||||
accepted_at: "2026-10-12T11:01:43Z"
|
||||
behavior_unverified_items: []
|
||||
human_verified:
|
||||
- test: "Foreground `dev-stack.sh up`: SIGINT stops Go and Nuxt and removes the pid file"
|
||||
result: "pass: ran by the orchestrator at the developer's request; exit=130, tmp/dev-stack/pids removed, ports 8423 and 3119 free, no fonoteka serve or nuxt dev process left; stack restarted with up --detach"
|
||||
recorded_at: "2026-10-12T11:01:43Z"
|
||||
- test: "Four test-tier prohibitions, checked with commands"
|
||||
result: "pass: (1) `git log df7876b..HEAD -- plugins/golem15/user` in fonoteka.go is empty; (2) vue-fonoteka-app `git status --short` is clean; (3) dev-stack.sh mentions /etc/centrifugo/config.json only in warn text and has no systemctl or publish call; (4) all 54 'E2E ...' albums in fonoteka_go_dev have deleted_at set, i.e. every spec's DELETE ran; soft delete matches PHP (Album.php uses Winter SoftDelete)"
|
||||
recorded_at: "2026-10-12T11:01:43Z"
|
||||
- test: "UAT items 1-11 (login, browse collections/albums, album detail, search, edit, rate, cover upload, wishlist add/remove, realtime, no 5xx/no PHP header, PHP cross-check)"
|
||||
uat_row: "1-11"
|
||||
recorded_at: "2026-10-11T21:04:28Z"
|
||||
note: "Files changed after the UAT (review fixes WR-01..09: accesslog.go, uploads.go, router.go, dev_seed.go, dev-stack.sh, runTinker.ts, realtime.spec.ts) were re-exercised by the gate's Go e2e run e2e-20261012T101126Z (17/0/0/0, started 10:11:27Z, after the last Go commit 261d9bc and the build stamp 09:28:54Z): login with the seed, cover decode through the Nuxt /storage proxy, search, wishlist, realtime subscribe and push. go.log has 0 5xx records. No re-test requested."
|
||||
human_verification: []
|
||||
---
|
||||
|
||||
# Phase 14.2: Local Fonoteka frontend on local SummerCMS backend: Verification Report
|
||||
|
||||
**Phase goal:** A developer runs the existing `vue-fonoteka-app` locally against the local `fonoteka.go` SummerCMS backend (not PHP), logs in, and uses the real Nuxt flows so the port is proven in daily use before production cutover.
|
||||
**Verified:** 2026-10-12T10:41:00Z
|
||||
**Status:** passed (after human verification on 2026-10-12T11:01:43Z: D-02 and D-12 overrides accepted, foreground Ctrl-C cleanup and the four prohibitions checked)
|
||||
**Re-verification:** No (initial verification; no previous VERIFICATION.md)
|
||||
|
||||
The phase goal is achieved in the codebase. All four roadmap success criteria are verified with fresh or post-fix evidence. Nothing blocks. Four items need a human decision: two deliberate contract narrowings from the review fixes (WR-05, WR-01), one cleanup invariant that no test exercises, and four test-tier prohibitions enforced only statically.
|
||||
|
||||
## MVP mode note (warning)
|
||||
|
||||
ROADMAP sets `**Mode:** mvp`, but the goal is not in user-story form. `user-story.validate` returned `valid: false` (no "As a …, I want to …, so that …"). The verifier contract says to refuse in that case. I did not refuse, because the orchestrator asked for goal-backward verification of a phase that was already planned, executed and UAT'd. Instead I read the goal as the implicit story "As a developer, I want to run the Nuxt app against local Go and use its flows, so that the port is proven before cutover". If the MVP framing should stay, run `/msd mvp-phase 14.2` to reformat the goal.
|
||||
|
||||
## User Flow Coverage
|
||||
|
||||
| Step | Expected | Evidence | Status |
|
||||
|------|----------|----------|--------|
|
||||
| Start the stack | One command starts Go and Nuxt, wired to each other | `fonoteka.go/scripts/dev-stack.sh` `up()` (lines 587-650): env, services, build, migrate, seed, reindex, serve, guard, pnpm dev, probe | ✓ |
|
||||
| Log in | The seeded user logs in through the Nuxt proxy to Go | `auth.setup.ts` seeds through `fonoteka:dev-seed` when `E2E_BACKEND=go`; gate e2e 17/0/0/0; UAT item 1 | ✓ |
|
||||
| Browse collections and albums | The collection lists albums with covers; album detail opens | UAT items 2-3 (human); `public-share`, `smoke` and `album-search` specs in the gate; covers served by the framework `/storage` mount | ✓ |
|
||||
| Search | A unique album is found | `album-search.spec.ts` (gate); UAT item 4; go.log 0 `album search engine failed` | ✓ |
|
||||
| Edit, rate, cover | Edit persists, rating persists, cover decodes | `album-edit-rate-cover.spec.ts` (`rating-stars`, `naturalWidth > 0` via `/storage/app/uploads/public/`); UAT items 5-7 | ✓ |
|
||||
| Wishlist add/remove | An entry is added on /wishlist/new and removed | `wishlist.spec.ts` (`wishlist-item-remove`); UAT item 8 | ✓ |
|
||||
| Outcome: port proven, not PHP | Every request reaches Go; failures are visible | guard (`--guard` refusals rerun), pinned `NUXT_INTERNAL_API_ORIGIN`, post-start probe through :3119; go.log 3081 `http request` records, 0 5xx | ✓ |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Roadmap success criteria
|
||||
|
||||
| # | Success criterion | Status | Evidence |
|
||||
|---|-------------------|--------|----------|
|
||||
| SC1 | Documented local run: Nuxt and Go start together; API base URL, cookies and CORS match how the SPA talks to Winter | ✓ VERIFIED | `fonoteka.go/README.md` § "Running locally with the Nuxt app" (ports, prerequisites, Centrifugo operator step, env table, commands, logs, MCP). The SPA keeps its unchanged `nuxt.config.ts` proxy: `NUXT_DEV_BACKEND_ORIGIN` / `NUXT_INTERNAL_API_ORIGIN` = `http://127.0.0.1:8423`, the same mechanism it uses for Winter, so the browser stays same-origin on :3119 (no CORS). The JWT lives in the app's own `auth_token` cookie and the backend sets none (RESEARCH line 110/200). Info: the README never states "cookies and CORS unchanged" in so many words. |
|
||||
| SC2 | A signed-in Go session covers browse collections/albums, search, edit, rate, cover upload, wishlist add/remove | ✓ VERIFIED | Gate report `fonoteka.go/tmp/dev-stack/e2e-20261012T101126Z.json`: expected 17, unexpected 0, skipped 0, flaky 0, over all 10 D-15 gate specs, after the review fixes. Plus human UAT items 1-10, all passed (2026-10-11). |
|
||||
| SC3 | Failures against Go are visible (no silent fall-through to PHP); the local stack does not require the production origin | ✓ VERIFIED | `dev-stack.sh` `guard_origins` / `guard_marker` / `probe`. I reran `check-phase14.2.sh --guard`: php-port, internal-unset, mismatch, production, fake-php and other-marker were all refused, and the control passed. Hosts are loopback only (Go addr, backend origin, Postgres DSN). The marker route is only on Go. The opt-in access log logs method/pattern/status (see the WR-01 human item about the path). |
|
||||
| SC4 | The new glue has tests or a fail-closed check script, delivered in the last plan | ✓ VERIFIED | `summercms.go/scripts/check-phase14.2.sh` (673 lines, plan 04). I reran self-test, go, docs, parity, contracts and guard, and all passed. `--e2e` was last run by the orchestrator after the fixes (the report above). Named tests: console 5, plugin 5, surf 5, all passing. |
|
||||
|
||||
### Plan must-have truths (50) and status
|
||||
|
||||
| Plan | Truths | Verified | Notes |
|
||||
|------|--------|----------|-------|
|
||||
| 14.2-01 | 13 | 12 | ? #9 (access log adds `path` for unmatched requests) superseded by WR-01: path never logged. #3 and #7 ("only when local or dev") still hold literally after the WR-05 narrowing to local only. Backstop #12 (orphan blob never served) and #13 (integer `duration_ms`) have explicit evidence: `TestUploadsMount` targets `uploadsOrphanDisk` and gets a 404, and `TestAccessLog` parses `duration_ms` as a non-negative integer. |
|
||||
| 14.2-02 | 14 | 13 | ⚠️ #12 foreground Ctrl-C/TERM cleanup is PRESENT_BEHAVIOR_UNVERIFIED (traps and `cleanup()` wired; no test). The others are verified by code (`up` order, `check_services`, namespace `history` probe, loopback/8422 rules in `load_env` lines 196-197, env stripping in `start_nuxt`) and by the rerun guard stage. |
|
||||
| 14.2-03 | 10 | 10 (1 by override) | #7 PHP cross-check: PASSED (override); the developer accepted the classification. #8 no `app/` change: `git diff --name-only 984031f..HEAD -- app` is empty, and only `tests/` changed (12 files). #9 operator step: proven by behaviour, since the realtime spec (subscribe on `collection:` without 102, push received) passed with 0 skips. |
|
||||
| 14.2-04 | 13 | 10 | ? #2 and #5 ("local and dev pass the gate", marker 200 for dev) superseded by WR-05. ? #7 ("raw path without query only for an unmatched request") superseded by WR-01. The rest is verified by the reruns. |
|
||||
|
||||
**Score:** 54/54 truths verified after human verification (previously 49/54 (4 roadmap SC plus 50 plan truths; 1 by override). 4 are UNCERTAIN (deliberate contract changes awaiting human acceptance) and 1 is present but behaviour-unverified.)
|
||||
|
||||
### Prohibitions (must-NOT)
|
||||
|
||||
| Prohibition | Tier | Enforcement | Disposition |
|
||||
|-------------|------|-------------|-------------|
|
||||
| Seed opens no DB / writes nothing outside local | test | `TestDevSeedRefusesProduction` (passes; now also refuses `dev`) | ✓ |
|
||||
| No manifest route response change; marker/uploads outside manifest | test | `TestParityCorpus` 175/175/0; `TestDevMarkerRoute` reads `parity/manifest.yaml` | ✓ |
|
||||
| sm-user-plugin not modified | test | none (git evidence only) | ⚠️ flagged: unverified-prohibition |
|
||||
| dev-stack.sh writes nothing in the Nuxt checkout | test | none (code plus clean `git status`) | ⚠️ flagged |
|
||||
| No fall-through to PHP/production; Nuxt only after the guard | test | `--guard` stage (rerun, passed); `up()` runs `guard` before `start_nuxt` | ✓ |
|
||||
| No read/edit/restart of the shared Centrifugo config | test | none (grep: the path appears only in warning text) | ⚠️ flagged |
|
||||
| App keeps working on PHP; no `app/` change; specs run on PHP | test | git diff (no app/), UAT item 11 accepted by the developer | ✓ (human-accepted) |
|
||||
| Specs touch only data they created | test | none (`finally` deletes, Date.now() names) | ⚠️ flagged |
|
||||
| PHP tinker path unchanged when `E2E_BACKEND` is unset | test | `e2e-backend-switch.test.mjs` 10/10; the diff of runTinker.ts/auth.setup.ts is add-only | ✓ |
|
||||
| Gate never passes on a skip | test | `--self-test` (planted skip cases), rerun, passed | ✓ |
|
||||
| Gate never downgrades a missing service or Docker to a pass | test | `--self-test` plus `set -euo pipefail`; services refused in `check_services` | ✓ |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `summercms.go/modules/surf/uploads.go` | ✓ VERIFIED | 171 lines; `mountUploads` is called in `BuildRouter` after the plugin route loop and before `cabana.Activate` (router.go:554); prefix validation, nosniff, CSP, attachment for non-raster types |
|
||||
| `summercms.go/modules/surf/accesslog.go` | ✓ VERIFIED | 155 lines; wraps the final handler in `compile` (router.go:382-383); `recordPattern` in `wrap` (router.go:453); `markPanicked` in the recoverers |
|
||||
| `fonoteka.go/.../console/dev_seed.go` | ✓ VERIFIED | 559 lines; env gate before any DB open; one `lagoon.Transaction` in `lighthouse.WithoutBroadcasting`; `seedSearch` last |
|
||||
| `fonoteka.go/.../console/database.go` | ✓ VERIFIED | `commandDatabase`/`commandBucket`; used by dev-seed, reindex, oauth-client, prune-notifications |
|
||||
| `fonoteka.go/.../dev_marker.go` | ✓ VERIFIED | `devMarkerRoutes`, called from routes.go:386; registered only when `DevEnvironment` is local; not in the manifest |
|
||||
| `fonoteka.go/scripts/dev-stack.sh` | ✓ VERIFIED | 845 lines; subcommands up/down/status/check/guard/exec/e2e/e2e-verdict/fresh/redact-seed |
|
||||
| `fonoteka.go/.env.dev.example` | ✓ VERIFIED | placeholders only (checked); `.gitignore` has `/.env.dev` and `/storage/app/uploads/public/` |
|
||||
| `fonoteka.go/README.md` run docs | ✓ VERIFIED | § Running locally with the Nuxt app |
|
||||
| `vue-fonoteka-app/tests/e2e/helpers/runTinker.ts` | ✓ VERIFIED | `E2E_BACKEND=go` branch; PHP path add-only |
|
||||
| `vue-fonoteka-app/tests/e2e/{album-edit-rate-cover,album-search,wishlist,realtime}.spec.ts` | ✓ VERIFIED | contain the planned test IDs; conditional `test.skip` guards turn into gate failures through `e2e-verdict` (skipped must be 0) |
|
||||
| `vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs` | ✓ VERIFIED | 10/10 (rerun) |
|
||||
| `summercms.go/scripts/check-phase14.2.sh` | ✓ VERIFIED | all stages present; 6 of 7 rerun green here |
|
||||
| Phase unit tests (dev_seed_test, dev_stack_test, dev_marker_test, uploads_test, accesslog_test) | ✓ VERIFIED | all named tests pass (rerun) |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| surf/router.go | uploads.go | `mountUploads(r, app)` after the routes, before cabana | WIRED |
|
||||
| surf/router.go | accesslog.go | `accessLog` in `compile`, `recordPattern` in `wrap` | WIRED |
|
||||
| fonoteka plugin.go | console/dev_seed.go | `console.DevSeedCommand(p.app)` (plugin.go:298) | WIRED |
|
||||
| dev_seed.go | classes StoreAlbumPhoto / CreateAlbum | inside the seed transaction | WIRED |
|
||||
| reindex/oauth_client/prune_notifications | console/database.go | `commandDatabase` | WIRED |
|
||||
| routes.go | dev_marker.go | `p.devMarkerRoutes(r)` | WIRED |
|
||||
| dev-stack.sh | marker | `MARKER_PATH`, used in guard, `wait_for_marker`, probe | WIRED |
|
||||
| dev-stack.sh | dev-seed | `bin/fonoteka fonoteka:dev-seed` → `seed.out` (mode 600) → probe cover URL | WIRED |
|
||||
| dev-stack.sh | nuxt.config.ts | `NUXT_DEV_BACKEND_ORIGIN` / `NUXT_INTERNAL_API_ORIGIN` env on `pnpm dev` | WIRED |
|
||||
| runTinker.ts | dev-seed | `execFileSync(E2E_GO_BIN, ['fonoteka:dev-seed'])` (contract test) | WIRED |
|
||||
| check-phase14.2.sh | dev-stack.sh | guard/up/fresh/e2e/e2e-verdict | WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Uploads route | cover bytes | published `*blob.Bucket` + `system_files` row via `attach.StaticHandlerPublic` | yes (cover decodes in the e2e run; the probe requires image/png) | ✓ FLOWING |
|
||||
| Marker | `{backend,app,env}` | marshalled at registration from `DevEnvironment` | static by design (a marker, not data) | ✓ (intended) |
|
||||
| Access log | method/pattern/status/duration | request plus `statusRecorder` | yes (go.log 3081 records) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks (run by the verifier)
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Gate self-test | `bash scripts/check-phase14.2.sh --self-test` | `phase14.2 self-test passed` | ✓ PASS |
|
||||
| Guard refusals | `bash scripts/check-phase14.2.sh --guard` | 6 refusals with the correct reasons, control passed | ✓ PASS |
|
||||
| vet + full Go suites + named tests | `bash scripts/check-phase14.2.sh --go` | `phase14.2 go passed`, exit 0, 0 FAIL lines | ✓ PASS |
|
||||
| Docs | `--docs` | `phase14.2 docs passed` | ✓ PASS |
|
||||
| Parity | `--parity` | `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0` | ✓ PASS |
|
||||
| Contracts | `--contracts` | blocking 10/10; report-only 288/289 (pre-existing `missing-catalog`) | ✓ PASS |
|
||||
| Surf named tests | `go test ./modules/surf -run 'TestUploadsMount\|TestUploadsResponseHeaders\|TestAccessLog'` | 5 PASS | ✓ PASS |
|
||||
| Seed/command/marker tests | `go test ./console -run 'TestDevSeed\|TestReindexCommand'`; `go test . -run 'TestDevStackSmoke\|…\|TestDevMarkerRoute'` | 10 PASS | ✓ PASS |
|
||||
| Go-gate Playwright subset | `--e2e` | not rerun: the auto-mode classifier denied any interaction with the running stack (status/curl). The latest gate report `e2e-20261012T101126Z.json` is 17/0/0/0, newer than the last Go commit and the build stamp; go.log 0 5xx, 0 search failures; `seed.out` mode 600 | ? recorded evidence |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` are declared or present for this phase. The phase gate script stands in for a probe and was run as above.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| QA-05 | 14.2-01..04 | Cutover: parity green and vue-fonoteka-app / fonoteka-mcp run unchanged against Go | ✓ SATISFIED (this phase's share) | vue-fonoteka-app runs against Go with no `app/` change; parity 175/175. The fonoteka-mcp leg is documented (token minted, README § MCP) but not exercised here. Full cutover is Phase 20. |
|
||||
|
||||
Info: REQUIREMENTS.md traceability maps QA-05 to "Phase 15 / Complete", while ROADMAP maps it to 14.2 and 20. The traceability row is stale.
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
`check.decision-coverage-verify`: 23/23 CONTEXT decisions honored by the substring heuristic. Two decisions were drifted by the review fixes and are not caught by the heuristic: D-02 ("local/dev") is now local only (WR-05), and D-12 ("records each request's path and status") now records the pattern only, never the path (WR-01). Both are raised as human items.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (phase files) | none | TBD/FIXME/XXX/TODO | none | no debt markers in any phase file |
|
||||
| `modules/surf/uploads_test.go` | helper | `t.Skip` under `-short` only | ℹ️ Info | CLAUDE.md requires it; the gate never runs `-short` and the detector refuses skips |
|
||||
| e2e specs | `test.skip(...)` guards | conditional skips | ℹ️ Info | turned into failures by `e2e-verdict` (skipped must be 0) |
|
||||
| `fonoteka.go/scripts/dev-stack.sh` | 369 | marker check ignores `.app` (IN-06) | ℹ️ Info | any SummerCMS app on the port passes the guard; this is not a PHP fall-through |
|
||||
| `fonoteka.go/scripts/dev-stack.sh` | 215-241 | Postgres loopback check bypassable via `hostaddr`/query `host` (IN-02) | ℹ️ Info | weakens the "local DB only" refusal; open advisory |
|
||||
| `summercms.go/scripts/check-phase14.2.sh` | 51 | hard-coded developer path default (IN-09) | ℹ️ Info | open advisory |
|
||||
|
||||
The 11 open review Info findings (IN-01..IN-11) are advisory. None is a blocker.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Accept the WR-05 narrowing (`dev` refused)
|
||||
**Test:** Decide whether `SUMMER_ENV=dev` should stay refused by `fonoteka:dev-seed`, the `_dev/backend` marker and `dev-stack.sh`.
|
||||
**Expected:** Override recorded, or D-02 updated to "local only".
|
||||
**Why human:** This changes a locked decision. Plan-04 truths 2 and 5 as written are now false; the tests assert the new rule.
|
||||
|
||||
#### 2. Accept the WR-01 narrowing (no path in the request log)
|
||||
**Test:** Decide whether the Go request log may omit the path of unmatched requests.
|
||||
**Expected:** Override recorded, or D-12 updated. An unported route shows as `status=404 pattern=""`, and the path comes from the browser devtools.
|
||||
**Why human:** This trades away the D-12 diagnostic for token privacy. Plan-01 truth 9 and plan-04 truth 7 as written are now false.
|
||||
|
||||
#### 3. Foreground `up` cleanup
|
||||
**Test:** `scripts/dev-stack.sh up` (foreground), then Ctrl-C; repeat with TERM.
|
||||
**Expected:** Both process groups stop, `tmp/dev-stack/pids` is removed, and the exit code is 130/143.
|
||||
**Why human:** This cleanup invariant is wired but not exercised by any test.
|
||||
|
||||
#### 4. Four statically enforced test-tier prohibitions
|
||||
**Test:** Review the evidence listed in the frontmatter (sm-user-plugin untouched, no writes to the Nuxt checkout, no Centrifugo config access, specs delete only their own data).
|
||||
**Expected:** The developer confirms each, or asks for a gate check.
|
||||
**Why human:** Declared `verification: test` but no test enforces them, so they cannot be green on code reading alone.
|
||||
|
||||
### Suggested overrides (if items 1 and 2 are accepted)
|
||||
|
||||
```yaml
|
||||
overrides:
|
||||
- must_have: "TestDevSeedRefusesProduction proves local and dev pass the gate; TestDevMarkerRoute answers 200 for local and dev"
|
||||
reason: "WR-05: dev is a common name for a shared server; seed, marker and dev-stack.sh accept local only"
|
||||
accepted_by: "<developer>"
|
||||
accepted_at: "<ISO timestamp>"
|
||||
- must_have: "http.access_log adds path without its query string only for unmatched requests"
|
||||
reason: "WR-01: share and invitation tokens travel in paths; the log records method, pattern, status and duration only"
|
||||
accepted_by: "<developer>"
|
||||
accepted_at: "<ISO timestamp>"
|
||||
```
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No blocking gaps. The local stack, the fail-closed guard, the seed, the framework uploads mount and request log, the Nuxt seed switch, the self-seeding specs and the phase gate all exist, are substantive and wired. Each is backed by passing tests that I reran (except `--e2e`, which is backed by the post-fix gate report), plus an approved manual UAT. The status is `human_needed` rather than `passed` because two review fixes changed locked decisions (D-02, D-12) without a recorded acceptance, one cleanup invariant has no test, and four test-tier prohibitions have only static evidence.
|
||||
|
||||
Further notes, none of them gaps:
|
||||
- `covered_files` lists only summercms.go-root files; the fonoteka.go and vue-fonoteka-app files escape the project root and cannot be fingerprinted.
|
||||
- `14.2-VALIDATION.md` is still `draft` / `nyquist_compliant: false`, left for `/msd-validate-phase 14.2`.
|
||||
- The PHP repo's `vue-fonoteka-app` submodule pointer is not bumped (`M vue-fonoteka-app` in `/media/nvme/dev/golem15/fonoteka`); that is the user's call.
|
||||
- Deferred to Phase 20 or follow-ups (deferred-items.md): fileblob cross-device TMPDIR, Vite cold start before the first e2e, the `PNG_1PX` fixture for PHP GD, the local PHP Centrifugo config, and the framework `lagoon.Validate` empty-pointer gap.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-12T10:41:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Phase 14.2 deferred items
|
||||
|
||||
## From plan 14.2-02
|
||||
|
||||
- **File bucket fails across devices (affects Phase 20 cutover).** With the real `config/storage.yaml` (`file://./storage/app/uploads/public`), gocloud `fileblob` writes each upload to a temporary file in `os.TempDir()` and renames it into the bucket. When `/tmp` is a tmpfs (this machine) the rename fails with `invalid cross-device link`, so `fonoteka:dev-seed` (and any cover upload) fails. `dev-stack.sh` works around it by running the Go processes with `TMPDIR=tmp/dev-stack/go-tmp`. A deployment fix belongs to the framework or the app config (fileblob `no_tmp_dir` URL option, or a TMPDIR on the storage device) and should be decided before cutover. TestDevStackSmoke did not catch it because its bucket lives in the test's temp directory.
|
||||
- **GORM "record not found" log noise in `fonoteka:dev-seed`.** `classes/active_collection.go:243` logs the first, expected lookup miss through GORM's default logger in colour on stderr. Cosmetic; out of scope.
|
||||
|
||||
## From plan 14.2-03
|
||||
|
||||
- **vue-fonoteka-app contract test fails on HEAD before this plan.** `tests/contracts/missing-catalog.test.mjs` ("Album DTO carries label, catalog_number and country without any") matches `\bany\b` against the Album type and trips on the comment "the cheapest current listing in any condition" added by vue-fonoteka-app `984031f` (feat(e5o)). `pnpm test:contracts`: 278 pass, 1 fail. It is an `app/` type comment, which this phase must not change (D-20).
|
||||
- **lagoon.Validate treats a pointer to "" as a value.** `isEmptyValue` returns false for a non-nil `*string` holding "", so a `nullable|in:` model rule fails where Laravel skips every non-implicit rule for an empty string. fonoteka works around it in `albumRuleValues` (fonoteka.go `7675d64`). A framework fix (dereference pointers, or apply Laravel's empty-string skip) would cover other models; it changes `required` semantics for `*string` too, so it needs its own change.
|
||||
- **`tmp/dev-stack/go.log` is appended across `up` runs.** A `grep -c 'status=5..'` over the whole file counts requests from earlier runs. The four 500s from before the empty-format fix were in it until the log was rotated before the Task 4 session; the session log is kept as `go.log.uat-session-20261011`. `up` could truncate or rotate the log.
|
||||
- **Orphan artist rows in the Go dev database.** The specs delete the albums they create, but the artists created with them (`E2E Artist`) stay in `fonoteka_go_dev`. Whether PHP removes orphan artists on album delete was not checked.
|
||||
- **Realtime self-echo guard hides same-user cross-tab edits.** `useRealtimeSync.ts` skips the refresh when `payload.actor.user_id` is the session user, so an edit in a second tab of the same user never refreshes the first tab (PHP and Go alike, UAT finding C). This is frontend design (`app/`, out of scope for D-20); worth a product decision if same-user multi-tab sync is wanted.
|
||||
- **`PNG_1PX` fixture is not decodable by PHP GD.** `album-edit-rate-cover.spec.ts` uploads a hand-built 1x1 PNG. PHP's Winter resizer fails on it (`imagealphablending(): Argument #1 must be of type GdImage, false given`, `Resizer.php:139`) and answers 500, while Go accepts it. Real photos work on PHP. Follow-up: use a GD-decodable fixture (for example an 8x8 PNG encoded with a standard encoder) so the spec passes on both backends.
|
||||
- **Local PHP env has no Centrifugo config.** On PHP, the live `realtime.spec.ts` test opens no socket (`Centrifugo: Skipping publish (not configured)`). The PHP side of that test needs the Centrifugo settings in the PHP app's local env.
|
||||
|
||||
## From plan 14.2-04
|
||||
|
||||
- **The first Playwright run after a fresh `dev-stack.sh up` can fail on Vite's cold start.** Right after `up` restarted Nuxt, `auth.setup.ts` got Nuxt's 500 page "Failed to fetch dynamically imported module: http://localhost:3119/_nuxt/layouts/public.vue" (Vite re-optimising dependencies on the first browser load), so the 16 dependent tests were skipped and `e2e-verdict` refused the run, as it should. The same specs passed 17/0/0/0 on the next run against the warm server. `up`'s probe fetches the marker, a 401 and a cover through Nuxt, never a page, so it does not warm the client bundle. Options: let `up` load one page in a headless browser, or pin Vite's `optimizeDeps` in the Nuxt dev config. The gate does not retry (a retry would hide real flakiness).
|
||||
- **TMPDIR inside a go.work tree breaks the framework tests.** With `TMPDIR=fonoteka.go/tmp/dev-stack/go-tmp`, the scaffold tests in `summercms.go/internal/build` ran `go work` inside fonoteka.go's workspace and added a `use` line to `fonoteka.go/go.work` (reverted), and `go test ./...` in fonoteka.go picked up the build directories as packages. `check-phase14.2.sh` leaves TMPDIR to the caller; `dev-stack.sh` already gives its Go processes and Playwright the on-device TMPDIR they need. Tests that scaffold could set `GOWORK=off` or check that their temp directory is outside any workspace.
|
||||
- **`go test ./...` at the fonoteka.go root covers only the root module.** The workspace's plugin modules (fonoteka, user, golem, feedback) are separate modules; `./...` from the root does not reach them. The gate runs the fonoteka plugin module separately (`go -C plugins/golem15/fonoteka test ./...`); the user, golem and feedback submodules are not part of this phase's gate.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 03
|
||||
subsystem: auth
|
||||
tags: [api-tokens, journal, ResolveBackend, release, g15, scopes]
|
||||
|
||||
requires:
|
||||
- phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
provides: cabana.ResolveBackend, account token API, admin SPA tokens card
|
||||
provides:
|
||||
- "journal and journal:read token scopes on sm-journal-plugin"
|
||||
- "Journal API auth via cabana.ResolveBackend with cover golem15.journal.*"
|
||||
- "ExampleResolveBackend docs example and Journal README token docs"
|
||||
- "summercms v0.1.21 release with Journal go.mod and host gitlink bumps"
|
||||
affects: [16.3-04, sm-journal-plugin, sm-grzybyfunkcjonalne-app]
|
||||
|
||||
actuals:
|
||||
tokens: 20568
|
||||
tasks: 4
|
||||
commits: 2
|
||||
plan_head_before: 611f98e33ba45b1f7718ead84cb8f8474e879f33
|
||||
plan_head_after: 2030fd881aa08347931c638a90548b16bc188f86
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Journal principals only via cabana.ResolveBackend(cover golem15.journal.*)"
|
||||
- "ErrTokenScope → 403 Insufficient permissions; other resolver errors → 401 Authentication required"
|
||||
- "Optional-auth reads treat cover/auth failure as anonymous"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/token_scopes.go
|
||||
- ../sm-journal-plugin/controllers/api/auth_test.go
|
||||
- ../sm-journal-plugin/journal_api_token_smoke_test.go
|
||||
- modules/cabana/example_tokens_test.go
|
||||
modified:
|
||||
- ../sm-journal-plugin/controllers/api/auth.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- docs/services/authentication.md
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
|
||||
key-decisions:
|
||||
- "Human resume: release v0.1.21 (release-now); no sm-summercmsio-app bump"
|
||||
- "Probe test scope acme.probe registered for cover-failure cases because admin:read is Full on GET"
|
||||
- "CSRF walk count updated 30→32 for account token create/revoke routes"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin API writes: ResolveBackend + resolveError mapping to PHP bodies"
|
||||
- "Release sheet at blocking-human gate before any tag/push"
|
||||
|
||||
requirements-completed: [SC-2, SC-4, SC-5]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "journal-scoped token minted via admin API creates a Journal post as its owner"
|
||||
requirement: SC-4
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: ../sm-journal-plugin/controllers/api/auth_test.go#TestResolveErrorMapping
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: ../sm-journal-plugin/journal_api_token_smoke_test.go#TestJournalTokenTracer
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Read/unrelated/revoked tokens, optional draft unlock, featured-image canEdit parity, code-declared roles"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: ../sm-journal-plugin/journal_api_token_smoke_test.go#TestJournalTokenExpansion
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Journal README and ExampleResolveBackend docs"
|
||||
requirement: SC-5
|
||||
verification:
|
||||
- kind: other
|
||||
ref: go test ./cmd/summer -run TestDocsTree; go run ./cmd/summer docs:build --check
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Human-approved release v0.1.21 with Journal require and host gitlink"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: git ls-remote --tags origin refs/tags/v0.1.21; release-ok verify
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Tag and push require human approval at blocking-human gate"
|
||||
|
||||
duration: 658min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.3 Plan 03: Journal scopes, ResolveBackend and v0.1.21 release Summary
|
||||
|
||||
**Journal registers `journal` / `journal:read`, authenticates writes through `cabana.ResolveBackend`, documents the path, and ships as summercms `v0.1.21` with plugin and host bumps.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 658 min (includes overnight pause at the blocking-human release gate)
|
||||
- **Started:** 2026-10-11T00:09:06Z
|
||||
- **Completed:** 2026-10-11T11:07:50Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 12 (across summercms.go, sm-journal-plugin, sm-grzybyfunkcjonalne-app)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Journal `TokenScopes` + lang keys; API auth switched to `cabana.ResolveBackend` with cover `golem15.journal.*`
|
||||
- Postgres tracer and expansion matrix (read scope, probe cover, drafts, featured images, revoke, developer role grants)
|
||||
- `ExampleResolveBackend` in cabana docs; Journal README scopes / Phase 15 D-15 reversal
|
||||
- Human-approved release: annotated tag `v0.1.21`, Journal `go.mod` require, host journal gitlink
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Tracer** — `4ec64a3` (sm-journal-plugin, feat)
|
||||
2. **Task 2: Matrix + docs** — `f2026c5` (sm-journal-plugin, feat); `60873a3` (summercms.go, docs)
|
||||
3. **Task 3: Release gate** — gate `release-gate-ok`; human reply `release v0.1.21`; CSRF inventory fix `2030fd8` (summercms.go, fix)
|
||||
4. **Task 4: Release** — tag `v0.1.21` object `dc181e6` on `2030fd8`; Journal bump `09308d9`; host gitlink `e44cfde`
|
||||
|
||||
**Plan metadata:** (this commit)
|
||||
|
||||
## Human reply (Task 3, verbatim)
|
||||
|
||||
```
|
||||
release v0.1.21
|
||||
```
|
||||
|
||||
No `and summercmsio` — sm-summercmsio-app was not bumped.
|
||||
|
||||
## Release
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Proposed / shipped tag | `v0.1.21` |
|
||||
| Tag object | `dc181e6224389647ce378ac1420e5df48eea2e04` |
|
||||
| Tag points at | `2030fd881aa08347931c638a90548b16bc188f86` |
|
||||
| summercms.go master (pushed) | `2030fd8` |
|
||||
| sm-journal-plugin main (pushed) | `09308d9` (requires `summercms v0.1.21`) |
|
||||
| sm-grzybyfunkcjonalne-app master (pushed) | `e44cfde` (journal gitlink `09308d9`) |
|
||||
| sm-summercmsio-app | not bumped |
|
||||
| UAT redeploy | follow-up (not this plan) |
|
||||
|
||||
### One-way surfaces published
|
||||
|
||||
`pact.TokenScope` / `HasTokenScopes`; `bouncer.ScopeLimit` / `Principal.Scope`; `party.Activated`; `cabana.BackendAPIToken`, `APITokenPrefix`, `ResolveBackend`, `ErrUnauthenticated`, `ErrTokenScope`; account token routes; `g15_` format; `backend_api_tokens` / migration `202610110001_backend_api_tokens`.
|
||||
|
||||
### Release sheet (pre-approval)
|
||||
|
||||
- Trees clean and not behind origin; tag absent on origin before create
|
||||
- Gate printed `release-gate-ok` (after CSRF count 30→32)
|
||||
- Open high findings: none (`16.3-REVIEW.md` / `16.3-SECURITY.md` absent)
|
||||
|
||||
## Postgres verify status
|
||||
|
||||
| Suite | Status |
|
||||
|-------|--------|
|
||||
| 16.3-01 cabana/lagoon Postgres | **RUN** (prior SUMMARY) |
|
||||
| `TestJournalTokenTracer` | **RUN** pass |
|
||||
| `TestJournalTokenExpansion` | **RUN** pass |
|
||||
| NOT RUN | none on this host for 16.3-03 |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Followed D-04/D-01/D-15 and featured-image PHP parity (CanEdit only).
|
||||
- Registered test-only `acme.probe` scope for unrelated-cover cases (`admin:read` is Full on GET).
|
||||
- Human chose release-now `v0.1.21` without summercmsio.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] CSRF state-changing route count stale after token API**
|
||||
- **Found during:** Task 3 release gate (`TestPhase10CSRF`)
|
||||
- **Issue:** Walk counted 32 routes (account token create/revoke) but expected 30
|
||||
- **Fix:** Updated expected count and comment to 32
|
||||
- **Files modified:** `modules/cabana/phase10_csrf_test.go`
|
||||
- **Verification:** Six cabana gate tests pass; `release-gate-ok`
|
||||
- **Committed in:** `2030fd8`
|
||||
|
||||
**2. [Rule 3 - Blocking] Probe scope for cover-failure GET path**
|
||||
- **Found during:** Task 2 expansion (`admin:read` on GET is Full, unlocks drafts as owner)
|
||||
- **Issue:** Plan suggested `admin:read` for anonymous GET; Full scopes skip cover
|
||||
- **Fix:** Test plugin `acme.probe` with `acme.probe.*` scope for cover refuse → anonymous GET
|
||||
- **Files modified:** `journal_api_token_smoke_test.go`
|
||||
- **Verification:** `TestJournalTokenExpansion` pass
|
||||
- **Committed in:** `f2026c5`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking)
|
||||
**Impact on plan:** Required for green release gate and correct cover semantics; no scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the deviations above. Overnight pause at Task 3 until human resume.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required. UAT redeploy remains a separate follow-up.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Ready for 16.3-04 (unit/backstop matrix). Framework + Journal token path released; hosts can mint `journal`-scoped tokens after migrate/redeploy.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Key files exist on disk
|
||||
- Tag `v0.1.21` on origin; Journal main = origin/main; host gitlink = Journal main; `release-ok`
|
||||
- Commits grepped for 16.3-03 present in both repos
|
||||
|
||||
---
|
||||
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
|
||||
*Completed: 2026-10-11*
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
plan: 04
|
||||
subsystem: testing
|
||||
tags: [api-tokens, unit-tests, vitest, testcontainers, phase-gate, nyquist]
|
||||
|
||||
requires:
|
||||
- phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
provides: token scopes, guard, account API, CLI, SPA card, Journal scopes
|
||||
provides:
|
||||
- "Full unit coverage of Phase 16.3 (lean rule 3)"
|
||||
- "Postgres token suites for Docker hosts"
|
||||
- "scripts/check-phase16.3.sh fail-closed gate"
|
||||
- "16.3-VALIDATION.md with nyquist_compliant true"
|
||||
affects: [verify-work, phase-16.3-close]
|
||||
|
||||
actuals:
|
||||
tokens: 34814
|
||||
tasks: 3
|
||||
commits: 7
|
||||
plan_head_before: 2030fd881aa08347931c638a90548b16bc188f86
|
||||
plan_head_after: 463541549f374c328b121433a4e884146f86d25c
|
||||
TASK2_START: 91830e506f5e61537ae6180c3ff282bf19b81583
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Phase gate stages go/admin/docs/postgres with detect_go and detect_vitest fail-closed"
|
||||
- "Journal TestJournalTokens matrix: writes × scopes with published_at for public Show"
|
||||
- "SPA plaintext lifetime asserted via DOM/toast absence after Done and Esc-confirm"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/api_tokens_test.go
|
||||
- modules/cabana/token_guard_test.go
|
||||
- modules/cabana/account_tokens_test.go
|
||||
- modules/cabana/token_commands_test.go
|
||||
- modules/cabana/token_postgres_test.go
|
||||
- scripts/check-phase16.3.sh
|
||||
- ../sm-journal-plugin/journal_api_tokens_test.go
|
||||
modified:
|
||||
- modules/cabana/token_scopes_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/bouncer/context_test.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
- ../sm-journal-plugin/token_scopes_test.go
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "TASK2_START=91830e506f5e61537ae6180c3ff282bf19b81583 (framework Task 1 SHA); Task 2 diffs are *_test.go only"
|
||||
- "Public Journal Show under optional-auth needs published_at in the past, not only published:true"
|
||||
- "Phase gate --all ran postgres PASS on this Docker host (not NOT RUN)"
|
||||
|
||||
patterns-established:
|
||||
- "check-phase16.3.sh mirrors 16.2 detectors; PHASE163_REQUIRE_POSTGRES=1 fails --all without Docker"
|
||||
- "Account SPA tests mount /account via helpers.mountApp with deferred fetch for loading skeletons"
|
||||
|
||||
requirements-completed: [SC-6, SC-1, SC-2, SC-3, SC-4]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Scope match, Allows, compile/overlap, route gates and SPA plaintext backstop"
|
||||
requirement: SC-6
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/cabana/token_scopes_test.go#TestScopeMatch
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: modules/cabana/token_routes_test.go#TestTokenRouteGates
|
||||
status: pass
|
||||
- kind: automated_ui
|
||||
ref: admin/tests/account/ApiTokenCreateDialog.test.ts#plaintext lifetime
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: modules/cabana/token_postgres_test.go#TestBackendTokenPostgres
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Full Go coverage: bouncer, party, lagoon migration, guard, account API, CLI, Journal matrix"
|
||||
requirement: SC-6
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/cabana (16 Docker-free named tests)
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: modules/lagoon + modules/cabana + sm-journal-plugin Postgres suites (3+4+3)
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "SPA UI-SPEC coverage, phase gate fail-closed, VALIDATION nyquist_compliant"
|
||||
requirement: SC-6
|
||||
verification:
|
||||
- kind: automated_ui
|
||||
ref: npm --prefix admin test (1251 passed)
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: bash scripts/check-phase16.3.sh --all → Phase 16.3 gate passed
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Real-browser clipboard (Manual-Only in VALIDATION)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Clipboard API permissions differ from happy-dom; kept as Manual-Only row"
|
||||
|
||||
duration: 30min
|
||||
completed: 2026-10-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.3 Plan 04: Unit tests, Postgres suites and phase gate Summary
|
||||
|
||||
**Phase-ending coverage: every token security rule has a unit test, Docker Postgres suites are green, and `scripts/check-phase16.3.sh --all` ends with `Phase 16.3 gate passed`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~30 min (executor wall clock for plan 04)
|
||||
- **Started:** 2026-10-11T11:11:34Z
|
||||
- **Completed:** 2026-10-11T11:41:20Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 16 across summercms.go + sm-journal-plugin
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Docker-free Go suite: 16 named cabana tests, bouncer Scope serialization, party Activate order; Journal `TestJournalTokenScopes` with `-short`.
|
||||
- Postgres: lagoon migration (3), cabana account/CLI/guard/OpenAPI (4), Journal token matrix (3) — all PASS on this Docker host.
|
||||
- Admin SPA: 33 account-token vitest cases; full suite 1251 passed; gate self-test and `--all` green; VALIDATION `nyquist_compliant: true`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Tracer deny-by-default** — framework `91830e5`, Journal `7bbafcb` (`test(16.3-04)`)
|
||||
2. **Task 2: Full Go coverage** — framework `78124ab`, Journal `58851dd` (`test(16.3-04)`); `TASK2_START=91830e5`
|
||||
3. **Task 3: SPA + gate + VALIDATION** — SPA `55999b2`, gate `9b811f6` (`test(16.3): phase gate`), VALIDATION `4635415` (`docs(16.3)`)
|
||||
|
||||
**Plan metadata:** (this SUMMARY commit)
|
||||
|
||||
_Note: No `fix(...)` production commits were required; Journal create fixture needed `published_at` for public Show (test-only)._
|
||||
|
||||
## Test counts
|
||||
|
||||
| Repository | Notes |
|
||||
|------------|--------|
|
||||
| summercms.go | 16 Docker-free cabana named tests; + bouncer/party; Postgres lagoon 3 + cabana 4 |
|
||||
| admin SPA | 14 panel + 19 dialog cases; full vitest 1251 |
|
||||
| sm-journal-plugin | `TestJournalTokenScopes` (short); Postgres `TestJournalTokens` + tracer + expansion |
|
||||
|
||||
## Docker-host commands
|
||||
|
||||
```bash
|
||||
bash scripts/check-phase16.3.sh --self-test
|
||||
bash scripts/check-phase16.3.sh --all # postgres PASS here
|
||||
# or, if NOT RUN postgres was printed:
|
||||
bash scripts/check-phase16.3.sh postgres
|
||||
```
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Public optional-auth Show asserts need `published_at` in the past alongside `published: true`.
|
||||
- Gate reports NOT RUN postgres only without Docker; this run executed postgres.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None material — plan executed as written. Journal Show fixture clarified with `published_at` inside the test file (not a production fix).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 16.3 plans 4/4 complete; ready for `/msd-verify-work` (and optional real-browser clipboard Manual-Only).
|
||||
- No blockers.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Acceptance greps for TestAccountTokens, TestTokenCommands, TestBackendAPITokensMigration, TestJournalTokens: one line each.
|
||||
- Task 2 Docker-free count 16; Postgres 3/4/3; gate `--all` last line `Phase 16.3 gate passed`.
|
||||
- VALIDATION: `nyquist_compliant: true`, zero TBD rows.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
phase: 16.3
|
||||
review: 16.3-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "TestPhase10Coverage golden list omits account token routes"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Create dialog Submit enabled with zero scopes selected"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "last_used_at in-process debounce stores success before the DB write"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: open
|
||||
title: "Create-dialog scope checkboxes lack associated accessible names beyond the wrapping label"
|
||||
open: 2
|
||||
total: 4
|
||||
recorded: 2026-10-11T11:48:56.057Z
|
||||
updated: 2026-10-11T11:53:00.000Z
|
||||
---
|
||||
|
||||
# Phase 16.3: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-01 | warning | fixed | a955d2a |
|
||||
| WR-02 | warning | fixed | cb4b0a9 |
|
||||
| IN-01 | info | open | - |
|
||||
| IN-02 | info | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
reviewed: 2026-10-11T11:47:43Z
|
||||
depth: standard
|
||||
files_reviewed: 59
|
||||
files_reviewed_list:
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/components/account/ApiTokenCreateDialog.vue
|
||||
- admin/src/components/account/ApiTokensPanel.vue
|
||||
- admin/src/components/ui/ConfirmDialog.vue
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- admin/tests/views/AccountView.test.ts
|
||||
- cmd/summer/runtime.go
|
||||
- docs/backend/admin-spa.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/examples/blog/token_scopes.go
|
||||
- docs/plugins/registration.md
|
||||
- docs/services/authentication.md
|
||||
- modules/bouncer/context.go
|
||||
- modules/bouncer/context_test.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/account_tokens_test.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/api_tokens_test.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/example_tokens_test.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/token_commands_test.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/token_guard_test.go
|
||||
- modules/cabana/token_postgres_smoke_test.go
|
||||
- modules/cabana/token_postgres_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/token_scopes.go
|
||||
- modules/cabana/token_scopes_test.go
|
||||
- modules/cabana/token_smoke_test.go
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- modules/pact/capabilities.go
|
||||
- modules/party/registry.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- scripts/check-phase16.3.sh
|
||||
- ../sm-journal-plugin/controllers/api/auth.go
|
||||
- ../sm-journal-plugin/controllers/api/auth_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/journal_api_token_smoke_test.go
|
||||
- ../sm-journal-plugin/journal_api_tokens_test.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/token_scopes.go
|
||||
- ../sm-journal-plugin/token_scopes_test.go
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 2
|
||||
info: 2
|
||||
total: 4
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 16.3: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-11T11:47:43Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 59
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed personal API token auth (cabana composite guard, scopes, account API, CLI), admin SPA tokens UI, Journal `ResolveBackend` integration, and phase unit/gate tests from plans 01–04 SUMMARY `key-files`.
|
||||
|
||||
Token auth design is sound: plaintext never stored (sha256 + hint), Bearer `g15_` routed before JWT, `interactiveOnly` blocks token use on account/token/session routes, `ResolveBackend` is cookie-less, CSRF wraps token create/revoke, and Journal maps `ErrTokenScope` → 403. No Critical security defects found in the auth path.
|
||||
|
||||
Two Warnings: a stale CSRF coverage golden leaves `go test ./modules/cabana` red while the phase gate stays green, and the create dialog enables Submit with no scope selected.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: TestPhase10Coverage golden list omits account token routes
|
||||
|
||||
**File:** `modules/cabana/phase10_coverage_test.go:90-124`
|
||||
**Issue:** Plan 01 added `POST /account/tokens` and `DELETE /account/tokens/{id}` and updated `TestPhase10CSRF` to expect 32 unsafe routes, but `TestPhase10Coverage/every_unsafe_mounted_route_is_CSRF-walked` still asserts the pre-token golden list (message still says "expects 30 besides login"). Confirmed failing:
|
||||
|
||||
```text
|
||||
go test ./modules/cabana -run 'TestPhase10Coverage/every_unsafe'
|
||||
# FAIL: missing DELETE /account/tokens/{id} and POST /account/tokens
|
||||
```
|
||||
|
||||
`scripts/check-phase16.3.sh` does not require `TestPhase10Coverage`, so the phase gate can pass while `go test ./modules/cabana` (and the project “green at every commit” rule) fails. CSRF behavior for the new routes is covered dynamically by `TestPhase10CSRF`; the coverage golden is the regression.
|
||||
|
||||
**Fix:** Add the two account-token routes to `want`, update the failure message to “32 besides login” (or count from `want`), and optionally add `TestPhase10Coverage` to `CABANA_RUN` / `CABANA_REQUIRE` in `scripts/check-phase16.3.sh`.
|
||||
|
||||
```go
|
||||
want := []string{
|
||||
"DELETE /account/avatar",
|
||||
"DELETE /account/tokens/{id}",
|
||||
// ...
|
||||
"POST /account/avatar",
|
||||
"POST /account/tokens",
|
||||
"POST /auth/login",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### WR-02: Create dialog Submit enabled with zero scopes selected
|
||||
|
||||
**File:** `admin/src/components/account/ApiTokenCreateDialog.vue:63-64`
|
||||
**Issue:** `canSubmit` only requires that the picker finished loading with a non-empty catalog; it does not require `checked` to contain at least one scope. Users can click Create with nothing selected, hit a 422, then get focus moved to scopes. Server validation is correct; the client control still presents an enabled primary action for an invalid form.
|
||||
|
||||
**Fix:** Include a selection check (and keep busy/loading/failure gates):
|
||||
|
||||
```ts
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!scopesLoading.value &&
|
||||
!scopesFailed.value &&
|
||||
scopes.value.length > 0 &&
|
||||
selectedScopes().length > 0 &&
|
||||
!busy.value,
|
||||
)
|
||||
```
|
||||
|
||||
(Or derive from `checked.value.size` / `starChecked` equivalently.)
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: last_used_at in-process debounce stores success before the DB write
|
||||
|
||||
**File:** `modules/cabana/api_tokens.go:277-289`
|
||||
**Issue:** `stampLastUsed` writes `lastStamp` before `StampLastUsed` returns. A failed UPDATE suppresses retries for up to a minute in that process (SQL also gates by minute; only the in-memory fast path is affected).
|
||||
**Fix:** Store into `lastStamp` only after a nil error, or delete the map entry on failure.
|
||||
|
||||
### IN-02: Create-dialog scope checkboxes lack associated accessible names beyond the wrapping label
|
||||
|
||||
**File:** `admin/src/components/account/ApiTokenCreateDialog.vue:491-517`
|
||||
**Issue:** Scope rows use a wrapping `<label>` (good) but the checkbox has no `aria-describedby` linking the description text; screen-reader users get the visible label text via the label association, which is acceptable. Optional improvement: wire description ids for longer scope blurbs.
|
||||
**Fix:** Optional only — add stable ids on description spans and `aria-describedby` on the inputs if UX audit wants richer announcements.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-11T11:47:43Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
phase: "16.3"
|
||||
slug: "backend-admin-personal-api-tokens-with-scopes"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-12"
|
||||
---
|
||||
|
||||
# Phase 16.3 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| client → admin API | Authorization header (JWT or g15_ token) and cookie; untrusted | credentials |
|
||||
| client → plugin routes via ResolveBackend | Bearer value only; plugin routes have no CSRF check | credentials |
|
||||
| plugin code → cabana boot | plugins declare token scopes and permissions compiled at boot | scope definitions |
|
||||
| operator → CLI | runs with database access; may mint for any user | plaintext token (stdout once) |
|
||||
| database → process | token rows; only hashes stored | sha256 hash, 4-char hint |
|
||||
| server → SPA | the 201 create response carries the plaintext once | plaintext token |
|
||||
| SPA → browser storage, clipboard | plaintext stays in component memory; clipboard is user-initiated | plaintext token |
|
||||
| server data → DOM | token names and plugin scope labels rendered as text | user/plugin strings |
|
||||
| agent → Journal API | Bearer JWT or g15_ token; untrusted | credentials |
|
||||
| executor → git origin | tags and branches read by other repositories | release refs |
|
||||
| tests → databases, network | throwaway containers only; no external servers | test data |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-16.3-01 | Spoofing | compositeGuard token branch | high | mitigate | `wellFormedAPIToken` (cabana/api_tokens.go:59) before DB; crypto/rand mint; `subtle.ConstantTimeCompare` on hash (token_guard.go:93); owner reloaded per request | closed |
|
||||
| T-16.3-02 | Information Disclosure | backend_api_tokens | medium | mitigate | `TokenHash` is `json:"-"`, only `token_hint` exposed (api_tokens.go:29-30) | closed |
|
||||
| T-16.3-03 | Information Disclosure | create response, logs | high | mitigate | `logTokenAuth` logs outcome, method, path, remote, admin_id, token_id only — no Authorization value (token_guard.go:226-247) | closed |
|
||||
| T-16.3-04 | Elevation of Privilege | cabana.Allows | high | mitigate | Scoped branch runs before the IsSuperuser/empty shortcut (contracts.go:201-206); `allowsUnderScope` + `ownerHas` | closed |
|
||||
| T-16.3-05 | Elevation of Privilege | account, token, refresh, logout routes | high | mitigate | `interactiveOnly` 403 `interactive_only` (token_guard.go:151), wrapped outside `requireAjax` (http.go:254) | closed |
|
||||
| T-16.3-06 | Spoofing / Tampering (CSRF) | token transport, ResolveBackend | high | mitigate | Token branch reads only `Authorization: Bearer` (token_guard.go:141); `ResolveBackend` requires a Bearer and uses the cookie-less resolver (token_guard.go:196) | closed |
|
||||
| T-16.3-07 | Elevation of Privilege | owner loading | medium | mitigate | `g.users.FindByID` per request via `lazyBackendUsers` (token_guard.go:101, http.go:231) | closed |
|
||||
| T-16.3-08 | Elevation of Privilege | issueAPIToken (API and CLI) | medium | mitigate | `issueAPIToken` runs `validateTokenRequest` against the registry before insert (api_tokens.go:169) | closed |
|
||||
| T-16.3-09 | Denial of Service | last_used_at writes | low | mitigate | `stampThrottle` + conditional UPDATE (api_tokens.go:267-273) | closed |
|
||||
| T-16.3-10 | Denial of Service | invalid token floods | low | accept | See Accepted Risks AR-01 | closed |
|
||||
| T-16.3-11 | Tampering | scope registration | medium | mitigate | Boot refuses reserved, invalid-method and malformed scopes (token_scopes.go:114, :138) | closed |
|
||||
| T-16.3-12 | Repudiation | revoke | low | mitigate | Soft revoke stores `revoked_at`/`revoked_by` (api_tokens.go:220; CLI nil at token_commands.go:193); `admin.auth` events | closed |
|
||||
| T-16.3-13 | Elevation of Privilege | routes outside Allows | high | mitigate | `tokenRouteGate` on field types and markdown preview (field_type.go:870, field_markdown.go:77); route classification test token_routes_test.go | closed |
|
||||
| T-16.3-14 | Information Disclosure | ApiTokenCreateDialog plaintext | high | mitigate | Dialog-local `plaintext` ref cleared on close (line 87) and `onBeforeUnmount` (line 345); no storage/console/v-html/router use in components/account | closed |
|
||||
| T-16.3-15 | Information Disclosure | show-once step | low | accept | See Accepted Risks AR-02 | closed |
|
||||
| T-16.3-16 | Tampering (XSS) | token names, scope labels | medium | mitigate | No `v-html` in components/account; text interpolation only | closed |
|
||||
| T-16.3-17 | Elevation of Privilege | scope picker | low | mitigate | SPA reads `GET /account/tokens/scopes`; server re-validates on create (T-16.3-08) | closed |
|
||||
| T-16.3-18 | Elevation of Privilege | Journal ownership branches | high | mitigate | sm-journal-plugin controllers/api/auth.go: every principal from `cabana.ResolveBackend(app, r, "golem15.journal.*")`; journal_api_tokens_test.go matrix | closed |
|
||||
| T-16.3-19 | Elevation of Privilege | journal:read on write routes | high | mitigate | `journal:read` declares GET/HEAD only (journal token_scopes.go:23); guard `methodFilterScopes` on `r.Method` (token_guard.go:110, token_scopes.go:378) | closed |
|
||||
| T-16.3-20 | Spoofing / Tampering (CSRF) | Journal write routes | high | mitigate | Journal uses Bearer-only `ResolveBackend` (see T-16.3-06) | closed |
|
||||
| T-16.3-21 | Information Disclosure | Journal error bodies | low | mitigate | `resolveError` writes only the PHP constants (`Insufficient permissions` / auth required) | closed |
|
||||
| T-16.3-22 | Tampering / Repudiation | tag and pushes | high | mitigate | Human release checkpoint in 16.3-03; v0.1.21 tagged after v0.1.20, no moved tags | closed |
|
||||
| T-16.3-23 | Elevation of Privilege | code-declared role grants on Journal API | low | accept | See Accepted Risks AR-03 | closed |
|
||||
| T-16.3-24 | Repudiation (false green) | phase gate | medium | mitigate | scripts/check-phase16.3.sh fail-closed detectors, `--self-test`, NOT RUN postgres reporting | closed |
|
||||
| T-16.3-25 | Tampering (security regression) | Allows, guard, route gates, plaintext | high | mitigate | token_scopes/guard/routes/account/commands tests, SPA backstops, Journal token matrix in the gate | closed |
|
||||
| T-16.3-26 | Tampering | test isolation | low | mitigate | Stubs, httptest, happy-dom; testcontainers for Postgres | closed |
|
||||
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No packages installed in any plan; committed lockfiles and go.sum; swag pinned v1.16.6 | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-01 | T-16.3-10 | Format check precedes an indexed unique lookup; 160-bit secrets are not guessable; the admin API has no throttle bucket beyond login, so no new one is added | Plan 16.3-01 threat model (user-approved plan) | 2026-10-12 |
|
||||
| AR-02 | T-16.3-15 | Full token shown once by design (UI-SPEC §5) so the user can verify it; overlay clicks cannot close the step, closing before copying asks first | Plan 16.3-02 threat model (user-approved plan) | 2026-10-12 |
|
||||
| AR-03 | T-16.3-23 | Honouring code-declared role grants on the Journal API is the intended fix (the admin API already honours them); documented in the Journal README as a behaviour change | Plan 16.3-03 threat model (user-approved plan) | 2026-10-12 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-12 | 27 | 27 | 0 | msd-secure-phase (orchestrator, ASVS L1 grep-depth; auditor skipped per short-circuit rule) |
|
||||
| 2026-10-12 | 27 | 27 | 0 | msd-secure-phase re-audit (State A): no changes to guarded code since b519c9c; register unchanged |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-12
|
||||
@@ -3,9 +3,9 @@ phase: "16.3"
|
||||
slug: "backend-admin-personal-api-tokens-with-scopes"
|
||||
# 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-11"
|
||||
---
|
||||
|
||||
@@ -22,8 +22,8 @@ created: "2026-10-11"
|
||||
| **Framework** | Go `testing` (+ testcontainers-go Postgres for DB suites); vitest + @vue/test-utils + happy-dom for the admin SPA |
|
||||
| **Config file** | `admin/vitest.config.ts` |
|
||||
| **Quick run command** | `go test ./modules/cabana ./modules/bouncer ./modules/pact -run 'Token|Scope|Allows' -count=1` |
|
||||
| **Full suite command** | `go vet ./... && go test ./... && npm --prefix admin test && (cd ../sm-journal-plugin && go vet ./... && go test ./...)` |
|
||||
| **Estimated runtime** | ~180 seconds (Postgres suites need a Docker host) |
|
||||
| **Full suite command** | `bash scripts/check-phase16.3.sh --all` |
|
||||
| **Estimated runtime** | ~145 seconds on a Docker host (gate --all including postgres) |
|
||||
|
||||
---
|
||||
|
||||
@@ -38,24 +38,24 @@ created: "2026-10-11"
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner from the RESEARCH.md test map and the four PLAN.md files. "Docker" commands need a reachable Docker daemon (testcontainers Postgres); without one they are recorded NOT RUN, never skipped. Plan 04 replaces each status with the real result.
|
||||
Filled by plan 04 with real results. Docker commands ran on this host (Docker daemon available).
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16.3-01-T1 | 01 | 1 | SC-1, SC-2 | T-16.3-01, T-16.3-02, T-16.3-04, T-16.3-07, T-16.3-11 | g15_ token runs as its owner; scope evaluated before the superuser shortcut; method refusal 403 token_scope; malformed tokens refused before the database | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory)$' ./modules/cabana`; Docker: `go test -count=1 -run '^TestTokenTracerPostgres$' ./modules/cabana` and `go test -count=1 -run '^(TestBackendAdminMetadataColumn\|TestPhase09MigrationsFreshRollback)$' ./modules/lagoon` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T2 | 01 | 1 | SC-2 | T-16.3-05, T-16.3-06, T-16.3-09, T-16.3-13 | interactive-only routes refuse every token; /field-types and /markdown/preview gated; ResolveBackend Bearer-only with cover; stamp debounced and non-fatal | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestTokenGatesSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestPhase10CSRF)$' ./modules/cabana`; Docker: `go test -count=1 -run '^(TestTokenTracerPostgres\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T3 | 01 | 1 | SC-3 | T-16.3-03, T-16.3-08 | plaintext only in the 201 create body; own-only soft revoke; 422 on unknown, empty or non-overlapping scopes | unit + script + Docker | `go test -count=1 -run '^(TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestTokenTracerSmoke\|TestTokenGatesSmoke)$' ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck`; Docker: `go test -count=1 -run '^(TestAccountTokensSmoke\|TestPhase10OpenAPIConformance)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T4 | 01 | 1 | SC-3, SC-5 | T-16.3-03, T-16.3-08 | admin:token:create prints only the token; CLI applies the overlap rule; docs name no consuming application | unit + Docker | `go vet ./... && go test -count=1 ./modules/party ./modules/pact && go test -count=1 -run '^(TestDocsTree\|TestDelegateCommandsHelp\|TestDocsCommandsMirrorGeneratedMain)$' ./cmd/summer && go run ./cmd/summer docs:build --check && go test -short -count=1 -run '^(TestPluginActivates\|TestRoutesRegistered)$' ./docs/examples/blog`; Docker: `go test -count=1 -run '^(TestTokenCommandsSmoke\|TestAdminCreateCommand)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-02-T1 | 02 | 2 | SC-3 | T-16.3-14, T-16.3-16 | plaintext shown once and cleared on Done; no storage, console or v-html in the account components | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-02-T2 | 02 | 2 | SC-3 | T-16.3-16 | revoke through the shared confirm; names rendered as text | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts tests/ui/ui.test.ts && bash scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-02-T3 | 02 | 2 | SC-3, SC-5 | T-16.3-14, T-16.3-15, T-16.3-17 | `*` sends only ["*"]; close-without-copy guard; clipboard fallback; server-filtered scopes only | vitest + docs | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-03-T1 | 03 | 3 | SC-4 | T-16.3-18, T-16.3-20, T-16.3-21 | Journal writes accept a journal-scoped token as the owner; resolver errors map to the PHP bodies | unit + Docker | `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' ./controllers/api/ && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' .` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-03-T2 | 03 | 3 | SC-2, SC-4, SC-5 | T-16.3-18, T-16.3-19, T-16.3-23 | journal:read and unrelated scopes refused on writes; drafts only as the owner may see; featured-image parity | docs + Docker | `go test -count=1 -run '^ExampleResolveBackend$\|^TestDocsTree$' ./modules/cabana ./cmd/summer && go run ./cmd/summer docs:build --check`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer\|TestJournalTokenExpansion\|TestJournalWriteUnauthenticated\|TestJournalFeaturedImageUnauthenticated)$' .` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-03-T3 | 03 | 3 | SC-4 | T-16.3-22 | human approves the tag with the one-way surfaces and NOT RUN suites listed | gate + checkpoint | release gate command in 16.3-03-PLAN.md Task 3 (`… && echo release-gate-ok`) | ✅ | ⬜ pending |
|
||||
| 16.3-03-T4 | 03 | 3 | SC-4 | T-16.3-22 | tag, Journal require and host gitlink pushed only after approval | git checks | release-ok command in 16.3-03-PLAN.md Task 4 | ✅ | ⬜ pending |
|
||||
| 16.3-04-T1 | 04 | 4 | SC-6, SC-2 | T-16.3-25 | scope rules, every route's token class, SPA plaintext backstop, Journal scope keys | unit + vitest + Docker | `go test -count=1 -run '^(TestScopeMatch\|TestAllowsScope\|TestTokenRouteGates)$' ./modules/cabana && npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts && go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' .`; Docker: `go test -count=1 -run '^TestBackendTokenPostgres$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-04-T2 | 04 | 4 | SC-6, SC-1..SC-4 | T-16.3-25, T-16.3-26 | full Go coverage incl. migration, API, CLI and Journal matrix | unit + Docker | the sixteen-name Docker-free cabana set in 16.3-04-PLAN.md Task 2; Docker: lagoon, cabana and Journal sets in the same task | ❌ W0 | ⬜ pending |
|
||||
| 16.3-04-T3 | 04 | 4 | SC-6 | T-16.3-24 | gate fails closed on skips, zero tests and missing names | vitest + gate | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase16.3.sh --self-test && bash scripts/check-phase16.3.sh --all` | ❌ W0 | ⬜ pending |
|
||||
| 16.3-01-T1 | 01 | 1 | SC-1, SC-2 | T-16.3-01, T-16.3-02, T-16.3-04, T-16.3-07, T-16.3-11 | g15_ token runs as its owner; scope evaluated before the superuser shortcut; method refusal 403 token_scope; malformed tokens refused before the database | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory)$' ./modules/cabana`; Docker: `go test -count=1 -run '^TestTokenTracerPostgres$' ./modules/cabana` and `go test -count=1 -run '^(TestBackendAdminMetadataColumn\|TestPhase09MigrationsFreshRollback)$' ./modules/lagoon` | ✅ | ✅ green |
|
||||
| 16.3-01-T2 | 01 | 1 | SC-2 | T-16.3-05, T-16.3-06, T-16.3-09, T-16.3-13 | interactive-only routes refuse every token; /field-types and /markdown/preview gated; ResolveBackend Bearer-only with cover; stamp debounced and non-fatal | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestTokenGatesSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestPhase10CSRF)$' ./modules/cabana`; Docker: `go test -count=1 -run '^(TestTokenTracerPostgres\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken)$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.3-01-T3 | 01 | 1 | SC-3 | T-16.3-03, T-16.3-08 | plaintext only in the 201 create body; own-only soft revoke; 422 on unknown, empty or non-overlapping scopes | unit + script + Docker | `go test -count=1 -run '^(TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestTokenTracerSmoke\|TestTokenGatesSmoke)$' ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck`; Docker: `go test -count=1 -run '^(TestAccountTokensSmoke\|TestPhase10OpenAPIConformance)$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.3-01-T4 | 01 | 1 | SC-3, SC-5 | T-16.3-03, T-16.3-08 | admin:token:create prints only the token; CLI applies the overlap rule; docs name no consuming application | unit + Docker | `go vet ./... && go test -count=1 ./modules/party ./modules/pact && go test -count=1 -run '^(TestDocsTree\|TestDelegateCommandsHelp\|TestDocsCommandsMirrorGeneratedMain)$' ./cmd/summer && go run ./cmd/summer docs:build --check && go test -short -count=1 -run '^(TestPluginActivates\|TestRoutesRegistered)$' ./docs/examples/blog`; Docker: `go test -count=1 -run '^(TestTokenCommandsSmoke\|TestAdminCreateCommand)$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.3-02-T1 | 02 | 2 | SC-3 | T-16.3-14, T-16.3-16 | plaintext shown once and cleared on Done; no storage, console or v-html in the account components | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh` | ✅ | ✅ green |
|
||||
| 16.3-02-T2 | 02 | 2 | SC-3 | T-16.3-16 | revoke through the shared confirm; names rendered as text | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts tests/ui/ui.test.ts && bash scripts/check-admin-dist.sh` | ✅ | ✅ green |
|
||||
| 16.3-02-T3 | 02 | 2 | SC-3, SC-5 | T-16.3-14, T-16.3-15, T-16.3-17 | `*` sends only ["*"]; close-without-copy guard; clipboard fallback; server-filtered scopes only | vitest + docs | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree` | ✅ | ✅ green |
|
||||
| 16.3-03-T1 | 03 | 3 | SC-4 | T-16.3-18, T-16.3-20, T-16.3-21 | Journal writes accept a journal-scoped token as the owner; resolver errors map to the PHP bodies | unit + Docker | `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' ./controllers/api/ && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' .` | ✅ | ✅ green |
|
||||
| 16.3-03-T2 | 03 | 3 | SC-2, SC-4, SC-5 | T-16.3-18, T-16.3-19, T-16.3-23 | journal:read and unrelated scopes refused on writes; drafts only as the owner may see; featured-image parity | docs + Docker | `go test -count=1 -run '^ExampleResolveBackend$\|^TestDocsTree$' ./modules/cabana ./cmd/summer && go run ./cmd/summer docs:build --check`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer\|TestJournalTokenExpansion\|TestJournalWriteUnauthenticated\|TestJournalFeaturedImageUnauthenticated)$' .` | ✅ | ✅ green |
|
||||
| 16.3-03-T3 | 03 | 3 | SC-4 | T-16.3-22 | human approves the tag with the one-way surfaces and NOT RUN suites listed | gate + checkpoint | release gate command in 16.3-03-PLAN.md Task 3 (`… && echo release-gate-ok`) | ✅ | ✅ green |
|
||||
| 16.3-03-T4 | 03 | 3 | SC-4 | T-16.3-22 | tag, Journal require and host gitlink pushed only after approval | git checks | release-ok command in 16.3-03-PLAN.md Task 4 | ✅ | ✅ green |
|
||||
| 16.3-04-T1 | 04 | 4 | SC-6, SC-2 | T-16.3-25 | scope rules, every route's token class, SPA plaintext backstop, Journal scope keys | unit + vitest + Docker | `go test -count=1 -run '^(TestScopeMatch\|TestAllowsScope\|TestTokenRouteGates)$' ./modules/cabana && npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts && go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' .`; Docker: `go test -count=1 -run '^TestBackendTokenPostgres$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.3-04-T2 | 04 | 4 | SC-6, SC-1..SC-4 | T-16.3-25, T-16.3-26 | full Go coverage incl. migration, API, CLI and Journal matrix | unit + Docker | sixteen-name Docker-free cabana set in 16.3-04-PLAN.md Task 2; Docker: lagoon=3, cabana=4, Journal=3 | ✅ | ✅ green |
|
||||
| 16.3-04-T3 | 04 | 4 | SC-6 | T-16.3-24 | gate fails closed on skips, zero tests and missing names | vitest + gate | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase16.3.sh --self-test && bash scripts/check-phase16.3.sh --all` → last line `Phase 16.3 gate passed` (postgres PASS on this Docker host) | ✅ | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -63,13 +63,13 @@ Filled in by the planner from the RESEARCH.md test map and the four PLAN.md file
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/cabana/token_smoke_test.go`, `token_postgres_smoke_test.go` — plan 01 smoke (tracer, gates, account API, commands)
|
||||
- [ ] `modules/cabana/token_scopes_test.go`, `api_tokens_test.go`, `token_guard_test.go`, `token_routes_test.go` — Allows / scope match / compile / overlap / mint / expiry / validation / guard / resolver / route classification (Docker-free, plan 04)
|
||||
- [ ] `modules/cabana/token_postgres_test.go`, `account_tokens_test.go`, `token_commands_test.go` — Postgres guard, account token API, token commands with stdout purity (plan 04)
|
||||
- [ ] `modules/lagoon/backend_admin_migrations_test.go` — `TestBackendAPITokensMigration` and the updated last-ID test
|
||||
- [ ] `admin/tests/account/ApiTokensPanel.test.ts`, `admin/tests/account/ApiTokenCreateDialog.test.ts` — UI-SPEC states and backstops (path chosen in plan 02; `tests/components/` does not exist)
|
||||
- [ ] `../sm-journal-plugin/controllers/api/auth_test.go`, `journal_api_token_smoke_test.go`, `token_scopes_test.go`, `journal_api_tokens_test.go` — Journal mapping, scopes and token matrix
|
||||
- [ ] `scripts/check-phase16.3.sh` — phase gate
|
||||
- [x] `modules/cabana/token_smoke_test.go`, `token_postgres_smoke_test.go` — plan 01 smoke (tracer, gates, account API, commands)
|
||||
- [x] `modules/cabana/token_scopes_test.go`, `api_tokens_test.go`, `token_guard_test.go`, `token_routes_test.go` — Allows / scope match / compile / overlap / mint / expiry / validation / guard / resolver / route classification (Docker-free, plan 04)
|
||||
- [x] `modules/cabana/token_postgres_test.go`, `account_tokens_test.go`, `token_commands_test.go` — Postgres guard, account token API, token commands with stdout purity (plan 04)
|
||||
- [x] `modules/lagoon/backend_admin_migrations_test.go` — `TestBackendAPITokensMigration` and the updated last-ID test
|
||||
- [x] `admin/tests/account/ApiTokensPanel.test.ts`, `admin/tests/account/ApiTokenCreateDialog.test.ts` — UI-SPEC states and backstops (path chosen in plan 02; `tests/components/` does not exist)
|
||||
- [x] `../sm-journal-plugin/controllers/api/auth_test.go`, `journal_api_token_smoke_test.go`, `token_scopes_test.go`, `journal_api_tokens_test.go` — Journal mapping, scopes and token matrix
|
||||
- [x] `scripts/check-phase16.3.sh` — phase gate
|
||||
|
||||
---
|
||||
|
||||
@@ -78,17 +78,17 @@ Filled in by the planner from the RESEARCH.md test map and the four PLAN.md file
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Clipboard copy of the show-once token in a real browser | SC-3 | Clipboard API permissions differ from happy-dom | Create a token in the admin SPA, copy it, use it as `Authorization: Bearer` against a Journal write route |
|
||||
| Postgres suites when an executor had no Docker daemon | SC-1..SC-4 | testcontainers needs Docker | On a Docker host: `bash scripts/check-phase16.3.sh postgres` (lagoon, cabana and Journal token suites by name) |
|
||||
| Postgres suites when an executor had no Docker daemon | SC-1..SC-4 | testcontainers needs Docker | On a Docker host: `bash scripts/check-phase16.3.sh postgres` (lagoon, cabana and Journal token suites by name). **This executor host ran postgres: PASS.** |
|
||||
|
||||
---
|
||||
|
||||
## 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 < 180s
|
||||
- [ ] `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 < 180s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** plan 04 executor — gate `--all` ended with `Phase 16.3 gate passed` (2026-10-11)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
verified: 2026-10-11T12:06:35Z
|
||||
status: passed
|
||||
score: 6/6 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-PLAN.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-PLAN.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-PLAN.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-PLAN.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-SUMMARY.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-UI-SPEC.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-REVIEW.md
|
||||
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-REVIEW-DISPOSITION.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/bouncer/context.go
|
||||
- modules/bouncer/context_test.go
|
||||
- modules/party/registry.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_scopes.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/example_tokens_test.go
|
||||
- modules/cabana/token_smoke_test.go
|
||||
- modules/cabana/token_postgres_smoke_test.go
|
||||
- modules/cabana/token_scopes_test.go
|
||||
- modules/cabana/api_tokens_test.go
|
||||
- modules/cabana/token_guard_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/token_postgres_test.go
|
||||
- modules/cabana/account_tokens_test.go
|
||||
- modules/cabana/token_commands_test.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/pact/README.md
|
||||
- modules/bouncer/README.md
|
||||
- modules/party/README.md
|
||||
- modules/lagoon/README.md
|
||||
- cmd/summer/runtime.go
|
||||
- admin/src/components/account/ApiTokensPanel.vue
|
||||
- admin/src/components/account/ApiTokenCreateDialog.vue
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/services/authentication.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/introduction.md
|
||||
- docs/plugins/registration.md
|
||||
- docs/examples/blog/token_scopes.go
|
||||
- scripts/check-phase16.3.sh
|
||||
covered_digest: "v2:sha256:45cf7b202d293412c7384f25aa1c456492f6571adae14b0413307c48022a218a"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: gaps_found
|
||||
previous_score: 5/6
|
||||
gaps_closed:
|
||||
- "SC-6 / phase gate: d1b043a retargeted ApiTokensPanel tests; orchestrator re-ran check-phase16.3.sh --all → Phase 16.3 gate passed (GATE_EXIT=0)"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
---
|
||||
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes Verification Report
|
||||
|
||||
**Phase Goal:** Backend users can create personal API tokens with a scope they pick at creation time (for example Journal only, or system-wide; later read-only Payment Gateway), so an agent or integration can call the admin and plugin APIs as that backend user, with no more access than the token's scope and the user's own permissions allow. This is the successor of Apparatus PersonalApiToken and reverses the Phase 15 D-15 rule that Journal writes accept only a backend JWT.
|
||||
**Verified:** 2026-10-11T12:06:35Z
|
||||
**Status:** passed
|
||||
**Re-verification:** Yes — after SC-6 gap closure (`d1b043a`)
|
||||
|
||||
ROADMAP Success Criteria (SC-1..SC-6) are the contract truths. REQUIREMENTS.md has no phase-16.3-specific IDs (AUTH-03 is an earlier Płytarium-era checkbox). Plan frontmatter truths expand the same SCs; they were checked as supporting evidence, not as a separate score denominator.
|
||||
|
||||
WR-01 / WR-02 from code review are dispositioned fixed (a955d2a / cb4b0a9). SC-6 stale-test gap closed in `d1b043a`. No SECURITY.md yet (secure-phase pending). Tag v0.1.21 exists; Journal `go.mod` requires `v0.1.21`.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | SC-1: The `backend` guard accepts a valid, unexpired, unrevoked `g15_` token as well as the backend JWT, and the request runs as the token's backend user | ✓ VERIFIED | `newCompositeGuard` routes `Bearer g15_` to token lookup; else JWT+cookie. `principal.Scope = limit`. Gate go + postgres `TestBackendTokenPostgres` PASS |
|
||||
| 2 | SC-2: A request outside the token's scope is refused even when the user's role would allow it, and a token never grants more than its owner's permissions | ✓ VERIFIED | `Allows` → `allowsUnderScope` before superuser / empty-list. `TestAllowsScope`, `TestTokenRouteGates`, Journal write matrix on Postgres PASS |
|
||||
| 3 | SC-3: A backend user can create, list and revoke their own tokens in the admin SPA; plaintext shown once; CLI does the same for any backend user | ✓ VERIFIED | AccountView → ApiTokensPanel; create dialog show-once + clipboard backstops (vitest). `TestAccountTokens`, `TestTokenCommands` on Postgres PASS |
|
||||
| 4 | SC-4: The Journal API accepts a Journal-scoped token for writes | ✓ VERIFIED | `journal` / `journal:read` scopes; `ResolveBackend` with cover `golem15.journal.*`. Postgres `TestJournalTokens` matrix PASS |
|
||||
| 5 | SC-5: Module READMEs and `docs/` cover the token API, scopes and CLI commands | ✓ VERIFIED | Module READMEs + backend/auth/console/plugin docs. Gate docs stage PASS |
|
||||
| 6 | SC-6: The new code has unit tests, delivered in the phase's last plan | ✓ VERIFIED | `d1b043a` adds disabled-submit case and retargets 422 scopes (select scope first). Re-verify: panel 15/15 + dialog 19/19 PASS; `check-phase16.3.sh admin` PASS (1252 vitest); orchestrator `--all` → `Phase 16.3 gate passed` (GATE_EXIT=0) |
|
||||
|
||||
**Score:** 6/6 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Deferred Items
|
||||
|
||||
None.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/pact/capabilities.go` | `TokenScope`, `HasTokenScopes` | ✓ VERIFIED | |
|
||||
| `modules/bouncer/context.go` | `ScopeLimit` on `Principal` | ✓ VERIFIED | |
|
||||
| `modules/lagoon/backend_admin_migrations.go` | `202610110001_backend_api_tokens` | ✓ VERIFIED | |
|
||||
| `modules/cabana/api_tokens.go` | mint/hash/`APITokenPrefix`/`issueAPIToken` | ✓ VERIFIED | |
|
||||
| `modules/cabana/token_scopes.go` | compile, `allowsUnderScope` | ✓ VERIFIED | |
|
||||
| `modules/cabana/token_guard.go` | composite guard, `ResolveBackend` | ✓ VERIFIED | |
|
||||
| `modules/cabana/account_tokens.go` | four account token handlers | ✓ VERIFIED | |
|
||||
| `modules/cabana/token_commands.go` | `admin:token:*` | ✓ VERIFIED | |
|
||||
| `admin/src/components/account/ApiTokensPanel.vue` | list/create/revoke card | ✓ VERIFIED | |
|
||||
| `admin/src/components/account/ApiTokenCreateDialog.vue` | two-step show-once | ✓ VERIFIED | WR-02 `canSubmit` |
|
||||
| `../sm-journal-plugin/token_scopes.go` | journal scopes | ✓ VERIFIED | Sibling repo |
|
||||
| `../sm-journal-plugin/journal_api_tokens_test.go` | write matrix | ✓ VERIFIED | |
|
||||
| `scripts/check-phase16.3.sh` | phase gate | ✓ VERIFIED | `--all` green after `d1b043a` |
|
||||
| Plan 01–04 artifact lists | `verify.artifacts` | ✓ VERIFIED | all_passed |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `modules/cabana/http.go` | composite guard | `newCompositeGuard` + `guards.Register(..., "backend", …)` | ✓ WIRED | Cookie-less `backendResolver` published |
|
||||
| `modules/cabana/token_guard.go` | `bouncer.Principal.Scope` | `principal.Scope = limit` | ✓ WIRED | |
|
||||
| `modules/cabana/contracts.go` `Allows` | `allowsUnderScope` | before superuser / empty list | ✓ WIRED | |
|
||||
| account API + CLI | `issueAPIToken` | shared mint path | ✓ WIRED | |
|
||||
| `token_commands.go` | `party.Activated` | `app.Lookup[party.Activated]()` | ✓ WIRED | |
|
||||
| Journal `auth.go` | `cabana.ResolveBackend` | cover `golem15.journal.*` | ✓ WIRED | |
|
||||
| AccountView | ApiTokensPanel | import + render | ✓ WIRED | |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|--------------------|--------|
|
||||
| ApiTokensPanel list | token rows | `GET /account/tokens` | Yes | ✓ FLOWING |
|
||||
| Create dialog plaintext | `plaintext` ref | `POST /account/tokens` 201 | Yes; cleared on Done / Esc-confirm | ✓ FLOWING |
|
||||
| CLI create stdout | plaintext | `issueAPIToken` | Yes | ✓ FLOWING |
|
||||
| Journal writes | principal | `ResolveBackend` | Yes | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Phase gate (full) | `bash scripts/check-phase16.3.sh --all` (orchestrator) | `Phase 16.3 gate passed`, GATE_EXIT=0 | ✓ PASS |
|
||||
| Gate admin (re-verify) | `bash scripts/check-phase16.3.sh admin` | `phase16.3 admin passed` (1252 vitest) | ✓ PASS |
|
||||
| SPA panel + dialog | `vitest … ApiTokensPanel + ApiTokenCreateDialog` | 34/34 PASS | ✓ PASS |
|
||||
| Gate go / docs / postgres | prior verify + orchestrator `--all` | PASS | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| — | — | No phase probes declared | SKIP |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| SC-1 | 01, 04 | Composite guard + `g15_` | ✓ SATISFIED | |
|
||||
| SC-2 | 01, 04 | Scope ≤ owner | ✓ SATISFIED | |
|
||||
| SC-3 | 01, 02, 04 | SPA + CLI token CRUD | ✓ SATISFIED | |
|
||||
| SC-4 | 03, 04 | Journal-scoped writes | ✓ SATISFIED | |
|
||||
| SC-5 | 01, 02, 03 | Docs / READMEs | ✓ SATISFIED | |
|
||||
| SC-6 | 04 | Unit tests + phase gate | ✓ SATISFIED | `d1b043a` + `--all` green |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| — | — | Prior stale 422 case | resolved | Fixed in `d1b043a` |
|
||||
| IN-01 / IN-02 (REVIEW) | — | Info findings open | ℹ️ Info | Not goal blockers |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. Clipboard / show-once behavior is covered by SPA unit backstops (`ApiTokenCreateDialog` plaintext lifetime + copy success/failure cases; panel dialog rules). VALIDATION.md’s real-browser clipboard row remains an optional nicety, not a must-have for this verdict. No SECURITY.md in this phase (secure-phase separate).
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No remaining gaps. Previous SC-6 failure closed by `d1b043a` (disabled-submit assertion + 422 path with a selected scope); full phase gate green.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-11T12:06:35Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
414
.planning/phases/17-blog-companions/17-01-PLAN.md
Normal file
414
.planning/phases/17-blog-companions/17-01-PLAN.md
Normal file
@@ -0,0 +1,414 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/party/registry.go
|
||||
- modules/party/README.md
|
||||
- modules/signpost/signpost.go
|
||||
- modules/signpost/signpost_smoke_test.go
|
||||
- modules/signpost/README.md
|
||||
- modules/seaglass/seaglass.go
|
||||
- modules/seaglass/seaglass_smoke_test.go
|
||||
- modules/seaglass/README.md
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_richeditor.go
|
||||
- modules/cabana/field_repeater.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/testdata/companions/controllers/links/config_form.yaml
|
||||
- modules/cabana/testdata/companions/controllers/links/config_list.yaml
|
||||
- modules/cabana/testdata/companions/controllers/links/config_reorder.yaml
|
||||
- modules/cabana/testdata/companions/models/link/fields.yaml
|
||||
- modules/cabana/testdata/companions/models/link/columns.yaml
|
||||
- modules/cabana/companions_smoke_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- docs/plugins/menu-item-types.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/settings.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, D-01, D-02, D-03, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
estimate:
|
||||
tokens: 210000
|
||||
raw_tokens: 210000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: a plugin that implements pact.HasMenuItemTypes returns typed pact.MenuItemType values (Key, Label, DefaultPattern, PatternFrom, Placeholders, Nesting, DynamicItems, References, Resolve); signpost.Activate collects them in party.Activate after every Register and before the first Boot and publishes one *signpost.Registry that consumers read with app.Lookup; no festival event and no string event name carries list, type info or resolve."
|
||||
- "SC-2 boot rules (edge probe, adjacency): two plugins registering the same type key fail boot with an error that names the key and both plugin IDs; an empty key, a nil Resolve, a DefaultPattern that is empty or does not start with a slash, a PatternFrom that names a type no plugin registered, the type itself or a type that sets its own PatternFrom, and a host override in menu_items.patterns for a type no plugin registered each fail boot with an error prefixed signpost:."
|
||||
- "D-02: every registered type declares a DefaultPattern (layer 1); the URL pattern is layered as the item override when non-empty, else the host config menu_items.patterns entry for the type key, else, for a list type that sets PatternFrom (the key of the type whose records its items are), the host entry for that record type, else the type's DefaultPattern; so a host override of a record type also reaches the children of the list types that name it, unless the host or the item overrides the list type itself; signpost Registry.Resolve passes that layered pattern to the type in pact.MenuItemResolve.Pattern and a list type builds its children from it; menu_items.patterns is a YAML map read once at boot and documented as file-only (type keys contain dashes, RESEARCH Pitfall 13)."
|
||||
- "D-03: pact.ResolvedMenuItem carries Title, URL, MTime and Alternates as an ordered locale-to-URL list ([]pact.MenuItemAlternate, never a Go map, so hreflang order is stable), plus nested Items for expanding types; a single-locale site resolves to one alternate."
|
||||
- "D-08 (one admin binding): a dropdown with dependsOn naming a sibling field (same form, or same repeater row) gets its options from the controller's pact.DependentOptionsProvider; signpost.ReferenceOptions flattens a type's reference tree into options with a depth; GET {admin api}/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value> answers {data:{applies, options:[{value,label,depth}]}}; a type without references answers applies false (edge probe, empty); a submitted value that is not among the options for the submitted sibling value is a 422 with form.dependent_invalid; null is accepted unless the field is required."
|
||||
- "D-10: the core richeditor field type sanitizes every submitted value (top-level and inside repeater rows) through seaglass, which wraps bluemonday v1.0.27 with the Journal-equivalent policy; HTML the policy leaves token-equal is stored byte-identical; removed elements, attributes and comments come back as meta.notices under the field path; no save path (controller form, repeater row) stores an unsanitized richeditor value, and settings and relation forms refuse richeditor, repeater and dependsOn at boot."
|
||||
- "D-10 resolved deviation (RESEARCH A1, PATTERNS note): Journal's category content stays type blogeditor (shipped in Phase 16.2, todo cabana-richeditor-field-type closed); this phase does not switch it to richeditor; the core richeditor serves FAQ description and the repeater answer."
|
||||
- "D-07: type repeater edits a JSON list in a jsonable column with nested form.fields (allowed nested types text, textarea, number, checkbox, switch, dropdown with static, method or dependsOn options, richeditor); keys prompt, maxItems, minItems, titleFrom; groups, style, a repeater inside a repeater and any other nested type fail boot; rows keep only nested field names with scalars coerced per type; required applies per row with error keys such as content.3.answer; maxItems and minItems are 422s on the field; the nested-value refusal in ProjectWritableFields gets a schema-driven exemption for repeater fields only."
|
||||
- "D-09: a controller with config_reorder.yaml (title, nameFrom, modelClass, parentColumn default parent_id, sortColumn default sort_order, maxDepth, optional partition block) gets GET and POST {admin api}/{vendor}/{plugin}/{controller}/reorder; toolbar.buttons accepts reorder only on such a controller; the POST runs in one transaction that locks the scoped set (ListExtendQuery plus the partition), requires the submitted id set to equal it exactly, refuses a cycle, a parent outside the set and a depth above maxDepth, then writes both parent and sort columns."
|
||||
- "SC-3 edge (concurrency): a reorder POST whose node set no longer matches the locked scoped set (a record added, removed or moved to another partition meanwhile) answers 409 reorder_stale and writes nothing."
|
||||
- "D-11: an admin controller implementing pact.AdminSingleton gets GET {admin api}/{vendor}/{plugin}/{controller}/singleton, which returns the one record's id after SingletonRecord creates it when absent; the form schema marks the controller singleton; its list, create, bulk and delete routes answer 404; a SettingsItem with Controller links to it."
|
||||
- "D-06: every new type, key and route has fail-loud boot checks, swag annotations, regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts, entries in phase09Routes, the token route classes, the phase 10 coverage list and the conformance inventory, and README plus docs updates in the same commit; signpost and seaglass are new beach-named modules with standard READMEs and root README rows; bluemonday enters go.mod with a decision note naming it."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth"
|
||||
contains: "HasMenuItemTypes"
|
||||
- path: "modules/signpost/signpost.go"
|
||||
provides: "menu item type registry: Activate, Registry, ReferenceOptions"
|
||||
contains: "func Activate"
|
||||
- path: "modules/seaglass/seaglass.go"
|
||||
provides: "bluemonday HTML policy with byte-stable SanitizeReport and Check"
|
||||
contains: "func SanitizeReport"
|
||||
- path: "modules/cabana/field_dependent.go"
|
||||
provides: "dependsOn compile, dependent-options route, save recheck"
|
||||
contains: "DependentOptionsProvider"
|
||||
- path: "modules/cabana/field_repeater.go"
|
||||
provides: "repeater compile, decode and nested checks"
|
||||
contains: "repeater"
|
||||
- path: "modules/cabana/field_richeditor.go"
|
||||
provides: "richeditor save gate through seaglass"
|
||||
contains: "seaglass.SanitizeReport"
|
||||
- path: "modules/cabana/reorder.go"
|
||||
provides: "config_reorder.yaml compile and the GET/POST reorder routes"
|
||||
contains: "reorder_stale"
|
||||
- path: "modules/cabana/singleton.go"
|
||||
provides: "singleton controller route"
|
||||
contains: "AdminSingleton"
|
||||
- path: ".planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md"
|
||||
provides: "decision note naming bluemonday v1.0.27 for the framework"
|
||||
contains: "bluemonday"
|
||||
key_links:
|
||||
- from: "modules/party/registry.go Activate"
|
||||
to: "modules/signpost/signpost.go Activate"
|
||||
via: "called after phrasebook and postcard activation, before the Boot loop"
|
||||
pattern: "signpost.Activate"
|
||||
- from: "modules/cabana/field_dependent.go"
|
||||
to: "pact.DependentOptionsProvider on the admin controller"
|
||||
via: "dependent-options route and save recheck"
|
||||
pattern: "DependentOptions\\("
|
||||
- from: "modules/cabana/field_richeditor.go and field_repeater.go"
|
||||
to: "modules/seaglass/seaglass.go"
|
||||
via: "every richeditor value, top-level or nested, is sanitized before it is written"
|
||||
pattern: "seaglass\\."
|
||||
- from: "modules/cabana/crud.go ProjectWritableFields"
|
||||
to: "modules/cabana/field_repeater.go"
|
||||
via: "schema-driven exemption for repeater fields; nestedValue unchanged"
|
||||
pattern: "repeater"
|
||||
prohibitions:
|
||||
- statement: "Framework modules (signpost, seaglass, cabana, pact, party) never import a plugin module (sm-*), never name a consuming application in code, README or docs, and never carry a site-specific URL pattern."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The nested-value refusal in ProjectWritableFields stays in force for every field that is not a repeater; no richeditor value reaches the database without passing seaglass."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing cabana field types, routes, payloads and the Journal blogeditor contract keep their behaviour byte for byte; only additive fields (Option.Depth with omitempty, new schema keys) change the admin schema."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format, flagged for `/msd mvp-phase 17` if a story is wanted): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 1 of 6, framework Go, wave 1): the typed menu-item type registry (D-01 to D-03) and the cabana features the three plugins' admin screens need (D-06 to D-11), server side only. The admin SPA, dist and the framework tag are plan 02.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC and no REQUIREMENTS IDs for this phase. The edge probe ran over SC-1..SC-5 as the requirement text (15 items, 2 unclassified). Items owned by this plan: SC-2 adjacency (duplicate key), SC-2 empty (type without references), SC-3 concurrency (stale reorder set); each is an explicit truth above. Prohibitions were recalled in-prompt and are descriptor-less (flagged-unverified). Canon security items (XSS, injection, IDOR) are not minted as prohibitions; they are in the threat model.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Registry module `signpost` (a beach signpost points the way to places), sanitizer module `seaglass` (glass the sea has worn smooth). Both get the standard README and a root README row.
|
||||
- Host pattern config key `menu_items.patterns` (map from type key to pattern), read with compass `LoadSection` once in `signpost.Activate`. Host apps put it in `config/menu_items.yaml`.
|
||||
- List types (items without a URL of their own, such as "all posts") carry `PatternFrom`, the key of the type whose records they list. Their children then follow the host override of that record type, so a host that changes the post or category URL sets it once and the list types follow. A host or item override of the list type still wins, which keeps the three D-02 layers. Every type, list types included, still declares a valid `DefaultPattern`.
|
||||
- Alternates are `[]pact.MenuItemAlternate` (ordered), honoring D-03's locale-to-URL mapping without Go map ordering (Pitfall 4).
|
||||
- The dependent-options payload carries `applies` so the SPA can tell "type takes no reference" from "no records yet" (UI-SPEC states).
|
||||
- Reorder stale set answers 409 with error code `reorder_stale`; other refusals (cycle, foreign parent, depth) are 422 `validation_failed`.
|
||||
- Settings and relation forms refuse `richeditor`, `repeater` and `dependsOn` at boot ("supported in controller forms only"), so neither save path can bypass the D-10 gate or the D-08 recheck. The sitemap definition uses the singleton controller form (RESEARCH A6, Pattern 7).
|
||||
- D-10 Journal clause: not executed; recorded as a resolved deviation (truth above). The bluemonday decision note is committed as a planning-doc commit before the code commit that adds the dependency.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/pact`: `HasMenuItemTypes`, `MenuItemType`, `MenuItemReference`, `MenuItemResolve`, `ResolvedMenuItem`, `MenuItemAlternate`, `DependentOptionsProvider`, `DependentOptionSet`, `AdminSingleton`; `Option` gains `Depth int` (json `depth,omitempty`).
|
||||
- `modules/signpost` (new): `Activate`, `Registry` (`Types`, `Type`, `Pattern`, `Resolve`), `ReferenceOptions`, `ConfigKey`.
|
||||
- `modules/seaglass` (new): `Sanitize`, `SanitizeReport`, `Check`, `Removals`.
|
||||
- `modules/cabana`: field types `richeditor` and `repeater`; field key `dependsOn`; `config_reorder.yaml`; toolbar button `reorder`; routes `GET …/fields/{field}/dependent-options`, `GET` and `POST …/reorder`, `GET …/singleton`; phrase keys for the server messages.
|
||||
- Docs: `docs/plugins/menu-item-types.md` (new), sections in `docs/backend/forms.md`, `lists-and-filters.md`, `admin-controllers.md`, `settings.md`; decision note `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`.
|
||||
- Smoke tests: `TestSignpostSmoke`, `TestSeaglassSmoke`, `TestCompanionsDependentSmoke`, `TestCompanionsRichEditorSmoke`, `TestCompanionsRepeaterSmoke`, `TestCompanionsReorderSmoke`, `TestCompanionsSingletonSmoke` (full unit tests are plan 06).
|
||||
|
||||
<objective>
|
||||
Give plugins a typed menu-item type registry and give cabana the repeater, dependent dropdown, richeditor, tree reorder and singleton form the three ports need, all fail-loud, documented and in the admin OpenAPI.
|
||||
|
||||
Purpose: SC-2 (typed successor of pages.menuitem.*) and the framework half of SC-1 (the PHP admin screens can be expressed in YAML).
|
||||
Output: two new modules, cabana features and routes, regenerated admin.json and schema.d.ts, docs, smoke tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-PATTERNS.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
|
||||
Concurrency: another session may hold uncommitted edits in modules/cabana or modules/lagoon. Before Task 1 run `git status --porcelain -- modules admin docs scripts` in summercms.go; if it lists files this plan touches, stop and report (do not stash or overwrite another session's work). Stage files by explicit path; planning docs and code in separate commits; no co-author tags. Docker-backed cabana smoke tests skip under -short; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`), and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
New pact contracts (names fixed by this plan; doc comment on every exported identifier):
|
||||
|
||||
```go
|
||||
type MenuItemType struct {
|
||||
Key string // unique across plugins, e.g. "journal-post"
|
||||
Label string // phrase key
|
||||
DefaultPattern string // layer 1 of D-02, required on every type, starts with "/"
|
||||
PatternFrom string // optional, list types only: key of the type whose records the items are (e.g. "acme-post"); its host override applies when this type has none
|
||||
Placeholders []string // e.g. ":slug", ":category", ":id", ":year", ":month", ":day"
|
||||
Nesting bool
|
||||
DynamicItems bool
|
||||
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` // nil: takes no reference
|
||||
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
|
||||
}
|
||||
type MenuItemReference struct { Value, Label string; Items []MenuItemReference }
|
||||
type MenuItemResolve struct { Type, Reference, Pattern string; Nesting bool; UnprefixedLocale string }
|
||||
type ResolvedMenuItem struct { Title, URL string; MTime *time.Time; Alternates []MenuItemAlternate; Items []ResolvedMenuItem }
|
||||
type MenuItemAlternate struct { Locale, URL, Title string }
|
||||
type HasMenuItemTypes interface { MenuItemTypes() []MenuItemType }
|
||||
|
||||
type DependentOptionSet struct { Applies bool; Options []Option }
|
||||
type DependentOptionsProvider interface {
|
||||
DependentOptions(ctx context.Context, field string, values map[string]any) (DependentOptionSet, error)
|
||||
}
|
||||
type AdminSingleton interface {
|
||||
SingletonRecord(ctx context.Context, db *gorm.DB) (any, error) // first or create
|
||||
}
|
||||
// Option gains: Depth int `json:"depth,omitempty"`
|
||||
```
|
||||
|
||||
signpost (new):
|
||||
|
||||
```go
|
||||
const ConfigKey = "menu_items.patterns"
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
|
||||
type Registry struct{ /* unexported */ }
|
||||
func (r *Registry) Types() []pact.MenuItemType // plugin activation order, then declaration order
|
||||
func (r *Registry) Type(key string) (pact.MenuItemType, bool)
|
||||
func (r *Registry) Pattern(key, itemOverride string) string // itemOverride, else host[key], else host[PatternFrom] when set, else DefaultPattern
|
||||
func (r *Registry) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
|
||||
func ReferenceOptions(ctx context.Context, r *Registry, typeKey string) (pact.DependentOptionSet, error)
|
||||
```
|
||||
|
||||
seaglass (new):
|
||||
|
||||
```go
|
||||
type Removals struct { Elements []string; Attributes []string; Comments int; Changed bool }
|
||||
func Sanitize(html string) string
|
||||
func SanitizeReport(html string) (string, Removals)
|
||||
func Check(html string) error
|
||||
```
|
||||
|
||||
Existing seams (read, do not change their behaviour): pact.FormFieldNotice and cabana localizeNotices/FieldNotice (meta.notices), cabana ProjectWritableFields and nestedValue (crud.go :159, :1272), requireSettingInputs (settings.go :287), markdownPreview route shape (field_markdown.go :71), fileReorderOn exact-set rule (field_file.go :1303), phrasebook.Activate wiring (party/registry.go :128).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a form's reference dropdown lists the records of the menu item type chosen in its sibling, served through the registry from a plugin's typed registration</name>
|
||||
<reversibility rating="costly">D-01 and D-08: pact.MenuItemType, HasMenuItemTypes and DependentOptionsProvider are implemented by Journal, menu, sitemap and every later content plugin; reshaping them means coordinated changes in all of them.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/signpost/signpost.go, modules/signpost/signpost_smoke_test.go, modules/signpost/README.md, modules/party/registry.go, modules/party/README.md, modules/cabana/form_schema.go, modules/cabana/field_dependent.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/companions/controllers/links/config_form.yaml, modules/cabana/testdata/companions/controllers/links/config_list.yaml, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/testdata/companions/models/link/columns.yaml, modules/cabana/companions_smoke_test.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, README.md, docs/plugins/menu-item-types.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/pact/capabilities.go (HasFormFieldTypes :491, SettingsItem :543, Option :568, DropdownOptionsProvider :574), modules/pact/README.md, modules/phrasebook/translator.go (Activate :84-120, the shape to copy), modules/party/registry.go (:100-150), modules/party/README.md, modules/cabana/form_schema.go (formFieldTypes and formFieldKeys :24-48, compileFieldNode), modules/cabana/field_markdown.go (markdownPreview :71-100: principal, tokenRouteGate, WriteData), modules/cabana/http.go (route table :250-398), modules/cabana/admin_openapi.go (AdminMarkdownPreview block :394-409), modules/cabana/security_coverage_test.go (phase09Routes :34), modules/cabana/token_routes_test.go (class map :51), modules/cabana/phase10_coverage_test.go (:101), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory :134, newConformEnv :787, the markdown case :261), modules/cabana/field_type_smoke_test.go (TestFieldTypeTracerSmoke :160, fixture plugin pattern), modules/cabana/testdata/roster (fixture layout), modules/festival/README.md (standard README structure), README.md (module table), docs/backend/field-types.md (front matter), docs/site.yaml, scripts/check-admin-openapi.sh, .planning/phases/17-blog-companions/17-RESEARCH.md (Patterns 1, 2, 6)</read_first>
|
||||
<action>Per D-01, D-02, D-03 and D-08, wire one dependent dropdown through every framework layer.
|
||||
|
||||
pact: add the contracts listed in the interfaces block of this plan's context, with a doc comment on each exported identifier; Option gains Depth (json depth, omitempty) so existing dropdown payloads are unchanged. pact README: API reference rows and a Features line.
|
||||
|
||||
signpost (new module, package signpost): Activate iterates the activated plugins in order, type-asserts pact.HasMenuItemTypes and builds a Registry. Boot errors, each prefixed "signpost: ": empty Key; duplicate Key (message names the key and both plugin IDs); nil Resolve; DefaultPattern empty or not starting with "/" (every type, list types included, D-02 layer 1); after all types are collected, a PatternFrom that names an unregistered key, the type's own key, or a type that itself sets PatternFrom (one level only). It then reads ConfigKey with app.Config.LoadSection into a map from type key to pattern (absent key means no overrides); an override for an unregistered key or one not starting with "/" is a boot error. It publishes the Registry with app.Publish. Registry.Pattern(key, itemOverride) returns itemOverride when non-empty, else the host override for key, else (when the type sets PatternFrom) the host override for the PatternFrom key, else the type's own DefaultPattern; this is how a list type's children follow the host pattern of their record type while an item or host override of the list type still wins (D-02). Registry.Resolve looks up the type (unknown key is an error), builds pact.MenuItemResolve with the layered pattern and calls the type's Resolve. ReferenceOptions returns Applies false when the type is unknown to the registry or its References is nil; otherwise it calls References and flattens the tree depth-first into options whose Depth is the nesting level (0 at the top). README in the standard structure (H1, summary sentence, import line, Name mnemonic, Overview, Features, Usage with an acme example, API reference, Configuration naming menu_items.patterns as YAML-file only, Dependencies, Testing); root README table row reusing the summary sentence.
|
||||
|
||||
party: call signpost.Activate(app, ordered) after postcard.Activate and before the Boot loop, wrapping errors as "party: menu item types: %w". party README notes the step.
|
||||
|
||||
cabana compile: `dependsOn` becomes a known field key, valid only on type dropdown (elsewhere a boot error naming the field); its value is a sibling field name in the same form that must exist (boot error otherwise); a dependsOn dropdown may not also declare options; the controller must implement pact.DependentOptionsProvider (boot error naming the controller and field otherwise). The compiled form schema exposes dependsOn to the SPA.
|
||||
|
||||
cabana route (field_dependent.go): register GET {vendor}/{plugin}/{controller}/fields/{field}/dependent-options in http.go next to the other controller routes. It authenticates like the form schema route (backend principal, controller RequiredPermissions, the token gate the schema routes use), reads query parameters values[name]=value into a map, refuses a field that is not a dependsOn dropdown with 404, calls the provider, localizes option labels in the request locale and writes {data:{applies, options}} with WriteData. Add the swag block (AdminDependentOptions, result type AdminDependentOptionsResult), the phase09Routes entry, the token route class (read route gated like GET schema/form), the phase 10 coverage entry and a conformance case; run scripts/check-admin-openapi.sh (regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts) and npm --prefix admin run typecheck.
|
||||
|
||||
Fixture and smoke: testdata/companions with controller links (fields type dropdown fed by DropdownOptionsProvider = built-in url plus the registry's types, and reference dropdown with dependsOn type). companions_smoke_test.go: a fixture plugin registers type acme-page with a two-level References tree and a Resolve that echoes the layered pattern, and a second type acme-list with nil References; TestCompanionsDependentSmoke (Postgres, conform-style env) asserts: values[type]=acme-page answers applies true with depth 0 and 1 options; values[type]=acme-list answers applies false; an unauthenticated call is 401. signpost_smoke_test.go TestSignpostSmoke (no Docker): duplicate keys fail Activate with both plugin IDs in the message; Pattern layers item override, host override and default; a list type acme-posts with PatternFrom acme-post follows a host override set only for acme-post, a host override of acme-posts beats it, and an item override beats both; a PatternFrom naming an unregistered type fails Activate.
|
||||
|
||||
Docs: new docs/plugins/menu-item-types.md (front matter section plugins) explaining HasMenuItemTypes, Resolve, alternates order, pattern layers including PatternFrom for list types (declare the same DefaultPattern as the listed type and build children from MenuItemResolve.Pattern) and menu_items.patterns with neutral acme examples; docs/backend/forms.md gains a dependsOn section with DependentOptionsProvider and signpost.ReferenceOptions. cabana README Features and API reference updated in the same commit.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^TestSignpostSmoke$' -v ./modules/signpost 2>&1 | grep -- '^--- PASS: TestSignpostSmoke' && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go test -count=1 ./modules/party ./modules/pact && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestSignpostSmoke" (duplicate keys accepted, the pattern layers wrong, or a PatternFrom list type ignores its record type's host override); fewer than three inventory passes (the new route lacks a phase09Routes entry, a token class or a conformance case); party or pact tests regress; admin.json or schema.d.ts drift from the swag annotations; the SPA no longer typechecks; the docs checker reports an unknown identifier, a missing README or a broken link.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestCompanionsDependentSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestCompanionsDependentSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the route did not answer the registry's references with depths, answered applies true for a type without references, or let an anonymous caller through.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type HasMenuItemTypes interface" modules/pact/capabilities.go` and `grep -n "type DependentOptionsProvider interface" modules/pact/capabilities.go` each print one line.
|
||||
- `grep -c "PatternFrom" modules/pact/capabilities.go modules/signpost/signpost.go docs/plugins/menu-item-types.md` reports at least 1 per file.
|
||||
- `grep -n "signpost.Activate" modules/party/registry.go` prints one line located after the postcard.Activate call.
|
||||
- `grep -n "dependent-options" modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/token_routes_test.go` prints at least one line per file.
|
||||
- `grep -c "signpost" README.md` is at least 1 and modules/signpost/README.md contains the lines "**Name:**" and "menu_items.patterns".
|
||||
- `bash scripts/check-admin-openapi.sh --check` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin's typed menu item type is collected at boot, and an admin dropdown that depends on its sibling lists that type's references through one route, documented and in the OpenAPI.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: richeditor field type on a new seaglass sanitizer (bluemonday, decision note): every save sanitized, clean HTML byte-identical, removals reported</name>
|
||||
<reversibility rating="costly">D-10: a new field type in the typed admin schema plus a framework dependency; later plugins and the SPA editor build on its value contract.</reversibility>
|
||||
<files>.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md, go.mod, go.sum, README.md, modules/seaglass/seaglass.go, modules/seaglass/seaglass_smoke_test.go, modules/seaglass/README.md, modules/cabana/form_schema.go, modules/cabana/field_richeditor.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/README.md, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/companions_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, docs/backend/forms.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/format_html.go (SanitizeHTMLReport :164, removalReport, CheckHTML :335, sanitizeHTML :348 and the policy it builds, sameTokens), ../sm-journal-plugin/classes/blog_editor.go (decodeBlogHTML :154, removalNotices :171), .planning/notes/journal-html-sanitizing-bluemonday.md (rejected regex gates, bypass list), modules/cabana/field_type.go (pluginNotice, decodeOne :530, localizeNotices :781), modules/cabana/relation_field.go (FieldNotice :104), modules/cabana/settings.go (requireSettingInputs :287 and the settings compile path), modules/cabana/relation_form.go (refusing registry for plugin types), modules/cabana/field_markdown.go (the existing markdown gate to leave untouched), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (form: block), ../sm-journal-plugin/lang/en/lang.yaml (editor.* notice copy to mirror), go.mod, .planning/phases/17-blog-companions/17-UI-SPEC.md (Copywriting: richeditor.removed_* and markup_changed)</read_first>
|
||||
<action>Per D-10 and D-06, add the core richeditor type with a real sanitizer.
|
||||
|
||||
Decision note first, committed alone as a planning doc: .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md names github.com/microcosm-cc/bluemonday v1.0.27 for the framework (same library and version Journal already uses, RESEARCH Standard Stack and Pitfall 12), says why the regex gate of cabana's markdown preview is not reused, and records the D-10 Journal-clause deviation (Journal category content stays blogeditor).
|
||||
|
||||
seaglass (new module): port Journal's policy construction from format_html.go without the four Blog Editor figure and embed rules (the framework must not import the plugin), keep its link scheme allowlist (http, https, mailto, tel, relative) and its rel handling, and its token-equality comparison through golang.org/x/net/html so re-escaping alone is not a change. SanitizeReport returns the input unchanged with a zero Removals when the policy output is token-equal; otherwise the clean string and the removed element names, attribute names and comment count with Changed true. Sanitize returns only the string; Check returns an error when anything would change. `go get github.com/microcosm-cc/bluemonday@v1.0.27`. README in the standard structure with Dependencies naming bluemonday; root README row.
|
||||
|
||||
cabana richeditor: add "richeditor" to formFieldTypes (accepted keys: the common field keys plus size). On create and update every submitted richeditor value must be a string or null (anything else is a 422 on the field); a string goes through seaglass.SanitizeReport and the clean value is written; a non-zero Removals becomes notices on the field path through the same notice plumbing plugin field types use (phrase keys richeditor.removed_elements, richeditor.removed_attributes, richeditor.removed_comments with count and names, richeditor.markup_changed when only markup changed), localized into meta.notices. Settings forms and relation forms refuse richeditor at boot with "richeditor is supported in controller forms only" (and the same for repeater and dependsOn), so no save path bypasses the gate. Phrase keys added to modules/phrasebook/backend/lang/en and pl with the UI-SPEC copy (CLDR plural forms for pl).
|
||||
|
||||
Smoke: TestSeaglassSmoke (no Docker): a clean paragraph-and-list snippet comes back byte-identical; a snippet with an inline event handler, a javascript: link and a script element comes back without them and with those names in Removals. TestCompanionsRichEditorSmoke (Postgres): the links fixture gains a description richeditor field; creating a record with a script element and an onclick attribute stores the clean value and answers meta.notices for description; saving clean HTML stores it byte-identical and answers no notices.
|
||||
|
||||
Docs: docs/backend/forms.md gains a richeditor section (value contract, sanitize on save, notices, controller forms only); cabana README Features and field type list updated in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^TestSeaglassSmoke$' -v ./modules/seaglass 2>&1 | grep -- '^--- PASS: TestSeaglassSmoke' && grep -q 'github.com/microcosm-cc/bluemonday v1.0.27' go.mod && go test -count=1 -short ./modules/cabana && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestSeaglassSmoke" (clean HTML was rewritten or an unsafe construct survived); go.mod lacks bluemonday v1.0.27; a Docker-free cabana test regressed (for example a settings or relation form accepted richeditor); the docs checker reports an unknown identifier or a missing README.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestCompanionsRichEditorSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestCompanionsRichEditorSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an unsanitized value was stored, clean HTML was not stored byte-identical, or the removal notices are missing from meta.notices.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` succeeds and `git log --format=%s -1 -- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` starts with "docs".
|
||||
- `grep -n '"richeditor"' modules/cabana/form_schema.go` prints at least one line and `grep -n "seaglass.SanitizeReport" modules/cabana/field_richeditor.go` prints at least one line.
|
||||
- `grep -c "removed_elements" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "seaglass" README.md` prints a table row.
|
||||
</acceptance_criteria>
|
||||
<done>A controller form field of type richeditor stores only sanitized HTML, leaves clean HTML byte-identical and tells the editor what it removed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: repeater field type with nested fields, per-row errors, nested richeditor sanitizing and nested dependsOn, plus the dependent save recheck</name>
|
||||
<reversibility rating="costly">D-07: the repeater schema keys and the stored JSON row shape are what FAQ content and sitemap definitions persist; reshaping them means migrating stored rows in every plugin that uses it.</reversibility>
|
||||
<files>modules/cabana/form_schema.go, modules/cabana/field_repeater.go, modules/cabana/field_dependent.go, modules/cabana/crud.go, modules/cabana/README.md, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/companions_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/crud.go (ProjectWritableFields :159-175, the second nestedValue use :1135, nestedValue :1272, create and update decode paths), modules/cabana/form_schema.go (compile of fields, formFieldKeys), modules/cabana/field_richeditor.go and field_dependent.go (Tasks 1 and 2), modules/cabana/field_type.go (sameJSON :651, untouched skip, notices), modules/lagoon/jsonable.go (Jsonable :21), modules/cabana/field_ml.go (nested compile of a wrapper field), .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 5, Pattern 6 save recheck), .planning/phases/17-blog-companions/17-UI-SPEC.md (RepeaterField contract, error copy repeater.max_items and min_items, form.dependent_invalid)</read_first>
|
||||
<action>Per D-07 and D-08, add the repeater and close the dependent dropdown on save.
|
||||
|
||||
Compile: "repeater" joins formFieldTypes. Accepted keys: label, comment, span (always compiled to full), required, prompt, maxItems, minItems, titleFrom, form. form must hold fields; groups and style are refused loudly ("repeater groups are not supported" and the same for style). Nested fields compile with the same rules as top-level fields restricted to text, textarea, number, checkbox, switch, dropdown and richeditor; any other nested type (relation, relation-manager, fileupload, widget, partial, password, datepicker, permissioneditor, markdown, ml types, plugin field types, repeater) fails boot naming the repeater and the nested field. titleFrom must name a nested field. A nested dropdown with dependsOn must name a sibling in the same row. minItems and maxItems are non-negative and minItems is at most maxItems. The compiled schema carries form.fields, prompt, maxItems, minItems and titleFrom.
|
||||
|
||||
Save: in ProjectWritableFields exempt only fields compiled as repeater from the nested-value refusal (nestedValue itself stays unchanged). The repeater decode accepts null (stored as an empty list) or an array of objects; anything else is a 422 on the field. Each row keeps only nested field names; scalars are coerced per nested type (number to a JSON number, checkbox and switch to bool, the rest to string or null); a nested required value missing in a row is a 422 under the key field.index.nested (for example content.3.answer); a row count above maxItems or below minItems is a 422 on the field with repeater.max_items or repeater.min_items. Nested richeditor values go through seaglass with notices under field.index.nested. The model column receives the decoded []map[string]any (models use lagoon.Jsonable).
|
||||
|
||||
Dependent recheck (D-08): on create and update, every dependsOn dropdown (top level, and in each repeater row with the row's sibling value) is rechecked by calling the provider with the submitted sibling value. When applies is false the stored value is null. When applies is true a non-null value that is not among the option values is a 422 with form.dependent_invalid on the field (row path for nested fields).
|
||||
|
||||
Phrase keys repeater.max_items, repeater.min_items and form.dependent_invalid (en, pl) from the UI-SPEC copy.
|
||||
|
||||
Smoke: the links fixture gains a repeater rows with nested title (text, required), body (richeditor) and target type plus reference (dependsOn type). TestCompanionsRepeaterSmoke (Postgres): a valid two-row save stores both rows with only nested keys; a row missing title answers 422 with key rows.1.title; a nested body with a script element is stored clean with a notice under rows.0.body; a nested reference not offered for its row's type answers 422 under rows.0.reference; a top-level reference outside the options answers 422 with form.dependent_invalid. Docs: docs/backend/forms.md repeater section with an acme example and the nested type list; cabana README updated.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/lagoon && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free cabana or lagoon test regressed (for example nested values are now accepted on non-repeater fields); the docs checker reports an unknown identifier.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestCompanionsRepeaterSmoke|TestCompanionsRichEditorSmoke|TestCompanionsDependentSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: a repeater row kept unknown keys, a per-row error used the wrong key, a nested richeditor value was stored unsanitized, or a dependent value outside its options was accepted.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"repeater"' modules/cabana/form_schema.go` prints at least one line.
|
||||
- The nestedValue body is unchanged against the pre-phase head 5c75b31 (one bash command, no temporary file, no unset environment variable; the git status is captured before any pipe) exits 0: `PRE=$(git show 5c75b31:modules/cabana/crud.go) && test "$(printf '%s\n' "$PRE" | grep -c '^func nestedValue')" -eq 1 && diff <(printf '%s\n' "$PRE" | sed -n '/^func nestedValue/,/^}/p') <(sed -n '/^func nestedValue/,/^}/p' modules/cabana/crud.go)`.
|
||||
- `grep -c "dependent_invalid" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
</acceptance_criteria>
|
||||
<done>Editors can save lists of rows with nested fields; every row is validated, sanitized and rechecked on the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: tree reorder (config_reorder.yaml, GET and POST routes, toolbar button) and the singleton controller, with OpenAPI, inventories and docs</name>
|
||||
<reversibility rating="costly">D-09 and D-11: the config_reorder.yaml keys, the reorder payload and pact.AdminSingleton become the contract the menu and sitemap plugins (and later ones) declare.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/reorder.go, modules/cabana/singleton.go, modules/cabana/list_schema.go, modules/cabana/form_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/companions/controllers/links/config_reorder.yaml, modules/cabana/testdata/companions/controllers/links/config_list.yaml, modules/cabana/companions_smoke_test.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md, docs/backend/settings.md</files>
|
||||
<read_first>modules/cabana/field_file.go (fileReorderOn :1303: exact visible-set rule, transaction shape), modules/cabana/list_schema.go (toolbarButtons :315-380, compileToolbarButtons), modules/cabana/crud.go (ListExtendQuery and FormExtendQuery use, delete and create handlers), modules/cabana/http.go (route table), modules/cabana/settings.go (SettingsItem Controller link handling), modules/cabana/admin_openapi.go, the four inventory test files, .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 7), .planning/phases/17-blog-companions/17-UI-SPEC.md (Reorder page saving contract, Singleton form), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/controllers/menuentries/reorder_config.yaml</read_first>
|
||||
<action>Per D-09, D-11 and D-06.
|
||||
|
||||
Reorder config: a controller's ConfigDir may hold config_reorder.yaml with keys title, nameFrom, modelClass (Winter), parentColumn (default parent_id), sortColumn (default sort_order), maxDepth (0 means unlimited) and an optional partition block {column, relation, nameFrom, label}; any other key fails boot. At boot the parent, sort and partition columns must be columns of the controller's record (NewRecord); the partition relation must be a belongsTo of that record. toolbar.buttons accepts reorder only when the controller has this config (boot error "toolbar.buttons: reorder needs config_reorder.yaml" otherwise); the list schema exposes reorder (and the partition column) to the SPA.
|
||||
|
||||
Routes (reorder.go): GET {vendor}/{plugin}/{controller}/reorder?partition=<id> answers {data:{title, partition:{label, options:[{value,label}], value}|null, nodes:[{id, parent_id, sort_order, name}]}} with the records scoped through ListExtendQuery and the partition filter, ordered by sort column then id; the partition defaults to the first option when absent. POST {vendor}/{plugin}/{controller}/reorder (requireAjax, CSRF like every write) with {partition, nodes:[{id, parent_id, sort_order}]} runs in one transaction: select the scoped set FOR UPDATE; when the submitted ids differ from it in any way answer 409 with error code reorder_stale and message reorder.stale; refuse a duplicate id, a parent_id outside the set, a cycle and a depth above maxDepth with 422 validation_failed; then update the parent and sort columns of every node and answer the new tree. Both routes require the controller's RequiredPermissions; the POST is a write route for tokens (gated like update).
|
||||
|
||||
Singleton (singleton.go): pact.AdminSingleton (SingletonRecord(ctx, db) (any, error), first-or-create) on an admin controller enables GET {vendor}/{plugin}/{controller}/singleton answering {data:{id}}; the compiled form schema carries singleton true; such a controller needs no config_list.yaml, and its list, create, bulk and delete routes answer 404 not_found. A SettingsItem whose Controller names a singleton controller stays a link (the SPA resolves the record).
|
||||
|
||||
Gates: swag blocks for the three routes, phase09Routes entries, token route classes, phase 10 coverage entries and conformance cases; scripts/check-admin-openapi.sh regenerates admin.json and schema.d.ts; npm --prefix admin run typecheck. Phrase keys reorder.stale and reorder.load_failed (server side) in en and pl.
|
||||
|
||||
Smoke (Postgres): TestCompanionsReorderSmoke on the links fixture with partition group: GET lists only the partition's nodes; a POST that nests one node under another writes both columns; a POST that omits a node answers 409 reorder_stale and changes nothing; a POST with a cycle answers 422; a POST naming a node of another partition answers 409. TestCompanionsSingletonSmoke: a fixture singleton controller creates its record on the first GET, returns the same id on the second, and answers 404 on its list and delete routes.
|
||||
|
||||
Docs: docs/backend/lists-and-filters.md reorder section (config_reorder.yaml keys, partition, the 409 contract); docs/backend/admin-controllers.md singleton section; docs/backend/settings.md notes a Controller link to a singleton; pact and cabana READMEs updated.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates|TestPhase09PermissionMatrix)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -short ./... && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; fewer than four inventory passes (a new route lacks an inventory entry, token class or conformance case); any Docker-free test in the module regressed; admin.json or schema.d.ts drift; the SPA does not typecheck; the docs checker fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestCompanionsReorderSmoke|TestCompanionsSingletonSmoke|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: a stale or foreign set was written, a cycle was accepted, both columns were not written, the singleton was created twice or its list answered, or a response does not match its OpenAPI schema.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "reorder_stale" modules/cabana/reorder.go` prints at least one line.
|
||||
- `grep -n "type AdminSingleton interface" modules/pact/capabilities.go` prints one line.
|
||||
- `grep -n '/reorder\|/singleton' modules/cabana/http.go` prints at least three lines.
|
||||
- `grep -n "config_reorder.yaml" docs/backend/lists-and-filters.md` and `grep -n "AdminSingleton" docs/backend/admin-controllers.md` each print at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Any parent and sort-order model can be reordered as a tree on the server with stale and cycle protection, and a controller can expose exactly one record as a form.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana API | authenticated but untrusted input (HTML, row arrays, reorder sets, query values) |
|
||||
| plugin code → signpost registry | trusted compiled code, validated at boot |
|
||||
| host config → signpost | operator-controlled patterns |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-01 | Tampering (stored XSS) | richeditor save, top-level and nested | high | mitigate | seaglass (bluemonday v1.0.27, Journal-equivalent policy) on every create and update; settings and relation forms refuse the type at boot; TestCompanionsRichEditorSmoke and TestCompanionsRepeaterSmoke |
|
||||
| T-17-02 | Tampering (mass assignment) | repeater rows | medium | mitigate | rows keep only compiled nested field names; the nested-value exemption is limited to repeater fields; non-array input is a 422 |
|
||||
| T-17-03 | Tampering | dependent dropdown values | medium | mitigate | server recheck against the provider's options for the submitted sibling value, per row; 422 form.dependent_invalid |
|
||||
| T-17-04 | Elevation of Privilege (IDOR) | reorder POST | high | mitigate | scoped set through ListExtendQuery plus partition, locked FOR UPDATE; the submitted id set must equal it exactly (409 otherwise); parents must be in the set; controller permissions and token write gate |
|
||||
| T-17-05 | Denial of Service / Tampering | reorder tree validation | medium | mitigate | cycle detection, duplicate ids and maxDepth refused before any write; one transaction |
|
||||
| T-17-06 | Information Disclosure | dependent-options and singleton routes | medium | mitigate | backend principal, controller RequiredPermissions and the same token gate as schema routes; anonymous 401 in the smoke test |
|
||||
| T-17-07 | Spoofing (CSRF) | reorder POST | medium | mitigate | requireAjax plus the existing CSRF check, like every cabana write route |
|
||||
| T-17-08 | Tampering | signpost boot | low | mitigate | duplicate keys, empty keys, nil Resolve, empty or relative DefaultPattern, a dangling, self or chained PatternFrom and unknown host overrides fail boot; no runtime registration |
|
||||
| T-17-09 | Tampering (supply chain) | bluemonday in go.mod | low | mitigate | exact v1.0.27, already used by Journal; decision note; go.sum committed |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is installed in this plan; the Go dependency is pinned with go.sum (package-legitimacy gate applies to the TipTap install in plan 02) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: signpost smoke, route inventories, OpenAPI drift check, typecheck, docs; the dependent-options smoke on a Docker host.
|
||||
- Task 2: seaglass smoke, bluemonday pinned, Docker-free cabana suite, docs; the richeditor smoke on a Docker host.
|
||||
- Task 3: Docker-free cabana and lagoon suites, docs; repeater, richeditor and dependent smokes on a Docker host.
|
||||
- Task 4: inventories, whole-repo -short suite, OpenAPI drift, typecheck, docs; reorder, singleton and OpenAPI conformance on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: the typed registry exists and fails boot on conflicts; the one admin binding (dependent dropdown) reads it.
|
||||
- SC-1 (framework half): repeater, richeditor, dependent dropdown, tree reorder and singleton form exist server side with fail-loud schemas, docs and OpenAPI.
|
||||
- `go vet ./...` and `go test -short ./...` stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-01-SUMMARY.md` when done, with the commits, the exported identifiers added per module, the Docker smoke tests run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
305
.planning/phases/17-blog-companions/17-01-SUMMARY.md
Normal file
305
.planning/phases/17-blog-companions/17-01-SUMMARY.md
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 01
|
||||
subsystem: api
|
||||
tags: [go, cabana, pact, signpost, seaglass, bluemonday, openapi, admin-api, postgres]
|
||||
|
||||
requires:
|
||||
- phase: 16.2
|
||||
provides: Journal blogeditor (category content stays on it), plugin field type notices plumbing (meta.notices)
|
||||
- phase: 15
|
||||
provides: markdown field framework-first pattern (D-11), cabana field type and route conventions
|
||||
provides:
|
||||
- pact menu item type contracts (HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate)
|
||||
- pact DependentOptionsProvider / DependentOptionSet, AdminSingleton, Option.Depth
|
||||
- signpost module (menu item type registry, layered URL patterns, ReferenceOptions), activated by party before Boot
|
||||
- seaglass module (bluemonday v1.0.27 sanitizer with byte-stable SanitizeReport, Sanitize, Check, Removals)
|
||||
- cabana field types richeditor and repeater, dropdown dependsOn with the dependent-options route and save recheck
|
||||
- cabana config_reorder.yaml tree reorder (GET/POST reorder) and singleton controllers (GET singleton)
|
||||
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts
|
||||
affects: [17-02 admin SPA, 17-03 journal types and sitemap, 17-04 menu and faq plugins, 17-05 site integration, 17-06 unit tests]
|
||||
|
||||
actuals:
|
||||
tokens: 61081
|
||||
tasks: 4
|
||||
commits: 5
|
||||
plan_head_before: 9016e2a6fd010997e892b3972f013e7bf2e45fca
|
||||
plan_head_after: 23f33c3911e90cab12bd2936a662e5bd2be78ede
|
||||
|
||||
tech-stack:
|
||||
added: [github.com/microcosm-cc/bluemonday v1.0.27 (framework go.mod), github.com/aymerick/douceur v0.2.0 (indirect), github.com/gorilla/css v1.0.1 (indirect)]
|
||||
patterns:
|
||||
- "Boot-time typed registry published on the app (signpost.Activate after phrasebook/postcard, before Boot), fail-loud with module-prefixed errors"
|
||||
- "Byte-stable sanitize: return the input when the policy output tokenizes to the same tokens; report removals as meta.notices"
|
||||
- "Controller-only field features (richeditor, repeater, dependsOn) refused on settings and relation forms so no save path bypasses their checks"
|
||||
- "Schema-driven exemption from the nested-value refusal (repeater lists only), nestedValue unchanged"
|
||||
- "Locked exact-set rule for tree writes: 409 reorder_stale on any difference, 422 for structural errors"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/signpost/signpost.go
|
||||
- modules/signpost/README.md
|
||||
- modules/signpost/signpost_smoke_test.go
|
||||
- modules/signpost/example_test.go
|
||||
- modules/seaglass/seaglass.go
|
||||
- modules/seaglass/README.md
|
||||
- modules/seaglass/seaglass_smoke_test.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_richeditor.go
|
||||
- modules/cabana/field_repeater.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/companions_smoke_test.go
|
||||
- modules/cabana/testdata/companions/
|
||||
- docs/plugins/menu-item-types.md
|
||||
- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md
|
||||
modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/party/registry.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- go.mod
|
||||
- go.sum
|
||||
- README.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/settings.md
|
||||
|
||||
key-decisions:
|
||||
- "Registry module signpost, sanitizer module seaglass; host pattern overrides at menu_items.patterns (YAML file only, dashed keys)"
|
||||
- "List types declare PatternFrom (one level, must name a registered non-list type) so a host override of the record type reaches list children; every type still declares a DefaultPattern"
|
||||
- "seaglass ports Journal's policy without the four Blog Editor rules and adds tel: links; Removals carries ElementCount/AttributeCount next to the names for plural notices"
|
||||
- "D-10 Journal clause not executed: Journal category content stays blogeditor (resolved deviation, decision note)"
|
||||
- "Dependent options for a dropdown inside a repeater row are addressed as {field}=repeater.nested, the same path the provider receives"
|
||||
- "Dependent sibling values are normalized to strings (nil when empty) for both the options route and the save recheck"
|
||||
- "Repeater-level required is checked on the rows (at least one row), not through lagoon rules on the Jsonable column"
|
||||
- "Reorder saves write parent and sort columns with UpdateColumns (no model hooks), so a save hook that renumbers sort values cannot undo the order"
|
||||
- "Reorder partition = a belongsTo relation field of the form; its options (RelationExtendOptionsQuery scope) are the partitions offered"
|
||||
|
||||
patterns-established:
|
||||
- "Phase 17 cabana smoke fixture acme.companions (testdata/companions) shared by the companions smokes and the OpenAPI conformance env"
|
||||
- "Docs Go snippets come from example_test.go functions that a test calls directly (docs:sync)"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, D-01, D-02, D-03, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Typed menu item type registry: HasMenuItemTypes collected at boot by signpost.Activate in party, duplicate/empty/invalid types fail boot, layered patterns incl. PatternFrom, Resolve and ReferenceOptions"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/signpost/signpost_smoke_test.go#TestSignpostSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/signpost/example_test.go#ExampleRegistry_Resolve"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Dependent dropdown (dependsOn) with GET fields/{field}/dependent-options fed by signpost.ReferenceOptions, depths, applies false for a type without references, 401 anonymous"
|
||||
requirement: "D-08"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsDependentSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "richeditor field type sanitized through seaglass on every save, clean HTML byte-identical, removals in meta.notices, non-string 422"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/seaglass/seaglass_smoke_test.go#TestSeaglassSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsRichEditorSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "repeater field type: nested keys only, per-row required (rows.1.title), nested richeditor sanitized, nested and top-level dependent recheck (form.dependent_invalid), row bounds"
|
||||
requirement: "D-07"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsRepeaterSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Tree reorder: config_reorder.yaml, GET/POST reorder with partition, both columns written, 409 reorder_stale for omitted or foreign nodes, 422 for cycles"
|
||||
requirement: "D-09"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsReorderSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Singleton controller: GET singleton first-or-create returns a stable id, list/create/delete 404, form schema singleton"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/companions_smoke_test.go#TestCompanionsSingletonSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Every new route has swag annotations, regenerated admin.json/schema.d.ts, inventory entries, token classes, CSRF walk and conformance cases; docs and READMEs pass the docs checker"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go test ./modules/cabana -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates|TestPhase09PermissionMatrix|TestPhase10CSRF)$'"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go run ./cmd/summer docs:build --check"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 41min
|
||||
completed: 2026-10-12
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 17 Plan 01: Framework Go for blog companions Summary
|
||||
|
||||
**Typed menu item type registry (signpost) with layered URL patterns, a bluemonday-backed sanitizer (seaglass), and cabana richeditor, repeater, dependent dropdown, tree reorder and singleton controllers, all with boot checks, swag/OpenAPI, inventories, docs and Postgres smoke tests**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 41 min
|
||||
- **Started:** 2026-10-12T12:54:21Z
|
||||
- **Completed:** 2026-10-12T13:35:49Z
|
||||
- **Tasks:** 4 (Task 1 tracer, verified end-to-end before expansion)
|
||||
- **Files modified:** 55 (5462 insertions, 81 deletions)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **pact:** `HasMenuItemTypes`, `MenuItemType` (Key, Label, DefaultPattern, PatternFrom, Placeholders, Nesting, DynamicItems, References, Resolve), `MenuItemReference`, `MenuItemResolve`, `ResolvedMenuItem` (ordered `[]MenuItemAlternate`), `MenuItemAlternate`, `DependentOptionsProvider`, `DependentOptionSet`, `AdminSingleton`; `Option.Depth` (`depth,omitempty`, existing payloads unchanged).
|
||||
- **signpost (new):** `Activate` (called by party after postcard, before Boot), `Registry` (`Types`, `Type`, `Pattern`, `Resolve`), `ReferenceOptions`, `ConfigKey` = `menu_items.patterns`. Boot errors prefixed `signpost:` for empty/duplicate keys (both plugin IDs named), nil Resolve, empty or relative DefaultPattern, dangling/self/chained PatternFrom, unknown or relative host overrides.
|
||||
- **seaglass (new):** `Sanitize`, `SanitizeReport`, `Check`, `Removals` over bluemonday v1.0.27 with Journal's byte-stable token comparison.
|
||||
- **cabana:** `type: richeditor`, `type: repeater` (`cabana.RepeaterForm`), `dependsOn` on dropdowns, `config_reorder.yaml` + `reorder` toolbar button (`cabana.ListReorder`), singleton controllers. New routes: `GET .../fields/{field}/dependent-options`, `GET` and `POST .../reorder`, `GET .../singleton`, with result types `AdminDependentOptionsResult`/`AdminDependentOption`, `AdminReorderTree`/`AdminReorderNode`/`AdminReorderPartition`/`AdminReorderPartitionOption`, `AdminReorderRequest`/`AdminReorderNodeInput`, `AdminSingletonResult`.
|
||||
- Backend phrase keys (en, pl with CLDR plurals): `richeditor.removed_elements|removed_attributes|removed_comments|markup_changed`, `repeater.max_items|min_items`, `form.dependent_invalid`, `reorder.stale|load_failed`.
|
||||
- Docs: new `docs/plugins/menu-item-types.md`; sections in `docs/backend/forms.md` (dependent dropdowns, repeater, rich text), `lists-and-filters.md` (tree reorder), `admin-controllers.md` (singleton controllers), `settings.md` (link to a singleton); READMEs for signpost, seaglass, pact, party, cabana and the root table.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): menu item type registry and dependent dropdown binding** - `0cdff4c` (feat)
|
||||
2. **Task 2: decision note** - `69bb1cc` (docs, planning doc committed alone before the dependency)
|
||||
3. **Task 2: richeditor on the seaglass sanitizer** - `71fa597` (feat)
|
||||
4. **Task 3: repeater and the dependent dropdown save recheck** - `46835fd` (feat)
|
||||
5. **Task 4: tree reorder and singleton controllers** - `23f33c3` (feat)
|
||||
|
||||
**Plan metadata:** recorded in the docs commit that adds this SUMMARY.
|
||||
|
||||
## Docker smoke tests
|
||||
|
||||
All ran on the local Docker daemon (testcontainers Postgres) and passed: `TestCompanionsDependentSmoke`, `TestCompanionsRichEditorSmoke`, `TestCompanionsRepeaterSmoke`, `TestCompanionsReorderSmoke`, `TestCompanionsSingletonSmoke`, `TestPhase10OpenAPIConformance`. The full `go test ./modules/cabana` (with Docker) passed after Task 4. `go vet ./...` and `go test -short ./...` were green at every task commit; `examples/hello` tests pass with the new dependency.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/signpost/*` - registry, README, smoke test, runnable examples used by the docs
|
||||
- `modules/seaglass/*` - sanitizer, README, smoke test
|
||||
- `modules/cabana/field_dependent.go` - dependsOn compile, controller-only refusals, dependent-options route, save recheck
|
||||
- `modules/cabana/field_richeditor.go` - richeditor keys, value check, sanitize and notices
|
||||
- `modules/cabana/field_repeater.go` - repeater compile, row decode/coerce, nested sanitize, record hydration
|
||||
- `modules/cabana/reorder.go`, `modules/cabana/singleton.go` - reorder config and routes, singleton route
|
||||
- `modules/cabana/testdata/companions/` - acme.companions fixture (links: type/reference/description/rows/group; profiles singleton)
|
||||
- `modules/cabana/companions_smoke_test.go` - fixture plugin, env and the five smoke tests (also mounted in the conformance env)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter. In short: module names signpost and seaglass; `menu_items.patterns` read once with compass `LoadSection`, YAML-file only; PatternFrom one level; Removals carries counts; nested dependent options addressed as `repeater.field`; reorder writes via `UpdateColumns` without model hooks; reorder partition is a belongsTo relation field of the form.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Route constraints for the nested dispatcher**
|
||||
- **Found during:** Task 1 and Task 3
|
||||
- **Issue:** `fields/{field}/dependent-options` cannot be its own ServeMux pattern next to the shared six-segment GET (it is served through `nestedGet`, like `fields/{field}/options`), and the `name` constraint refused the hyphen of `dependent-options`; the `segment` constraint refused the `repeater.field` path.
|
||||
- **Fix:** `constrainNested` admits a hyphen in `name` and one dot in `segment`; handlers still resolve names against the compiled schema, so anything else answers 404. Inventory entry uses `mounted: nestedGetRoute`.
|
||||
- **Files modified:** modules/cabana/http.go, modules/cabana/security_coverage_test.go
|
||||
- **Committed in:** 0cdff4c, 46835fd
|
||||
|
||||
**2. [Rule 2 - Missing critical] seaglass.Removals counts**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The planned `Removals{Elements, Attributes, Comments, Changed}` keeps at most ten distinct names, so plural notices (`:count elements were removed`) could not be exact.
|
||||
- **Fix:** Added `ElementCount` and `AttributeCount` (Journal's report has the same fields).
|
||||
- **Files modified:** modules/seaglass/seaglass.go
|
||||
- **Committed in:** 71fa597
|
||||
|
||||
**3. [Rule 2 - Missing critical] Repeater accepts tab and context**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan's key list for repeater omitted `tab` and `context`; ported Winter forms flatten their tabs into `tab:` keys (RESEARCH Pitfall 9), so FAQ content in a tab would fail boot.
|
||||
- **Fix:** `tab` and `context` accepted on the repeater field itself (nested fields still refuse them).
|
||||
- **Files modified:** modules/cabana/field_repeater.go
|
||||
- **Committed in:** 46835fd
|
||||
|
||||
**4. [Rule 3 - Blocking] `prompt` was fileupload-only**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `compileFileuploadKeys` refused `prompt` on every other type, so the repeater's add-button text failed boot.
|
||||
- **Fix:** `prompt` allowed on `repeater` (error message updated to "fileupload or repeater"); the repeater compile decodes it.
|
||||
- **Files modified:** modules/cabana/field_file.go, modules/cabana/field_repeater.go
|
||||
- **Committed in:** 46835fd
|
||||
|
||||
**5. [Rule 3 - Blocking] OpenAPI regenerated in Task 3**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `FormField` gained `form`, `maxItems`, `minItems`, `titleFrom`, so the committed admin.json and schema.d.ts drifted (Task 3's file list did not name them).
|
||||
- **Fix:** Ran `scripts/check-admin-openapi.sh`, typecheck passed, committed the regenerated files with Task 3.
|
||||
- **Files modified:** admin/openapi/admin.json, admin/src/api/schema.d.ts
|
||||
- **Committed in:** 46835fd
|
||||
|
||||
**6. [Rule 3 - Blocking] Docs Go snippets need src= references**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The docs checker refuses Go code blocks without a `src=` reference to code a test runs.
|
||||
- **Fix:** `modules/signpost/example_test.go` (runnable examples plus a test that calls the documented methods directly); docs blocks synced with `summer docs:sync`.
|
||||
- **Files modified:** modules/signpost/example_test.go, docs/plugins/menu-item-types.md, docs/backend/forms.md
|
||||
- **Committed in:** 0cdff4c
|
||||
|
||||
### Resolved deviation recorded by the plan
|
||||
|
||||
- **D-10 Journal clause:** Journal category content stays `type: blogeditor` (shipped in 16.2); this plan does not switch it to `richeditor`. Recorded in the decision note `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 auto-fixed (2 missing critical, 4 blocking). **Impact:** all additive and needed for the planned contracts to boot, render or be documented; no scope creep, and no existing payload changed (Option.Depth is omitempty; new schema keys are omitempty).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The fixture login name first used a temp-dir base name, which repeats across tests and hit the unique `backend_users.login`; switched to a nanosecond stamp.
|
||||
- A reorder smoke assertion reused one GORM struct for two `First` calls (the stale primary key filtered the second); fixed with separate variables.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 02 (admin SPA) can build RepeaterField, RichEditorField, the dependent DropdownField fetch, ReorderView and the singleton form on the regenerated `schema.d.ts`; server-side phrase keys exist, SPA copy keys (UI-SPEC) are still to be added there.
|
||||
- Plans 03 and 04 can implement `pact.HasMenuItemTypes` (Journal), `pact.DependentOptionsProvider` with `signpost.ReferenceOptions` (menu entries, sitemap rows), `config_reorder.yaml` with a menu partition, and `pact.AdminSingleton` for the sitemap definition.
|
||||
- No framework tag yet (plan 02 owns the release gate and dist).
|
||||
|
||||
---
|
||||
*Phase: 17-blog-companions*
|
||||
*Completed: 2026-10-12*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 16 created key files exist; commits 0cdff4c, 69bb1cc, 71fa597, 46835fd and 23f33c3 are in the log; `commits` (5) is measured from the plan ledger.
|
||||
340
.planning/phases/17-blog-companions/17-02-PLAN.md
Normal file
340
.planning/phases/17-blog-companions/17-02-PLAN.md
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["17-01"]
|
||||
files_modified:
|
||||
- admin/package.json
|
||||
- admin/package-lock.json
|
||||
- admin/src/components/form/fields/RichEditorField.vue
|
||||
- admin/src/components/form/richeditor/editor.ts
|
||||
- admin/src/components/form/richeditor/LinkPopover.vue
|
||||
- admin/src/components/form/richeditor/lossy.ts
|
||||
- admin/src/components/form/fields/RepeaterField.vue
|
||||
- admin/src/components/form/fields/DropdownField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/views/ReorderView.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/SettingsIndexView.vue
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/tests/form/RichEditorField.test.ts
|
||||
- admin/tests/form/RepeaterField.test.ts
|
||||
- admin/tests/form/DependentDropdown.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- admin/tests/fixtures/companions.form-schema.json
|
||||
- admin/tests/fixtures/lang.json
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-10 (SPA): richeditor renders the RichEditorField from the UI-SPEC (Reka toolbar with bold, italic, underline, heading 3, bullet and numbered list, quote, link and unlink, undo and redo, an Edit HTML toggle, a link popover that accepts only http, https, mailto, tel and relative URLs) on TipTap 3.31.4 (core, pm, starter-kit pinned exact, installed only after the human package check); the TipTap code is a lazy chunk with a skeleton while it loads and a plain HTML textarea fallback when it fails."
|
||||
- "D-10 untouched rule (UI-SPEC backstop): a loaded richeditor value the user does not edit is submitted byte-identical (never re-serialized by TipTap); after the first edit the value is the editor HTML and an editor holding only an empty paragraph emits an empty string; imported HTML with elements outside the editor schema shows the lossy note and stays editable without loss in the HTML view."
|
||||
- "D-10 notices: removal notices the server returns in meta.notices render under the field through the existing FormField warning callout, and the editor shows the sanitized value the save returned."
|
||||
- "D-07 (SPA): RepeaterField renders rows with drag handle, index, titleFrom title (repeater.untitled when empty), error chip, move up and down, expand and collapse, remove (asking first only for a non-empty row), an outline add button with the field prompt, the maxItems counter, the dashed empty state, collapse all and expand all; a collapsed row does not mount its richeditor; path errors such as content.3.answer expand the row and focus the first invalid nested control; every add, remove, reorder and nested edit marks the form dirty and nothing is sent before Save."
|
||||
- "D-08 (SPA): a dropdown with dependsOn shows the UI-SPEC states (waiting for the sibling, loading with aria-busy, not used when applies is false, empty with form.dependent_empty, loaded with depth-indented labels, failed with a retry button); a sibling change refetches with last-request-wins and caches per sibling value, and a value no longer offered is cleared to null, marks the form dirty and is announced politely; the same component serves the menu entry form and the sitemap repeater rows."
|
||||
- "D-09 (SPA): a list whose schema declares reorder shows an outline Reorder button before New record that opens ReorderView with the partition query; ReorderView renders the indented tree with drag handle and move up, move down, indent and outdent buttons (keyboard arrows on the handle do the same), refuses drops into a node's own subtree, saves the whole node set in one POST debounced 400 ms after the last move, flushes on leave, reverts with reorder.failed on error, reloads with reorder.stale on a 409, and shows loading, empty, load-failure and 403 states."
|
||||
- "D-11 (SPA): a settings entry that links to a singleton controller opens that controller's one record in FormView update mode through GET …/singleton, with no Delete button, back to the settings index, and a subtitle link supplied by the form schema."
|
||||
- "D-06: admin/src/api/schema.d.ts stays in sync, modules/boardwalk/dist is rebuilt and committed so scripts/check-admin-dist.sh passes, and every new UI string is a key in modules/phrasebook/backend/lang en and pl with the UI-SPEC copy (no literal UI strings in components)."
|
||||
- "Release (user checkpoint): after a human approves at a blocking checkpoint, an annotated summercms tag (the next free v0.1.x after the highest at execution time; v0.1.22 at planning time) containing plans 01 and 02 is on origin and master is pushed; no pushed tag is moved."
|
||||
artifacts:
|
||||
- path: "admin/src/components/form/fields/RichEditorField.vue"
|
||||
provides: "richeditor field on TipTap with untouched rule, HTML view, link popover, lossy note"
|
||||
contains: "richeditor"
|
||||
- path: "admin/src/components/form/fields/RepeaterField.vue"
|
||||
provides: "repeater rows with nested FormGrid"
|
||||
contains: "data-repeater"
|
||||
- path: "admin/src/views/ReorderView.vue"
|
||||
provides: "tree reorder page"
|
||||
contains: "data-reorder-tree"
|
||||
- path: "admin/tests/form/RichEditorField.test.ts"
|
||||
provides: "untouched byte-identical smoke"
|
||||
contains: "byte-identical"
|
||||
- path: "modules/boardwalk/dist"
|
||||
provides: "committed SPA build"
|
||||
key_links:
|
||||
- from: "admin/src/components/form/registry.ts"
|
||||
to: "RichEditorField.vue and RepeaterField.vue"
|
||||
via: "renderers map entries richeditor and repeater; repeater in groupLabelledTypes"
|
||||
pattern: "\\['repeater', RepeaterField\\]"
|
||||
- from: "admin/src/components/form/fields/DropdownField.vue"
|
||||
to: "GET …/fields/{field}/dependent-options"
|
||||
via: "openapi-fetch client keyed on the sibling value"
|
||||
pattern: "dependent-options"
|
||||
- from: "admin/src/views/ReorderView.vue"
|
||||
to: "GET and POST …/reorder"
|
||||
via: "debounced full-set POST"
|
||||
pattern: "reorder"
|
||||
prohibitions:
|
||||
- statement: "No package is installed before the human approves it at the legitimacy checkpoint; only @tiptap/core, @tiptap/pm and @tiptap/starter-kit enter admin/package.json, pinned exact; no drag-and-drop or UI library is added."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No tag or branch is pushed before the human approves at the release checkpoint; no pushed tag is moved or deleted; no credential is printed or committed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new admin tokens, colours, fonts, radii or shadows; components reuse the Direction C classes named in the UI-SPEC."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 2 of 6, admin SPA plus framework release, wave 2, runs beside plans 03 and 04 which never edit summercms.go): the admin renders and edits the plan 01 features, the dist is committed, and a human-approved summercms tag publishes them.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Edge items owned by this plan are the UI-SPEC backstops lifted into truths (untouched value byte-identical, repeater remove and reorder semantics, reorder save contract). Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## UI Considerations (lifted from 17-UI-SPEC.md)
|
||||
|
||||
Resolved explicit items are truths above (states of richeditor, repeater, dependent dropdown, reorder tree, singleton form). Backstops: `{statement: "loading HTML with b, div and attributes and saving without an edit submits the stored string byte-identical", verification: backstop}`, `{statement: "removing a non-empty repeater row asks first, an empty one does not; arrow keys and buttons reorder and announce repeater.moved; nothing is sent before Save", verification: backstop}`, `{statement: "three quick tree moves send one debounced POST with the full node set; a failed POST restores the previous tree; each keyboard move announces in the live region", verification: backstop}`. This plan writes smoke specs for the first; plan 06 completes all three. No unresolved items.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- TipTap 3.31.4 (core, pm, starter-kit) mounted on a ref element without @tiptap/vue-3 (RESEARCH Supporting table), starter-kit configured as the UI-SPEC says (headings [3], Link with openOnClick false, autolink false, protocols http, https, mailto, tel; Code, CodeBlock, HorizontalRule off; injectCSS false).
|
||||
- Native HTML5 drag and drop plus button and keyboard alternatives, as FileuploadField does; no drag-and-drop dependency.
|
||||
- Singleton handling lives in FormView behind the schema's singleton flag (no separate view).
|
||||
- Release tag: the highest `v0.1.*` plus one at execution time, annotated, created only after the checkpoint. Hosts are not bumped here; plan 05 bumps the plugins' go.mod requires to this tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- SPA: `RichEditorField.vue` with `richeditor/{editor.ts, LinkPopover.vue, lossy.ts}`, `RepeaterField.vue`, dependsOn support in `DropdownField.vue`, `ReorderView.vue` and its route, the Reorder list button, singleton handling in `FormView.vue` and `SettingsIndexView.vue`.
|
||||
- Phrase keys (en, pl): `repeater.*`, `richeditor.*`, `reorder.*`, `form.dependent_*`, `messages.list.reorder`.
|
||||
- Smoke specs: `RichEditorField.test.ts`, `RepeaterField.test.ts`, `DependentDropdown.test.ts`, `ReorderView.test.ts`.
|
||||
- Rebuilt `modules/boardwalk/dist`; summercms annotated tag (next free v0.1.x) on origin.
|
||||
|
||||
<objective>
|
||||
Let administrators use the plan 01 features in the admin SPA and publish the framework as a tagged release.
|
||||
|
||||
Purpose: SC-1 needs working admin screens; D-06 requires the SPA, dist and types to ship with the framework features.
|
||||
Output: four SPA features with smoke specs, rebuilt dist, a pushed tag after human approval.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
|
||||
Plan 01's routes and schema keys are the contract: dependsOn on dropdown fields, the repeater schema (form.fields, prompt, maxItems, minItems, titleFrom), richeditor, GET …/fields/{field}/dependent-options answering {applies, options[{value,label,depth}]}, GET and POST …/reorder (409 reorder_stale), GET …/singleton, the list schema reorder flag and the form schema singleton flag. Types come from admin/src/api/schema.d.ts (already regenerated by plan 01).
|
||||
|
||||
Concurrency: before Task 2 run `git status --porcelain -- admin modules/boardwalk modules/phrasebook` in summercms.go; stop and report if another session has edits there. Stage by explicit path; no co-author tags; SPA source, dist and planning docs in separate commits.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Package legitimacy check before installing TipTap in the admin SPA (all three packages flagged too-new)</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-RESEARCH.md (Package Legitimacy Audit, Standard Stack JS), admin/package.json, ../sm-journal-plugin/package.json (the same versions the Phase 16.2 check approved for Journal)</read_first>
|
||||
<action>Install nothing. Run the read-only registry query in the verify command and print, for @tiptap/core, @tiptap/pm and @tiptap/starter-kit at 3.31.4: version, license, repository URL, publish time and the scripts object (preinstall, install and postinstall must be absent). Print https://www.npmjs.com/package/@tiptap/core, https://www.npmjs.com/package/@tiptap/pm, https://www.npmjs.com/package/@tiptap/starter-kit and https://github.com/ueberdosis/tiptap/releases. State the recorded choice: 3.31.4 is the version Journal already pins after the Phase 16.2 human check; the flag is the release age, not the packages. Stop for the human. This checkpoint is never auto-approved.</action>
|
||||
<what-built>Nothing yet: a read-only registry report for the three editor packages.</what-built>
|
||||
<how-to-verify>Open each npm page: publisher is the TipTap organisation (ueberdosis), repository github.com/ueberdosis/tiptap, license MIT, version 3.31.4 exists with normal weekly downloads and no install scripts; check the 3.31.4 release notes for a security advisory or a yanked release.</how-to-verify>
|
||||
<resume-signal>Reply "packages approved" to install 3.31.4, "use X.Y.Z" to pin another 3.x release for all three (the executor re-runs the query for it), or "stop".</resume-signal>
|
||||
<verify>
|
||||
<automated>for p in @tiptap/core @tiptap/pm @tiptap/starter-kit; do npm view "$p@3.31.4" version license repository.url --json || exit 1; npm view "$p@3.31.4" scripts --json | grep -E '"(preinstall|install|postinstall)"' && exit 1; done; echo registry-ok</automated>
|
||||
<fails_when>Non-zero exit or "registry-ok" absent: a package or version is missing from the registry, or a package declares a preinstall, install or postinstall script.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed registry-ok and the SUMMARY records each package's license, repository URL and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the exact TipTap version, or the plan stopped.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: an administrator edits a richeditor field, saves, sees what the server removed, and an untouched imported value is submitted byte-identical</name>
|
||||
<precondition>Task 1 was answered "packages approved" (or "use X.Y.Z" with a passing re-run of its query) in this session.</precondition>
|
||||
<files>admin/package.json, admin/package-lock.json, admin/src/components/form/fields/RichEditorField.vue, admin/src/components/form/richeditor/editor.ts, admin/src/components/form/richeditor/LinkPopover.vue, admin/src/components/form/richeditor/lossy.ts, admin/src/components/form/registry.ts, admin/tests/form/RichEditorField.test.ts, admin/tests/fixtures/companions.form-schema.json, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 1 RichEditorField, Copywriting richeditor.*, UI Considerations rows for richeditor), admin/src/components/form/fields/MarkdownField.vue (control classes, read-only box, summer-markdown), admin/src/components/form/control.ts, admin/src/components/form/FormField.vue (notices callout), admin/src/components/form/registry.ts (renderers :64-82, valueless :96, groupLabelledTypes :101), admin/src/views/FormView.vue (dirty tracking and submit body), admin/src/app/i18n.ts, admin/tests/form/MarkdownField.test.ts (vitest harness), ../sm-journal-plugin/assets/src/blogeditor/element.ts and extensions.ts (TipTap 3.31.4 setup and the untouched-value pattern), modules/phrasebook/backend/lang/en/lang.yaml, scripts/check-admin-dist.sh</read_first>
|
||||
<action>Per D-10 and the UI-SPEC RichEditorField contract.
|
||||
|
||||
Install: `npm --prefix admin install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4` (or the version the human named).
|
||||
|
||||
Component: RichEditorField.vue renders the container (controlClass minus px-3.5, overflow-hidden, focus-within ring, data-richeditor), the Reka ToolbarRoot with the buttons, separators, aria-pressed state, mousedown.prevent and labels from richeditor.* keys, the editing area (summer-markdown, min-h-[160px], max-h-[480px], role textbox, aria-multiline, aria-labelledby the field label), and the HTML view textarea (font-mono 13px, spellcheck false) toggled by the Edit HTML button. editor.ts creates the TipTap Editor on a ref element with StarterKit configured as this plan's decisions say and is imported lazily (dynamic import) so TipTap is its own chunk; while it loads the container shows the 200px skel block with aria-busy; when the import fails the container becomes the HTML view with the toggle hidden and richeditor.load_failed above it. LinkPopover.vue (Reka Popover, 320px) validates http, https, mailto, tel, a leading slash, #, ? or ./ and bare relative paths, shows richeditor.link_invalid otherwise; Enter applies, Esc closes and refocuses the Link button; Ctrl or Cmd+K opens it. lossy.ts compares the stored HTML's tag names (case-insensitive) with p, br, strong, b, em, i, u, s, h3, ul, ol, li, blockquote, a, div, span and shows the role note note with richeditor.lossy_note when another tag is present.
|
||||
|
||||
Value contract: the field emits nothing until the user edits (the loaded string stays the model value byte for byte); after the first edit it emits editor.getHTML(), an empty paragraph emits an empty string, and the HTML view emits the textarea text as typed. Read-only shows the existing read-only box with summer-markdown or a muted dash. Notices for the field from meta.notices render through FormField as they do for plugin field types; after a save the field shows the returned value.
|
||||
|
||||
registry.ts: add ['richeditor', RichEditorField]. Phrase keys richeditor.* from the UI-SPEC copy (EN and PL) in modules/phrasebook/backend/lang and the test lang fixture.
|
||||
|
||||
Smoke: RichEditorField.test.ts with a test named "RichEditorField tracer: untouched value is submitted byte-identical" (loads HTML with b, div and an attribute, mounts the field in a form, saves without editing and asserts the submitted string equals the stored string exactly) and "RichEditorField edit emits editor HTML" (types into the editor, asserts the emitted value is the editor HTML).
|
||||
|
||||
Build: `npm --prefix admin run build`, then commit modules/boardwalk/dist separately; `bash scripts/check-admin-dist.sh`. Docs: docs/backend/forms.md richeditor section gains the admin behaviour (untouched rule, HTML view, lossy note).</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/RichEditorField.test.ts && grep -q '"@tiptap/core": "3.31.4"' admin/package.json && bash scripts/check-admin-dist.sh && go test -count=1 ./modules/phrasebook ./modules/boardwalk</automated>
|
||||
<fails_when>Non-zero exit: the SPA does not typecheck; a RichEditorField spec fails or vitest reports zero tests for the file; TipTap is not pinned exact; the committed dist differs from a fresh build; phrasebook or boardwalk tests fail (for example an en key missing in pl).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\['richeditor', RichEditorField\]" admin/src/components/form/registry.ts` prints one line.
|
||||
- `grep -n "import(" admin/src/components/form/fields/RichEditorField.vue admin/src/components/form/richeditor/editor.ts` shows the lazy TipTap import.
|
||||
- `grep -c "lossy_note" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 per file.
|
||||
- The vitest output for RichEditorField.test.ts lists the test "RichEditorField tracer: untouched value is submitted byte-identical" as passed.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can edit HTML in the richeditor, sees server removals, and opening and saving an imported value changes nothing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: RepeaterField with nested rows and the dependent dropdown states, per the UI-SPEC</name>
|
||||
<files>admin/src/components/form/fields/RepeaterField.vue, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/components/form/FormGrid.vue, admin/src/api/types.ts, admin/tests/form/RepeaterField.test.ts, admin/tests/form/DependentDropdown.test.ts, admin/tests/fixtures/companions.form-schema.json, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 2 RepeaterField and 3 Dependent dropdown, Copywriting repeater.* and form.dependent_*, UI Considerations rows), admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/MLField.vue (wrapper rendering inner fields), admin/src/components/form/FormGrid.vue (idPrefix, spans), admin/src/components/form/formState.ts (fieldErrors, focusField), admin/src/components/ui/ConfirmDialog.vue and confirm.ts (useConfirm), admin/src/components/form/fields/FileuploadField.vue (native drag and drop :904-910), admin/src/api/client.ts, admin/src/api/schema.d.ts (dependent-options operation), admin/tests/form/FormGrid.test.ts and MLField.test.ts</read_first>
|
||||
<action>Per D-07 and D-08 and the UI-SPEC contracts 2 and 3.
|
||||
|
||||
RepeaterField.vue: the role group container labelled by the field label (repeater joins groupLabelledTypes), data-repeater, rows in Reka Collapsible (open by default), header with drag handle (repeater.move), index, title from titleFrom (dropdown option label, richeditor text without tags, else repeater.untitled), error chip with form.tab_errors and border-danger, move up and down (disabled at the ends), expand and collapse (aria-expanded), remove (repeater.remove_named, disabled at minItems; asks through the shared ConfirmDialog with repeater.remove_confirm only when the row has any non-empty value; focus moves to the next handle, else the previous, else the add button). The body renders a nested FormGrid with idPrefix form-field-rowKey so ids, labels, errors at field.index.nested and notices resolve per row; a collapsed row keeps its values and does not mount its richeditor. Footer: outline add button (prompt or repeater.add, data-action repeater-add, disabled at maxItems, appends a row with nested defaults and focuses its first control), counter repeater.count only with maxItems, collapse all and expand all when more than one row. Empty: the dashed block with repeater.empty and repeater.empty_hint. Reorder by drag (opacity-50 source, a dashed sel drop slot of the header height), ArrowUp and ArrowDown on the handle and the buttons; focus stays on the moved handle and a polite live region announces repeater.moved. A 422 with path keys expands the rows with errors and focusField focuses the first invalid nested control (formState maps field.index.nested paths). Every change marks the form dirty; nothing is sent before Save. Read-only renders rows expanded without controls.
|
||||
|
||||
DropdownField.vue dependsOn: when the schema field has dependsOn, read the sibling value (same form, or same repeater row through the row's form scope) and render the UI-SPEC states: sibling empty (disabled, form.dependent_waiting with the sibling label), loading (disabled, aria-busy, form.loading_options), applies false (disabled, form.dependent_not_used, value sent null), zero options (disabled, form.dependent_empty plus the same text as a muted described-by line), loaded (options with labels prefixed by four U+00A0 per depth), stale stored value (the disabled prompt option is selected, never a wrong choice), failed (role alert line with form.dependent_failed and a ghost Retry button form.dependent_retry). Fetch through the openapi-fetch client; cache per sibling value for the life of the form view; discard late responses for an old sibling value; when the current value is not in new options set it to null, mark dirty and announce form.dependent_cleared in the form's polite live region.
|
||||
|
||||
registry.ts: ['repeater', RepeaterField]; neither type is valueless. Phrase keys repeater.* and form.dependent_* (EN, PL) from the UI-SPEC.
|
||||
|
||||
Smoke specs: RepeaterField.test.ts "RepeaterField tracer: add, edit nested, reorder and remove change form state only" and DependentDropdown.test.ts "DependentDropdown loads options for the sibling value and clears a stale value". Rebuild and commit dist; check-admin-dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/RepeaterField.test.ts tests/form/DependentDropdown.test.ts tests/form/FormGrid.test.ts tests/form/MLField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 ./modules/phrasebook</automated>
|
||||
<fails_when>Non-zero exit: typecheck fails; a repeater or dependent spec fails or vitest finds zero tests in a named file; existing FormGrid or MLField specs regress; dist is stale; a phrase key is missing in one locale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\['repeater', RepeaterField\]" admin/src/components/form/registry.ts` prints one line.
|
||||
- `grep -n "dependent-options" admin/src/components/form/fields/DropdownField.vue` prints at least one line.
|
||||
- `grep -c "dependent_not_used\|empty_hint" modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>Editors add, edit, reorder and remove repeater rows, and dependent dropdowns follow their sibling with every UI-SPEC state.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: ReorderView with the list Reorder button, and the singleton form opened from settings</name>
|
||||
<files>admin/src/views/ReorderView.vue, admin/src/views/ListView.vue, admin/src/views/FormView.vue, admin/src/views/SettingsIndexView.vue, admin/src/app/router.ts, admin/tests/views/ReorderView.test.ts, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 4 Reorder page and 5 Singleton form, Copywriting reorder.*, UI Considerations rows), admin/src/views/ListView.vue (header button group), admin/src/views/FormView.vue (header, footer buttons, unsaved guard, load states), admin/src/views/SettingsIndexView.vue (Controller link entries), admin/src/app/router.ts and controllerRoutes.ts, admin/src/state/useToasts.ts, admin/src/components/form/fields/FileuploadField.vue (debounced reorder POST, native drag and drop), admin/tests/form/FormView.test.ts and tests/app/router.test.ts</read_first>
|
||||
<action>Per D-09 and D-11.
|
||||
|
||||
ListView: when the list schema declares reorder, render the outline ArrowUpDown button with messages.list.reorder (overridable label from the schema) and data-action reorder before the primary New record button, linking to the controller's reorder route with ?partition from the current filter on the partition column.
|
||||
|
||||
ReorderView.vue (new route next to the controller form route): header copied from FormView (40px back button to the list keeping its query, h1 with the config title, save status with LoaderCircle and form.saving or reorder.saved for 2 s in a polite region, partition select labelled with the partition label that replaces ?partition and reloads); card with the nested ul (role list, aria-label reorder.tree, data-reorder-tree), rows with min-h-row, indent calc(16px + depth * 24px), data-reorder-node, drag handle (reorder.move), the nameFrom name or reorder.untitled, and move up, move down, outdent and indent buttons disabled when impossible. Pointer drag with before, after and inside zones (2px primary line or the dashed sel inside target), no drop into the node's own subtree. Keyboard on the handle: ArrowUp, ArrowDown, ArrowRight (nest under the previous sibling), ArrowLeft (out one level); focus stays on the moved handle and a separate polite region announces reorder.moved, reorder.nested or reorder.unnested. Saving: one POST with the whole scoped node set, debounced 400 ms after the last move, flushed when leaving; on failure revert to the last saved tree and show the reorder.failed danger toast; on 409 show reorder.stale and reload. States: five skeleton rows with aria-busy, the load-failure alert block, the empty block with reorder.empty and reorder.empty_hint, a 403 with the form.forbidden alert, one node with every move disabled.
|
||||
|
||||
Singleton (D-11): when SettingsIndexView opens a Controller link whose form schema is singleton, call GET …/singleton and open FormView in update mode for the returned id; FormView in singleton mode hides Delete, sends Cancel and Save and close back to the settings index (settings.back), and renders the schema's subtitle link when present.
|
||||
|
||||
Phrase keys reorder.*, messages.list.reorder (EN, PL). Smoke spec ReorderView.test.ts "ReorderView tracer: a keyboard move sends one debounced POST with the full node set". Rebuild and commit dist; check-admin-dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && go test -count=1 ./modules/phrasebook ./modules/boardwalk</automated>
|
||||
<fails_when>Non-zero exit: typecheck fails; any admin vitest spec fails (including the existing ListView, FormView and router specs); dist or OpenAPI output is stale; a phrase key is missing in one locale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ReorderView" admin/src/app/router.ts` prints at least one line.
|
||||
- `grep -n 'data-action="reorder"' admin/src/views/ListView.vue` prints one line.
|
||||
- `grep -n "singleton" admin/src/views/FormView.vue admin/src/views/SettingsIndexView.vue` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>Editors reorder tree records by pointer or keyboard with saved feedback, and a singleton settings entry opens its one record directly.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-01-SUMMARY.md, this plan's Task 1-4 SUMMARY notes (commits, Docker smoke tests NOT RUN), .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md (how the last release ran), .planning/notes/2026-10-07-embedded-admin-release-gate.md, 17-REVIEW.md and 17-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; if origin has commits this checkout lacks, stop and report (never merge or rebase on your own).
|
||||
|
||||
Tag: propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.23 if nobody tagged after v0.1.22) and confirm with `git ls-remote --tags origin` that it does not exist.
|
||||
|
||||
Run the gate (verify). Release sheet: the proposed tag; the one-way surfaces it publishes (pact HasMenuItemTypes, MenuItemType with its PatternFrom field, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth; signpost Activate, Registry, ReferenceOptions and config key menu_items.patterns; seaglass; cabana field types richeditor and repeater, the dependsOn key, config_reorder.yaml keys, toolbar button reorder; routes dependent-options, reorder GET and POST, singleton); commits to push; the Docker smoke tests recorded NOT RUN in plans 01 and 02; open high findings from 17-REVIEW.md or 17-SECURITY.md if they exist (an open high finding allows only docker-first or hold); that plans 03 and 04 (wave 2) build against this checkout through go.work and get their go.mod requires bumped to this tag in plan 05. Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes the registry, sanitizer and cabana contracts above; it cannot be moved, only superseded. The unit-test plan (06) runs after this release, as in 16.2 and 16.3; its tests ship with the next tag.</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>Plugins and hosts can require the framework features immediately; plan 05 is unblocked</pros>
|
||||
<cons>Docker smoke tests recorded NOT RUN run later; a defect then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host smoke tests first, then release</name>
|
||||
<pros>Every companions smoke test and the OpenAPI conformance suite run before the tag exists</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays the release</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>Plan 05 cannot push plugins that require the new framework API</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the suites ran), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails, a Docker-free Go test fails, fewer than four cabana inventory passes, a docs check fails, an admin spec fails, or the dist or OpenAPI drift gate reports stale output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok and summercms.go was clean and not behind origin.
|
||||
- The SUMMARY records the release sheet 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="auto">
|
||||
<name>Task 6: Release: annotated summercms tag and master pushed</name>
|
||||
<reversibility rating="one-way">A pushed annotated tag publishes pact.HasMenuItemTypes, the signpost registry and the cabana field and route contracts to the Go module proxy and to every plugin go.mod that requires it; it can only be superseded.</reversibility>
|
||||
<precondition>Task 5 was answered "release vX" or "docker passed, release vX"; RELEASE_TAG is that tag in the session and `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.</precondition>
|
||||
<files>none (git tag and push only)</files>
|
||||
<read_first>this plan's Task 5 release sheet in the SUMMARY, .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md (Release table)</read_first>
|
||||
<action>Re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with message "$RELEASE_TAG: menu item type registry (signpost), seaglass sanitizer, cabana repeater, richeditor, dependent dropdown, tree reorder and singleton form", then push master and the tag. Record the tag object, the tagged commit and the pushed head in the SUMMARY. Do not bump any plugin or host here (plan 05 does).</action>
|
||||
<verify>
|
||||
<automated>test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && test "$(git rev-parse master)" = "$(git rev-parse origin/master)" && test "$(git cat-file -t "$RELEASE_TAG")" = tag && echo release-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-ok" absent: the tag is not on origin, master is not pushed, or the tag is lightweight instead of annotated.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag.
|
||||
- The verify prints release-ok and the SUMMARY records RELEASE_TAG for plan 05.
|
||||
</acceptance_criteria>
|
||||
<done>The framework release carrying plans 01 and 02 is on origin.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored HTML → admin editor | imported or saved HTML rendered inside the admin |
|
||||
| admin SPA → npm registry | new editor dependency |
|
||||
| executor → git origin | the tag is read by other repositories |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-10 | Tampering (XSS in admin) | RichEditorField read-only view and editor | high | mitigate | the read-only view renders server-sanitized HTML only; the editor parses through the TipTap schema; link popover allowlist; the server gate (plan 01) is authoritative |
|
||||
| T-17-11 | Tampering | link insertion | medium | mitigate | client allowlist http, https, mailto, tel and relative; StarterKit Link protocols restricted; server sanitizer re-checks |
|
||||
| T-17-12 | Tampering (silent data rewrite) | untouched imported HTML | medium | mitigate | untouched rule: no emit until edit; byte-identical smoke spec; lossy note plus HTML view for out-of-schema markup |
|
||||
| T-17-13 | Elevation of Privilege | ReorderView and singleton calls | low | mitigate | the server enforces permissions, scope and the stale set (plan 01); the SPA shows the 403 and 409 states and never retries a write silently |
|
||||
| T-17-14 | Tampering / Repudiation | tag and push | high | mitigate | clean, not-behind tree and an absent tag checked at a blocking-human checkpoint; one-way surfaces listed; heads recorded; no pushed tag moved |
|
||||
| T-17-SC | Tampering | npm installs (@tiptap/core, @tiptap/pm, @tiptap/starter-kit) | high | mitigate | blocking-human package legitimacy checkpoint (Task 1, never auto-approved) before install; exact pins; lockfile committed; no install scripts verified |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: registry-ok and a human approval.
|
||||
- Tasks 2-4: typecheck, named vitest specs, dist and OpenAPI drift checks, phrasebook key parity.
|
||||
- Task 5: release-gate-ok and a human decision.
|
||||
- Task 6: release-ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 (admin half): every plan 01 feature is usable in the admin SPA per the UI-SPEC.
|
||||
- D-06: dist, types and phrase keys ship with the features; the framework is tagged after human approval.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-02-SUMMARY.md` when done, with the commits, the package check reply, the release sheet, the human's reply, RELEASE_TAG and the pushed heads.
|
||||
</output>
|
||||
343
.planning/phases/17-blog-companions/17-03-PLAN.md
Normal file
343
.planning/phases/17-blog-companions/17-03-PLAN.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["17-01"]
|
||||
coupling_justified: ["17-02: 17-02 edits only admin/, modules/boardwalk/dist and modules/phrasebook lang in summercms.go; this plan reads summercms.go through go.work and never edits it, so the order between the two is irrelevant", "17-04: disjoint repositories (sm-journal-plugin and sm-sitemap-plugin here, sm-menu-plugin and sm-faq-plugin there); both only read summercms.go"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/menu_item_types.go
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go
|
||||
- ../sm-journal-plugin/menu_item_types_smoke_test.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-sitemap-plugin/.gitignore
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.sum
|
||||
- ../sm-sitemap-plugin/go.work
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
- ../sm-sitemap-plugin/plugin.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/admin.go
|
||||
- ../sm-sitemap-plugin/admin_permissions.go
|
||||
- ../sm-sitemap-plugin/assets/sitemap.xsl
|
||||
- ../sm-sitemap-plugin/config/config.yaml
|
||||
- ../sm-sitemap-plugin/lang/en/lang.yaml
|
||||
- ../sm-sitemap-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sitemap-plugin/models/registry.go
|
||||
- ../sm-sitemap-plugin/models/definition.go
|
||||
- ../sm-sitemap-plugin/models/definition_item.go
|
||||
- ../sm-sitemap-plugin/models/settings.go
|
||||
- ../sm-sitemap-plugin/models/definition/fields.yaml
|
||||
- ../sm-sitemap-plugin/models/settings/fields.yaml
|
||||
- ../sm-sitemap-plugin/controllers/admin_registry.go
|
||||
- ../sm-sitemap-plugin/controllers/definitions.go
|
||||
- ../sm-sitemap-plugin/controllers/definitions/config_form.yaml
|
||||
- ../sm-sitemap-plugin/classes/generator.go
|
||||
- ../sm-sitemap-plugin/classes/serialize.go
|
||||
- ../sm-sitemap-plugin/classes/urlto.go
|
||||
- ../sm-sitemap-plugin/classes/tiers.go
|
||||
- ../sm-sitemap-plugin/classes/manifest.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite.go
|
||||
- ../sm-sitemap-plugin/classes/events.go
|
||||
- ../sm-sitemap-plugin/classes/golden_test.go
|
||||
- ../sm-sitemap-plugin/classes/testdata/tracer.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/tracer.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/urlto.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/urlto.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.golden
|
||||
- ../sm-sitemap-plugin/tests/php/gen-goldens.php
|
||||
- ../sm-sitemap-plugin/console/helpers.go
|
||||
- ../sm-sitemap-plugin/console/import.go
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-sitemap-plugin/scripts/winter-export-definitions.sql
|
||||
- ../sm-sitemap-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-sitemap-plugin/updates/registry.go
|
||||
- ../sm-sitemap-plugin/updates/202610110001_create_golem15_sitemap_definitions.go
|
||||
- ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go
|
||||
- ../sm-sitemap-plugin/testmain_test.go
|
||||
- ../sm-sitemap-plugin/sitemap_smoke_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-10, D-11, D-12, D-13, D-14]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04: sm-journal-plugin implements pact.HasMenuItemTypes with exactly the five PHP keys journal-category, all-journal-categories, journal-post, all-journal-posts and category-journal-posts (labels golem15.journal::lang.menuitem.* in en and pl with the UI-SPEC copy); the change is additive: no existing Journal route, payload, table, migration ID, config key or field definition changes."
|
||||
- "D-10 resolved deviation: Journal's category content field stays type blogeditor (Phase 16.2); this plan does not switch it to richeditor."
|
||||
- "D-02 (every type has a valid DefaultPattern; list children follow their record type's host pattern): journal-post /blog/:slug and journal-category /blog/category/:slug; the list types declare the DefaultPattern of the type they list plus pact.MenuItemType.PatternFrom naming it: all-journal-categories /blog/category/:slug with PatternFrom journal-category, all-journal-posts and category-journal-posts /blog/:slug with PatternFrom journal-post; every list type builds its children from MenuItemResolve.Pattern, which signpost layers as item override, host override of the list key, host override of the PatternFrom record key, DefaultPattern; so with a host config that overrides only journal-category to /:slug the all-journal-categories children resolve to /<slug>, and an item urlPattern on a list item (PHP buildAlternateLinks with item->urlPattern) still reaches its children."
|
||||
- "D-02 and D-03 (Journal semantics from the frozen PHP tree): the PHP placeholders (:slug, :id; posts also :category, :year, :month, :day, the date ones only for a published post with published_at); Resolve fills the layered pattern by ordered literal replacement like PHP buildUrlFromPattern; alternates are one entry per enabled translate locale in sort_order (edge probe, ordering: ties by id), the unprefixed locale (MenuItemResolve.UnprefixedLocale, else the default locale) without a prefix and the others with /code, slugs translated per locale; with no enabled locales the only alternate is x-default; journal-category with nesting returns the subtree with default URLs only; all-journal-categories orders by name, all-journal-posts and category-journal-posts list published posts ordered by title (category-journal-posts includes descendant categories), journal-post applies no published filter."
|
||||
- "SC-3 and D-14: GET /sitemap.xml serves the single definition (theme = the fixed key default, D-11) in the PHP libxml format: XML declaration and stylesheet PI each followed by a newline, everything else on one line ending with the closing urlset tag and a newline, lastmod as 2006-01-02T15:04:05+00:00 in UTC and omitted when there is neither an mtime nor a manifest date, xhtml:link alternates self-closing and emitted only when there are more than one, Content-Type application/xml; the bytes equal the goldens generated from the local PHP with the vendored Winter router."
|
||||
- "SC-3 edge (encoding): text nodes escape ampersand, less-than, greater-than and carriage return; attributes also escape double quote, tab and newline; single quotes and UTF-8 stay literal; Url::to is ported (root plus trimmed path, a bare root keeps its slash, a trailing slash is dropped, every path segment re-encoded like PHP rawurlencode of rawurldecode, valid absolute, mailto, tel and fragment URLs kept) and pinned by the urlto golden table."
|
||||
- "SC-3 edges (boundary, empty, precision): the 50,000th URL is emitted and the 50,001st is not; a definition with no items answers 200 application/xml with an empty body, items that all resolve to nothing produce a self-closing urlset, and no definition answers 404; priority is written as the stored or settings string (1.0, 0.5) and never reformatted through a float."
|
||||
- "D-14 rewrite (edge probe, adjacency): when settings frontend_url is set, the backend URL (app.url when set, else the request scheme from a trusted X-Forwarded-Proto or TLS plus Host, right-trimmed of slashes) is replaced literally inside every loc element, every xhtml:link href and the exact stylesheet href, as PHP str_replace does; when both URLs are equal the output is unchanged; frontend_default_locale reaches Resolve as UnprefixedLocale."
|
||||
- "D-14 hooks: golem15.sitemap.beforeAddItem and addItem are typed festival events classes.BeforeAddItem (UntilHandled; a listener sets Skip to drop the URL) and classes.AddItem (Fire; listeners may append pre-escaped child XML to Extra, written before the url closing tag); the generation order follows Definition::addItemToSet (cap, url fill, lastmod, BeforeAddItem, empty URL drop, count, tier or item priority, elements, AddItem)."
|
||||
- "D-12: item types are url plus registry types only (static pages and cmsPage dropped); the per-locale rule reproduces PHP: when a resolved item has no URL (a list type) each child is emitted once per alternate, otherwise each child once with its default URL; updates_manifest_path is an optional setting read as a regular JSON file (size-capped) mapping a URL path to an ISO date."
|
||||
- "D-13: enable_priority_tiers and priority_homepage, _services, _blog, _categories, _legal port as settings with the PHP defaults; the tier path rules come from config golem15.sitemap.priority_tiers (a list of {setting, exact or pattern}, first match wins, path = URL path right-trimmed of slashes), compiled at boot as RE2 (a bad pattern fails boot); the plugin's default list reproduces the PHP getPriorityForUrl rules so a site configured the same way produces the same output, and a host config may replace the list."
|
||||
- "D-11 and SC-1: the sitemap admin opens the one definition through the definitions singleton controller (settings link, permission golem15.sitemap.access_definitions) whose form is a repeater on data with row fields type (url plus registry types), reference (dependsOn type through signpost.ReferenceOptions), url, urlPattern, priority, changefreq and nesting; a Sitemap settings form holds frontend_url, frontend_default_locale, updates_manifest_path and the tier settings; Definition.Fillable is exactly theme and data (PHP MassAssignmentTest); stored PHP-shaped item JSON decodes tolerantly (string, number and bool coercion, unknown keys such as cmsPage, exists and fillable ignored)."
|
||||
- "SC-1 commands: sitemap:import FILE with --theme and --append ports the PHP ImportCommand JSON format 1:1; sitemap:import-winter --definitions FILE --settings FILE copies a Winter export (scripts/winter-export-definitions.sql and winter-export-settings.sql) into the singleton definition and the settings row, maps type values (url kept, static and CMS page types dropped, extra old=new pairs through repeatable --map), drops an item whose reference is not among the type's current references, prints a report of every dropped or remapped item, refuses a non-empty definition without --force and supports --dry-run; both commands open and publish the database themselves when none is published."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/classes/menu_item_types.go"
|
||||
provides: "the five Journal menu item types with references, resolve and alternates"
|
||||
contains: "all-journal-categories"
|
||||
- path: "../sm-sitemap-plugin/classes/serialize.go"
|
||||
provides: "libxml-compatible sitemap writer"
|
||||
contains: "xhtml:link"
|
||||
- path: "../sm-sitemap-plugin/classes/urlto.go"
|
||||
provides: "Winter Url::to and rawurlencode port"
|
||||
contains: "rawurlencode"
|
||||
- path: "../sm-sitemap-plugin/classes/testdata/full.golden"
|
||||
provides: "PHP-generated golden output"
|
||||
- path: "../sm-sitemap-plugin/tests/php/gen-goldens.php"
|
||||
provides: "golden generator run with the local php and the vendored Winter router"
|
||||
- path: "../sm-sitemap-plugin/routes.go"
|
||||
provides: "/sitemap.xml and /sitemap.xsl"
|
||||
contains: "/sitemap.xml"
|
||||
- path: "../sm-sitemap-plugin/README.md"
|
||||
provides: "plugin README with the Winter YAML adaptations"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/menu_item_types.go"
|
||||
to: "summercms modules/signpost Activate"
|
||||
via: "pact.HasMenuItemTypes on the Journal plugin"
|
||||
pattern: "MenuItemTypes\\(\\)"
|
||||
- from: "../sm-journal-plugin/classes/menu_item_types.go list types"
|
||||
to: "summercms modules/signpost Registry.Pattern"
|
||||
via: "PatternFrom journal-category or journal-post; children built from MenuItemResolve.Pattern so the host override of the record type reaches them"
|
||||
pattern: "PatternFrom"
|
||||
- from: "../sm-sitemap-plugin/classes/generator.go"
|
||||
to: "summercms modules/signpost Registry.Resolve"
|
||||
via: "app.Lookup of *signpost.Registry at request time"
|
||||
pattern: "signpost"
|
||||
- from: "../sm-sitemap-plugin/controllers/definitions.go"
|
||||
to: "signpost.ReferenceOptions"
|
||||
via: "DependentOptions for the repeater row reference field"
|
||||
pattern: "ReferenceOptions"
|
||||
prohibitions:
|
||||
- statement: "The sitemap output is not improved over PHP: element order, escaping, whitespace, date format, headers and the str_replace rewrite stay exactly as the PHP output and goldens show (CLAUDE.md core value)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Plugin code, READMEs and lang files never name a consuming application or site; the PHP site-specific tier paths appear only as the documented default rule list that reproduces the PHP behaviour."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither plugin edits summercms.go in this plan (wave 2 runs beside the framework release); a framework gap found here is reported in the SUMMARY as a deviation, not patched in place."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed: the new sm-sitemap-plugin repository and the Journal commits stay local until plan 05's human-approved push."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 3 of 6, wave 2): Journal registers its five item types (D-04) and the sitemap plugin is ported with byte parity against PHP (SC-3), its admin screens and its import commands (SC-1). Both repositories build against the local summercms.go through go.work; nothing is pushed here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Edge items owned by this plan, all explicit truths above: SC-2 ordering (alternates order), SC-3 boundary (50k), adjacency (rewrite), empty (no items, no definition), encoding (escaping, Url::to), precision (priority strings). Backstop: `{statement: "list-type children ordered by name or title under Postgres ICU pl-PL may differ from the PHP MySQL order for non-ASCII names; parity is byte-equal on ASCII fixtures and set-equal on live data (plan 05)", verification: backstop}`. Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Fixed definition theme key: `default` (constant `models.DefinitionTheme`); imports write it regardless of the PHP theme name and report the original. `sitemap:import --theme` keeps the PHP flag; a value other than `default` prints that /sitemap.xml serves only the default definition.
|
||||
- Tier config key `golem15.sitemap.priority_tiers` in the plugin's `config/config.yaml` (list of `{setting, exact: [...]}` or `{setting, pattern: '...'}`), defaults equal to PHP `getPriorityForUrl` (RESEARCH Code Examples). A host overrides it in `config/golem15.sitemap.yaml`.
|
||||
- Events live in package `classes` (`classes.BeforeAddItem`, `classes.AddItem`) because the generator fires them and the root package imports classes.
|
||||
- Backend URL for the rewrite: `app.url` when set, else request scheme (trusted `X-Forwarded-Proto` through surf trusted proxies, else TLS) plus `Host` (RESEARCH A7; plan 05 verifies on staging).
|
||||
- Headers: `/sitemap.xml` `Content-Type: application/xml`; `/sitemap.xsl` `text/xsl; charset=UTF-8`; both `Cache-Control: no-cache, private` (live PHP responses).
|
||||
- Winter export SQL lives in the plugin (`scripts/winter-export-definitions.sql` and `scripts/winter-export-settings.sql`, one output each) so any Winter site can use it with `sitemap:import-winter` (RESEARCH Open Question 5).
|
||||
- Journal list types take the record type's DefaultPattern and set PatternFrom (17-01) instead of asking hosts to override five keys: a host overrides journal-post and journal-category, and the three list types follow.
|
||||
- The sitemap tests use a fake type plugin; the real Journal plus sitemap combination is proven by the host test in plan 05, so neither plugin's go.mod requires the other.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `sm-journal-plugin`: `(*Plugin).MenuItemTypes`, `classes.MenuItemTypes(app func() *backpack.App) []pact.MenuItemType`, lang `menuitem.*`, README section; smoke `TestMenuItemTypesSmoke`.
|
||||
- New local repository `../sm-sitemap-plugin` (branch main, module `git.golem15.com/golem15/sm-sitemap-plugin`, plugin ID `golem15.sitemap`): tables `golem15_sitemap_definitions` (id, theme nullable indexed, data text, timestamps) and `golem15_sitemap_settings` (singleton id 1); routes `/sitemap.xml`, `/sitemap.xsl`; settings entries Sitemap (link to the singleton) and Sitemap settings; permission `golem15.sitemap.access_definitions`; commands `sitemap:import`, `sitemap:import-winter`; events `classes.BeforeAddItem`, `classes.AddItem`; goldens and `tests/php/gen-goldens.php`; smoke tests `TestSitemapGolden`, `TestSitemapTracer`, `TestSitemapAdminSmoke`, `TestSitemapImportSmoke`.
|
||||
|
||||
<objective>
|
||||
Make Journal content addressable through the typed registry and serve a PHP-identical sitemap.xml from a plugin editors manage in the admin.
|
||||
|
||||
Purpose: SC-2 (Journal registers, sitemap consumes) and SC-3 (sitemap parity), plus the sitemap half of SC-1.
|
||||
Output: Journal types, the sm-sitemap-plugin repository with goldens, admin and commands.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-PATTERNS.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
|
||||
Repositories: ../sm-journal-plugin (main, builds against ../summercms.go through its go.work replace); ../sm-sitemap-plugin is created here with `git init -b main`, no remote (plan 05 adds it after a human creates the repository). Its go.work copies Journal's: `use .` plus replaces to ../summercms.go and ../sm-translate-plugin; its go.mod requires summercms at the current highest tag (nominal; plan 05 bumps it to the plan 02 tag) with the same `replace git.golem15.com/golem15/summercms => ../summercms.go` line Journal carries. Do not edit summercms.go in this plan. Commits: no co-author tags, stage by explicit path in each repository. Docker: Postgres smoke tests run through a TestMain that calls flag.Parse() and skips under testing.Short(); record NOT RUN when no daemon answers. PHP: the golden generator runs with the local `php` (8.5) and `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor/autoload.php`; the goldens are committed so CI needs no PHP.
|
||||
|
||||
Port sources (frozen): /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/ (models/Definition.php, classes/DefinitionItem.php, classes/definitionitem/fields.yaml, models/settings/fields.yaml, routes.php, console/ImportCommand.php, assets/sitemap.xsl, lang/, tests/security/, README.md) and /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/ (Plugin.php :242 extendWinterPagesPlugin, models/Category.php :119-460, models/Post.php :804-1140).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: Journal registers journal-post, and /sitemap.xml serves a stored definition with a url item and a registry item, byte-identical to the PHP golden</name>
|
||||
<reversibility rating="costly">D-03 and D-14: the serialized item shape and the stored data JSON (PHP DefinitionItem keys) are what copied PHP rows and later admin saves share; changing them means migrating stored definitions.</reversibility>
|
||||
<files>../sm-journal-plugin/menu_item_types.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types_smoke_test.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/.gitignore, ../sm-sitemap-plugin/go.mod, ../sm-sitemap-plugin/go.sum, ../sm-sitemap-plugin/go.work, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/models/definition.go, ../sm-sitemap-plugin/models/definition_item.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/updates/202610110001_create_golem15_sitemap_definitions.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/serialize.go, ../sm-sitemap-plugin/classes/urlto.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/tracer.json, ../sm-sitemap-plugin/classes/testdata/tracer.golden, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/testmain_test.go, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/field_types.go (lazy app accessor pattern), ../sm-journal-plugin/classes/blog_editor.go (BlogEditorTypes :80), ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/post.go and category.go, ../sm-journal-plugin/admin_harness_test.go (TestMain :53, Postgres boot), ../sm-translate-plugin/models/locale.go and classes/translatable.go (TranslatedExact :73), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (:804-1140), summercms modules/signpost/signpost.go and modules/pact/capabilities.go (plan 01 contracts), /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/models/Definition.php (:94-320 generateSitemap, getXmlObject, getUrlSet, addItemToSet), classes/DefinitionItem.php, routes.php, ../sm-journal-plugin/go.mod and go.work, ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go and updates/registry.go, ../sm-journal-plugin/routes.go, .planning/phases/17-blog-companions/17-RESEARCH.md (Patterns 3, 4, 4b, Code Examples)</read_first>
|
||||
<action>Per D-01 to D-04, D-11 and D-14, one Journal type and one sitemap request end to end.
|
||||
|
||||
Journal (additive, D-04): menu_item_types.go adds `func (p *Plugin) MenuItemTypes() []pact.MenuItemType` returning classes.MenuItemTypes with a lazy app accessor (the field_types.go pattern) and the compile-time assertion for pact.HasMenuItemTypes. classes/menu_item_types.go implements journal-post: References lists posts ordered by title (value = id, label = title); Resolve loads the post by reference id, fills the layered pattern (DefaultPattern /blog/:slug) by ordered literal replacement of :slug, :category, :id, :year, :month, :day as PHP buildUrlFromPattern does, sets MTime from updated_at and builds the ordered alternates (PHP buildAlternateLinks: enabled locales in sort_order, the unprefixed locale without a prefix, the others with /code, translated slugs through translate TranslatedExact; x-default only when there are no enabled locales). Lang menuitem.journal_post (en "Journal post", pl "Wpis (Journal)"). TestMenuItemTypesSmoke (Postgres): Journal boots through party with translate; the signpost registry holds journal-post; Resolve with a host pattern override returns the overridden URL and pl and en alternates in sort_order.
|
||||
|
||||
Sitemap repository: create ../sm-sitemap-plugin (git init -b main, .gitignore like Journal's), go.mod, go.work, plugin.go (ID golem15.sitemap, Requires none, Register keeps the app, HasMigrations, HasModels, HasRoutes, HasLang added in Task 4), migration 202610110001_create_golem15_sitemap_definitions (id SERIAL, theme VARCHAR(255) NULL with an index, data TEXT NULL, created_at, updated_at; Rollback drops it), models.Definition (TableName golem15_sitemap_definitions, MorphName Golem15\Sitemap\Models\Definition, Fillable theme and data, Data as a typed item list in a lagoon.Jsonable or custom Scanner) and models.DefinitionItem with a tolerant UnmarshalJSON (type, url, reference, urlPattern, nesting, priority, changefreq; numbers, strings and bools coerced; cmsPage, exists, fillable and any other key ignored) whose MarshalJSON writes the PHP toArray key set (changefreq, priority, nesting, type, url, reference, cmsPage as null, urlPattern).
|
||||
|
||||
classes: urlto.go ports Url::to (RESEARCH Pattern 4b: valid-URL test ^(#|//|https?://|(mailto|tel|sms):) or a parseable absolute URL; otherwise root plus "/" plus the path trimmed of slashes; an empty path keeps the root slash; each path segment re-encoded with rawurlencode(rawurldecode(seg)), escaping everything except A-Z a-z 0-9 - _ . ~). serialize.go writes the PHP libxml format (RESEARCH Pattern 4 format facts and escape tables) with a strings.Builder, never encoding/xml. generator.go walks the definition items: a url item goes through addItem; a registry item goes through Registry.Resolve with the item's urlPattern as override and its nesting; a resolved item with a URL is added; its Items are added recursively (the per-locale rule arrives in Task 3). routes.go mounts GET /sitemap.xml at the root (not under a throttle group, not behind the backend middleware): it loads the definition with theme default (404 when absent, empty body when it has no items), builds the base URL and writes the XML with Content-Type application/xml.
|
||||
|
||||
Golden generator: tests/php/gen-goldens.php reads a JSON input (base URL plus items with loc, lastmod, changefreq, priority and alternates, or Url::to inputs), runs the same DOMDocument calls as Definition.php getXmlObject, getUrlSet and addItemToSet and the vendored Winter UrlGenerator for Url::to, and writes the golden file. Generate tracer.golden from tracer.json (one url item "/", one item with two alternates). TestSitemapGolden (Docker-free) feeds tracer.json through the Go serializer and compares bytes with tracer.golden. TestSitemapTracer (Postgres): boots summercms with a fake test plugin that registers type acme-post resolving to a fixed URL with two alternates, stores a definition with a url item and an acme-post item, GETs /sitemap.xml and compares the body with tracer.golden and the Content-Type header.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapGolden$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestSitemapGolden' && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: either repository fails vet; no "--- PASS: TestSitemapGolden" (the Go serializer differs from the PHP golden by a byte); a Journal classes or controllers test regressed.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapTracer$' -v . 2>&1 | grep -- '^--- PASS: TestSitemapTracer' && go -C ../sm-journal-plugin test -count=1 -run '^TestMenuItemTypesSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuItemTypesSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or /sitemap.xml differs from the golden or has the wrong Content-Type, or Journal's journal-post does not resolve through the registry with the override pattern and ordered alternates.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-sitemap-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-sitemap-plugin remote` prints nothing.
|
||||
- `grep -n "module git.golem15.com/golem15/sm-sitemap-plugin" ../sm-sitemap-plugin/go.mod` prints one line.
|
||||
- `grep -n "encoding/xml" ../sm-sitemap-plugin/classes/serialize.go` prints nothing.
|
||||
- `grep -n "journal-post" ../sm-journal-plugin/classes/menu_item_types.go` prints at least one line and `git -C ../sm-journal-plugin diff --stat 58851dd -- routes.go controllers/api models/category/fields.yaml updates` prints nothing (58851dd is the pre-phase Journal head).
|
||||
</acceptance_criteria>
|
||||
<done>A Journal post is addressable through the registry, and /sitemap.xml from a stored definition matches PHP byte for byte.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The other four Journal types: category tree references, nesting, list types, category posts, alternates and lang</name>
|
||||
<files>../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types_smoke_test.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/menu_item_types.go (Task 1), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php (:119-460 getMenuTypeInfo, resolveMenuItem, buildAlternateLinks, buildUrlFromPattern), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (:804-1140), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php (:242-260), ../sm-journal-plugin/controllers/api/posts.go (serializeCategoryTree, published filter), ../sm-journal-plugin/models/category.go (nest_left, nest_right, parent_id), ../sm-journal-plugin/README.md (Features, API reference, Testing), .planning/phases/17-blog-companions/17-UI-SPEC.md (Plugin admin copy: journal registry type labels)</read_first>
|
||||
<action>Per D-04, D-02 and D-03, complete the five PHP types (RESEARCH Pattern 3).
|
||||
|
||||
Patterns (D-02, every type valid at signpost boot): each list type declares the DefaultPattern of the type it lists and sets PatternFrom to that type's key, and builds every child URL from in.Pattern (the pattern signpost already layered), never from a hard-coded default and never by looking up the registry itself; PHP likewise built list children from the item's urlPattern.
|
||||
|
||||
journal-category: References is the category tree (parent_id, ordered by nest_left then name; value id, label name, children in Items); DefaultPattern /blog/category/:slug with :slug and :id; Resolve returns the category's URL (the first alternate's URL, or the pattern with the default slug), MTime updated_at, alternates as in Task 1; with Nesting its Items are the subtree, each child with its default URL only (no per-locale entries), built from the same in.Pattern. all-journal-categories: DefaultPattern /blog/category/:slug, PatternFrom journal-category, no References (nil); Resolve returns an empty URL and Items = every category ordered by name, each with URL, MTime and alternates from in.Pattern. journal-post: unchanged from Task 1. all-journal-posts: DefaultPattern /blog/:slug, PatternFrom journal-post, no References; Items = published posts (the API's published rule) ordered by title, each with URL, MTime and alternates from in.Pattern. category-journal-posts: DefaultPattern /blog/:slug, PatternFrom journal-post; References is the category tree; Items = published posts in the category or any descendant (nest_left and nest_right range), ordered by title, URLs from in.Pattern. Post patterns fill :category from the post's first category slug translated per locale, and :year, :month, :day only for a published post with published_at. Ordering ties break by id.
|
||||
|
||||
Lang menuitem.journal_category, all_journal_categories, journal_post, all_journal_posts, category_journal_posts in en and pl with the UI-SPEC labels. README: a "Menu item types" section (the five keys, default patterns, PatternFrom of the three list types, placeholders, alternates rule, how a host overrides patterns through menu_items.patterns: override journal-post and journal-category and the list types follow, or override a list key to give its children a different pattern) and an API reference row for MenuItemTypes; state that category content stays on blogeditor. TestMenuItemTypesSmoke grows: all five keys registered, each with a DefaultPattern starting with a slash and the three list types with PatternFrom set as above; with a test host config menu_items.patterns that overrides only journal-category to /:slug and journal-post to /p/:slug, all-journal-categories children resolve to /<slug>, all-journal-posts and category-journal-posts children to /p/<slug>, and an all-journal-posts resolve with the item override /x/:slug returns children at /x/<slug>; journal-category with nesting returns its child with the default URL only; all-journal-categories returns name-ordered items with no top URL; category-journal-posts includes a post of a child category and excludes an unpublished one; with no enabled locales the single alternate is x-default.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && grep -c "category_journal_posts" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml | grep -c ':1$' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Journal classes or controllers test regressed, or a menuitem label is missing in one locale.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-journal-plugin test -count=1 -run '^TestMenuItemTypesSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuItemTypesSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a type is missing or lacks a valid DefaultPattern, a list type's children ignore the host override of their record type or the item override, nesting returned per-locale children, list ordering or the published filter differs from PHP, or the x-default rule is wrong.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -o '"\(journal-category\|all-journal-categories\|journal-post\|all-journal-posts\|category-journal-posts\)"' ../sm-journal-plugin/classes/menu_item_types.go | sort -u | wc -l` prints 5.
|
||||
- `grep -n "Menu item types" ../sm-journal-plugin/README.md` prints one line and `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` still prints one line.
|
||||
- `grep -c "PatternFrom" ../sm-journal-plugin/classes/menu_item_types.go` is at least 3 and `grep -c "DefaultPattern" ../sm-journal-plugin/classes/menu_item_types.go` is at least 5.
|
||||
</acceptance_criteria>
|
||||
<done>Menus and the sitemap can address every Journal content shape PHP offered, with per-locale URLs.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Full sitemap generation: per-locale list rule, lastmod and manifest, priority tiers, typed hooks, 50k cap, frontend rewrite, XSL, settings model, and the two import commands</name>
|
||||
<files>../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/assets/sitemap.xsl, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/tiers.go, ../sm-sitemap-plugin/classes/manifest.go, ../sm-sitemap-plugin/classes/rewrite.go, ../sm-sitemap-plugin/classes/events.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/urlto.json, ../sm-sitemap-plugin/classes/testdata/urlto.golden, ../sm-sitemap-plugin/classes/testdata/full.json, ../sm-sitemap-plugin/classes/testdata/full.golden, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/console/helpers.go, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/scripts/winter-export-definitions.sql, ../sm-sitemap-plugin/scripts/winter-export-settings.sql, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
|
||||
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/models/Definition.php (whole file: MAX_URLS, addItemToSet :204-310, lookupManifestLastmod :318, getUpdatesManifest :342, normalizeManifestPath :372, getPriorityForUrl :393), routes.php, console/ImportCommand.php, assets/sitemap.xsl, models/settings/fields.yaml, models/Settings.php, summercms modules/festival/bus.go (Handleable :18, UntilHandled :100), summercms modules/surf/clientip.go (TrustedProxies), ../sm-journal-plugin/models/settings.go and updates/202610060006_create_golem15_journal_settings.go (singleton settings table), ../sm-journal-plugin/console/import_posts.go (:39-64 command shape), ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go (lookupDB :50-69), ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql (header and JSON_OBJECT style), .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 4 generation order, Pitfalls 1, 2, 6, 7, 8, 11, Code Examples default tier config)</read_first>
|
||||
<action>Per D-12, D-13 and D-14 and SC-3.
|
||||
|
||||
Settings: migration 202610110002_create_golem15_sitemap_settings (id 1 singleton like Journal's: frontend_url, frontend_default_locale, updates_manifest_path as nullable text; enable_priority_tiers boolean default true; priority_homepage '1.0', priority_services '0.8', priority_blog '0.6', priority_categories '0.4', priority_legal '0.2' as strings) and models.Settings with Fillable and Rules mirroring PHP (no validation beyond PHP).
|
||||
|
||||
Generator (Definition::addItemToSet order): stop at 50,000 URLs; fill a missing URL through Url::to; lastmod = the resolved MTime, else the manifest date for the URL path, else omitted; fire BeforeAddItem through app.Events.UntilHandled (handled means skip); drop an empty URL and pass it through Url::to again; count it; priority = the first matching tier's settings value when enable_priority_tiers is on, else the item priority string; emit loc, lastmod, changefreq, priority and the alternates (only when more than one, each href through Url::to); write AddItem.Extra before the url closing tag after app.Events.Fire. Per-locale rule (D-12, Pitfall 3): when a resolved registry item has an empty URL, each child is emitted once per alternate (loc = that alternate's URL, the full alternate list as hreflang); otherwise each child is emitted once with its default URL. Resolve receives the settings frontend_default_locale as UnprefixedLocale. events.go defines BeforeAddItem {Item, Info, Skip} with IsHandled and AddItem {Item, Info, Extra []string} with doc comments naming the PHP events.
|
||||
|
||||
plugin.go adds pact.HasConfig (embedded config/config.yaml), pact.HasCommands and a Boot that compiles the tier rules. tiers.go: read golem15.sitemap.priority_tiers with LoadSection at Boot into compiled rules (setting name must be one of the five priority settings, exactly one of exact or pattern, RE2 compile errors fail boot); config/config.yaml ships the PHP getPriorityForUrl rules as the default list (homepage exact "" and "/en"; services, blog, categories patterns; legal exact list) with a comment that a host replaces the list. manifest.go: read updates_manifest_path only when it is a regular file of at most 5 MiB holding a JSON object, normalize keys like PHP normalizeManifestPath, cache per request, and on any error omit lastmod (fail safe, never echo file content). rewrite.go: the three literal replacements of routes.php after serialization (inside every loc, every xhtml:link href, and the exact stylesheet href), with the backend URL from app.url or the trusted request base, right-trimmed of slashes. routes.go adds GET /sitemap.xsl serving the embedded assets/sitemap.xsl copied byte-identical from the PHP plugin, and the headers from this plan's decisions.
|
||||
|
||||
Goldens: extend gen-goldens.php and commit urlto.json and urlto.golden (the RESEARCH Pattern 4b table plus non-ASCII, colon, query and fragment cases) and full.json and full.golden (ampersands, quotes, angle brackets, carriage returns, tabs and newlines in loc, alternates and priorities; an empty urlset case; an hreflang list type expanded per alternate; tier priorities over the default rules; a rewritten frontend URL). TestSitemapGolden runs every case.
|
||||
|
||||
Commands (console/helpers.go copies lookupDB): sitemap:import FILE [--theme] [--append] ports ImportCommand (JSON format, non-append keeps non-url items first then the imported URLs) into the singleton definition. sitemap:import-winter --definitions FILE --settings FILE [--map old=new ...] [--force] [--dry-run]: definitions JSONL rows {id, theme, data, created_at, updated_at} and the settings JSON object from system_settings; the type map keeps url, drops the static and CMS page types and cmsPage keys, applies --map pairs; an item whose registry type takes references is dropped when its reference is not among signpost.ReferenceOptions for that type; every dropped or remapped item is printed; a non-empty definition is refused without --force; the settings row is upserted (id 1). scripts/winter-export-definitions.sql (one JSON_OBJECT line per golem15_sitemap_definitions row) and scripts/winter-export-settings.sql (the system_settings value of item golem15_sitemap_settings), both read-only SELECTs with the categories.sql style header (how to run with mysql -N -B --raw, what to check first).
|
||||
|
||||
Smoke (Postgres) in sitemap_smoke_test.go: TestSitemapImportSmoke imports a fixture export (one url item, one page item, one mapped list type) and reports the dropped page item; /sitemap.xml with frontend_url set rewrites loc and the stylesheet href; /sitemap.xsl answers the PHP bytes with text/xsl; a BeforeAddItem listener that sets Skip drops its URL.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -count=1 -short ./... && go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapGolden$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestSitemapGolden' && cmp ../sm-sitemap-plugin/assets/sitemap.xsl /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; a golden case differs by a byte (escaping, Url::to, tiers, per-locale expansion, rewrite or empty urlset); the embedded XSL differs from the PHP file.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-sitemap-plugin test -count=1 -run '^(TestSitemapTracer|TestSitemapImportSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the import kept an unmapped type, the rewrite missed loc or the stylesheet href, the XSL headers differ, or a skipped BeforeAddItem URL was still written.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "50000\|50_000" ../sm-sitemap-plugin/classes/generator.go` prints at least one line.
|
||||
- `grep -n "priority_tiers" ../sm-sitemap-plugin/config/config.yaml` prints one line.
|
||||
- `grep -n "type BeforeAddItem struct\|type AddItem struct" ../sm-sitemap-plugin/classes/events.go` prints two lines.
|
||||
- The export SQL is SELECT-only: `cat ../sm-sitemap-plugin/scripts/winter-export-definitions.sql ../sm-sitemap-plugin/scripts/winter-export-settings.sql | grep -v '^[[:space:]]*--' | grep -ciE '\b(insert|update|delete|drop|alter|truncate|replace)\b'` prints 0.
|
||||
- `grep -n "HasConfig\|HasCommands" ../sm-sitemap-plugin/plugin.go` prints at least two lines.
|
||||
</acceptance_criteria>
|
||||
<done>The sitemap reproduces every PHP output rule, is extensible through typed hooks, and a Winter site's definition and settings can be copied in.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Sitemap admin: the singleton definition form with a repeater and the registry-bound reference, the settings form, permissions, lang and the README</name>
|
||||
<files>../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/admin.go, ../sm-sitemap-plugin/admin_permissions.go, ../sm-sitemap-plugin/controllers/admin_registry.go, ../sm-sitemap-plugin/controllers/definitions.go, ../sm-sitemap-plugin/controllers/definitions/config_form.yaml, ../sm-sitemap-plugin/models/definition/fields.yaml, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (adminFS embed, AdminControllers, Settings :38-50), ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/controllers/tags.go and controllers/tags/config_form.yaml, ../sm-journal-plugin/controllers/admin_registry.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/Plugin.php (permissions :42, settings registration), classes/definitionitem/fields.yaml, models/settings/fields.yaml, lang/en/lang.php and lang/pl/lang.php, README.md, summercms modules/cabana/README.md (repeater, dependsOn, singleton, settings Controller link from plan 01), .planning/phases/17-blog-companions/17-UI-SPEC.md (§5 Singleton form, §6 Sitemap screens, Copywriting sitemap rows), .planning/phases/17-blog-companions/17-RESEARCH.md (Pitfall 9 adaptations)</read_first>
|
||||
<action>Per D-11, D-12, D-13 and SC-1.
|
||||
|
||||
Controller golem15.sitemap.definitions (ModelName Golem15\Sitemap\Models\Definition, ConfigDir controllers/definitions, RequiredPermissions golem15.sitemap.access_definitions) implements pact.AdminSingleton (first or create the row with theme default), pact.DropdownOptionsProvider for data.type (url "URL" plus every registry type in registry order with its label) and pact.DependentOptionsProvider for the row reference (url answers applies false; registry types call signpost.ReferenceOptions). config_form.yaml with PHP modelClass and messages; models/definition/fields.yaml: one full-width repeater data (prompt golem15.sitemap::lang.item.add_item, titleFrom url) with nested type (dropdown, left), reference (dropdown, dependsOn type, right), url (text, left), urlPattern (text with url_pattern_comment, right), priority (dropdown 0.1 to 1.0, left), changefreq (dropdown with the seven PHP frequencies, right), nesting (checkbox, default on, full). The form schema subtitle links to /sitemap.xml (definition.view).
|
||||
|
||||
Settings (admin.go): HasSettings with two entries in the CMS category: sitemap (label plugin.name, icon network, Controller golem15.sitemap.definitions) and sitemap_settings (label settings.label, icon settings, Form models/settings/fields.yaml, NewModel Settings); both require golem15.sitemap.access_definitions. models/settings/fields.yaml: frontend_url (left), frontend_default_locale (right), updates_manifest_path (full), enable_priority_tiers (checkbox default on, full), the five priority_* text fields with their defaults and the generic tier comments from the UI-SPEC; the PHP hint and section blocks become comments and README text, trigger conditions are dropped (fields always show). HasPermissions registers golem15.sitemap.access_definitions (PHP label, developer role). Lang en and pl keep the PHP key names and values with the UI-SPEC corrections (add_item without the access-key markup, new pl values, no site names).
|
||||
|
||||
README (standard plugin README, no consuming application or site names): overview, the two routes and their PHP parity rules, the definition singleton, item types (url plus registry types; static and CMS pages dropped, D-12), settings, priority tiers and how to replace the rule list in host config, the two hooks with listener examples, the two commands and the export SQL, the Winter adaptations list (tabs flattened, span none mapped, hint and section to comments, trigger dropped, the SitemapItems widget replaced by a repeater, empty_confirm not ported), dependencies and testing.
|
||||
|
||||
Smoke (Postgres) TestSitemapAdminSmoke: the singleton route creates the definition once; saving rows through the admin API stores PHP-keyed item JSON; a reference not offered for its row type answers 422; the settings form saves the frontend URL; a user without golem15.sitemap.access_definitions gets 403.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -count=1 -short ./... && grep -q "golem15.sitemap.access_definitions" ../sm-sitemap-plugin/admin_permissions.go && grep -q "## " ../sm-sitemap-plugin/README.md</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (for example the admin YAML does not compile at boot); the permission code is missing; the README has no sections.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-sitemap-plugin test -count=1 -run '^(TestSitemapTracer|TestSitemapImportSmoke|TestSitemapAdminSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: the singleton was created twice, rows were not stored with PHP keys, an invalid reference was accepted, settings did not save, or permissions were not enforced.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: repeater" ../sm-sitemap-plugin/models/definition/fields.yaml` and `grep -n "dependsOn: type" ../sm-sitemap-plugin/models/definition/fields.yaml` each print one line.
|
||||
- `grep -n "SingletonRecord" ../sm-sitemap-plugin/controllers/definitions.go` prints one line.
|
||||
- `grep -ci "grzyby\|horoskopia\|fonoteka\|www\.golem15\.com" ../sm-sitemap-plugin/README.md ../sm-sitemap-plugin/lang/en/lang.yaml ../sm-sitemap-plugin/lang/pl/lang.yaml` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Editors manage the sitemap definition and settings in the admin, and the plugin is documented for any host.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → /sitemap.xml, /sitemap.xsl | anonymous GETs |
|
||||
| admin → definition and settings | authenticated editors; stored item text reaches public XML |
|
||||
| Winter export files → import commands | operator-supplied JSONL and JSON |
|
||||
| operator setting → manifest file path | a server path read by the plugin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-15 | Tampering (XML injection) | serialize.go | high | mitigate | libxml-equivalent text and attribute escaping for every value (PHP XmlEscapingTest spec); golden cases with ampersands, quotes, angle brackets and control characters; no string concatenation of unescaped input |
|
||||
| T-17-16 | Tampering (mass assignment) | models.Definition | medium | mitigate | Fillable exactly theme and data (PHP MassAssignmentTest); settings Fillable lists only the settings columns |
|
||||
| T-17-17 | Information Disclosure | updates_manifest_path | medium | mitigate | regular file only, 5 MiB cap, JSON object only; only dates are derived; errors omit lastmod and never echo content |
|
||||
| T-17-18 | Denial of Service | /sitemap.xml generation | medium | mitigate | 50,000 URL cap; tier patterns compiled once as RE2 (no backtracking); manifest cached per request |
|
||||
| T-17-19 | Spoofing | backend URL for the rewrite | medium | mitigate | X-Forwarded-Proto honored only from surf trusted proxies; app.url preferred when set; literal replace only of the backend URL |
|
||||
| T-17-20 | Elevation of Privilege | definitions controller and settings | medium | mitigate | permission golem15.sitemap.access_definitions on both settings entries and the controller; dependent reference rechecked on save |
|
||||
| T-17-21 | Tampering | import commands | medium | mitigate | refuse a non-empty definition without --force; unmapped and reference-less items dropped with a report; dry run; SELECT-only export SQL |
|
||||
| T-17-22 | Tampering | Journal additive change | low | mitigate | no existing Journal route, field or migration changes (diff check against 58851dd) |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package installs; Go dependencies are the framework's own and pinned in go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: sitemap golden and Journal Docker-free suites; the sitemap tracer and Journal smoke on a Docker host.
|
||||
- Task 2: Journal suites and lang parity; Journal smoke on a Docker host.
|
||||
- Task 3: full golden table, short suite, XSL byte check; import and tracer smokes on a Docker host.
|
||||
- Task 4: short suite, permission and README checks; admin smoke on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: Journal registers its five PHP types through the registry and the sitemap consumes them.
|
||||
- SC-3: sitemap.xml and sitemap.xsl match PHP output, including the frontend_url rewrite and hreflang.
|
||||
- SC-1 (sitemap): schema, models, admin screens and import commands ported in their own repository with a README.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-03-SUMMARY.md` when done, with the commits per repository (sm-journal-plugin, sm-sitemap-plugin), the golden generator command used, Docker smoke tests run or NOT RUN, and any framework gap found (reported, not patched).
|
||||
</output>
|
||||
387
.planning/phases/17-blog-companions/17-04-PLAN.md
Normal file
387
.planning/phases/17-blog-companions/17-04-PLAN.md
Normal file
@@ -0,0 +1,387 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["17-01"]
|
||||
coupling_justified: ["17-02: 17-02 edits only admin/, modules/boardwalk/dist and modules/phrasebook lang in summercms.go; this plan reads summercms.go through go.work and never edits it", "17-03: disjoint repositories; both only read summercms.go"]
|
||||
files_modified:
|
||||
- ../sm-faq-plugin/.gitignore
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-faq-plugin/go.work
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-faq-plugin/plugin.go
|
||||
- ../sm-faq-plugin/routes.go
|
||||
- ../sm-faq-plugin/admin.go
|
||||
- ../sm-faq-plugin/admin_navigation.go
|
||||
- ../sm-faq-plugin/admin_permissions.go
|
||||
- ../sm-faq-plugin/lang/en/lang.yaml
|
||||
- ../sm-faq-plugin/lang/pl/lang.yaml
|
||||
- ../sm-faq-plugin/models/registry.go
|
||||
- ../sm-faq-plugin/models/faq.go
|
||||
- ../sm-faq-plugin/models/faq/fields.yaml
|
||||
- ../sm-faq-plugin/models/faq/columns.yaml
|
||||
- ../sm-faq-plugin/controllers/admin_registry.go
|
||||
- ../sm-faq-plugin/controllers/faqs.go
|
||||
- ../sm-faq-plugin/controllers/faqs/config_form.yaml
|
||||
- ../sm-faq-plugin/controllers/faqs/config_list.yaml
|
||||
- ../sm-faq-plugin/controllers/api/faqs.go
|
||||
- ../sm-faq-plugin/console/helpers.go
|
||||
- ../sm-faq-plugin/console/import_winter.go
|
||||
- ../sm-faq-plugin/scripts/winter-export.sql
|
||||
- ../sm-faq-plugin/updates/registry.go
|
||||
- ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go
|
||||
- ../sm-faq-plugin/testmain_test.go
|
||||
- ../sm-faq-plugin/faq_smoke_test.go
|
||||
- ../sm-menu-plugin/.gitignore
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-menu-plugin/go.work
|
||||
- ../sm-menu-plugin/README.md
|
||||
- ../sm-menu-plugin/plugin.go
|
||||
- ../sm-menu-plugin/routes.go
|
||||
- ../sm-menu-plugin/admin.go
|
||||
- ../sm-menu-plugin/admin_navigation.go
|
||||
- ../sm-menu-plugin/admin_permissions.go
|
||||
- ../sm-menu-plugin/lang/en/lang.yaml
|
||||
- ../sm-menu-plugin/lang/pl/lang.yaml
|
||||
- ../sm-menu-plugin/models/registry.go
|
||||
- ../sm-menu-plugin/models/menu.go
|
||||
- ../sm-menu-plugin/models/menu_entry.go
|
||||
- ../sm-menu-plugin/models/menu/fields.yaml
|
||||
- ../sm-menu-plugin/models/menu/columns.yaml
|
||||
- ../sm-menu-plugin/models/menuentry/fields.yaml
|
||||
- ../sm-menu-plugin/models/menuentry/columns.yaml
|
||||
- ../sm-menu-plugin/classes/links.go
|
||||
- ../sm-menu-plugin/classes/tree.go
|
||||
- ../sm-menu-plugin/controllers/admin_registry.go
|
||||
- ../sm-menu-plugin/controllers/menus.go
|
||||
- ../sm-menu-plugin/controllers/menus/config_form.yaml
|
||||
- ../sm-menu-plugin/controllers/menus/config_list.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries.go
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_form.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_list.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_filter.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml
|
||||
- ../sm-menu-plugin/controllers/api/menus.go
|
||||
- ../sm-menu-plugin/console/helpers.go
|
||||
- ../sm-menu-plugin/console/import_winter.go
|
||||
- ../sm-menu-plugin/scripts/winter-export-menus.sql
|
||||
- ../sm-menu-plugin/scripts/winter-export-entries.sql
|
||||
- ../sm-menu-plugin/updates/registry.go
|
||||
- ../sm-menu-plugin/updates/202610110001_create_golem15_menu_menus.go
|
||||
- ../sm-menu-plugin/updates/202610110002_create_golem15_menu_menu_entries.go
|
||||
- ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go
|
||||
- ../sm-menu-plugin/testmain_test.go
|
||||
- ../sm-menu-plugin/menu_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, D-02, D-05, D-07, D-08, D-09, D-10, D-16, D-17]
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1 (FAQ): table golem15_faq_faqs keeps the PHP columns (id, name, slug, locale_id NOT NULL, is_active default false, description text, content text holding the jsonable question and answer list) and timestamps; the admin has a top-level FAQ entry (order 500, side menu FAQs, permission golem15.faq.faqs) whose form has name, slug (preset from name), locale (relation to the translate Locale, nameFrom name), is_active, description (richeditor, D-10) and content (repeater, prompt add_question, titleFrom question, nested question text required and answer richeditor, D-07); the list adds a locale column so rows of the same slug are told apart."
|
||||
- "SC-1 (menu): tables golem15_menu_menus (id, name, code unique, is_active default true, css_class) and golem15_menu_menu_entries (id, menu_id, parent_id, nest_left, nest_right, nest_depth NOT NULL, sort_order, type, title, url, reference, code, icon, css_class, target, is_published default true) keep the PHP columns, plus one additive nullable url_pattern column on entries for the D-02 per-item pattern override; MenuEntry saves set nest_left, nest_right and nest_depth to 0 when unset, set sort_order to the menu's maximum plus one when unset and inherit menu_id from the parent, as PHP beforeSave did."
|
||||
- "D-05: the entry type dropdown offers the built-ins url (Link) and divider (Divider) followed by every registry type with its owning plugin's label; the reference dropdown depends on type through signpost.ReferenceOptions (built-ins answer applies false, D-08); the entry url accepts only http, https, mailto, tel, a leading slash or a fragment (anything else is a 422 with errors.url_scheme, RESEARCH Pitfall 15) and target is _self, _blank or empty."
|
||||
- "D-09: the menu entries list has a menu filter and the toolbar buttons reorder, create and delete; config_reorder.yaml (title Reorder menu entries, nameFrom title, partition menu_id through relation menu, nameFrom name) lets editors drag entries into a tree scoped to one menu; the PHP Children tab and partial are not ported."
|
||||
- "D-05 and D-19 import: menu:import-winter --menus FILE --entries FILE maps link to url and blog_categories to all-journal-categories, drops page entries (and their subtrees) and any type that is neither built-in, registered nor mapped through repeatable --map old=new, drops a registry entry whose reference is not among the type's references, prints one report line per dropped or remapped entry, keeps ids, menu_id and parent_id, bumps both sequences with setval, refuses non-empty tables without --force and supports --dry-run; faq:import-winter --file FILE remaps locale_id by locale code (an unknown code aborts before writing), keeps ids, bumps the sequence and has the same --force and --dry-run rules; the export SQL files are read-only SELECTs."
|
||||
- "SC-1 edge (empty): each import command given an empty input file writes nothing and exits 0 with a zero-count report."
|
||||
- "D-16 (one-way, after the human's checkpoint answer): GET /_menu/api/v1/menus/{code} is anonymous, throttled by bucket menu-public-api (120 per minute per client IP), answers 404 JSON for an unknown or inactive menu, and returns the menu with its published entries as a nested tree ordered by sort_order then id within each parent (edge probe, ordering), each entry with id, title, type, url, target, css_class, icon, code and items; url entries return their stored URL only when it passes the scheme allowlist (else null); dividers return a null URL; single registry types return the resolved URL for the requested locale (else the default URL) through signpost with the entry's url_pattern as the item override; list registry types keep the entry's stored URL and title and carry the resolved children in items; an entry whose type is not registered or whose reference no longer resolves is left out."
|
||||
- "D-17 (one-way, after the human's checkpoint answer): GET /_faq/api/v1/faqs/{slug}?locale=xx is anonymous, throttled by bucket faq-public-api, resolves the locale by code among enabled locales (default: the translate default locale), selects the active row with that slug and locale ordered by id (edge probe, adjacency: duplicate slugs serve the lowest id), answers 404 JSON when none, and returns name, slug, locale, description and content as [{question, answer}] with description and every answer passed through seaglass on output, so rows copied straight into the database are sanitized too (RESEARCH Pitfall 10)."
|
||||
- "D-17 locale relation: FAQ Requires golem15.translate and keeps one row per locale through locale_id (1:1 with PHP)."
|
||||
artifacts:
|
||||
- path: "../sm-faq-plugin/models/faq.go"
|
||||
provides: "FAQ model with jsonable content and the Locale relation"
|
||||
contains: "golem15_faq_faqs"
|
||||
- path: "../sm-faq-plugin/controllers/api/faqs.go"
|
||||
provides: "public FAQ API with output sanitizing"
|
||||
contains: "seaglass"
|
||||
- path: "../sm-menu-plugin/models/menu_entry.go"
|
||||
provides: "MenuEntry with the PHP beforeSave rules"
|
||||
contains: "nest_left"
|
||||
- path: "../sm-menu-plugin/controllers/menuentries/config_reorder.yaml"
|
||||
provides: "tree reorder scoped to one menu"
|
||||
contains: "partition"
|
||||
- path: "../sm-menu-plugin/controllers/api/menus.go"
|
||||
provides: "public menu API with registry resolution"
|
||||
contains: "signpost"
|
||||
- path: "../sm-menu-plugin/console/import_winter.go"
|
||||
provides: "Winter menu import with the D-05 type map"
|
||||
contains: "all-journal-categories"
|
||||
key_links:
|
||||
- from: "../sm-menu-plugin/controllers/menuentries.go"
|
||||
to: "signpost.ReferenceOptions"
|
||||
via: "DependentOptions for the reference field"
|
||||
pattern: "ReferenceOptions"
|
||||
- from: "../sm-menu-plugin/controllers/api/menus.go"
|
||||
to: "signpost Registry.Resolve"
|
||||
via: "registry entries resolved at request time with url_pattern as the item override"
|
||||
pattern: "Resolve\\("
|
||||
- from: "../sm-faq-plugin/controllers/api/faqs.go"
|
||||
to: "summercms modules/seaglass"
|
||||
via: "description and answers sanitized on output"
|
||||
pattern: "seaglass\\.Sanitize"
|
||||
prohibitions:
|
||||
- statement: "The two public API shapes are written only after the human answers the D-16 and D-17 checkpoint; once shipped they are not changed in place (a change is a new version path)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Plugin code, READMEs and lang files never name a consuming application or site; examples use neutral names (acme, main)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither plugin edits summercms.go in this plan; a framework gap is reported in the SUMMARY; nothing is pushed (plan 05 pushes after human approval)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 4 of 6, wave 2): the FAQ and menu plugins are ported in their own local repositories with admin screens, Winter import commands and the two published read APIs (D-16, D-17) behind a human checkpoint on their shapes.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Edge items owned by this plan, explicit truths above: SC-1 adjacency (duplicate FAQ slug serves the lowest id), SC-1 empty (empty import files), SC-1 ordering (equal sort_order breaks by id). Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Menu built-in type key `url` (PHP stored `link`, mapped on import; RESEARCH A5) and `divider`.
|
||||
- Per-item pattern override for menu entries through an additive nullable `url_pattern` column (D-02 requires a per-item override; the PHP table has none; RESEARCH A4 / Open Question 2). PHP rows copy with NULL there. The UI-SPEC's optional `url_pattern` form field is added after `reference`.
|
||||
- A new `target` dropdown (`_self`, `_blank`) in the entry form (RESEARCH Open Question 7; the PHP column exists, the PHP form lacked it).
|
||||
- The menu API filters `is_published` (D-16 "published tree") although the PHP component did not; documented in the README as a deliberate D-16 behaviour.
|
||||
- JSON field names are proposed at the checkpoint (Task 4); errors mirror Journal: `{"error":"Menu not found"}` and `{"error":"FAQ not found"}`; success bodies use the Journal envelope `{"data": …}`.
|
||||
- Import refusals: unknown FAQ locale code aborts the whole import (the operator adds the locale first); a dropped menu entry drops its subtree, each dropped entry reported.
|
||||
- Menu navigation: top-level "Menus" (icon list) with side items Menus and Menu entries (PHP hung them on SiteManager, which is out of scope), documented as an adaptation.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- New local repository `../sm-faq-plugin` (branch main, module `git.golem15.com/golem15/sm-faq-plugin`, plugin ID `golem15.faq`, Requires golem15.translate): table `golem15_faq_faqs`, controller `golem15.faq.faqs`, permission `golem15.faq.faqs`, route `GET /_faq/api/v1/faqs/{slug}`, bucket `faq-public-api`, command `faq:import-winter`, `scripts/winter-export.sql`, README.
|
||||
- New local repository `../sm-menu-plugin` (branch main, module `git.golem15.com/golem15/sm-menu-plugin`, plugin ID `golem15.menu`): tables `golem15_menu_menus`, `golem15_menu_menu_entries` (+ `url_pattern`), controllers `golem15.menu.menus` and `golem15.menu.menuentries` (reorder), permission `golem15.menu.manage_menu`, route `GET /_menu/api/v1/menus/{code}`, bucket `menu-public-api`, command `menu:import-winter`, `scripts/winter-export-menus.sql` and `winter-export-entries.sql`, README.
|
||||
- Smoke tests: `TestFaqAdminSmoke`, `TestMenuAdminSmoke`, `TestFaqImportSmoke`, `TestMenuImportSmoke`, `TestFaqAPISmoke`, `TestMenuAPISmoke`.
|
||||
|
||||
<objective>
|
||||
Give editors FAQ and menu screens in the admin, copy Winter data in, and publish the two read APIs the Nuxt layers will consume.
|
||||
|
||||
Purpose: SC-1 (menu and FAQ ports) and SC-3's API half (D-16, D-17).
|
||||
Output: two plugin repositories with admin, imports, APIs and READMEs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-PATTERNS.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
|
||||
Repositories: ../sm-faq-plugin and ../sm-menu-plugin are created here (git init -b main, no remote; plan 05 adds remotes after a human creates them). go.work and go.mod copy Journal's layout (`use .`, replaces to ../summercms.go and ../sm-translate-plugin; the summercms require is nominal and plan 05 bumps it to the plan 02 tag). Do not edit summercms.go here. Commits: no co-author tags, stage by explicit path in each repository. Docker: Postgres smoke tests run through a TestMain with flag.Parse() and testing.Short(); record NOT RUN when no daemon answers. Menu tests use a fake plugin that registers two types (one with references, one list type) so the menu plugin needs no Journal require.
|
||||
|
||||
Port sources (read-only): /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/ (Plugin.php :60, models/FAQ.php, models/faq/fields.yaml and columns.yaml, updates/v1.0.1, controllers/faqs/, lang/) and /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/ (Plugin.php :77, models/Menu.php, MenuEntry.php :106 beforeSave, models/*/fields.yaml and columns.yaml, updates/v1.0.1 and v1.0.2, controllers/menus/, controllers/menuentries/ incl. reorder_config.yaml, components/Menu.php, lang/), and the rendered markup in /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu/default.htm and g15_faq/.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an editor creates a FAQ with questions in the admin, and the stored answers are sanitized with a notice</name>
|
||||
<files>../sm-faq-plugin/.gitignore, ../sm-faq-plugin/go.mod, ../sm-faq-plugin/go.sum, ../sm-faq-plugin/go.work, ../sm-faq-plugin/plugin.go, ../sm-faq-plugin/admin.go, ../sm-faq-plugin/admin_navigation.go, ../sm-faq-plugin/admin_permissions.go, ../sm-faq-plugin/lang/en/lang.yaml, ../sm-faq-plugin/lang/pl/lang.yaml, ../sm-faq-plugin/models/registry.go, ../sm-faq-plugin/models/faq.go, ../sm-faq-plugin/models/faq/fields.yaml, ../sm-faq-plugin/models/faq/columns.yaml, ../sm-faq-plugin/controllers/admin_registry.go, ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/controllers/faqs/config_form.yaml, ../sm-faq-plugin/controllers/faqs/config_list.yaml, ../sm-faq-plugin/updates/registry.go, ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go, ../sm-faq-plugin/testmain_test.go, ../sm-faq-plugin/faq_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/plugin.go, admin.go, admin_navigation.go, admin_permissions.go, controllers/tags.go, controllers/tags/config_form.yaml and config_list.yaml, models/tag.go, models/registry.go, updates/202610060003_create_golem15_journal_tags.go, updates/registry.go, admin_harness_test.go (TestMain, bootJournal, e.admin, adminAPI), go.mod, go.work; ../sm-translate-plugin/models/locale.go; summercms modules/lagoon/jsonable.go; summercms modules/cabana/README.md (repeater and richeditor from plan 01); /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/ (whole plugin); .planning/phases/17-blog-companions/17-UI-SPEC.md (§6 FAQ, plugin copy rows for faq)</read_first>
|
||||
<action>Per SC-1, D-07, D-10 and D-17 (relation), the FAQ admin path end to end.
|
||||
|
||||
Repository: git init -b main in ../sm-faq-plugin; go.mod (module git.golem15.com/golem15/sm-faq-plugin, requires summercms and sm-translate-plugin like Journal, the summercms replace line), go.work like Journal's. plugin.go: ID golem15.faq, Requires golem15.translate, HasMigrations, HasModels, HasLang, HasAdminControllers, AdminAssets, HasPermissions, HasNavigation, the compile-time assertions.
|
||||
|
||||
Schema: migration 202610110001_create_golem15_faq_faqs with the PHP columns (id SERIAL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, locale_id INTEGER NOT NULL, is_active BOOLEAN NOT NULL DEFAULT false, description TEXT NULL, content TEXT NULL, created_at and updated_at TIMESTAMP NULL) and a non-unique index on (slug, locale_id); Rollback drops the table. models.FAQ: TableName golem15_faq_faqs, MorphName Golem15\Faq\Models\FAQ, Content as lagoon.Jsonable of []map[string]any, a belongsTo Locale (translate models.Locale through locale_id), Fillable name, slug, locale_id, is_active, description, content; Rules name, slug and locale required.
|
||||
|
||||
Admin: controller golem15.faq.faqs (ModelName Golem15\Faq\Models\FAQ, ConfigDir controllers/faqs, RequiredPermissions golem15.faq.faqs, NewRecord). columns.yaml: id, name, slug, locale (relation, nameFrom name), is_active (switch), created_at (invisible), updated_at. fields.yaml (flat, Pitfall 9): name (left), slug (right, preset name), locale (relation, nameFrom name, comment labels.locale_comment, left), is_active (switch, comment, right), description (richeditor, full), content (repeater, prompt golem15.faq::lang.labels.add_question, titleFrom question; form.fields question text required, answer richeditor). Navigation: top-level FAQ (icon circle-question-mark, order 500) with side item FAQs; permission golem15.faq.faqs (developer and publisher roles). Lang en and pl with the PHP key names and the UI-SPEC copy (pl added).
|
||||
|
||||
TestFaqAdminSmoke (Postgres; harness modelled on Journal's admin_harness_test.go): boot translate plus faq, create an admin with golem15.faq.faqs, POST a FAQ through the admin API with two content rows where one answer holds a script element and an onclick attribute; expect 201, the stored answer without them, a meta.notices entry under content.1.answer, and the list endpoint showing the row with its locale name.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -short ./... && git -C ../sm-faq-plugin rev-parse --abbrev-ref HEAD | grep -qx main</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free test or the boot-time YAML compile fails (for example a Winter key cabana does not accept), or the repository is not on branch main.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-faq-plugin test -count=1 -run '^TestFaqAdminSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestFaqAdminSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the FAQ was not created, an answer was stored unsanitized, the notice is missing under content.1.answer, or the list lacks the locale name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: repeater" ../sm-faq-plugin/models/faq/fields.yaml` and `grep -c "type: richeditor" ../sm-faq-plugin/models/faq/fields.yaml` (at least 2) both succeed.
|
||||
- `grep -n '"golem15.translate"' ../sm-faq-plugin/plugin.go` prints one line.
|
||||
- `git -C ../sm-faq-plugin remote` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An editor can create and edit FAQs with question rows in the admin, and every answer is stored sanitized.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Menu admin: menus and entries with the PHP save rules, the D-05 type set, the registry-bound reference, URL scheme validation and the tree reorder page</name>
|
||||
<files>../sm-menu-plugin/.gitignore, ../sm-menu-plugin/go.mod, ../sm-menu-plugin/go.sum, ../sm-menu-plugin/go.work, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/admin.go, ../sm-menu-plugin/admin_navigation.go, ../sm-menu-plugin/admin_permissions.go, ../sm-menu-plugin/lang/en/lang.yaml, ../sm-menu-plugin/lang/pl/lang.yaml, ../sm-menu-plugin/models/registry.go, ../sm-menu-plugin/models/menu.go, ../sm-menu-plugin/models/menu_entry.go, ../sm-menu-plugin/models/menu/fields.yaml, ../sm-menu-plugin/models/menu/columns.yaml, ../sm-menu-plugin/models/menuentry/fields.yaml, ../sm-menu-plugin/models/menuentry/columns.yaml, ../sm-menu-plugin/classes/links.go, ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menus/config_form.yaml, ../sm-menu-plugin/controllers/menus/config_list.yaml, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/menuentries/config_form.yaml, ../sm-menu-plugin/controllers/menuentries/config_list.yaml, ../sm-menu-plugin/controllers/menuentries/config_filter.yaml, ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml, ../sm-menu-plugin/updates/registry.go, ../sm-menu-plugin/updates/202610110001_create_golem15_menu_menus.go, ../sm-menu-plugin/updates/202610110002_create_golem15_menu_menu_entries.go, ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go, ../sm-menu-plugin/testmain_test.go, ../sm-menu-plugin/menu_smoke_test.go</files>
|
||||
<read_first>../sm-faq-plugin (Task 1 layout to copy), ../sm-journal-plugin/models/category.go (parent_id and nest columns), ../sm-journal-plugin/controllers/posts/config_filter.yaml (filter shape), summercms modules/cabana/README.md (dependsOn, config_reorder.yaml, toolbar reorder, FormBeforeCreate and FormBeforeUpdate, DropdownOptionsProvider), summercms modules/signpost/README.md (ReferenceOptions, Registry.Types), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/ (Plugin.php :77, models/MenuEntry.php :106-130, models/menuentry/fields.yaml, models/menu/fields.yaml, columns.yaml files, updates/v1.0.1 and v1.0.2, controllers/menuentries/reorder_config.yaml and _list_toolbar.php), .planning/phases/17-blog-companions/17-UI-SPEC.md (§6 Menu, plugin copy rows for menu)</read_first>
|
||||
<action>Per SC-1, D-02, D-05, D-08 and D-09.
|
||||
|
||||
Repository like Task 1 (module git.golem15.com/golem15/sm-menu-plugin, plugin ID golem15.menu, no Requires). Migrations: 202610110001_create_golem15_menu_menus (id SERIAL, name VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL UNIQUE, is_active BOOLEAN NOT NULL DEFAULT true, css_class VARCHAR(255) NULL, timestamps), 202610110002_create_golem15_menu_menu_entries (the PHP v1.0.1 columns: menu_id INTEGER NOT NULL, parent_id INTEGER NULL, nest_left, nest_right, nest_depth INTEGER NOT NULL, sort_order INTEGER NULL, type VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, url, reference, code, icon, css_class, target VARCHAR(255) NULL, is_published BOOLEAN NOT NULL DEFAULT true, timestamps) with an index on (menu_id, parent_id, sort_order), and 202610110003_add_url_pattern_to_golem15_menu_menu_entries (url_pattern VARCHAR(255) NULL, D-02); each Rollback undoes only its own step.
|
||||
|
||||
Models: Menu (golem15_menu_menus, MorphName Golem15\Menu\Models\Menu, hasMany entries, Fillable name, code, is_active, css_class, Rules name and code required) and MenuEntry (golem15_menu_menu_entries, MorphName Golem15\Menu\Models\MenuEntry, belongsTo menu and parent, Fillable menu_id, parent_id, type, title, url, url_pattern, reference, code, icon, css_class, target, is_published). A GORM BeforeSave hook ports PHP beforeSave: nest_left, nest_right and nest_depth become 0 when unset, sort_order becomes the menu's maximum plus one when nil, menu_id is copied from the parent when a parent is set, title becomes an empty string when nil. classes/links.go: AllowedURL(url string) bool (http, https, mailto, tel, a leading slash or #; empty allowed) and a target check (_self, _blank or empty), used by the form hooks (a 422 field error errors.url_scheme or errors.target) and later by the API.
|
||||
|
||||
Admin: controller golem15.menu.menus (list and form: name left, code right with preset name, css_class left, is_active switch right). Controller golem15.menu.menuentries: list with the PHP columns, config_filter.yaml menu filter, toolbar buttons reorder, create, delete (showCheckboxes); config_reorder.yaml (title golem15.menu::lang.models.menuentry.reorder_title, nameFrom title, partition {column menu_id, relation menu, nameFrom name, label golem15.menu::lang.models.menuentry.menu}); form fields per the UI-SPEC table (menu dropdown full, parent relation nameFrom title with emptyOption, type dropdown, title, url with url_comment, reference dropdown with dependsOn type and reference_comment, url_pattern text with the url_pattern comment, code, icon, css_class, target dropdown default _self, is_published switch default on with context update). The controller implements DropdownOptionsProvider (menu: menus by name; type: url "Link", divider "Divider", then the signpost registry's types with their labels), DependentOptionsProvider (reference: url and divider answer applies false; registry types call signpost.ReferenceOptions; an unknown type answers applies false) and FormBeforeCreate and FormBeforeUpdate for the URL and target checks. Navigation: top-level Menus (icon list) with side items Menus (list) and Menu entries (list-ordered); permission golem15.menu.manage_menu (developer, publisher). Lang en and pl with the PHP keys and the UI-SPEC copy (Menuses corrected to Menus, Is_published to Published).
|
||||
|
||||
TestMenuAdminSmoke (Postgres, fake type plugin registering acme-page with references and acme-list without): create a menu and three entries through the admin API; the second entry saved without sort_order gets the maximum plus one and nest columns 0; a child inherits menu_id from its parent; reference outside acme-page's options answers 422; url javascript:alert(1) answers 422 with errors.url_scheme; a reorder POST nests the third entry under the first and writes parent_id and sort_order; a reorder POST naming an entry of another menu answers 409.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-menu-plugin vet ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && grep -q "golem15.menu.manage_menu" ../sm-menu-plugin/admin_permissions.go</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free test or the boot-time admin YAML compile fails (for example reorder declared without config_reorder.yaml, or dependsOn without a provider), or the PHP permission code is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-menu-plugin test -count=1 -run '^TestMenuAdminSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuAdminSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the PHP save rules, the reference recheck, the URL scheme refusal or the menu-scoped reorder did not behave as stated.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "url_pattern" ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go` prints at least one line.
|
||||
- `grep -n "buttons: \[reorder" ../sm-menu-plugin/controllers/menuentries/config_list.yaml` prints one line.
|
||||
- `grep -n "dependsOn: type" ../sm-menu-plugin/models/menuentry/fields.yaml` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Editors manage menus and their entries, pick registry content by type, and drag entries into a tree per menu.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Winter import commands and read-only export SQL for both plugins (D-05 type map, locale remap, sequences)</name>
|
||||
<reversibility rating="costly">D-05: the stored menu type values (url, divider and registry keys) written by the import are what the menu tables keep; renaming them later needs a data migration.</reversibility>
|
||||
<files>../sm-faq-plugin/plugin.go, ../sm-faq-plugin/console/helpers.go, ../sm-faq-plugin/console/import_winter.go, ../sm-faq-plugin/scripts/winter-export.sql, ../sm-faq-plugin/faq_smoke_test.go, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/console/helpers.go, ../sm-menu-plugin/console/import_winter.go, ../sm-menu-plugin/scripts/winter-export-menus.sql, ../sm-menu-plugin/scripts/winter-export-entries.sql, ../sm-menu-plugin/menu_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/console/import_posts.go (:39-64 bonfire.Command shape, flags, dry run), ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go (lookupDB :50-69) and import_categories.go (JSONL reading and report), ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql and README.md (header, mysql -N -B --raw, translate table name checks), .planning/phases/17-blog-companions/17-RESEARCH.md (Runtime State Inventory, Pitfalls 7, 11, 14, Open Questions 3 and 5), .planning/phases/17-blog-companions/17-CONTEXT.md (D-05, D-19)</read_first>
|
||||
<action>Per D-05 and D-19 (plugin half) and SC-1.
|
||||
|
||||
Both plugins add pact.HasCommands; console/helpers.go copies lookupDB (use the published *gorm.DB, else lagoon.OpenFromApp plus lagoon.Publish) so the commands work in a summer build host (Pitfall 14).
|
||||
|
||||
faq:import-winter --file FILE [--force] [--dry-run]: JSON Lines rows {id, name, slug, locale_code, is_active, description, content, created_at, updated_at} where content is the PHP JSON string or an array; locale_id is the id of the translate Locale with that code (an unknown code aborts before any write, naming the code); one transaction; explicit ids kept; then SELECT setval(pg_get_serial_sequence('golem15_faq_faqs','id'), max(id)); a non-empty table is refused unless --force, which replaces the rows in the same transaction; --dry-run prints the report only; an empty file writes nothing and reports zero rows.
|
||||
|
||||
menu:import-winter --menus FILE --entries FILE [--map old=new ...] [--force] [--dry-run]: menus rows {id, name, code, is_active, css_class, created_at, updated_at}; entries rows with every PHP column; type map link to url and blog_categories to all-journal-categories plus --map pairs; page entries and entries of any type that is neither built-in, registered in signpost nor mapped are dropped with their subtree; a registry entry whose reference is not among signpost.ReferenceOptions for its type is dropped; every dropped or remapped entry prints one report line (id, title, old type, new type or reason); ids, menu_id, parent_id, nest columns and sort_order kept; setval for both tables; the same --force, --dry-run and empty-file rules.
|
||||
|
||||
Export SQL (read-only SELECT JSON_OBJECT, header in the categories.sql style: how to run with mysql -N -B --raw, what to check first, the translate table prefix check): sm-faq-plugin/scripts/winter-export.sql joins the translate locales table to emit locale_code; sm-menu-plugin/scripts/winter-export-menus.sql and winter-export-entries.sql emit one row per line.
|
||||
|
||||
Smoke (Postgres): TestFaqImportSmoke (two locales by code, ids kept, a new admin create after the import gets max id plus one, an unknown code aborts with nothing written, a second run without --force is refused, an empty file reports zero). TestMenuImportSmoke (link becomes url, blog_categories becomes all-journal-categories when the fake plugin registers it, a page entry and its child are dropped and reported, ids and parent_id kept, sequences bumped, --dry-run writes nothing).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin vet ./... && go -C ../sm-menu-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -short ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && cat ../sm-faq-plugin/scripts/winter-export.sql ../sm-menu-plugin/scripts/winter-export-menus.sql ../sm-menu-plugin/scripts/winter-export-entries.sql | grep -v '^[[:space:]]*--' | grep -ciE '\b(insert|update|delete|drop|alter|truncate)\b' | grep -qx 0</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free test fails in either plugin, or an export SQL file contains a writing statement outside comments.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-faq-plugin test -count=1 -run '^TestFaqImportSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestFaqImportSmoke' && go -C ../sm-menu-plugin test -count=1 -run '^TestMenuImportSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuImportSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a type was not mapped, a dropped entry was not reported, a locale was not remapped, a sequence was not bumped, or a refusal wrote rows.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"faq:import-winter"' ../sm-faq-plugin/console/import_winter.go` and `grep -n '"menu:import-winter"' ../sm-menu-plugin/console/import_winter.go` each print one line.
|
||||
- `grep -n "setval" ../sm-faq-plugin/console/import_winter.go ../sm-menu-plugin/console/import_winter.go` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>A Winter site's menus and FAQs can be copied into the plugins with mapped types, remapped locales and a report of everything left out.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Confirm the published shapes of GET /_menu/api/v1/menus/{code} and GET /_faq/api/v1/faqs/{slug} before they are written (D-16, D-17)</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-CONTEXT.md (D-16, D-17), ../sm-journal-plugin/controllers/api/posts.go (envelope and error bodies), .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 and §8 layer needs), this plan's Task 1-3 SUMMARY notes</read_first>
|
||||
<action>Print the proposal below with one example body each, built from the Task 1-3 fixtures, then stop.
|
||||
|
||||
Menu: GET /_menu/api/v1/menus/{code}?locale=xx (code matches letters, digits, dash and underscore). 200: {"data":{"code","name","css_class","items":[Item]}}; Item: {"id" (entry id, null for generated children), "title", "type" (stored type; generated children carry the parent's registry type key), "url" (string or null), "target" ("_self" or "_blank" or null), "css_class", "icon", "code", "items":[Item]}. Unknown or inactive code: 404 {"error":"Menu not found"}. Rules as in this plan's D-16 truth (published only, sort_order then id, scheme-filtered URLs, registry resolution with the requested locale's alternate, list types keep stored URL and title, unresolvable entries left out).
|
||||
|
||||
FAQ: GET /_faq/api/v1/faqs/{slug}?locale=xx (slug lowercase letters, digits and dashes). 200: {"data":{"name","slug","locale","description","content":[{"question","answer"}]}}; description and answers sanitized HTML strings (description may be null); unknown slug, inactive row or unknown or disabled locale: 404 {"error":"FAQ not found"}.
|
||||
|
||||
Both: anonymous, throttled per client IP (menu-public-api and faq-public-api, 120 per minute, 429 body as surf), JSON written with wire.WriteJSON, no CORS in the plugin (the host's cors.paths adds _menu/api/* and _faq/api/* in plan 05).</action>
|
||||
<decision>Publish the two API shapes as proposed?</decision>
|
||||
<context>D-16 and D-17 are one-way: the Nuxt layers (plan 05) and every later site consume these paths and field names; a change after release means a new version path and layer releases. The rest of this plan is reversible.</context>
|
||||
<options>
|
||||
<option id="approve">
|
||||
<name>Approve as proposed</name>
|
||||
<pros>Mirrors the Journal envelope and error bodies; covers every field D-16 and D-17 list</pros>
|
||||
<cons>Field names are fixed from now on</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Approve with named changes</name>
|
||||
<pros>The human renames or adds fields before anything consumes them</pros>
|
||||
<cons>The executor applies the named changes to Task 5 and the layer plan inherits them</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the APIs</name>
|
||||
<pros>Nothing is published</pros>
|
||||
<cons>Plan 05 cannot build the layers; the site keeps its hardcoded menu and FAQ</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "approve", "adjust: …" listing the changes, or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin test -count=1 -short ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && echo shapes-ready</automated>
|
||||
<fails_when>Non-zero exit or "shapes-ready" absent: a plugin's Docker-free suite fails, so the proposal would rest on broken fixtures.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the printed proposal, the example bodies and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved (possibly with named changes) or held the two published shapes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: The public menu and FAQ APIs with registry resolution, output sanitizing and throttling, and both READMEs</name>
|
||||
<reversibility rating="one-way">D-16 and D-17: once the layers and sites consume /_menu/api/v1 and /_faq/api/v1, their paths and field names cannot change without a new version path.</reversibility>
|
||||
<precondition>Task 4 was answered "approve" or "adjust: …" in this session; the approved shape (with any named changes) is recorded in the SUMMARY.</precondition>
|
||||
<files>../sm-faq-plugin/plugin.go, ../sm-faq-plugin/routes.go, ../sm-faq-plugin/controllers/api/faqs.go, ../sm-faq-plugin/faq_smoke_test.go, ../sm-faq-plugin/README.md, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/routes.go, ../sm-menu-plugin/classes/tree.go, ../sm-menu-plugin/controllers/api/menus.go, ../sm-menu-plugin/menu_smoke_test.go, ../sm-menu-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/routes.go, plugin.go (Buckets :97-130), controllers/api/posts.go (Categories :158-173, writeAPIError, gdb), summercms modules/seaglass/README.md, summercms modules/signpost/README.md (Registry.Resolve, Pattern), ../sm-translate-plugin/models/locale.go (code, is_enabled, is_default, sort_order), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/components/Menu.php and themes/arsha/partials/g15_menu/default.htm (what the site renders), ../sm-journal-plugin/README.md (plugin README structure), this plan's Task 4 SUMMARY (approved shape)</read_first>
|
||||
<action>Per D-16 and D-17 with the shape approved in Task 4 (apply any named changes).
|
||||
|
||||
Menu: routes.go mounts r.Group("/_menu/api/v1", surf.Use("throttle:menu-public-api"), …) with GET /menus/{code} and a Where on code; never the backend middleware. Buckets() adds menu-public-api (Max 120, Decay one minute, key "menu-public|" plus surf.ClientIP with the configured trusted proxies). controllers/api/menus.go loads the active menu by code (404 {"error":"Menu not found"} otherwise), loads its published entries ordered by sort_order then id, and classes/tree.go builds the nested tree: url entries return the stored URL when classes.AllowedURL accepts it (else null); dividers return null; registry entries call the signpost Registry.Resolve with the entry type, reference, url_pattern (item override, D-02) and the type's Nesting; a single type's URL is the alternate whose locale equals ?locale when given, else the resolved URL; a list type keeps the stored URL and title and its resolved Items (their URL picked the same way) follow the stored children; a resolve error or an unregistered type leaves the entry out and logs it; target outside _self and _blank becomes null. Response through wire.WriteJSON with the data envelope.
|
||||
|
||||
FAQ: routes.go mounts /_faq/api/v1 with throttle:faq-public-api and GET /faqs/{slug} (Where slug [a-z0-9][a-z0-9\-]*); Buckets() adds faq-public-api. controllers/api/faqs.go resolves the locale (by ?locale code among enabled locales, else the default locale; an unknown or disabled code is a 404), selects the active row by slug and locale_id ordered by id, and returns name, slug, locale code, description and content as [{question, answer}] with description and each answer passed through seaglass.Sanitize (questions are plain strings); 404 {"error":"FAQ not found"}.
|
||||
|
||||
READMEs (standard plugin structure, neutral examples, no consuming application or site names): overview, tables, admin screens and permissions, the API with example request and response and the error bodies, throttle buckets and the host CORS paths to add, the import command and export SQL workflow, the Winter adaptations (FAQ: tabs flattened, select to nameFrom, locale column added; menu: SiteManager navigation replaced, Children tab replaced by the reorder page, url_pattern and target added, published filter in the API), dependencies (summercms, sm-translate-plugin for FAQ) and testing.
|
||||
|
||||
Smoke (Postgres): TestFaqAPISmoke (default locale when ?locale is absent; ?locale=en picks the en row; an inactive row and an unknown locale are 404; a row inserted with raw SQL containing a script element in an answer and an onerror attribute in the description is served sanitized; duplicate slugs serve the lowest id; anonymous access works). TestMenuAPISmoke (unknown and inactive codes are 404; unpublished entries and their subtrees are absent; order by sort_order then id; a javascript: URL stored with raw SQL comes back null; the acme-list entry keeps its stored title and URL and carries generated children; an entry with a url_pattern override resolves with it; ?locale=en picks the en alternate).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin vet ./... && go -C ../sm-menu-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -short ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && grep -q "_faq/api/v1" ../sm-faq-plugin/README.md && grep -q "_menu/api/v1" ../sm-menu-plugin/README.md</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free test fails, or a README does not document its API path.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-faq-plugin test -count=1 -run '^(TestFaqAdminSmoke|TestFaqImportSmoke|TestFaqAPISmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go -C ../sm-menu-plugin test -count=1 -run '^(TestMenuAdminSmoke|TestMenuImportSmoke|TestMenuAPISmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes in a plugin: unsanitized HTML served, an inactive or unpublished record exposed, a disallowed URL returned, the wrong locale picked, or the tree order differs.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"menu-public-api"' ../sm-menu-plugin/plugin.go` and `grep -n '"faq-public-api"' ../sm-faq-plugin/plugin.go` each print at least one line.
|
||||
- `grep -n "seaglass.Sanitize" ../sm-faq-plugin/controllers/api/faqs.go` prints at least one line.
|
||||
- `grep -ci "grzyby\|horoskopia\|fonoteka" ../sm-menu-plugin/README.md ../sm-faq-plugin/README.md` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>A frontend can read a published menu tree and an FAQ by slug and locale as safe JSON, and both plugins are documented.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → /_menu/api/v1, /_faq/api/v1 | anonymous GETs |
|
||||
| database rows → public JSON | rows copied from PHP bypass the admin save gate |
|
||||
| admin → menu and FAQ records | authenticated editors |
|
||||
| Winter export files → import commands | operator-supplied JSONL |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-23 | Tampering (stored XSS) | FAQ description and answers | high | mitigate | richeditor gate on save (plan 01) and seaglass.Sanitize on API output for every row, including raw-SQL copies; TestFaqAPISmoke inserts unsafe HTML directly |
|
||||
| T-17-24 | Tampering (XSS through links) | menu entry url | high | mitigate | scheme allowlist on save (422 errors.url_scheme) and again in the API (null URL); target enum; the layer refuses non-allowlisted schemes too (plan 05) |
|
||||
| T-17-25 | Information Disclosure | unpublished entries, inactive menus and FAQs | medium | mitigate | API filters is_active and is_published; 404 for inactive; smoke tests cover each |
|
||||
| T-17-26 | Denial of Service | public APIs | medium | mitigate | surf throttle buckets menu-public-api and faq-public-api, 120 per minute per trusted client IP |
|
||||
| T-17-27 | Tampering (mass assignment) | FAQ, Menu, MenuEntry | medium | mitigate | explicit Fillable lists; nest columns and ids not admin-writable |
|
||||
| T-17-28 | Elevation of Privilege (IDOR) | entry reorder across menus | high | mitigate | config_reorder.yaml partition on menu_id with the framework's locked exact-set rule (plan 01); smoke test posts a foreign entry and expects 409 |
|
||||
| T-17-29 | Tampering | dependent reference | medium | mitigate | provider recheck on save through signpost.ReferenceOptions (plan 01) |
|
||||
| T-17-30 | Tampering | import commands | medium | mitigate | refuse non-empty tables without --force; unknown locales abort; unmapped types and stale references dropped with a report; one transaction; SELECT-only export SQL |
|
||||
| T-17-31 | Repudiation / Tampering | published API shape | medium | mitigate | blocking-human checkpoint before the one-way shape is written; approved shape recorded in the SUMMARY |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package installs; Go dependencies are the framework's and translate's, pinned in go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: FAQ vet and short suite; FAQ admin smoke on a Docker host.
|
||||
- Task 2: menu vet, short suite and permission; menu admin smoke on a Docker host.
|
||||
- Task 3: both short suites and the SELECT-only check; import smokes on a Docker host.
|
||||
- Task 4: shapes-ready and a human decision.
|
||||
- Task 5: short suites and READMEs; all six smokes on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: the FAQ and menu schema, models, admin screens and import commands are ported 1:1 in their own repositories with READMEs.
|
||||
- SC-3 (API half): menu and FAQ expose anonymous read APIs a Nuxt layer can consume, with the shape a human approved.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-04-SUMMARY.md` when done, with the commits per repository, the approved API shape and the human's reply, Docker smoke tests run or NOT RUN, and any framework gap found (reported, not patched).
|
||||
</output>
|
||||
398
.planning/phases/17-blog-companions/17-05-PLAN.md
Normal file
398
.planning/phases/17-blog-companions/17-05-PLAN.md
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["17-02", "17-03", "17-04"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/go.work
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- ../sm-grzybyfunkcjonalne-app/main.go
|
||||
- ../sm-grzybyfunkcjonalne-app/config/http.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/companions_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/menus.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/entries.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/faqs.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-definitions.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-settings.json
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../vue-menu-layer/package.json
|
||||
- ../vue-menu-layer/pnpm-lock.yaml
|
||||
- ../vue-menu-layer/pnpm-workspace.yaml
|
||||
- ../vue-menu-layer/nuxt.config.ts
|
||||
- ../vue-menu-layer/tsconfig.json
|
||||
- ../vue-menu-layer/.gitignore
|
||||
- ../vue-menu-layer/.nvmrc
|
||||
- ../vue-menu-layer/README.md
|
||||
- ../vue-menu-layer/.playground/nuxt.config.ts
|
||||
- ../vue-menu-layer/.playground/app/app.vue
|
||||
- ../vue-menu-layer/app/components/Menu/Nav.vue
|
||||
- ../vue-menu-layer/app/components/Menu/NavItem.vue
|
||||
- ../vue-menu-layer/app/composables/useMenu.ts
|
||||
- ../vue-menu-layer/app/composables/useMenuApi.ts
|
||||
- ../vue-menu-layer/app/utils/client.ts
|
||||
- ../vue-menu-layer/app/utils/types.ts
|
||||
- ../vue-menu-layer/app/utils/menuLinks.ts
|
||||
- ../vue-menu-layer/app/assets/css/menu-tokens.css
|
||||
- ../vue-menu-layer/app/assets/css/menu-base.css
|
||||
- ../vue-menu-layer/tests/menuLinks.test.ts
|
||||
- ../vue-menu-layer/tests/client.test.ts
|
||||
- ../vue-menu-layer/testing/fixture-api.mjs
|
||||
- ../vue-menu-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-menu-layer/testing/fixtures/menus/main.json
|
||||
- ../vue-menu-layer/testing/expect.txt
|
||||
- ../vue-faq-layer/package.json
|
||||
- ../vue-faq-layer/pnpm-lock.yaml
|
||||
- ../vue-faq-layer/pnpm-workspace.yaml
|
||||
- ../vue-faq-layer/nuxt.config.ts
|
||||
- ../vue-faq-layer/tsconfig.json
|
||||
- ../vue-faq-layer/.gitignore
|
||||
- ../vue-faq-layer/.nvmrc
|
||||
- ../vue-faq-layer/README.md
|
||||
- ../vue-faq-layer/.playground/nuxt.config.ts
|
||||
- ../vue-faq-layer/.playground/app/app.vue
|
||||
- ../vue-faq-layer/app/components/Faq/Block.vue
|
||||
- ../vue-faq-layer/app/components/Faq/List.vue
|
||||
- ../vue-faq-layer/app/components/Faq/Item.vue
|
||||
- ../vue-faq-layer/app/composables/useFaq.ts
|
||||
- ../vue-faq-layer/app/composables/useFaqApi.ts
|
||||
- ../vue-faq-layer/app/utils/client.ts
|
||||
- ../vue-faq-layer/app/utils/types.ts
|
||||
- ../vue-faq-layer/app/assets/css/faq-tokens.css
|
||||
- ../vue-faq-layer/app/assets/css/faq-base.css
|
||||
- ../vue-faq-layer/tests/client.test.ts
|
||||
- ../vue-faq-layer/testing/fixture-api.mjs
|
||||
- ../vue-faq-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-faq-layer/testing/fixtures/faqs/acme.json
|
||||
- ../vue-faq-layer/testing/expect.txt
|
||||
- .planning/todos/pending/site-cache-purge-for-menu-and-faq.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-3, SC-4, D-05, D-15, D-18, D-19]
|
||||
user_setup:
|
||||
- service: gitea (git.golem15.com)
|
||||
why: "Five new repositories must exist before the first push"
|
||||
dashboard_config:
|
||||
- task: "Create golem15/sm-sitemap-plugin, golem15/sm-menu-plugin and golem15/sm-faq-plugin (same visibility as sm-journal-plugin) and golem15/vue-menu-layer and golem15/vue-faq-layer (public, installed by pnpm over https without credentials), all empty"
|
||||
location: "https://git.golem15.com -> New repository"
|
||||
- service: grzybyfunkcjonalne.pl PHP database (live server)
|
||||
why: "D-19 copies menu, FAQ and sitemap rows from the Winter database; only the operator has its credentials"
|
||||
dashboard_config:
|
||||
- task: "Run the read-only discovery queries and the five plugin export SQL files with mysql -N -B --raw; place the outputs in /srv/grzybyfunkcjonalne/import/companions/"
|
||||
location: "PHP server shell (STAGING_SSH host)"
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: on UAT (https://uat.grzybyfunkcjonalne.pl) the site header renders the main menu from GET /_menu/api/v1/menus/{code} through vue-menu-layer's MenuNav and the homepage FAQ section renders the FAQ what-mushrooms-can-give-you from GET /_faq/api/v1/faqs/{slug} through vue-faq-layer's FaqBlock; app/data/menu.ts and app/data/faq.pl.json are gone from vue-grzybyfunkcjonalne-app."
|
||||
- "D-19 and D-05: the PHP menu, FAQ and sitemap rows are copied from the live Winter database by read-only export SQL into JSONL and imported by the plugin import commands as an ordered scripted step (scripts/import-companions.sh, run by migrate.sh after the Journal and locale steps and runnable alone on an existing database), with menu type values mapped (link to url, blog_categories to all-journal-categories, page dropped, extra pairs from the recorded discovery), FAQ locale_id remapped by code, every dropped or remapped item reported in the SUMMARY."
|
||||
- "D-15: nginx routes exactly /sitemap.xml and /sitemap.xsl, plus the prefixes /_menu/ and /_faq/, to the Go binary (Host and X-Forwarded-Proto forwarded), the @nuxtjs/seo sitemap module is disabled in the site app (sitemap enabled false) while robots, meta, OG and canonical stay on @nuxtjs/seo, robots points at /sitemap.xml, and editors manage the sitemap at /gfadmin; this reverses Phase 16 D-13 for the sitemap only."
|
||||
- "SC-3 (live): /sitemap.xml on UAT answers 200 application/xml in the PHP format with the frontend_url rewrite applied; its loc set equals the live PHP sitemap's loc set modulo the host, and the remaining differences (lastmod omitted for URL items, PHP's older plugin faked it; category and post order under Postgres collation) are listed in the SUMMARY."
|
||||
- "D-18: vue-menu-layer and vue-faq-layer are new public repositories under git.golem15.com/golem15 with the vue-journal-layer distribution (Nuxt 4.4.8 layer, absolute currentDir paths, tokens CSS then base CSS, runtimeConfig server apiBase plus public same-origin apiBase, node --test tests and a .playground SSR smoke, no modules declared, tag v0.1.0), each mirroring one plugin with a typed client and render components; the site app extends both pinned as git+https dependencies at #v0.1.0."
|
||||
- "D-18 and UI-SPEC: MenuNav renders the WAI disclosure navigation (links through NuxtLink for relative paths, plain anchors for absolute, mailto and tel, a span with no href for a URL outside the allowlist, rel noopener noreferrer on _blank, dividers aria-hidden, active link aria-current page with the trail class, mobile toggle 44x44, Esc and route change close) and renders nothing (or the fallback slot) on 404, error or no entries; FaqBlock renders the title, the server-sanitized description and native details items (exclusive and openFirst props) and nothing on 404, error or no items; neither layer ships a component named FaqSection."
|
||||
- "UI-SPEC backstop: {statement: \"a layer node test asserts that javascript: and data: URLs render as a span with no href and that _blank links carry rel noopener noreferrer\", verification: backstop}"
|
||||
- "SC-2 in the host: the grzyby host config menu_items.patterns sets journal-post to /blog/:slug and journal-category to /:slug (P16 D-15 URLs) and no list-type key; the list types all-journal-categories, all-journal-posts and category-journal-posts follow those two through PatternFrom (17-01, 17-03), and the host test proves that all-journal-categories children reach the menu API and the sitemap at /<slug> (not the Journal default /blog/category/<slug>) and all-journal-posts children reach the sitemap at /blog/<slug>."
|
||||
- "Release discipline: the four plugin repositories (Journal, sitemap, menu, FAQ) require the plan 02 summercms tag in go.mod and are pushed before the host commits their gitlinks; every gitlink bump is its own host commit; nothing is pushed or deployed before the human approves at Task 1."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/companions_test.go"
|
||||
provides: "host test: imports fixture exports and serves menu, FAQ and sitemap"
|
||||
contains: "_menu/api/v1"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh"
|
||||
provides: "ordered D-19 import step"
|
||||
contains: "menu:import-winter"
|
||||
- path: "../vue-menu-layer/app/components/Menu/Nav.vue"
|
||||
provides: "MenuNav component"
|
||||
- path: "../vue-faq-layer/app/components/Faq/Block.vue"
|
||||
provides: "FaqBlock component"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
provides: "nginx routes for sitemap, menu and FAQ to Go"
|
||||
contains: "location = /sitemap.xml"
|
||||
key_links:
|
||||
- from: "vue-grzybyfunkcjonalne-app SiteHeader.vue"
|
||||
to: "vue-menu-layer MenuNav"
|
||||
via: "extends plus the MenuNav component with the recorded menu code"
|
||||
pattern: "MenuNav"
|
||||
- from: "vue-grzybyfunkcjonalne-app FaqSection.vue"
|
||||
to: "vue-faq-layer FaqBlock"
|
||||
via: "FaqBlock with slug what-mushrooms-can-give-you"
|
||||
pattern: "FaqBlock"
|
||||
- from: "nginx /sitemap.xml"
|
||||
to: "sm-sitemap-plugin route"
|
||||
via: "exact location proxied to 127.0.0.1:8096"
|
||||
pattern: "sitemap.xml"
|
||||
prohibitions:
|
||||
- statement: "The live PHP site and its database are only read: the export SQL files are SELECT-only, the Winter vhost and php-fpm configuration are untouched, and no secret is printed, copied back or committed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No repository is pushed, no tag is created and nothing is deployed before the human approves at Task 1; no pushed tag is moved; no git add -A, and no file inside a submodule is staged from the host repository."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The layers are module-free (no @nuxtjs/i18n, @nuxtjs/seo or fonts) and ship no site copy and no consuming application name; per-post meta, OG, canonical and robots stay on @nuxtjs/seo."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 5 of 6, wave 3): grzybyfunkcjonalne.pl adopts the three plugins and two new layers, its PHP rows are copied in, and UAT serves the menu, FAQ and sitemap from the plugins (SC-4).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
SC-4 is unclassified by the edge probe; it is surfaced as an explicit flagged assumption: the deployed rendering is verified by the human UAT check (end-of-phase `<human-check>`), not by an automated predicate alone. SC-3 ordering backstop from plan 03 is checked here on live data (set-equal loc sets). Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## UI Considerations (lifted from 17-UI-SPEC.md, layers)
|
||||
|
||||
Resolved explicit items for the menu nav and FAQ block (loading through SSR, error and empty render nothing or the fallback slot, populated, zero-one-many, overflow, long text) are truths above. Backstop: the URL-scheme node test (truth above). No unresolved items.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Menu code: the PHP theme selects menu id 1; its code is read from the live database at Task 1 (MENU_CODE) and used in SiteHeader and the smoke lines.
|
||||
- Extra type mappings (PHP Blog-plugin keys, RESEARCH A9) come from Task 1's DISTINCT type query and are passed as `--map old=new` to the import commands; recorded in DEPLOY.md and the SUMMARY.
|
||||
- `scripts/import-companions.sh` is a separate script that migrate.sh calls with `--companions DIR`, so UAT (already migrated, migrate.sh refuses a database with posts) runs the same step alone.
|
||||
- The menu and FAQ edits show on the site after the swr TTL (at most 300 s); a follow-up todo records a framework-level purge (RESEARCH Open Question 4).
|
||||
- Repository visibility: plugins as sm-journal-plugin; layers public (pnpm installs them over https).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Pushed: sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin (main, requiring the plan 02 tag); vue-menu-layer and vue-faq-layer (main and tag v0.1.0); vue-grzybyfunkcjonalne-app (main); sm-grzybyfunkcjonalne-app (master).
|
||||
- Host: three submodules at `plugins/golem15/{sitemap,menu,faq}`, go.work, go.mod, summer.yaml, regenerated `plugins.gen.go` and `main.go`, `config/menu_items.yaml`, CORS paths, `companions_test.go` with fixture exports, `scripts/import-companions.sh`, migrate.sh step, nginx locations, smoke lines, DEPLOY.md "Blog companions" section.
|
||||
- Layers: `MenuNav`, `MenuNavItem`, `useMenu`, `useMenuApi`, `menuLinks`; `FaqBlock`, `FaqList`, `FaqItem`, `useFaq`, `useFaqApi`; tokens and base CSS; tests and SSR smokes.
|
||||
- UAT: plugin tables migrated, PHP rows imported, both programs restarted, smoke green.
|
||||
|
||||
<objective>
|
||||
Move grzybyfunkcjonalne.pl's menu, FAQ and sitemap from app code onto the plugins and layers, with its PHP data, and prove it on UAT.
|
||||
|
||||
Purpose: SC-4, the live half of SC-3, and D-15, D-18, D-19.
|
||||
Output: pushed plugin and layer repositories, the integrated host and site app, an updated UAT.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-02-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-03-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-04-SUMMARY.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
|
||||
Session values: RELEASE_TAG from 17-02-SUMMARY (the summercms tag), the approved API shape from 17-04-SUMMARY, STAGING_SSH and STAGING_URL as in Phase 16 (DEPLOY.md Server facts). Workflow per core-plugins-own-repos.md: commit and push a plugin first, then the pointer bump in the host as a separate commit; never stage files inside a submodule from the host. vue-grzybyfunkcjonalne-app is itself a submodule of the host (branch main); it is committed and pushed in its own checkout, then its gitlink is bumped in the host. Host branch master. No co-author tags. Deploy steps follow ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Release, Smoke, Rollback).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: Create the five repositories, export the Winter rows read-only, record the menu code and type values, and approve pushes and the UAT redeploy</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Server facts, Release, Rollback), ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-sitemap-plugin/scripts/winter-export-definitions.sql and winter-export-settings.sql, ../sm-menu-plugin/scripts/winter-export-menus.sql and winter-export-entries.sql, ../sm-faq-plugin/scripts/winter-export.sql, 17-02-SUMMARY.md (RELEASE_TAG), 17-03-SUMMARY.md and 17-04-SUMMARY.md (local commits per repository, NOT RUN suites), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm (menu = 1)</read_first>
|
||||
<action>Print a checklist and a release sheet, then stop.
|
||||
|
||||
Release sheet: RELEASE_TAG; the commits to push per repository (sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin, then the layers, the site app and the host); the one-way surfaces (/_menu/api/v1 and /_faq/api/v1 shapes as approved in 17-04, layer tags v0.1.0, plugin table and migration IDs, the menu built-in key url); the Postgres suites recorded NOT RUN in plans 03 and 04; open high findings in 17-REVIEW.md or 17-SECURITY.md if present.
|
||||
|
||||
Checklist for the human: (1) create the five empty repositories named in user_setup; (2) on the PHP server run the read-only discovery queries SELECT id, code, name, is_active FROM golem15_menu_menus; SELECT DISTINCT type FROM golem15_menu_menu_entries; SELECT id, theme FROM golem15_sitemap_definitions; SELECT id, slug, locale_id, is_active FROM golem15_faq_faqs; SHOW TABLES LIKE '%translate_locales'; and report the results (no secret values); (3) run the five export SQL files with mysql -N -B --raw after checking the translate table prefix, writing menus.jsonl, entries.jsonl, faqs.jsonl, sitemap-definitions.jsonl and sitemap-settings.json to /srv/grzybyfunkcjonalne/import/companions/ readable by the deploy user; (4) approve the pushes and the UAT redeploy. Record in the SUMMARY: MENU_CODE (the code of menu id 1), the DISTINCT menu types and sitemap item types with the mapping decided for each (old=new or drop), the FAQ slugs and locales, and the reply.</action>
|
||||
<instructions>
|
||||
Claude built and tested the plugins locally (plans 03 and 04) and printed the release sheet and checklist.
|
||||
The human creates the repositories, runs the read-only queries and exports on the PHP server (only the operator holds those credentials), and approves.
|
||||
</instructions>
|
||||
<verification>The verify command prints companions-ready.</verification>
|
||||
<resume-signal>Reply "repos created, exports placed, menu code X, maps a=b c=d, push approved" (maps may be "none"), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in sm-sitemap-plugin sm-menu-plugin sm-faq-plugin vue-menu-layer vue-faq-layer; do git ls-remote "git@git.golem15.com:golem15/$r.git" >/dev/null || exit 1; done; test -n "$STAGING_SSH" && ssh "$STAGING_SSH" 'cd /srv/grzybyfunkcjonalne/import/companions && test -s menus.jsonl && test -s entries.jsonl && test -s faqs.jsonl && test -s sitemap-definitions.jsonl && test -s sitemap-settings.json && for f in *.jsonl; do jq -c . "$f" >/dev/null || exit 1; done && jq -e type sitemap-settings.json >/dev/null' && echo companions-ready</automated>
|
||||
<fails_when>Non-zero exit or "companions-ready" absent: a repository does not exist or is not reachable over SSH, STAGING_SSH is unset, an export file is missing or empty, or a line does not parse as JSON.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The five repositories answer git ls-remote and the five export files exist and parse on the server.
|
||||
- The SUMMARY records MENU_CODE, every DISTINCT type with its mapping decision, the FAQ rows and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>Repositories exist, the Winter rows are exported read-only, the mappings are decided and the human approved pushes and the redeploy.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: the host boots with the three plugins from their pushed repositories, imports fixture exports, and serves the menu with Journal category children, the FAQ and the sitemap</name>
|
||||
<reversibility rating="costly">D-19 and SC-1: the submodule layout, plugin IDs and table names become what UAT and the production cutover run; moving them later means data and deploy changes.</reversibility>
|
||||
<precondition>Task 1 printed companions-ready and the human approved pushes; RELEASE_TAG from 17-02-SUMMARY exists on origin (`git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` in summercms.go prints a line).</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-sitemap-plugin/go.mod, ../sm-menu-plugin/go.mod, ../sm-faq-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap, ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu, ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-grzybyfunkcjonalne-app/config/http.yaml, ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/companions_test.go, ../sm-grzybyfunkcjonalne-app/testdata/companions/menus.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/entries.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/faqs.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-definitions.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-settings.json</files>
|
||||
<read_first>.planning/notes/core-plugins-own-repos.md, ../sm-grzybyfunkcjonalne-app/.gitmodules, go.work, go.mod, summer.yaml, config/http.yaml, boot_test.go (TestBootUserTranslateJournal :19, TestCORS :265), plugins/grzyby/console/testmain_test.go (Postgres harness with GRZYBY_TEST_ADMIN_DSN or testcontainers), plugins/grzyby/console/import_categories_test.go, ../sm-journal-plugin/go.mod, 17-02-SUMMARY.md, 17-03-SUMMARY.md, 17-04-SUMMARY.md, this plan's Task 1 SUMMARY (MENU_CODE, mappings)</read_first>
|
||||
<action>Per D-19, D-05, SC-2 and SC-4, end to end in the host.
|
||||
|
||||
Plugins: in each of ../sm-journal-plugin, ../sm-sitemap-plugin, ../sm-menu-plugin and ../sm-faq-plugin run `go mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG` (the go.work replace stays), `go mod tidy` only where the require graph changed, vet, and commit go.mod and go.sum alone as "chore(deps): require summercms $RELEASE_TAG". Add origin git@git.golem15.com:golem15/<repo>.git to the three new repositories and push main for all four (Journal's push also publishes plan 03's commits).
|
||||
|
||||
Host: `git submodule add -b main git@git.golem15.com:golem15/sm-<name>-plugin.git plugins/golem15/<name>` for sitemap, menu and faq, and move plugins/golem15/journal to the pushed Journal main; commit each submodule change alone (chore: add <name> plugin submodule; chore: bump journal plugin (menu item types)). Then in one host commit: go.work use lines for the three; go.mod require plus replace lines (sm-<name>-plugin => ./plugins/golem15/<name>) like Journal's; summer.yaml entries golem15.sitemap, golem15.menu, golem15.faq with their modules; regenerate plugins.gen.go and main.go with the summer CLI build (never hand-edit them); config/menu_items.yaml with patterns journal-post /blog/:slug and journal-category /:slug (P16 D-15) and no list-type keys, because the three Journal list types inherit these through PatternFrom (a comment in the file says so); config/http.yaml cors.paths gains _menu/api/* and _faq/api/* after _journal/api/*. boot_test.go: the activated plugin list now has seven entries including golem15.sitemap, golem15.menu and golem15.faq, the migrating set includes them, and TestCORS covers the two new paths.
|
||||
|
||||
Fixtures: testdata/companions/*.jsonl and sitemap-settings.json shaped exactly like the export SQL output, small and synthetic (no live content needed): menu MENU_CODE with a link entry /blog, a blog_categories entry with title Grzyby Funkcjonalne, a divider and a page entry; one FAQ what-mushrooms-can-give-you in pl with two questions, one answer containing a script element; a sitemap definition with url /, an all-journal-categories item and an all-journal-posts item (both with an empty urlPattern, so the host patterns apply) and settings with frontend_url. companions_test.go with TestCompanionsHost (Postgres, the console harness approach): boot the host plugins, migrate, seed the pl locale and two Journal categories and one published post, run faq:import-winter, menu:import-winter (with the Task 1 maps) and sitemap:import-winter through their bonfire commands, then through the assembled router GET /_menu/api/v1/menus/MENU_CODE (the blog_categories entry became all-journal-categories and carries the two categories with URLs exactly /<slug>; the page entry is gone), GET /_faq/api/v1/faqs/what-mushrooms-can-give-you (200, the script element absent), and GET /sitemap.xml (200 application/xml; with the host part removed, the loc paths include exactly /<category slug> for both categories and /blog/<post slug> for the post, and no loc path starts with /blog/category/).</action>
|
||||
<verify>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app && go vet ./... && go test -count=1 -short ./... && for r in sm-journal-plugin sm-sitemap-plugin sm-menu-plugin sm-faq-plugin; do test "$(git -C ../$r rev-parse main)" = "$(git -C ../$r rev-parse origin/main)" || exit 1; grep -q "git.golem15.com/golem15/summercms $RELEASE_TAG" ../$r/go.mod || exit 1; done; for s in sitemap menu faq; do L=$(git submodule status plugins/golem15/$s) || exit 1; case "$L" in " "*) ;; *) exit 1 ;; esac; done; echo host-wired</automated>
|
||||
<fails_when>Non-zero exit or "host-wired" absent: the host fails vet or a Docker-free test (boot list, CORS paths, regenerated main), a plugin's main is not pushed or does not require RELEASE_TAG, or a submodule is missing or not checked out at its recorded commit.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && cd ../sm-grzybyfunkcjonalne-app && go test -count=1 -run '^TestCompanionsHost$' -v . 2>&1 | grep -- '^--- PASS: TestCompanionsHost'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an import failed, the menu lacks the resolved Journal categories at /<slug>, the page entry survived, the FAQ is unsanitized, or the sitemap lacks the Journal URLs or carries a list child at the Journal default pattern instead of the host pattern.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- For each of sm-sitemap-plugin, sm-menu-plugin and sm-faq-plugin, `grep -n "golem15/<name>.git" ../sm-grzybyfunkcjonalne-app/.gitmodules` prints a line, and for each of golem15.sitemap, golem15.menu and golem15.faq `grep -n "id: <id>$" ../sm-grzybyfunkcjonalne-app/summer.yaml` prints a line.
|
||||
- `grep -n "_menu/api/\*" ../sm-grzybyfunkcjonalne-app/config/http.yaml` and `grep -n "_faq/api/\*" ../sm-grzybyfunkcjonalne-app/config/http.yaml` each print a line.
|
||||
- The host overrides exactly the two record types: `grep -v '^[[:space:]]*#' ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml | grep -oE "^[[:space:]]+[a-z0-9-]+:[[:space:]]+[\"']?/" | sed -E 's/^[[:space:]]+//; s/:.*//' | sort | paste -sd, -` prints `journal-category,journal-post`.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app log --format=%s -5` shows the submodule additions and the Journal bump as separate commits.
|
||||
</acceptance_criteria>
|
||||
<done>The host runs the three plugins from their own repositories and serves a Journal-resolved menu, a sanitized FAQ and the sitemap from imported Winter rows.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: vue-menu-layer and vue-faq-layer: typed clients, MenuNav and FaqBlock per the UI-SPEC, tests, SSR smokes, tag v0.1.0 and push</name>
|
||||
<reversibility rating="costly">D-18: the two repositories, their tag-pinned git distribution and their component names become what sites extend and override; restructuring means coordinated layer and site releases.</reversibility>
|
||||
<precondition>Task 1's human reply approved pushes; the two empty layer repositories answer git ls-remote.</precondition>
|
||||
<files>../vue-menu-layer/package.json, ../vue-menu-layer/pnpm-lock.yaml, ../vue-menu-layer/pnpm-workspace.yaml, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/tsconfig.json, ../vue-menu-layer/.gitignore, ../vue-menu-layer/.nvmrc, ../vue-menu-layer/README.md, ../vue-menu-layer/.playground/nuxt.config.ts, ../vue-menu-layer/.playground/app/app.vue, ../vue-menu-layer/app/components/Menu/Nav.vue, ../vue-menu-layer/app/components/Menu/NavItem.vue, ../vue-menu-layer/app/composables/useMenu.ts, ../vue-menu-layer/app/composables/useMenuApi.ts, ../vue-menu-layer/app/utils/client.ts, ../vue-menu-layer/app/utils/types.ts, ../vue-menu-layer/app/utils/menuLinks.ts, ../vue-menu-layer/app/assets/css/menu-tokens.css, ../vue-menu-layer/app/assets/css/menu-base.css, ../vue-menu-layer/tests/menuLinks.test.ts, ../vue-menu-layer/tests/client.test.ts, ../vue-menu-layer/testing/fixture-api.mjs, ../vue-menu-layer/testing/smoke-ssr.mjs, ../vue-menu-layer/testing/fixtures/menus/main.json, ../vue-menu-layer/testing/expect.txt, ../vue-faq-layer/package.json, ../vue-faq-layer/pnpm-lock.yaml, ../vue-faq-layer/pnpm-workspace.yaml, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/tsconfig.json, ../vue-faq-layer/.gitignore, ../vue-faq-layer/.nvmrc, ../vue-faq-layer/README.md, ../vue-faq-layer/.playground/nuxt.config.ts, ../vue-faq-layer/.playground/app/app.vue, ../vue-faq-layer/app/components/Faq/Block.vue, ../vue-faq-layer/app/components/Faq/List.vue, ../vue-faq-layer/app/components/Faq/Item.vue, ../vue-faq-layer/app/composables/useFaq.ts, ../vue-faq-layer/app/composables/useFaqApi.ts, ../vue-faq-layer/app/utils/client.ts, ../vue-faq-layer/app/utils/types.ts, ../vue-faq-layer/app/assets/css/faq-tokens.css, ../vue-faq-layer/app/assets/css/faq-base.css, ../vue-faq-layer/tests/client.test.ts, ../vue-faq-layer/testing/fixture-api.mjs, ../vue-faq-layer/testing/smoke-ssr.mjs, ../vue-faq-layer/testing/fixtures/faqs/acme.json, ../vue-faq-layer/testing/expect.txt</files>
|
||||
<read_first>../vue-journal-layer/nuxt.config.ts, package.json, pnpm-workspace.yaml, tsconfig.json, .gitignore, .nvmrc, README.md, .playground/, app/utils/client.ts and types.ts, app/composables/useJournalApi.ts, app/assets/css/journal-tokens.css and journal-base.css, app/components/Journal/PostList.vue, tests/client.test.ts, testing/fixture-api.mjs, testing/nuxt-server.mjs, testing/smoke-ssr.mjs, scripts/check-git-install.sh; ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue and FaqSection.vue (markup the layers replace); .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 vue-menu-layer, §8 vue-faq-layer, Color and Typography for layers, layer accessibility props); 17-04-SUMMARY.md (approved API shape)</read_first>
|
||||
<action>Per D-18 and the UI-SPEC §7 and §8, with the API shape approved in 17-04.
|
||||
|
||||
Both layers copy vue-journal-layer's skeleton: package.json (private, type module, main nuxt.config.ts, engines node at least 22.18, peer nuxt 4.4.8, dev nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3 only, scripts dev, build:playground, test "node --test --test-reporter=tap tests/*.test.ts" and smoke:ssr), nuxt.config.ts with an absolute currentDir, no modules key, css tokens then base, runtimeConfig menu.apiBase http://127.0.0.1:8096/_menu/api/v1 with public.menu.apiBase /_menu/api/v1 (faq: the /_faq/api/v1 pair), a .playground app, README (install as a pinned git+https dependency, tokens to override on :root:root, components and props, overriding a component by a same-name file, the API it expects, no site names).
|
||||
|
||||
vue-menu-layer: utils/types.ts (the approved menu shape), utils/client.ts (fetch with an injected Fetcher so node tests can stub; 404 resolves to null), utils/menuLinks.ts (allowlist http, https, mailto, tel, a leading slash or #; linkKind for NuxtLink, anchor or text; rel noopener noreferrer for _blank; path normalisation and active and trail matching against useRoute().path and the request host), composables useMenuApi and useMenu (useAsyncData key menu:code:locale, fetched during SSR, server base on the server and public base in the browser). Components Menu/Nav.vue (MenuNav: props code, locale, label, openLabel, closeLabel, submenuLabel; nav with the menu css_class; mobile toggle; #item and #fallback slots; renders nothing on 404, error or no items) and Menu/NavItem.vue (MenuNavItem, recursive, every row of the UI-SPEC entry table including dividers aria-hidden, disclosure toggles with aria-expanded and aria-controls, Esc, focus-out, outside pointer and route change closing, hover opening only for hover-capable pointers, sibling closing). menu-tokens.css and menu-base.css with the UI-SPEC tokens and the 767px breakpoint. Tests: menuLinks.test.ts (javascript: and data: become text with no href; _blank gets rel noopener noreferrer; active and trail matching; trailing slash and query ignored) and client.test.ts (URL built from base, code and locale; 404 gives null).
|
||||
|
||||
vue-faq-layer: utils/types.ts and client.ts (same pattern), useFaqApi and useFaq (key faq:slug:locale), Faq/Block.vue (FaqBlock: slug, locale, headingLevel 2 to 4, showHeading, showDescription, exclusive; section labelled by the heading; description v-html of the server-sanitized HTML; renders nothing on 404, error or no items unless #fallback), Faq/List.vue (FaqList: items, exclusive, openFirst), Faq/Item.vue (FaqItem: native details with a shared name when exclusive, summary with the CSS chevron that respects prefers-reduced-motion, answer v-html). faq-tokens.css and faq-base.css with the .faq-html rules. No component named FaqSection. client.test.ts as for the menu layer.
|
||||
|
||||
SSR smoke per layer: testing/fixture-api.mjs (node:http serving testing/fixtures), smoke-ssr.mjs reusing the journal layer's approach, expect.txt lines that the playground page renders the menu titles and an aria-current link (menu) and the FAQ title and a details element without the fixture's unsafe markup (faq fixture already sanitized, as the server sends it).
|
||||
|
||||
Install with pnpm (lockfile committed), run tests and the smoke, commit, git init -b main earlier, add origin, create annotated tag v0.1.0, push main and the tag for both layers.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test && pnpm -C ../vue-menu-layer run smoke:ssr && pnpm -C ../vue-faq-layer run smoke:ssr && for r in vue-menu-layer vue-faq-layer; do test -n "$(git -C ../$r ls-remote --tags origin refs/tags/v0.1.0)" || exit 1; done; echo layers-ok</automated>
|
||||
<fails_when>Non-zero exit or "layers-ok" absent: a node test fails (an unsafe URL got an href, a _blank link lacks rel, a client builds the wrong URL), an SSR smoke expectation fails, or a v0.1.0 tag is not on origin.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Neither layer declares Nuxt modules: `cat ../vue-menu-layer/nuxt.config.ts ../vue-faq-layer/nuxt.config.ts | grep -v '^[[:space:]]*//' | grep -c 'modules:'` prints 0.
|
||||
- `test ! -e ../vue-faq-layer/app/components/FaqSection.vue` succeeds.
|
||||
- `grep -ci "grzyby" ../vue-menu-layer/README.md ../vue-faq-layer/README.md` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Any Nuxt site can render a plugin menu and FAQ by extending two tagged layers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The site app adopts both layers: MenuNav in the header, FaqBlock on the homepage, app menu and FAQ data removed, the Nuxt sitemap disabled</name>
|
||||
<precondition>Task 3 printed layers-ok (both v0.1.0 tags on origin); MENU_CODE is recorded in this plan's SUMMARY.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, package.json, app/components/SiteHeader.vue, FaqSection.vue, app/data/menu.ts, app/data/faq.pl.json, app/assets/css/tokens.css, i18n/locales/pl.json, tests/content.test.ts, tests/ssr.test.ts, tests/fixtures/expect.txt, README.md; ../vue-menu-layer/README.md and ../vue-faq-layer/README.md (Task 3); .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 and §8 Overrides); .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-13, D-15)</read_first>
|
||||
<action>Per D-15, D-18 and D-19 (site half) and SC-4.
|
||||
|
||||
package.json: add vue-menu-layer and vue-faq-layer as git+https://git.golem15.com/golem15/<layer>.git#v0.1.0 dependencies; pnpm install (lockfile committed). nuxt.config.ts: extends vue-journal-layer, vue-menu-layer and vue-faq-layer (still no modules key); runtimeConfig menu.apiBase and faq.apiBase for SSR on 127.0.0.1:8096 (NUXT_MENU_API_BASE, NUXT_FAQ_API_BASE); sitemap enabled false with a comment that the Go plugin serves /sitemap.xml and nginx routes it (D-15, reverses P16 D-13 for the sitemap only); robots sitemap /sitemap.xml and disallow /_menu and /_faq next to /_journal; linkChecker excludes /_menu/**, /_faq/** and /sitemap.xsl. SiteHeader.vue keeps its bar, logo and skip link and replaces the MAIN_MENU loop with MenuNav code MENU_CODE and the i18n labels (menu.label, menu.open, menu.close, submenu label); tokens.css maps the --menu-* and --faq-* tokens to the site's --site-* palette. FaqSection.vue keeps its section wrapper and heading and renders FaqBlock slug what-mushrooms-can-give-you with show-heading false (its own title and intro stay in pl.json only if still used). Delete app/data/menu.ts and app/data/faq.pl.json, remove i18n keys that nothing uses any more, update tests/content.test.ts (no longer reads those files; asserts that SiteHeader uses MenuNav and FaqSection uses FaqBlock), and keep tests/fixtures/expect.txt green (the SSR smoke has no menu or FAQ API, so those regions render nothing and every existing page still answers its status). README: the menu and FAQ now come from the plugins through the layers, edited in /gfadmin; edits show after the swr TTL.
|
||||
|
||||
Build (pnpm build), pnpm test and pnpm smoke:ssr; commit in the site app checkout and push its main; then bump the vue-grzybyfunkcjonalne-app gitlink in the host as its own commit and push host master.</action>
|
||||
<verify>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app && pnpm test && pnpm build && pnpm smoke:ssr && test ! -e app/data/menu.ts && test ! -e app/data/faq.pl.json && grep -q "MenuNav" app/components/SiteHeader.vue && grep -q "FaqBlock" app/components/FaqSection.vue && test "$(git rev-parse main)" = "$(git rev-parse origin/main)" && echo site-ok</automated>
|
||||
<fails_when>Non-zero exit or "site-ok" absent: a site test or the build fails, an SSR smoke line fails, the old data files still exist, the header or FAQ section does not use the layer components, or main is not pushed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "enabled: false" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` prints a line inside the sitemap block.
|
||||
- `grep -n "vue-menu-layer.git#v0.1.0" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` and `grep -n "vue-faq-layer.git#v0.1.0" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` each print a line.
|
||||
- The host commit bumping vue-grzybyfunkcjonalne-app touches only that gitlink.
|
||||
</acceptance_criteria>
|
||||
<done>The site renders its menu and FAQ through the layers and no longer carries them as app data or generates its own sitemap.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Ordered import step, nginx routes, deploy guide and smoke lines; redeploy UAT, import the Winter rows, smoke, and compare the sitemap with PHP</name>
|
||||
<precondition>Tasks 2 and 4 are committed and pushed; STAGING_SSH and STAGING_URL are set and `ssh "$STAGING_SSH" true` succeeds.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md, .planning/todos/pending/site-cache-purge-for-menu-and-faq.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/scripts/migrate.sh (step list and flag parsing), scripts/php-export/README.md, scripts/urls.txt (grammar), scripts/smoke.sh (--url mode, routing of /gfadmin and /_journal), scripts/check-deploy.sh, deploy/nginx/grzybyfunkcjonalne-staging.conf (location ^~ /_journal/ :132 and the header comments about add_header), DEPLOY.md (Release, Smoke, Rollback), this plan's Task 1 SUMMARY (MENU_CODE, maps), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-PLAN.md (how the last redeploy ran)</read_first>
|
||||
<action>Per D-15 and D-19 and SC-3 and SC-4.
|
||||
|
||||
scripts/import-companions.sh --bin PATH --workdir DIR --dir EXPORT_DIR [--map old=new ...] [--force] [--dry-run]: runs, in order, faq:import-winter (after the translate locales exist), menu:import-winter (after Journal so registry references resolve), sitemap:import-winter, passing the maps to the menu and sitemap commands, and prints each command's report; set -euo pipefail, absolute paths, refuses a missing file. migrate.sh gains --companions DIR (and repeated --companions-map) and calls import-companions.sh after its Journal, category and author steps; its help text lists the new step. scripts/php-export/README.md points at the plugin export SQL files and the discovery queries.
|
||||
|
||||
nginx (staging vhost, before location /): location = /sitemap.xml and location = /sitemap.xsl, and location ^~ /_menu/ and ^~ /_faq/, each proxy_pass to the Go upstream with Host $host and X-Forwarded-Proto $scheme exactly like the /_journal/ block and GET and HEAD only; keep the server-level headers rule the file documents. check-deploy.sh asserts the four locations. urls.txt: remote lines for 200 /sitemap.xml type=application/xml contains=<urlset, 200 /sitemap.xsl type=text/xsl, 200 /_menu/api/v1/menus/MENU_CODE type=application/json, 200 /_faq/api/v1/faqs/what-mushrooms-can-give-you type=application/json, and the homepage containing the first menu title and the first FAQ question; smoke.sh routes /_menu, /_faq and /sitemap.* to Go in the local rehearsal. DEPLOY.md: a "Blog companions" section (export, import-companions.sh on an existing database, the recorded maps, nginx locations, the sitemap now from Go with frontend_url and the tier settings, edits visible after the swr TTL, rollback: remove the four locations and re-enable the Nuxt sitemap). README points to it. Commit the host changes and push master.
|
||||
|
||||
Todo (planning doc commit in summercms.go): .planning/todos/pending/site-cache-purge-for-menu-and-faq.md records that menu and FAQ admin saves do not purge the Nuxt swr cache yet (RESEARCH Open Question 4) and proposes a framework-level purge service.
|
||||
|
||||
Redeploy UAT per DEPLOY.md Release: scripts/build.sh; upload the binary, config and scripts, and the Nuxt output; on the server run bin/grzybyfunkcjonalne migrate (creates the plugin tables), then scripts/import-companions.sh --workdir /srv/grzybyfunkcjonalne/go --dir /srv/grzybyfunkcjonalne/import/companions with the recorded maps (dry run first, then real), keeping both reports for the SUMMARY; install the updated vhost (sudo nginx -t before reload); restart both supervisor programs. Run scripts/smoke.sh --url "$STAGING_URL". Fetch https://grzybyfunkcjonalne.pl/sitemap.xml and $STAGING_URL/sitemap.xml, compare their loc sets with the host part removed, and record the differences and their reasons in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL" && curl -fsS -D - -o /dev/null "$STAGING_URL/sitemap.xml" | grep -qi '^content-type: application/xml' && curl -fsS "$STAGING_URL/_faq/api/v1/faqs/what-mushrooms-can-give-you" | grep -q '"data"' && echo uat-companions-ok</automated>
|
||||
<fails_when>Non-zero exit or "uat-companions-ok" absent: check-deploy finds a missing location, smoke.sh prints "smoke: FAIL" or lacks its ok line, /sitemap.xml is not served as application/xml through nginx, or the FAQ API does not answer the data envelope.</fails_when>
|
||||
<automated>LIVE=$(curl -fsS https://grzybyfunkcjonalne.pl/sitemap.xml) && UAT=$(curl -fsS "$STAGING_URL/sitemap.xml") && diff <(printf '%s' "$LIVE" | grep -o '<loc>[^<]*' | sed -E 's#<loc>https?://[^/]+##' | sort -u) <(printf '%s' "$UAT" | grep -o '<loc>[^<]*' | sed -E 's#<loc>https?://[^/]+##' | sort -u) && echo loc-sets-equal</automated>
|
||||
<fails_when>Non-zero exit or "loc-sets-equal" absent: the UAT sitemap's path set differs from the live PHP sitemap's (a URL missing or extra); record the diff and fix the source (definition import, maps or patterns) unless the SUMMARY explains it as a live-site change since the export.</fails_when>
|
||||
<human-check>Open $STAGING_URL/: the header menu shows Blog and the Grzyby Funkcjonalne dropdown with every category (desktop hover and click, mobile toggle, keyboard Esc), the active link is marked on a category page, and the FAQ section lists the questions with answers opening. In $STAGING_URL/gfadmin: Menus and Menu entries (edit an entry title, reorder by drag and by keyboard, the reference dropdown follows the type), FAQ (edit an answer in the richeditor, add a question row, save and see the change on the site after the cache TTL), Settings, Sitemap (the definition form with its rows) and Sitemap settings. Open $STAGING_URL/sitemap.xml in a browser (XSL view renders). Reply "uat approved" or list issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Each of `location = /sitemap.xml`, `location = /sitemap.xsl`, `location ^~ /_menu/` and `location ^~ /_faq/` is found by `grep -nF` in ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf.
|
||||
- `grep -n "import-companions.sh" ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh` prints at least one line.
|
||||
- The SUMMARY records both import reports, the smoke output, the loc-set comparison and the human-check reply; no secret value appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>UAT serves the menu, FAQ and sitemap from the plugins with the copied Winter data, behind nginx, and the deploy is documented.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| executor → PHP production server | redeploy next to the live Winter site |
|
||||
| live PHP database → export files | read-only queries run by the operator |
|
||||
| internet → nginx → Go | new public locations |
|
||||
| Nuxt SSR → plugin APIs | server-side fetches on loopback |
|
||||
| executor → git origins | five new repositories, tags, gitlinks |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-32 | Tampering (XSS) | layer v-html sinks and links | high | mitigate | FAQ HTML is sanitized by the server on output (plan 04); MenuNav renders a span for any URL outside the allowlist and adds rel noopener noreferrer on _blank; node test pins it |
|
||||
| T-17-33 | Tampering / Information Disclosure | export from the live PHP database | high | mitigate | SELECT-only export SQL run by the operator; outputs only on the server; no credentials in repositories or the SUMMARY |
|
||||
| T-17-34 | Spoofing | sitemap base URL behind nginx | medium | mitigate | nginx forwards Host and X-Forwarded-Proto; Go trusts them only from configured trusted proxies (127.0.0.1); the loc-set comparison and smoke check the result |
|
||||
| T-17-35 | Denial of Service | /_menu and /_faq public routes | medium | mitigate | plugin throttle buckets keyed by the trusted client IP; nginx GET and HEAD only on the new locations |
|
||||
| T-17-36 | Tampering | host CORS for the new APIs | low | accept | the public read APIs carry no credentials; the host's existing cors config (allowed origins *) applies only to the listed paths, like _journal/api |
|
||||
| T-17-37 | Tampering / Repudiation | pushes, tags and gitlinks | high | mitigate | blocking-human approval at Task 1; plugins pushed before gitlinks; every push verified against origin; heads and tags recorded; no tag moved |
|
||||
| T-17-38 | Denial of Service | UAT redeploy | medium | mitigate | nginx -t before reload; DEPLOY.md rollback (remove the four locations, re-enable the Nuxt sitemap, previous binary kept); the Winter vhost untouched |
|
||||
| T-17-SC | Tampering | npm installs (layer dev dependencies, layer git dependencies) | high | mitigate | only the already-pinned nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3 (the vue-journal-layer set, no new package names); the layers are first-party repositories pinned by tag; lockfiles committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: companions-ready and a human approval with the recorded discovery facts.
|
||||
- Task 2: host vet and short suite, plugins pushed with the tag require, submodules present; the host companions test on a Docker host.
|
||||
- Task 3: layer tests, SSR smokes and tags on origin.
|
||||
- Task 4: site tests, build, SSR smoke and push.
|
||||
- Task 5: check-deploy, UAT smoke, sitemap content type and FAQ API through nginx, loc-set comparison with the live PHP sitemap, and the end-of-phase human check.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: grzybyfunkcjonalne.pl (UAT) reads its menu and FAQ from the plugins instead of app config.
|
||||
- SC-3 (live): the Go sitemap answers through nginx and lists the same paths as the PHP sitemap.
|
||||
- D-18: two tagged, public, module-free layers exist and the site consumes them.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-05-SUMMARY.md` when done, with the discovery facts, the maps, commits and pushed heads per repository, the layer tags, the import reports, the smoke output, the loc-set comparison and the human-check reply.
|
||||
</output>
|
||||
279
.planning/phases/17-blog-companions/17-06-PLAN.md
Normal file
279
.planning/phases/17-blog-companions/17-06-PLAN.md
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["17-01", "17-02", "17-03", "17-04", "17-05"]
|
||||
files_modified:
|
||||
- scripts/check-phase17.sh
|
||||
- .planning/phases/17-blog-companions/17-VALIDATION.md
|
||||
- modules/signpost/signpost_test.go
|
||||
- modules/seaglass/seaglass_test.go
|
||||
- modules/pact/menu_item_types_test.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/cabana/repeater_test.go
|
||||
- modules/cabana/dependent_test.go
|
||||
- modules/cabana/richeditor_test.go
|
||||
- modules/cabana/reorder_test.go
|
||||
- modules/cabana/singleton_test.go
|
||||
- modules/cabana/companions_postgres_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- admin/tests/form/RichEditorField.test.ts
|
||||
- admin/tests/form/RepeaterField.test.ts
|
||||
- admin/tests/form/DependentDropdown.test.ts
|
||||
- admin/tests/form/SingletonForm.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- ../sm-journal-plugin/classes/menu_item_types_test.go
|
||||
- ../sm-journal-plugin/menu_item_types_test.go
|
||||
- ../sm-sitemap-plugin/classes/urlto_test.go
|
||||
- ../sm-sitemap-plugin/classes/tiers_test.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite_test.go
|
||||
- ../sm-sitemap-plugin/classes/serialize_test.go
|
||||
- ../sm-sitemap-plugin/classes/generator_test.go
|
||||
- ../sm-sitemap-plugin/classes/manifest_test.go
|
||||
- ../sm-sitemap-plugin/models/definition_test.go
|
||||
- ../sm-sitemap-plugin/import_test.go
|
||||
- ../sm-menu-plugin/classes/links_test.go
|
||||
- ../sm-menu-plugin/classes/tree_test.go
|
||||
- ../sm-menu-plugin/menu_api_test.go
|
||||
- ../sm-menu-plugin/models/menu_entry_test.go
|
||||
- ../sm-faq-plugin/models/faq_test.go
|
||||
- ../sm-faq-plugin/faq_api_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/companions_test.go
|
||||
- ../vue-menu-layer/tests/menuLinks.test.ts
|
||||
- ../vue-menu-layer/tests/client.test.ts
|
||||
- ../vue-faq-layer/tests/client.test.ts
|
||||
autonomous: true
|
||||
requirements: [SC-5, SC-1, SC-2, SC-3, SC-4]
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-5 (CLAUDE.md lean rule 2): every piece of Phase 17 code has unit tests in this last plan: pact contracts, signpost, seaglass, the party hook, cabana repeater, dependent dropdown, richeditor, reorder and singleton, the four SPA features, the five Journal types, the sitemap serializer, Url::to, tiers, rewrite, manifest, hooks and imports, the menu and FAQ models, APIs and imports, the host wiring and both layers."
|
||||
- "D-01 and SC-2 pinned: TestRegistryDuplicateKey fails Activate with both plugin IDs; TestRegistryActivate covers empty key, nil Resolve, an empty or relative DefaultPattern, a PatternFrom naming an unregistered type, the type itself or a type with its own PatternFrom, and an unknown host override; TestRegistryPatternLayers covers item override over host config over default, and for a PatternFrom list type item override over the list key's host override over the record type's host override over the list's DefaultPattern; TestActivateMenuItemTypesOrder proves the registry exists before the first Boot; TestReferenceOptions covers depth flattening and applies false."
|
||||
- "D-10 pinned: TestSanitizeByteStable (clean HTML returned byte-identical), TestSanitizeReport (element, attribute and comment names), TestSanitizeBypassCorpus (the regex-gate bypasses from the bluemonday decision notes: event handlers without a space, entity-encoded javascript scheme, svg onload, data URLs) and TestRichEditor (save stores the clean value with notices; settings and relation forms refuse the type at boot)."
|
||||
- "D-07, D-08, D-09 and D-11 pinned: TestRepeaterCompile (every refused key and nested type), TestRepeaterDecode and TestRepeaterSave (row keys, coercion, per-row required paths, min and max, nested richeditor notices, nested dependsOn), TestDependentDropdownCompile and TestDependentRecheck (options route, null when applies is false, 422 outside the options), TestReorderPostgres (exact set, 409 stale, 409 foreign partition, 422 cycle, duplicate and maxDepth, both columns written, nothing written on refusal) and TestSingleton (first-or-create once, 404 on list, create and delete)."
|
||||
- "D-04 and D-02 pinned: TestMenuItemTypes covers all five Journal types on Postgres (a valid DefaultPattern on each and PatternFrom on the three list types, list children following a host override set only on journal-category or journal-post and an item override on the list item, references, nesting with default URLs only, list ordering, published filters, descendant categories, x-default, alternates in sort_order with ties by id, date placeholders only for published posts) and TestMenuItemTypesPatterns covers the ordered pattern fill without a database."
|
||||
- "SC-3 and D-12 to D-14 pinned: TestURLTo (the PHP golden table), TestXMLEscaping (PHP XmlEscapingTest as behaviour: every value escaped in text and attributes), TestFillable (PHP MassAssignmentTest: exactly theme and data), TestTiers (each default rule, first match wins, disabled tiers, host replacement, bad pattern fails boot), TestRewrite (loc, xhtml:link href and stylesheet href; equal URLs unchanged; trusted versus untrusted forwarded proto), TestHooks (Skip drops, Extra appended before the url closing tag), TestManifest (missing, oversized, non-object and non-regular files omit lastmod), TestDefinitionItemJSON (PHP-shaped strings, numbers, bools and unknown keys), TestURLCap (50,000th in, 50,001st out) and TestImportWinter (maps, drops with report, --force, --dry-run)."
|
||||
- "D-05, D-16 and D-17 pinned: TestAllowedURL and TestMenuEntryBeforeSave (PHP save rules), TestMenuTree (order and expansion without a database), TestMenuAPI and TestFaqAPI on Postgres (404 bodies, published and active filters, locale choice, duplicate slug lowest id, raw-SQL unsafe HTML sanitized, URL scheme filter, url_pattern override), and the import edge cases (empty file, unknown locale, page subtree dropped)."
|
||||
- "UI-SPEC backstops completed in vitest: untouched richeditor value byte-identical; repeater remove asks only for a non-empty row, arrow keys and buttons reorder and announce repeater.moved, nothing is sent before Save; three quick tree moves send one debounced POST with the full node set, a failed POST restores the tree, a 409 reloads with reorder.stale, each keyboard move announces; dependent dropdown states and last-request-wins; singleton form without Delete. Layer node tests: javascript: and data: URLs render as text without href, _blank gets rel noopener noreferrer, active and trail matching, clients build the right URLs and map 404 to null."
|
||||
- "scripts/check-phase17.sh runs every runnable suite fail-closed across summercms.go, the four plugin repositories, the host and both layers (stages go, plugins, admin, layers, docs, postgres; --self-test proves the detectors fail on planted output; --all ends with \"Phase 17 gate passed\" and says NOT RUN postgres loudly without Docker); 17-VALIDATION.md has every row filled with task IDs, commands and status, and nyquist_compliant true."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase17.sh"
|
||||
provides: "phase gate"
|
||||
contains: "Phase 17 gate passed"
|
||||
- path: "modules/signpost/signpost_test.go"
|
||||
provides: "registry unit tests"
|
||||
contains: "TestRegistryDuplicateKey"
|
||||
- path: "modules/seaglass/seaglass_test.go"
|
||||
provides: "sanitizer unit tests including the bypass corpus"
|
||||
contains: "TestSanitizeBypassCorpus"
|
||||
- path: "modules/cabana/reorder_test.go"
|
||||
provides: "reorder Postgres tests"
|
||||
contains: "TestReorderPostgres"
|
||||
- path: "../sm-sitemap-plugin/classes/urlto_test.go"
|
||||
provides: "Url::to golden table test"
|
||||
contains: "TestURLTo"
|
||||
- path: "../sm-menu-plugin/menu_api_test.go"
|
||||
provides: "menu API Postgres tests"
|
||||
contains: "TestMenuAPI"
|
||||
- path: ".planning/phases/17-blog-companions/17-VALIDATION.md"
|
||||
provides: "filled validation map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase17.sh"
|
||||
to: "every suite named in 17-VALIDATION.md"
|
||||
via: "fail-closed stages with required test name lists"
|
||||
pattern: "check-phase17"
|
||||
- from: "modules/cabana/openapi_conformance_test.go"
|
||||
to: "dependent-options, reorder and singleton routes"
|
||||
via: "conformance cases against admin/openapi/admin.json"
|
||||
pattern: "reorder"
|
||||
prohibitions:
|
||||
- statement: "No test touches a shared database, a server, git origin or the network: pure tests use stubs and in-memory stores, Postgres tests use throwaway testcontainers databases, layer tests stub fetch."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests are not weakened to pass: existing assertions stay; a production defect found here is fixed in a separate fix(17-06) commit with its own test and named in the SUMMARY."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in this plan; test commits in the plugin, host and layer repositories stay local and are listed in the SUMMARY for the next release."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 6 of 6, wave 4, CLAUDE.md lean rule 2): the unit tests for all new code (SC-5), the fail-closed phase gate and the filled validation map.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
SC-5 is unclassified by the edge probe; it is surfaced as a flagged assumption: coverage is judged by the filled 17-VALIDATION.md map and the gate's required test lists, not by an edge predicate. Every explicit edge truth of plans 01 to 05 gets a named test here. Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Test names are fixed by this plan (truths above and the task behaviours) so the gate's required lists are deterministic; a smoke test from an earlier plan keeps its name and joins the lists.
|
||||
- The gate follows scripts/check-phase16.3.sh: stages, go test -json parsing, vitest JSON reports, NOT RUN postgres without Docker, PHASE17_REQUIRE_POSTGRES=1 to fail instead, environment overrides for every sibling repository path.
|
||||
- Plugin, host and layer test commits stay local (no push in this plan).
|
||||
- Plan 02 tagged the framework and plan 05 deployed UAT before this plan runs, so a fix(17-06) commit that changes production code ships in neither. The SUMMARY flags each such commit as needing a follow-up release, naming the route: a framework patch tag, a plugin push plus a host pointer bump, or a layer tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase17.sh` (stages go, plugins, admin, layers, docs, postgres; `--self-test`, `--all`; overrides `PHASE17_ROOT`, `JOURNAL`, `SITEMAP`, `MENU`, `FAQ`, `HOST`, `MENU_LAYER`, `FAQ_LAYER`).
|
||||
- Unit test files listed in files_modified; `17-VALIDATION.md` filled with `nyquist_compliant: true`.
|
||||
|
||||
<objective>
|
||||
Pin every Phase 17 behaviour with unit tests, wire them into one fail-closed gate, and fill the validation map.
|
||||
|
||||
Purpose: SC-5 and the Nyquist validation contract.
|
||||
Output: tests across eight repositories, scripts/check-phase17.sh, 17-VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-VALIDATION.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-02-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-03-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-04-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-05-SUMMARY.md
|
||||
|
||||
Repositories: summercms.go (this repo), ../sm-journal-plugin, ../sm-sitemap-plugin, ../sm-menu-plugin, ../sm-faq-plugin, ../sm-grzybyfunkcjonalne-app, ../vue-menu-layer, ../vue-faq-layer. Docker: Postgres tests skip under -short through a TestMain with flag.Parse() and testing.Short(); run them on a Docker host (sudo -u golem when the socket is denied) and record NOT RUN otherwise. Commits: tests and fixes in separate commits, no co-author tags, stage by explicit path per repository, nothing pushed.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the phase gate runs one contract test per layer end to end, fail-closed (registry, sanitizer, sitemap bytes, menu and FAQ APIs, SPA untouched value, layer link safety)</name>
|
||||
<files>scripts/check-phase17.sh, modules/signpost/signpost_test.go, modules/seaglass/seaglass_test.go</files>
|
||||
<read_first>scripts/check-phase16.3.sh (whole file: stage layout, go test -json detectors, vitest JSON detectors, --self-test, --all, NOT RUN handling, env overrides), scripts/check-phase16.2.sh (multi-repository stages), .planning/phases/17-blog-companions/17-VALIDATION.md, the smoke test names recorded in 17-01 to 17-05 SUMMARY files</read_first>
|
||||
<action>Create scripts/check-phase17.sh modelled on check-phase16.3.sh with stages go (vet in every Go repository; Docker-free framework suites by required name), plugins (vet and -short suites in Journal, sitemap, menu, FAQ and the host by required name), admin (typecheck and the vitest JSON report with required files and names), layers (pnpm test in both layers with TAP parsing: any not ok, zero tests or a missing required file fails), docs (TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh), and postgres (Docker host only: the Postgres suites by required name in every Go repository). Detectors fail on a non-zero exit, a failed, skipped or zero-match go test -json run, "no tests to run", a missing required name, a vitest failure, skip, todo or missing file, and a layer TAP failure. --self-test plants failing output for each detector and proves it fails. --all runs self-test, go, plugins, admin, layers and docs, then postgres when Docker answers, else prints NOT RUN postgres; PHASE17_REQUIRE_POSTGRES=1 makes that a failure. Paths default to the sibling checkouts and are overridable (PHASE17_ROOT, JOURNAL, SITEMAP, MENU, FAQ, HOST, MENU_LAYER, FAQ_LAYER). The final line on success is "Phase 17 gate passed".
|
||||
|
||||
Start the required lists with one contract test per layer, writing the two new framework ones now: TestRegistryDuplicateKey in modules/signpost/signpost_test.go (two fake plugins with the same key fail Activate, the error names both IDs) and TestSanitizeByteStable in modules/seaglass/seaglass_test.go (a set of clean editor HTML snippets come back byte-identical, including attributes in their original order and entities as written). The other contract tests come from earlier plans: TestSitemapGolden (sitemap), TestFaqAPISmoke and TestMenuAPISmoke (postgres stage), the RichEditorField tracer spec (admin) and the menu layer menuLinks test (layers).</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase17.sh --self-test && bash scripts/check-phase17.sh go && bash scripts/check-phase17.sh plugins && bash scripts/check-phase17.sh layers</automated>
|
||||
<fails_when>Non-zero exit: a detector accepts planted failing output in --self-test, or a stage reports a failed, skipped or missing required test.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Phase 17 gate passed" scripts/check-phase17.sh` prints one line and `grep -n "NOT RUN postgres" scripts/check-phase17.sh` prints at least one line.
|
||||
- `grep -n "PHASE17_REQUIRE_POSTGRES" scripts/check-phase17.sh` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>One command proves the phase's contracts at every layer and fails closed when any is missing or broken.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Full framework coverage: pact, signpost, seaglass, party hook, cabana repeater, dependent dropdown, richeditor, reorder, singleton and conformance; admin SPA specs</name>
|
||||
<files>modules/signpost/signpost_test.go, modules/seaglass/seaglass_test.go, modules/pact/menu_item_types_test.go, modules/party/registry_test.go, modules/cabana/repeater_test.go, modules/cabana/dependent_test.go, modules/cabana/richeditor_test.go, modules/cabana/reorder_test.go, modules/cabana/singleton_test.go, modules/cabana/companions_postgres_test.go, modules/cabana/openapi_conformance_test.go, admin/tests/form/RichEditorField.test.ts, admin/tests/form/RepeaterField.test.ts, admin/tests/form/DependentDropdown.test.ts, admin/tests/form/SingletonForm.test.ts, admin/tests/views/ReorderView.test.ts, scripts/check-phase17.sh</files>
|
||||
<read_first>modules/signpost/signpost.go, modules/seaglass/seaglass.go, modules/cabana/field_repeater.go, field_dependent.go, field_richeditor.go, reorder.go, singleton.go, modules/cabana/companions_smoke_test.go, field_type_test.go (table style), openapi_conformance_test.go, modules/party/registry_test.go, .planning/notes/journal-html-sanitizing-bluemonday.md and 2026-10-11-framework-html-sanitizer-bluemonday.md (bypass list), admin components and smoke specs from plan 02, .planning/phases/17-blog-companions/17-UI-SPEC.md (UI Considerations backstops), admin/tests/form/MarkdownField.test.ts and tests/helpers.ts</read_first>
|
||||
<behavior>
|
||||
- TestRegistryActivate, TestRegistryPatternLayers, TestReferenceOptions, TestRegistryHostOverrideUnknown, TestActivateMenuItemTypesOrder, TestOptionDepthOmitEmpty
|
||||
- TestSanitizeReport, TestSanitizeBypassCorpus, TestCheck
|
||||
- TestRepeaterCompile, TestRepeaterDecode, TestDependentDropdownCompile, TestRichEditorCompile, TestReorderConfigCompile, TestSingletonCompile (Docker-free)
|
||||
- TestRepeaterSave, TestDependentRecheck, TestRichEditor, TestReorderPostgres, TestSingleton, TestPhase10OpenAPIConformance with the new routes (Postgres)
|
||||
- vitest: RichEditorField (untouched byte-identical, edit emits HTML, empty paragraph emits "", lossy note, link allowlist, lazy-load failure fallback, notices callout), RepeaterField (remove confirm only when non-empty, keyboard and button reorder with repeater.moved, path errors expand and focus, min and max, collapsed rows do not mount richeditor), DependentDropdown (every UI-SPEC state, cache, last request wins, clear and announce), ReorderView (debounced full-set POST, revert on failure, 409 reload, keyboard moves announce, subtree drop refused, partition switch), SingletonForm (opens through /singleton, no Delete, back to settings)
|
||||
</behavior>
|
||||
<action>Per D-01 to D-03, D-06 to D-11: write the tests named in the behaviour block as tables where the framework already uses tables (field_type_test.go style), against the real compile and route code (no mocks of cabana internals), with Postgres tests in companions_postgres_test.go using the conform-style environment. TestRegistryPatternLayers is a table over fake types acme-post and acme-posts (PatternFrom acme-post) with every combination of item override, list-key host override and record-key host override, asserting the D-02 truth above; TestRegistryActivate rows include the three bad PatternFrom cases. TestSanitizeBypassCorpus takes its cases from the two bluemonday decision notes and asserts each comes back without the dangerous construct and with Changed true. TestReorderPostgres asserts row values before and after every refused request to prove nothing was written. Add conformance cases for every new route response (200, 404, 409, 422 where defined). Add the admin specs by the UI-SPEC backstop wording. Append every new name to the required lists in scripts/check-phase17.sh (Docker-free ones to the go stage, Postgres ones to the postgres stage, spec files and names to the admin stage). A production defect found while writing a test is fixed in its own fix(17-06) commit with that test.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/signpost ./modules/seaglass ./modules/pact ./modules/party ./modules/cabana && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase17.sh go && bash scripts/check-phase17.sh admin</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free framework test fails, an admin spec fails, or the gate reports a missing or skipped required name.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestRepeaterSave|TestDependentRecheck|TestRichEditor|TestReorderPostgres|TestSingleton|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than six passes: a framework save, recheck, reorder or singleton rule regressed, or a new route response does not match the OpenAPI schema.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestSanitizeBypassCorpus" modules/seaglass/seaglass_test.go` and `grep -n "func TestReorderPostgres" modules/cabana/reorder_test.go` each print one line.
|
||||
- `grep -n "TestRepeaterSave" scripts/check-phase17.sh` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework behaviour added in plans 01 and 02 has a named, gated test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Full plugin and host coverage: Journal types, sitemap parity units and imports, menu and FAQ models, APIs and imports, host wiring</name>
|
||||
<files>../sm-journal-plugin/classes/menu_item_types_test.go, ../sm-journal-plugin/menu_item_types_test.go, ../sm-sitemap-plugin/classes/urlto_test.go, ../sm-sitemap-plugin/classes/tiers_test.go, ../sm-sitemap-plugin/classes/rewrite_test.go, ../sm-sitemap-plugin/classes/serialize_test.go, ../sm-sitemap-plugin/classes/generator_test.go, ../sm-sitemap-plugin/classes/manifest_test.go, ../sm-sitemap-plugin/models/definition_test.go, ../sm-sitemap-plugin/import_test.go, ../sm-menu-plugin/classes/links_test.go, ../sm-menu-plugin/classes/tree_test.go, ../sm-menu-plugin/menu_api_test.go, ../sm-menu-plugin/models/menu_entry_test.go, ../sm-faq-plugin/models/faq_test.go, ../sm-faq-plugin/faq_api_test.go, ../sm-grzybyfunkcjonalne-app/companions_test.go, scripts/check-phase17.sh</files>
|
||||
<read_first>../sm-journal-plugin/classes/menu_item_types.go and menu_item_types_smoke_test.go, ../sm-sitemap-plugin/classes/*.go, classes/testdata/*.json and *.golden, tests/php/gen-goldens.php, models/definition_item.go, console/import_winter.go, sitemap_smoke_test.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/tests/security/XmlEscapingTest.php and MassAssignmentTest.php; ../sm-menu-plugin/classes/links.go and tree.go, models/menu_entry.go, controllers/api/menus.go, menu_smoke_test.go; ../sm-faq-plugin/controllers/api/faqs.go, models/faq.go, faq_smoke_test.go; ../sm-grzybyfunkcjonalne-app/companions_test.go and boot_test.go</read_first>
|
||||
<behavior>
|
||||
- Journal: TestMenuItemTypesPatterns (Docker-free), TestMenuItemTypes (Postgres) per the D-04 truth
|
||||
- Sitemap Docker-free: TestURLTo, TestXMLEscaping, TestFillable, TestTiers, TestRewrite, TestHooks, TestManifest, TestDefinitionItemJSON, TestURLCap, TestSitemapGolden
|
||||
- Sitemap Postgres: TestImportWinter (maps, drops with report, --force, --dry-run, empty file, settings upsert)
|
||||
- Menu: TestAllowedURL, TestMenuTree (Docker-free); TestMenuEntryBeforeSave, TestMenuAPI, TestMenuImport (Postgres)
|
||||
- FAQ: TestFaqContentDecode (Docker-free); TestFaqAPI, TestFaqImport (Postgres)
|
||||
- Host: TestCompanionsHost extended with the CORS preflight for _menu/api and _faq/api and the sitemap Content-Type (its plan 05 assertions that list children use the host patterns /<slug> and /blog/<slug> stay)
|
||||
</behavior>
|
||||
<action>Per D-02 to D-05, D-12 to D-14, D-16, D-17 and SC-3: write the tests named in the behaviour block in each repository's existing style. TestURLTo iterates every case of classes/testdata/urlto.json against urlto.golden. TestXMLEscaping ports the PHP XmlEscapingTest as behaviour (ampersand, angle brackets, quotes, carriage return, tab and newline in loc, changefreq, priority and hreflang href produce exactly the libxml escapes; no raw value reaches the output). TestFillable ports MassAssignmentTest (Definition.Fillable equals theme and data; an admin save with extra keys writes only those). TestURLCap feeds 50,001 items and counts 50,000 url elements. TestTiers covers each default rule with paths taken from the rule list, trailing slashes, a disabled setting, a replaced host list and a bad RE2 pattern failing Boot. TestMenuAPI and TestFaqAPI cover every D-16 and D-17 rule in the truths, including raw-SQL unsafe rows. Imports cover empty files, refusals and sequences. Append every name to the plugins and postgres stage lists of scripts/check-phase17.sh. A production defect found is fixed in its own fix(17-06) commit in that repository with its test. Commit tests per repository; push nothing.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase17.sh plugins && go -C ../sm-sitemap-plugin test -count=1 -run '^(TestURLTo|TestXMLEscaping|TestFillable|TestTiers|TestRewrite|TestHooks|TestManifest|TestDefinitionItemJSON|TestURLCap|TestSitemapGolden)$' -v ./... 2>&1 | grep -c '^--- PASS: Test' | grep -qx 10</automated>
|
||||
<fails_when>Non-zero exit: the plugins stage reports a failure or a missing required name, or fewer than ten sitemap parity tests pass (a golden, escaping, tier, rewrite, hook, manifest, JSON or cap rule regressed).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && bash scripts/check-phase17.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a Postgres suite in any repository fails, skips or misses a required name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestURLTo" ../sm-sitemap-plugin/classes/urlto_test.go`, `grep -n "func TestMenuAPI" ../sm-menu-plugin/menu_api_test.go` and `grep -n "func TestFaqAPI" ../sm-faq-plugin/faq_api_test.go` each print one line.
|
||||
- `git -C ../sm-sitemap-plugin status --porcelain` prints nothing after the commits, and the SUMMARY lists the unpushed commit range per repository.
|
||||
</acceptance_criteria>
|
||||
<done>Every plugin and host behaviour of plans 03 to 05 has a named, gated test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Layer coverage, the complete gate run and the filled validation map</name>
|
||||
<files>../vue-menu-layer/tests/menuLinks.test.ts, ../vue-menu-layer/tests/client.test.ts, ../vue-faq-layer/tests/client.test.ts, scripts/check-phase17.sh, .planning/phases/17-blog-companions/17-VALIDATION.md</files>
|
||||
<read_first>../vue-menu-layer/app/utils/menuLinks.ts and client.ts, ../vue-faq-layer/app/utils/client.ts, ../vue-journal-layer/tests/client.test.ts, .planning/phases/17-blog-companions/17-VALIDATION.md, every 17-0N-SUMMARY.md (task IDs, smoke names, NOT RUN suites)</read_first>
|
||||
<behavior>
|
||||
- menuLinks: every allowlisted scheme and relative form gives a link; javascript:, JAVASCRIPT:, data:, vbscript: and protocol-relative to a foreign host give text without href; _blank adds rel noopener noreferrer; active matching ignores query, hash and a trailing slash except on /; trail marks ancestors only; an absolute URL is active only on the same host
|
||||
- clients (both layers): base plus encoded code or slug plus locale; 404 resolves null; other errors reject; the server base is used on the server and the public base in the browser
|
||||
</behavior>
|
||||
<action>Per D-18 and the UI-SPEC layer backstop: complete the layer node tests named in the behaviour block, commit them in each layer (no push, no new tag). Append the layer test files to the layers stage. Run `bash scripts/check-phase17.sh --all` (with Docker when available) and record its full output. Fill 17-VALIDATION.md: every row of the Per-Task Verification Map gets the task ID (17-0N-TT), the exact automated command from the gate, File Exists ✅ and the status from the run (✅ green, or NOT RUN for Postgres rows without Docker); add rows for the API, import, layer and host suites; tick the Wave 0 items now delivered (check-phase17.sh, the PHP golden generator, go.work per plugin, the test harnesses); keep the two manual-only rows with the plan 05 human-check reference; set status validated, wave_0_complete true and nyquist_compliant true; Approval with the date. Commit the gate and the map in summercms.go (tests and planning docs in separate commits).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test && bash scripts/check-phase17.sh --all 2>&1 | tail -n 1 | grep -qx 'Phase 17 gate passed' && grep -q '^nyquist_compliant: true' .planning/phases/17-blog-companions/17-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: a layer test fails, the gate's last line is not "Phase 17 gate passed", or the validation map is not marked nyquist_compliant true.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "17-0[1-6]-" .planning/phases/17-blog-companions/17-VALIDATION.md` is at least 12 (every map row carries a task ID).
|
||||
- `grep -n "wave_0_complete: true" .planning/phases/17-blog-companions/17-VALIDATION.md` prints one line.
|
||||
- The SUMMARY carries the full --all output, including NOT RUN postgres when no Docker daemon answered.
|
||||
</acceptance_criteria>
|
||||
<done>The phase has one green gate, a filled Nyquist map and tests for everything it built.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test process → Docker | throwaway Postgres containers |
|
||||
| gate script → sibling repositories | reads and runs tests in local checkouts |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-39 | Repudiation (false green) | scripts/check-phase17.sh | high | mitigate | fail-closed detectors for zero-match, skipped and missing required tests; --self-test plants failing output; NOT RUN printed loudly; PHASE17_REQUIRE_POSTGRES for strict runs |
|
||||
| T-17-40 | Tampering | test fixtures with unsafe HTML and URLs | low | accept | fixtures exist only inside test processes and testcontainers databases; never rendered outside tests |
|
||||
| T-17-41 | Information Disclosure | test databases and secrets | low | mitigate | tests use testcontainers or GRZYBY_TEST_ADMIN_DSN throwaway databases and the existing test-only secrets; nothing touches shared databases, servers or origins |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new packages; tests run on the lockfiles committed in plans 02 and 05 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: gate self-test and the go, plugins and layers stages.
|
||||
- Task 2: framework Docker-free suites, admin specs, gate go and admin stages; framework Postgres suites on a Docker host.
|
||||
- Task 3: plugins stage and the sitemap parity tests; postgres stage on a Docker host.
|
||||
- Task 4: layer tests, the full gate ending in "Phase 17 gate passed", the validation map marked compliant.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-5: the new code has unit tests, delivered in the phase's last plan.
|
||||
- `bash scripts/check-phase17.sh --all` passes; 17-VALIDATION.md is filled and nyquist_compliant true.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-06-SUMMARY.md` when done, with the tests per repository, the unpushed commit ranges, any fix(17-06) commits (each one that changes production code flagged "needs follow-up release" with its repository and release route, because the plan 02 tag and the plan 05 UAT deploy predate it), the full gate output and the Postgres suites run or NOT RUN.
|
||||
</output>
|
||||
504
.planning/phases/17-blog-companions/17-PATTERNS.md
Normal file
504
.planning/phases/17-blog-companions/17-PATTERNS.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Phase 17: Blog companions - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-11
|
||||
**Files analyzed:** ~60 new or modified files across 8 repos (grouped by the confirmed 6-plan split)
|
||||
**Analogs found:** 52 / ~60 (the rest have no analog and follow RESEARCH.md patterns)
|
||||
|
||||
Paths are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. Every analog named here is git-tracked source (checked with `git ls-files`). The new repos (`sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin`, `vue-menu-layer`, `vue-faq-layer`) copy their structure from `sm-journal-plugin` and `vue-journal-layer`.
|
||||
|
||||
## File Classification
|
||||
|
||||
### Plan 01: framework Go (summercms.go)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `summercms.go/modules/pact/capabilities.go` (+`HasMenuItemTypes`, `MenuItemType`, `MenuItemResolve`, `ResolvedMenuItem`, `MenuItemAlternate`, `DependentOptionsProvider`, optional `Option.Depth`) | contract | n/a | same file: `HasFormFieldTypes` (l.491), `DropdownOptionsProvider` (l.574) | exact |
|
||||
| `summercms.go/modules/<registry>/` (new beach module, e.g. `signpost`): `registry.go`, `README.md` | service/registry | boot-time collect + request-response lookup | `modules/phrasebook/translator.go` `Activate` (l.84-120) | exact |
|
||||
| `summercms.go/modules/party/registry.go` (call `<registry>.Activate`) | config/wiring | boot | same file, l.122-133 (`phrasebook.Activate`, `postcard.Activate`) | exact |
|
||||
| `summercms.go/modules/<sanitizer>/` (new, e.g. `seaglass`): bluemonday policy + `SanitizeReport` | utility | transform | `sm-journal-plugin/classes/format_html.go` (`SanitizeHTMLReport` l.164, `CheckHTML` l.335, `sanitizeHTML` l.348) | exact (port) |
|
||||
| `summercms.go/modules/cabana/form_schema.go` (add `repeater`, `richeditor` to `formFieldTypes`; keys `dependsOn`, `form`, `prompt`, `maxItems`, `minItems`, `titleFrom`) | config/schema | transform | same file l.24-40 | exact |
|
||||
| `summercms.go/modules/cabana/field_repeater.go` | service (field type) | CRUD decode/project | `modules/cabana/field_markdown.go`, `field_ml.go`; nested-value rule in `crud.go` l.159-175, l.1272 | role-match |
|
||||
| `summercms.go/modules/cabana/field_richeditor.go` | service (field type) | transform (save gate) | `sm-journal-plugin/classes/blog_editor.go` (`decodeBlogHTML` l.154, `removalNotices` l.171) + `cabana/field_markdown.go` | exact |
|
||||
| `summercms.go/modules/cabana/field_dependent.go` (options route + save recheck) | controller | request-response | `cabana/field_markdown.go` `markdownPreview` (l.71-100) for route shape | role-match |
|
||||
| `summercms.go/modules/cabana/reorder.go` (config_reorder.yaml, GET/POST) | controller | CRUD (tx write) | `cabana/field_file.go` file reorder ("exactly the visible files") + `crud.go` | role-match |
|
||||
| `summercms.go/modules/cabana/admin_openapi.go` (+swag ops) | config/docs | n/a | same file l.394-409 (`AdminMarkdownPreview`) | exact |
|
||||
| `summercms.go/modules/cabana/README.md`, `docs/…`, `.planning/notes/<date>-framework-html-sanitizer-bluemonday.md` | docs | n/a | `.planning/notes/journal-html-sanitizing-bluemonday.md` | exact |
|
||||
|
||||
### Plan 02: admin SPA + framework tag
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `summercms.go/admin/src/components/form/fields/RichEditorField.vue` | component | event-driven | `sm-journal-plugin` TipTap editor (vite build, `package.json` l.14-18) + `admin/src/components/form/fields/MarkdownField.vue` | role-match |
|
||||
| `summercms.go/admin/src/components/form/fields/RepeaterField.vue` | component | CRUD (list of rows) | `admin/src/components/form/fields/MLField.vue` (wrapper rendering inner fields) | partial |
|
||||
| `summercms.go/admin/src/components/form/fields/DropdownField.vue` (dependsOn fetch) | component | request-response | same file | exact |
|
||||
| `summercms.go/admin/src/views/ReorderView.vue` | view | CRUD | `admin/src/components/form/fields/FileuploadField.vue` native DnD (l.904-910) + `views/ListView.vue` | partial |
|
||||
| `summercms.go/admin/src/components/form/registry.ts` | config | n/a | same file l.64-82, `valueless` set l.96 | exact |
|
||||
| `summercms.go/admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist` | generated | n/a | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
|
||||
|
||||
### Plan 03: Journal types + sm-sitemap-plugin
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `sm-journal-plugin/menu_item_types.go` | wiring | n/a | `sm-journal-plugin/field_types.go` (whole file) | exact |
|
||||
| `sm-journal-plugin/classes/menu_item_types.go` | service | request-response (resolve) | `sm-journal-plugin/classes/blog_editor.go` `BlogEditorTypes(app func() *backpack.App)` l.80; tree from `controllers/api/posts.go` `serializeCategoryTree` | exact |
|
||||
| `sm-sitemap-plugin/plugin.go`, `go.mod`, `go.work`, `README.md` | plugin wiring | n/a | `sm-journal-plugin/plugin.go` l.1-120 | exact |
|
||||
| `sm-sitemap-plugin/routes.go` (`/sitemap.xml`, `/sitemap.xsl`) | route | request-response | `sm-journal-plugin/routes.go` | exact |
|
||||
| `sm-sitemap-plugin/classes/{generator,serialize,urlto,tiers}.go` | utility | transform | none (hand-rolled per RESEARCH Pattern 4/4b) | none |
|
||||
| `sm-sitemap-plugin/models/{definition,settings}.go` + `fields.yaml` | model | CRUD | `sm-journal-plugin/models/tag.go`, `models/settings.go` | exact |
|
||||
| `sm-sitemap-plugin/controllers/definitions.go` + `config_form.yaml` | controller | CRUD | `sm-journal-plugin/controllers/tags.go` + `controllers/tags/config_form.yaml` | exact |
|
||||
| `sm-sitemap-plugin/admin.go` (Settings link, permissions) | config | n/a | `sm-journal-plugin/admin.go` l.38-50, `admin_permissions.go` | exact |
|
||||
| `sm-sitemap-plugin/updates/*.go` | migration | n/a | `sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go` | exact |
|
||||
| `sm-sitemap-plugin/console/import.go` (`sitemap:import`) | command | file-I/O | `sm-journal-plugin/console/import_posts.go` l.39-64 | exact |
|
||||
| `sm-sitemap-plugin/events.go` (BeforeAddItem/AddItem) | event | event-driven | `summercms.go/modules/festival/bus.go` `Handleable` l.18, `UntilHandled` l.100 | exact |
|
||||
| `sm-sitemap-plugin/assets/sitemap.xsl` | asset | static | copy byte-identical from `/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl`; embed like `langFS` in journal plugin.go | exact |
|
||||
|
||||
### Plan 04: sm-menu-plugin + sm-faq-plugin
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `sm-{menu,faq}-plugin/plugin.go`, `routes.go`, `admin*.go`, `go.mod`, `README.md`, `lang/{en,pl}` | plugin wiring | n/a | `sm-journal-plugin/plugin.go`, `routes.go`, `admin_navigation.go`, `admin_permissions.go` | exact |
|
||||
| `sm-menu-plugin/models/{menu,menu_entry}.go` | model | CRUD | `sm-journal-plugin/models/category.go` (parent_id, nest_*) + `models/tag.go` | exact |
|
||||
| `sm-faq-plugin/models/faq.go` (Jsonable content, Locale relation) | model | CRUD | `sm-journal-plugin/models/tag.go`; `summercms.go/modules/lagoon/jsonable.go` l.21; `sm-translate-plugin/models/locale.go` | role-match |
|
||||
| `sm-{menu,faq}-plugin/controllers/*.go` + `config_{list,form,reorder}.yaml` | controller | CRUD | `sm-journal-plugin/controllers/tags.go` + `controllers/tags/*.yaml` | exact |
|
||||
| `sm-menu-plugin/controllers/api/menus.go`, `sm-faq-plugin/controllers/api/faqs.go` | controller | request-response | `sm-journal-plugin/controllers/api/posts.go` `Categories` l.158-173 | exact |
|
||||
| `sm-{menu,faq}-plugin/updates/*.go` | migration | n/a | journal `updates/2026100600*_create_*.go` | exact |
|
||||
| `sm-{menu,faq}-plugin/console/import.go` | command | file-I/O | `sm-journal-plugin/console/import_posts.go`; DB lookup from `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go` l.50-69 | exact |
|
||||
|
||||
### Plan 05: site integration
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `vue-{menu,faq}-layer/nuxt.config.ts` | config | n/a | `vue-journal-layer/nuxt.config.ts` l.1-40 (minus modules list) | exact |
|
||||
| `vue-{menu,faq}-layer/package.json` | config | n/a | `vue-journal-layer/package.json` (drop i18n/seo/fonts deps) | exact |
|
||||
| `vue-{menu,faq}-layer/app/utils/{client,types}.ts` | utility | request-response | `vue-journal-layer/app/utils/client.ts` l.1-30, `types.ts` | exact |
|
||||
| `vue-{menu,faq}-layer/app/composables/use{Menu,Faq}Api.ts` | hook | request-response | `vue-journal-layer/app/composables/useJournalApi.ts` | exact |
|
||||
| `vue-{menu,faq}-layer/app/components/{Menu,Faq}/*.vue`, `app/assets/css/*-tokens.css` | component | render | `vue-journal-layer/app/components/Journal/PostList.vue`, `app/assets/css/journal-tokens.css` | role-match |
|
||||
| `vue-{menu,faq}-layer/tests/*.test.ts`, `testing/*` | test | n/a | `vue-journal-layer/tests/client.test.ts`, `testing/smoke-ssr.mjs`, `testing/fixture-api.mjs` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/{.gitmodules,go.work,go.mod,summer.yaml}` | config | n/a | existing journal entries in same files | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/config/http.yaml` (`cors.paths`) | config | n/a | same file l.2-4 | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/nginx/*.conf` (4 locations) | config | n/a | same file `location ^~ /_journal/` (l.132-) | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/scripts/php-export/{menus,faqs,sitemap}.sql`, `scripts/migrate.sh` | script | batch | `scripts/php-export/categories.sql` header + `JSON_OBJECT` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/nuxt.config.ts`, removed menu config/static FAQ | config | n/a | same file (`extends`) | exact |
|
||||
|
||||
### Plan 06: unit tests + check-phase17.sh
|
||||
|
||||
| File | Role | Analog |
|
||||
|---|---|---|
|
||||
| `summercms.go/scripts/check-phase17.sh` | gate script | `summercms.go/scripts/check-phase16.3.sh` (stages go/admin/docs/postgres, `--self-test`, `--all`, env overrides) |
|
||||
| framework tests (`modules/<registry>/*_test.go`, `cabana/{repeater,richeditor,dependent,reorder}_test.go`) | test | `cabana/markdown_preview_route_test.go`, `cabana/field_type_test.go`, `cabana/example_field_type_test.go` |
|
||||
| plugin tests incl. PHP goldens (`classes/testdata/*.golden`) | test | `sm-journal-plugin/classes/format_html_test.go`, `updates/migrations_test.go`, `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go`, `testmain_test.go` |
|
||||
| admin vitest specs | test | existing `admin/src/**/*.spec.ts` next to `MarkdownField.vue` |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Capability interface (pact) — `modules/pact/capabilities.go`
|
||||
|
||||
**Analog:** same file, l.491-493 and l.567-576:
|
||||
```go
|
||||
type HasFormFieldTypes interface {
|
||||
FormFieldTypes() []FormFieldType
|
||||
}
|
||||
|
||||
// Option is one dropdown choice. Label may be a phrase key until request time.
|
||||
type Option struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// DropdownOptionsProvider serves method-backed dropdown options.
|
||||
type DropdownOptionsProvider interface {
|
||||
DropdownOptions(field string) []Option
|
||||
}
|
||||
```
|
||||
Add `HasMenuItemTypes { MenuItemTypes() []MenuItemType }` beside it; add a ctx-taking `DependentOptionsProvider` (the existing one has no ctx/DB). Doc comment on every exported identifier; docs checker requires README/docs mentions to exist.
|
||||
|
||||
### Registry module — `modules/<registry>/registry.go`
|
||||
|
||||
**Analog:** `modules/phrasebook/translator.go` l.84-105:
|
||||
```go
|
||||
// Activate loads HasLang catalogs in plugin order, then publishes one
|
||||
// Translator on app before Boot.
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("phrasebook: app is nil")
|
||||
}
|
||||
...
|
||||
for _, p := range plugins {
|
||||
hl, ok := any(p).(pact.HasLang)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := cat.Load(p.ID(), hl.LangFS()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
Same shape: iterate plugins, type-assert `pact.HasMenuItemTypes`, fail loud on duplicate `Key` (error naming both plugin IDs), read host pattern overrides from `app.Config`, `app.Publish(reg)`. Consumers: `app.Lookup[*registry.Registry]()`.
|
||||
|
||||
**Wiring:** `modules/party/registry.go` l.128-133:
|
||||
```go
|
||||
if err := phrasebook.Activate(app, ordered); err != nil {
|
||||
return nil, fmt.Errorf("party: lang: %w", err)
|
||||
}
|
||||
if err := postcard.Activate(app, ordered); err != nil {
|
||||
return nil, fmt.Errorf("party: mail: %w", err)
|
||||
}
|
||||
```
|
||||
Insert `<registry>.Activate` after these, before the Boot loop, with `fmt.Errorf("party: menu item types: %w", err)`.
|
||||
|
||||
### Sanitizer module
|
||||
|
||||
**Analog:** `sm-journal-plugin/classes/format_html.go` l.164-174, 335-350:
|
||||
```go
|
||||
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
|
||||
clean, changed := sanitizeHTML(doc)
|
||||
if !changed || clean == doc {
|
||||
return doc, HTMLRemovals{}
|
||||
}
|
||||
report := removalReport(doc, clean)
|
||||
...
|
||||
}
|
||||
func CheckHTML(doc string) error {
|
||||
if _, changed := sanitizeHTML(doc); changed {
|
||||
return errors.New("journal: HTML contains elements, attributes or URLs that are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// sanitizeHTML: policy output compared by sameTokens (golang.org/x/net/html), so re-escaping alone is not a change.
|
||||
```
|
||||
Port the policy + token comparison into the framework (framework must not import the plugin). Journal keeps its own copy (no breaking change).
|
||||
|
||||
### cabana field types
|
||||
|
||||
**Schema registration** `modules/cabana/form_schema.go` l.24-30:
|
||||
```go
|
||||
formFieldTypes = map[string]struct{}{
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
...
|
||||
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||
}
|
||||
```
|
||||
Add `"repeater"`, `"richeditor"`; add new keys to `formFieldKeys` (l.37-) and fail-loud checks (unknown nested type, missing `dependsOn` sibling, `groups`/`style` refused).
|
||||
|
||||
**Nested-value bypass** `modules/cabana/crud.go` l.1272:
|
||||
```go
|
||||
func nestedValue(val any) bool {
|
||||
switch val.(type) {
|
||||
case map[string]any, []any:
|
||||
return true
|
||||
...
|
||||
```
|
||||
Used at l.173 in `ProjectWritableFields` and l.1135. Add a schema-driven exemption for `repeater` fields only; do not loosen `nestedValue`.
|
||||
|
||||
**richeditor save gate:** copy `decodeBlogHTML` (l.154) + `removalNotices` (l.171) from `sm-journal-plugin/classes/blog_editor.go`, calling the new framework sanitizer; notices to `meta.notices`.
|
||||
|
||||
**New admin route shape** (dependent options, reorder) — `modules/cabana/field_markdown.go` l.71-100:
|
||||
```go
|
||||
func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
if !tokenRouteGate(principal, w) {
|
||||
return
|
||||
}
|
||||
var body AdminMarkdownPreviewRequest
|
||||
if err := s.decodeStrictBody(w, r, &body); err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
...
|
||||
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed",
|
||||
map[string]any{"markdown": []string{msgMarkdownPreviewRefused}})
|
||||
...
|
||||
WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil)
|
||||
}
|
||||
```
|
||||
Plus a swag block like `admin_openapi.go` l.394-409 and a conformance inventory case (`openapi_conformance_test.go`).
|
||||
|
||||
### Admin SPA — `admin/src/components/form/registry.ts`
|
||||
|
||||
l.64-82 renderer map; l.96 `valueless` set:
|
||||
```ts
|
||||
const renderers = new Map<string, Component>([
|
||||
['text', TextField],
|
||||
...
|
||||
['markdown', MarkdownField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
])
|
||||
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])
|
||||
```
|
||||
Add `['repeater', RepeaterField]`, `['richeditor', RichEditorField]`. Repeater is group-labelled (add to `groupLabelledTypes` l.101). TipTap pins `3.31.4` exact (`--save-exact`), human-verify checkpoint before `npm install`.
|
||||
|
||||
### Plugin wiring — every new `sm-*-plugin/plugin.go`
|
||||
|
||||
**Analog:** `sm-journal-plugin/plugin.go` l.1-56:
|
||||
```go
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.journal" }
|
||||
func (p *Plugin) Requires() []string { return []string{"golem15.translate"} }
|
||||
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
```
|
||||
Buckets (l.97-): copy `"journal-public-api"` entry → `"menu-public-api"` / `"faq-public-api"` (Max 120, Decay time.Minute, key `"<x>-public|" + surf.ClientIP(r, trusted)`). IDs: `golem15.sitemap`, `golem15.menu`, `golem15.faq`; FAQ `Requires()` translate; sitemap/menu do not hard-require journal (they read the registry).
|
||||
|
||||
**Lazy app for type closures** — `sm-journal-plugin/field_types.go`:
|
||||
```go
|
||||
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return classes.BlogEditorTypes(func() *backpack.App {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.app
|
||||
})
|
||||
}
|
||||
var _ pact.HasFormFieldTypes = (*Plugin)(nil)
|
||||
```
|
||||
→ `menu_item_types.go`: `func (p *Plugin) MenuItemTypes() []pact.MenuItemType { return classes.MenuItemTypes(func() *backpack.App {...}) }`.
|
||||
|
||||
### Public routes — `routes.go`
|
||||
|
||||
**Analog:** `sm-journal-plugin/routes.go`:
|
||||
```go
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
|
||||
g.Get("/categories", apicontrollers.Categories(p.app))
|
||||
g.Get("/posts/{slug}", apicontrollers.Show(p.app))
|
||||
g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Never attach the cabana `backend` middleware to public groups. Sitemap mounts `/sitemap.xml`, `/sitemap.xsl` at root (no throttle group needed, planner's call).
|
||||
|
||||
### Public API handler
|
||||
|
||||
**Analog:** `sm-journal-plugin/controllers/api/posts.go` l.158-173:
|
||||
```go
|
||||
func Categories(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
|
||||
}
|
||||
}
|
||||
```
|
||||
Menu/FAQ: same, 404 JSON on unknown/inactive code or slug; FAQ sanitizes `description`/`answer` on output.
|
||||
|
||||
### Admin controller
|
||||
|
||||
**Analog:** `sm-journal-plugin/controllers/tags.go` (whole file):
|
||||
```go
|
||||
const (
|
||||
TagsControllerID = "golem15.journal.tags"
|
||||
PermissionAccessTags = "golem15.journal.access_tags"
|
||||
)
|
||||
type tagsAdminController struct{ app func() *backpack.App }
|
||||
func (tagsAdminController) ID() string { return TagsControllerID }
|
||||
func (tagsAdminController) ModelName() string { return `Golem15\Journal\Models\Tag` }
|
||||
func (tagsAdminController) ConfigDir() string { return "controllers/tags" }
|
||||
func (tagsAdminController) RequiredPermissions() []string { return []string{PermissionAccessTags} }
|
||||
func (tagsAdminController) NewRecord() any { return &models.Tag{} }
|
||||
var (
|
||||
_ pact.AdminController = tagsAdminController{}
|
||||
_ pact.AdminPermissioned = tagsAdminController{}
|
||||
_ pact.AdminRecordSource = tagsAdminController{}
|
||||
)
|
||||
```
|
||||
YAML: copy `controllers/tags/config_form.yaml` / `config_list.yaml` (PHP `modelClass`, `~/plugins/golem15/<x>/models/...` form paths, messages keys). Menu entries add `ListExtendQuery` scoped to `menu_id` and `config_reorder.yaml`; menu-entry and definition controllers implement `DependentOptions` calling the registry's `ReferenceOptions`. PHP permission codes: `golem15.sitemap.access_definitions`, `golem15.menu.manage_menu`, `golem15.faq.faqs`.
|
||||
|
||||
**Settings link** — `sm-journal-plugin/admin.go` l.38-50:
|
||||
```go
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{
|
||||
Code: "journal",
|
||||
Label: "golem15.journal::lang.journal.menu_label",
|
||||
Category: "golem15.journal::lang.journal.menu_label",
|
||||
Icon: "icon-pencil",
|
||||
Permissions: []string{"golem15.journal.manage_settings"},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
```
|
||||
Sitemap: one `HasSettings` form (settings) + one `SettingsItem{Controller: ...}` linking the definitions controller (singleton form).
|
||||
|
||||
### Model
|
||||
|
||||
**Analog:** `sm-journal-plugin/models/tag.go`:
|
||||
```go
|
||||
type Tag struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description *string `gorm:"column:description"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
func (Tag) TableName() string { return "golem15_journal_tags" }
|
||||
func (Tag) MorphName() string { return `Golem15\Journal\Models\Tag` }
|
||||
func (Tag) Fillable() []string { return []string{"name", "slug", "description"} }
|
||||
func (Tag) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func init() { Register(Tag{}) }
|
||||
```
|
||||
JSON columns: `lagoon.Jsonable[T]` (`modules/lagoon/jsonable.go` l.21) with tolerant `UnmarshalJSON` for PHP-shaped data (Pitfall 7). Menu entry `BeforeSave` hook sets `nest_*` = 0, `sort_order = max+1`, inherits `menu_id`.
|
||||
|
||||
### Migration
|
||||
|
||||
**Analog:** `sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go`:
|
||||
```go
|
||||
var createPosts = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610060001_create_golem15_journal_posts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE golem15_journal_posts ( id SERIAL PRIMARY KEY, ... )`,
|
||||
`CREATE UNIQUE INDEX ... ON ... (slug)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_posts`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
func init() { Register(createPosts...) }
|
||||
```
|
||||
Plus `updates/registry.go` (`Register`/`All`). Tables/columns from RESEARCH "PHP port inventory" verbatim.
|
||||
|
||||
### Console import
|
||||
|
||||
**Analog:** `sm-journal-plugin/console/import_posts.go` l.39-64:
|
||||
```go
|
||||
func ImportPosts(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "journal:import-posts",
|
||||
Description: "Import journal posts with translations from JSON files.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Import directory (default: journal-export)"},
|
||||
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
||||
{Name: "dry-run", Description: "...", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... },
|
||||
}
|
||||
}
|
||||
```
|
||||
DB access via lookup-or-open — `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go` l.50-69:
|
||||
```go
|
||||
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
|
||||
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
|
||||
return db, func() {}, nil
|
||||
}
|
||||
if lagoon.DSN(app.Config) == "" { return nil, nil, fmt.Errorf("...") }
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||
...
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil { _ = sqlDB.Close(); return nil, nil, err }
|
||||
return gdb, func() { _ = sqlDB.Close() }, nil
|
||||
}
|
||||
```
|
||||
Imports: `sitemap:import {file} --theme --append` (PHP JSON 1:1), plus JSONL importers for menu/FAQ (remap `locale_id` by code, `setval` sequences, refuse non-empty without `--force`).
|
||||
|
||||
### Typed sitemap events
|
||||
|
||||
**Analog:** `modules/festival/bus.go` l.18-20, 100-110:
|
||||
```go
|
||||
type Handleable interface {
|
||||
IsHandled() bool
|
||||
}
|
||||
func (b *Bus) UntilHandled[T any](ctx context.Context, event T) (bool, error) { ... }
|
||||
```
|
||||
`BeforeAddItem` struct implements `IsHandled()` → skip; `AddItem` via `Fire`.
|
||||
|
||||
### Nuxt layer
|
||||
|
||||
**Analog:** `vue-journal-layer/nuxt.config.ts` l.1-40:
|
||||
```ts
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
runtimeConfig: {
|
||||
journal: { apiBase: 'http://127.0.0.1:8096/_journal/api/v1' },
|
||||
public: { journal: { apiBase: '/_journal/api/v1' } },
|
||||
},
|
||||
})
|
||||
```
|
||||
Drop `modules: [...]` (layers stay module-free) and `pages: true` unless pages are added. Client: `vue-journal-layer/app/utils/client.ts` l.1-30 (injected `Fetcher` type so `node --test` can stub). `package.json`: same scripts (`"test": "node --test --test-reporter=tap tests/*.test.ts"`), engines, peer `nuxt 4.4.8`, no runtime deps.
|
||||
|
||||
### App integration
|
||||
|
||||
- `summer.yaml`: add `- id: golem15.sitemap / module: git.golem15.com/golem15/sm-sitemap-plugin` (same for menu, faq), mirroring journal entry.
|
||||
- `go.work` `use (...)`: add `./plugins/golem15/{sitemap,menu,faq}`.
|
||||
- `.gitmodules`: copy the journal block (`url = git@git.golem15.com:golem15/sm-<x>-plugin.git`, `branch = main`).
|
||||
- `config/http.yaml` `cors.paths`: add `_menu/api/*`, `_faq/api/*` after `_journal/api/*`.
|
||||
- nginx: copy `location ^~ /_journal/ { proxy_pass http://127.0.0.1:8096; ... }` for `/_menu/`, `/_faq/`, and `location = /sitemap.xml`, `location = /sitemap.xsl` (before `location /`).
|
||||
- `scripts/php-export/*.sql`: copy the `categories.sql` header (read-only, `mysql -N -B --raw <db> < x.sql > x.jsonl`, `SELECT JSON_OBJECT(...)`) and add README rows; order in `scripts/migrate.sh`.
|
||||
- Commit plugin repos first, then submodule pointer bumps as separate app commits.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Fail-loud boot / schema validation
|
||||
**Source:** `modules/phrasebook/translator.go` `Activate`, `modules/cabana/form_schema.go` key sets. **Apply to:** registry, repeater, dependsOn, reorder config. Errors prefixed with the module name (`"<module>: ..."`).
|
||||
|
||||
### Public API errors and JSON
|
||||
**Source:** `sm-journal-plugin/controllers/api/posts.go` (`writeAPIError`, `wire.WriteJSON(w, 200, map[string]any{"data": ...})`). **Apply to:** menu and FAQ APIs.
|
||||
|
||||
### Rate limiting
|
||||
**Source:** `sm-journal-plugin/plugin.go` `Buckets()` + `surf.Use("throttle:<bucket>")` in routes. **Apply to:** `/_menu`, `/_faq`.
|
||||
|
||||
### HTML sanitizing
|
||||
**Source:** `sm-journal-plugin/classes/format_html.go` → new framework sanitizer. **Apply to:** cabana `richeditor` save, repeater nested richeditor, FAQ API output, optional FAQ import report.
|
||||
|
||||
### Admin write route
|
||||
**Source:** `cabana/field_markdown.go` `markdownPreview` (bouncer principal, `tokenRouteGate`, `decodeStrictBody`, `WriteErrorDetails` 422). **Apply to:** dependent-options and reorder routes; plus swag annotation + OpenAPI regen + conformance case.
|
||||
|
||||
### Docs-in-same-commit
|
||||
Any exported API/config/dependency change updates module README + `docs/`; run `summer docs:build --check`. New module = beach name + root README table row. No consumer names (grzyby) in framework/plugin READMEs.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `sm-sitemap-plugin/classes/serialize.go` | utility | transform | libxml-exact XML writer; follow RESEARCH Pattern 4 + PHP goldens |
|
||||
| `sm-sitemap-plugin/classes/urlto.go` | utility | transform | Laravel/Winter `Url::to` + `rawurlencode` port; RESEARCH Pattern 4b |
|
||||
| `sm-sitemap-plugin/classes/tiers.go` | utility | transform | Priority tiers from config; port from fonoteka `Definition.php` |
|
||||
| `summercms.go/modules/cabana/reorder.go` (tree validation) | controller | CRUD | No tree reorder exists; nearest is file reorder in `field_file.go` (set-equality rule only) |
|
||||
| `summercms.go/admin/src/views/ReorderView.vue` | view | CRUD | No tree view; reuse native DnD from `FileuploadField.vue` l.904-910 |
|
||||
| `summercms.go/admin/src/components/form/fields/RepeaterField.vue` | component | CRUD | No nested-row field; `MLField.vue` is the closest wrapper |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{pact,party,phrasebook,festival,cabana,lagoon}`, `summercms.go/admin/src`, `summercms.go/scripts`, `sm-journal-plugin/**`, `vue-journal-layer/**`, `sm-grzybyfunkcjonalne-app/{plugins/grzyby,scripts,config,deploy,summer.yaml,go.work,.gitmodules}`
|
||||
**Files scanned:** ~45
|
||||
**Pattern extraction date:** 2026-10-11
|
||||
**Note (RESEARCH conflict):** do not switch Journal category content to `richeditor`; it is already `blogeditor` (Phase 16.2).
|
||||
798
.planning/phases/17-blog-companions/17-RESEARCH.md
Normal file
798
.planning/phases/17-blog-companions/17-RESEARCH.md
Normal file
@@ -0,0 +1,798 @@
|
||||
# Phase 17: Blog companions - Research
|
||||
|
||||
**Researched:** 2026-10-11
|
||||
**Domain:** Go plugin ports (sitemap, menu, FAQ) on SummerCMS, a framework menu-item type registry, cabana field/list features (repeater, dependent dropdown, richeditor, tree reorder), two Nuxt layers, one site migration
|
||||
**Confidence:** HIGH for the port sources, the framework seams and the sitemap byte format (verified against live PHP output and a local PHP 8.5 probe); MEDIUM for the new framework API shapes (design proposals); LOW only where flagged in the Assumptions Log
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Menu-item type registry (framework)
|
||||
- **D-01:** The successor of `pages.menuitem.*` is an **optional capability interface**. It follows the `pact.Has*` pattern and could be named something like `HasMenuItemTypes`. A plugin returns typed `MenuItemType` values that carry their own info (label, reference options, nesting support) and a `Resolve` method. A framework registry collects them at boot. There are no stringly festival events for list, info or resolve. — **Reversibility:** costly — Journal, menu, sitemap and every later content plugin (pages, blog) implement this contract.
|
||||
- **D-02:** **Headless URLs come from patterns** such as `/blog/:post` and `/:category`, resolved in three layers. Each type declares a default pattern. The host config can override the pattern per type, and that override is how grzyby keeps its P16 D-15 URLs. A menu item or sitemap item can override it once more. — **Reversibility:** costly — the config keys and item fields become a published plugin contract.
|
||||
- **D-03:** **Resolved items carry per-locale alternates.** `Resolve` returns title, URL, mtime and a locale→URL alternates map, built from translate-plugin slugs. Nested `items` are returned for types that expand, such as `all-journal-categories`. Sitemap hreflang and localized menus both read these alternates. A single-locale site gets a single entry. — **Reversibility:** costly — the shape of the resolve result.
|
||||
- **D-04:** **Journal registers its 5 PHP types under the PHP keys:** `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. The source is `Plugin.php` `extendWinterPagesPlugin` in the frozen Journal tree. This is an additive change to `sm-journal-plugin` and leaves its existing API untouched.
|
||||
- **D-05:** **Menu types are menu built-ins plus registry types.**
|
||||
- `url` (link) and `divider` stay menu built-ins.
|
||||
- Every content-backed type comes from the registry.
|
||||
- PHP `blog_categories` maps to Journal's `all-journal-categories`.
|
||||
- PHP `page` (Winter.Pages) is dropped.
|
||||
- The import or DB copy maps the stored type values to the new ones.
|
||||
- **Reversibility:** costly — stored type values in the menu tables.
|
||||
|
||||
#### Admin editing (framework first)
|
||||
- **D-06:** Close the cabana gaps **in `summercms.go` this phase**, following the P15 markdown-field pattern: generic features with fail-loud schema validation, the README and `docs/` updated, the admin OpenAPI and generated TS types refreshed, and `dist/` committed in the same change. — **Reversibility:** costly — new field types in the typed admin schema that later plugins will use.
|
||||
- **D-07:** **`repeater` field type.** It edits a JSON list stored in a jsonable column, with a nested `form.fields`. FAQ `content` (question/answer) uses it, and so do the sitemap Definition items (`data`).
|
||||
- **D-08:** **A dropdown with options that depend on a sibling field** (Winter's `dependsOn`). Its options come from a provider keyed on the value of the sibling `type` field and are served through the registry's type info. The sitemap repeater rows and the menu entry form use this same type/reference binding, so the registry has exactly one admin binding.
|
||||
- **D-09:** **Drag-and-drop tree reorder page.** It succeeds Winter's ReorderController and works on any `parent_id` + `sort_order` model, writing both columns. Menu entries use it, scoped to one menu. It replaces the PHP reorder screen and the "Children" partial.
|
||||
- **D-10:** **`richeditor` field type (HTML WYSIWYG)** in cabana and the SPA, which folds in the todo `cabana-richeditor-field-type.md`. The server sanitizes on save, the way Journal's `classes.CheckHTML` does, and the field must not bypass that gate. FAQ `description` and the repeater `answer` use it, matching PHP. Journal's category `content` switches from `textarea` to `richeditor`, as that todo asks. This decision replaces an earlier choice of the markdown field: the markdown preview drops raw HTML, so it can't show the imported PHP FAQ HTML. — **Reversibility:** costly — a new field type plus a JS editor dependency in the admin SPA.
|
||||
|
||||
#### Sitemap headless adaptation
|
||||
- **D-11:** **One definition per app.** The `theme` column stays so the schema is 1:1, filled with a fixed key. `/sitemap.xml` serves that single definition. The admin opens it directly, like a settings page, the same way PHP auto-created one definition per theme.
|
||||
- **D-12:** **Item types are URL plus registry types only.** Static Page, All Static Pages and the CMS Page field are dropped. In the user's words: "no static pages or cms pages, just URL items, we configure it like that in wintercms sitemap plugin on horoskopia etc". `updates_manifest_path` is ported 1:1 as an optional setting.
|
||||
- **D-13:** **Priority tiers stay, with configurable patterns.** `enable_priority_tiers` and the tier values port unchanged. The tier path patterns move from code to settings or host config. The PHP golem15.com patterns (`/uslugi`, `/realizacje`, legal pages and so on) become documented defaults or an example, not hardcoded behavior. A site configured the same way must produce the same output as PHP.
|
||||
- **D-14:** `sitemap.xml` and `sitemap.xsl` keep their PHP behavior:
|
||||
- the `frontend_url` rewrite of `<loc>`, of `xhtml:link href` and of the XSL href
|
||||
- `frontend_default_locale`
|
||||
- the 50,000 URL limit
|
||||
- XML escaping
|
||||
|
||||
The PHP `tests/security` cases (`XmlEscapingTest`, `MassAssignmentTest`) are a behavioral spec. The `golem15.sitemap.beforeAddItem` and `addItem` hooks become typed festival events (planner's call on shape).
|
||||
- **D-15:** **grzybyfunkcjonalne.pl switches to the plugin's sitemap.** nginx routes `/sitemap.xml` and `/sitemap.xsl` to Go, the Nuxt `@nuxtjs/seo` sitemap module is disabled in the app (or layer config), and editors manage the sitemap in `/gfadmin`. This reverses **P16 D-13** for the sitemap only; per-post meta, OG, canonical and robots stay on `@nuxtjs/seo`. — **Reversibility:** reversible — the nginx route and the Nuxt module toggle.
|
||||
|
||||
#### Public APIs + Nuxt consumption
|
||||
- **D-16:** **Menu API: `GET /_menu/api/v1/menus/{code}`**, mirroring `/_journal/api/v1`, anonymous. It returns the published, nested tree with registry types already resolved: URLs built, expanding types expanded into child items, plus title, target, css_class, icon and code. The Nuxt side only renders, and computes the active state from the current route. — **Reversibility:** one-way — a published API that the layers and later sites consume.
|
||||
- **D-17:** **FAQ API: `GET /_faq/api/v1/faqs/{slug}?locale=pl`**, active rows only. It returns name, slug, description and content `[{question, answer}]`, with HTML sanitized server-side. The `locale_id` → translate `Locale` relation stays 1:1 (one FAQ row per locale), and the locale defaults to the app default. — **Reversibility:** one-way — published API.
|
||||
- **D-18:** **Sibling Nuxt layers `vue-menu-layer` and `vue-faq-layer`**, new repos under `git.golem15.com/golem15/`. They use the same distribution as `vue-journal-layer` (P16 D-02 to D-04: pnpm git dependency pinned to a tag, a neutral design driven by CSS tokens, components the app can override). Each one mirrors one plugin, so a site without a blog can use them without Journal. Each ships a typed client and render components. — **Reversibility:** costly — repo and distribution layout.
|
||||
- **D-19:** **grzyby data is copied from the PHP DB.** Tables keep their PHP names and columns, so rows for the menu, the FAQ (slug `what-mushrooms-can-give-you`) and the sitemap definition are copied from the live Winter DB as a scripted, ordered migration step, the same way P16 handles its import. Menu and sitemap type values are mapped (D-05). After the copy, the P16 typed menu config and the static FAQ content are removed from `vue-grzybyfunkcjonalne-app`, which then uses the new layers.
|
||||
|
||||
### Claude's Discretion
|
||||
- Go package, module and identifier names for the registry, and whether it lives in `pact` (interface) plus an existing or new module (registry), provided D-01 to D-03 hold and the module README and docs are updated.
|
||||
- Config key names for the per-type URL patterns and for the priority tier patterns.
|
||||
- The exact JSON field names in the menu and FAQ responses, provided they cover the fields listed in D-16 and D-17.
|
||||
- Which JS WYSIWYG editor the SPA uses for `richeditor`. It must be stdlib-first-compatible and add no Go dependency; if an npm dependency is needed, name it in research.
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". The framework work (registry plus cabana features) likely lands first and gets a framework tag.
|
||||
- Whether the PHP sitemap `ImportCommand` JSON format is kept byte-compatible. Default: yes, port 1:1.
|
||||
- Admin permissions and navigation for the three plugins follow the PHP `Plugin.php` registrations.
|
||||
- Run the security-review agent: the phase adds public HTTP endpoints and an HTML-accepting field type.
|
||||
|
||||
### Folded Todos
|
||||
- `sitemap-plugin-port.md`: the Golem15.Sitemap 1:1 port, adapted per D-11 to D-15.
|
||||
- `menu-plugin-port.md`: the Golem15.Menu port with a public read API, per D-05, D-09 and D-16.
|
||||
- `faq-plugin-port.md`: the Golem15.Faq port with a read API by slug, per D-07, D-10 and D-17.
|
||||
- `cabana-richeditor-field-type.md`: the cabana `richeditor` field, plus switching the Journal category content to it (D-10).
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Pages plugin (Winter.Pages successor) and its Static Page menu and sitemap types: dropped here per D-12. A future pages port would register through the D-01 registry.
|
||||
- Sitemap definitions keyed per host, for multi-domain apps.
|
||||
- FAQ index listing endpoint (`GET /faqs`).
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- `journal-author-profile-fields.md`: belongs to the Journal author API work and quick task 261009-mys, not this phase.
|
||||
- `nest-framework-packages-under-modules.md`: keyword match only; Phase 10.2 covers it.
|
||||
- The remaining matches (READMEs, scaffold, wristband, lagoon, benchmark, translate JSON editor): keyword matches only.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are assigned (ROADMAP: "Requirements: TBD"). The success criteria and CONTEXT decisions are the source items.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | Three plugins port PHP schema, models, admin screens and import commands 1:1, each in its own repo with a README | §PHP port inventory, §Recommended Project Structure, §YAML adaptations (Pitfall 9), §Data copy |
|
||||
| SC-2 | Typed successor of `pages.menuitem.listTypes/getTypeInfo/resolveItem`; Journal registers through it; menu and sitemap consume it | §Pattern 1 (registry), §Pattern 2 (pattern layers), §Journal type semantics, Pitfalls 3-5 |
|
||||
| SC-3 | `sitemap.xml`/`sitemap.xsl` match PHP output incl. `frontend_url` rewrite and hreflang; menu and FAQ public read APIs | §Pattern 4 (byte-exact serializer), §Url::to port, Pitfalls 1-2, 6-8; §Pattern 8/9 (APIs) |
|
||||
| SC-4 | grzybyfunkcjonalne.pl reads menu and FAQ from the plugins | §Data copy (D-19), §Runtime State Inventory, §Nuxt layers |
|
||||
| SC-5 | Unit tests in the last plan | §Validation Architecture |
|
||||
| D-01..D-05 | Registry, patterns, alternates, Journal types, menu types | §Pattern 1-3 |
|
||||
| D-06..D-10 | cabana repeater, dependent dropdown, tree reorder, richeditor | §Pattern 5-7, §Conflict with Phase 16.2 |
|
||||
| D-11..D-15 | Sitemap headless adaptation | §Pattern 4, §Singleton definition |
|
||||
| D-16..D-19 | APIs, layers, data copy | §Pattern 8-10 |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global instructions:
|
||||
|
||||
- `go vet ./...` and `go test ./...` stay green at every commit; Docker tests skip under `-short`, and a `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()`.
|
||||
- One Go module `git.golem15.com/golem15/summercms`; a new framework module gets a beach name, a README in the standard structure (H1, summary sentence, import line, `**Name:**` mnemonic, Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing) and a row in the root `README.md` table.
|
||||
- Compiled plugins only: plugins implement `pact` capability interfaces; no runtime plugin loading.
|
||||
- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- **Standard library first. A new dependency needs a research doc or a phase/decision note naming it** (this file names bluemonday for the framework and TipTap for the admin SPA; a decision note is still recommended, see Pitfall 12).
|
||||
- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages **in the same commit**; every identifier in a README/docs page must exist (`summer docs:build --check`, `go test ./cmd/summer -run TestDocsTree`).
|
||||
- Admin changes: swag annotations → `scripts/check-admin-openapi.sh` → `admin/openapi/admin.json` → `admin/src/api/schema.d.ts`; rebuild and commit `modules/boardwalk/dist`, pass `scripts/check-admin-dist.sh` before tagging.
|
||||
- Framework code, READMEs and docs never name a consuming application (no "grzyby", no "golem15.com" in framework or plugin READMEs; use `acme`, `blog`).
|
||||
- Shared plugins live in `sm-<name>-plugin` repos, module path = repo path, mounted as submodules at `plugins/<vendor>/<name>`; commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo.
|
||||
- Core plugin contracts (routes, payloads, tables, migration IDs, config keys) stay non-breaking (Journal's existing API untouched, D-04).
|
||||
- Lean planning: few large plans; present plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** State via `msd_run query state.*` handlers. Post a short g15office comment when the phase advances.
|
||||
- No co-author tags in commits (user global rule overrides the attribution reminder). Planning docs and code in separate commits. Stage files by explicit path; several sessions work concurrently (another session may be in `modules/cabana`).
|
||||
- Don't push, tag or deploy without the user's go-ahead.
|
||||
- User memory: for protocol and security code, recommend the maintained Go library plus a decision note, not hand-rolled code.
|
||||
|
||||
## Summary
|
||||
|
||||
The phase has three layers of work, and the order matters. First, the framework (`summercms.go`) needs a menu-item type registry (a `pact` capability plus a small new module that collects types at boot) and five cabana capabilities the PHP admin screens rely on but cabana lacks today: `repeater`, a dependent dropdown (`dependsOn`), `richeditor`, a tree reorder page, and a way to open the single sitemap definition directly. None of these exist in cabana: `formFieldTypes` is a closed set without them, and there is no `tabs`, `trigger`, `dependsOn`, `cssClass`, `hint` or `section` support [VERIFIED: modules/cabana/form_schema.go:24-30]. Second, the three plugins are ported into new repos (`sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin`), and Journal registers its five types. Third, grzybyfunkcjonalne.pl mounts the plugins, copies its PHP rows, switches nginx and Nuxt, and adopts two new layers.
|
||||
|
||||
The highest-risk item is SC-3, "sitemap.xml matches the PHP output". It is achievable byte for byte, but not with `encoding/xml`. PHP builds the document with `DOMDocument::saveXML()` (libxml2), and Laravel/Winter `Url::to` normalizes URLs in specific ways: `/` becomes `https://host/`, `/blog/` becomes `https://host/blog`, and path segments are `rawurlencode`d, so `:` becomes `%3A`. Both behaviors were reproduced this session against the local PHP 8.5.10 and the vendored Winter router, and against live output from two production sites. The Go port should use a small hand-written serializer and a `Url::to` port, both pinned by golden fixtures generated from the local PHP. This is the one place where "don't hand-roll" does not apply: the format is fixed by the PHP output, not by XML correctness.
|
||||
|
||||
The second risk is a decision conflict. **Phase 16.2 already shipped** the Journal Blog Editor (`blogeditor`) for Journal category content, and it closed the `cabana-richeditor-field-type.md` todo (now in `todos/done/`). D-10's clause "Journal's category content switches from textarea to richeditor" is therefore obsolete, and executing it would regress the editor. The remaining D-10 scope is a core `richeditor` for FAQ `description` and the repeater `answer`. It needs a real HTML sanitizer in the framework: bluemonday v1.0.27, the same library and version Journal uses, plus a decision note, because cabana's markdown gate is the regex blocklist that Journal had to replace.
|
||||
|
||||
**Primary recommendation:** Build the framework first, in two plans (Go APIs, then the SPA plus release tag). Then do Journal types and sitemap together, because sitemap parity is only testable with Journal types. Then menu and FAQ, then the site integration (layers, data copy, nginx), and finally unit tests. Generate every PHP-parity golden (XML escaping, `Url::to`, tier priorities, alternates order) with the local `php` binary, and commit the goldens.
|
||||
|
||||
### Conflict with shipped work (Phase 16.2), needs user confirmation
|
||||
|
||||
| D-10 clause | Status in the repo | Recommendation |
|
||||
|---|---|---|
|
||||
| "Journal's category `content` switches from `textarea` to `richeditor`" | Already `type: blogeditor` [VERIFIED: ../sm-journal-plugin/models/category/fields.yaml:28 `type: blogeditor`]; todo closed: "Done in Phase 16.2: the Journal `blogeditor`/`mlblogeditor` plugin field type edits category and post HTML content." [VERIFIED: .planning/todos/done/cabana-richeditor-field-type.md] | **Drop this clause.** Leave Journal category content on `blogeditor`. |
|
||||
| "folds in the todo `cabana-richeditor-field-type.md`" | Todo is done | Nothing to fold. A core `richeditor` is still needed for FAQ (16.2 deferred "Core cabana `richeditor`/`mlricheditor` type for plugins without their own editor"). |
|
||||
| "a JS editor dependency in the admin SPA" | 16.2 D-16 said "The admin SPA itself gains no editor dependency" | D-10 (later and locked) overrides this for the core type. Use the same TipTap 3.31.4 that Journal pins, so the workspace carries one editor version. |
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Menu-item type contract (`HasMenuItemTypes`) | Framework `pact` | — | Every content plugin implements it; pact is the capability home |
|
||||
| Type registry (collect, dedupe, look up, pattern layers 1-2) | Framework new module | `party.activate` hook | Built once at boot like phrasebook; fail-loud on duplicate keys |
|
||||
| Type info / references / resolve | Owning plugin (Journal) | translate plugin (slugs, locales) | Only the content owner knows its records and translations |
|
||||
| Pattern layer 3 (item override), per-locale sitemap expansion, tiers | Consumer plugin (sitemap, menu) | — | Consumer-specific semantics |
|
||||
| Repeater, dependent dropdown, richeditor, tree reorder | Framework cabana (API) + admin SPA | — | D-06: generic admin features |
|
||||
| HTML sanitizing (richeditor save, FAQ API output) | Framework (new sanitizer package) | FAQ plugin calls it | One policy, maintained library |
|
||||
| `/sitemap.xml`, `/sitemap.xsl` | sm-sitemap-plugin (API/backend) | nginx route | PHP parity; Nuxt sitemap disabled |
|
||||
| `/_menu/api/v1`, `/_faq/api/v1` | Plugins (API/backend) | surf throttle + host CORS | Mirrors `/_journal/api/v1` |
|
||||
| Menu/FAQ rendering, active state | Nuxt layers (SSR + client) | Site app overrides | D-16: Nuxt only renders |
|
||||
| Data copy from PHP | App scripts (SQL on PHP server) + plugin import commands | `migrate.sh` ordering | Same pattern as P16 |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (Go)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/microcosm-cc/bluemonday` | v1.0.27 (latest; 2024-07-04) | HTML sanitizer for `richeditor` saves and FAQ API output | Already the project's sanitizer (`journal-html-sanitizing-bluemonday.md`); latest tag confirmed with `go list -m -json ...@latest` [VERIFIED: proxy.golang.org via go list]. **New to the framework's go.mod**, so it needs a decision note |
|
||||
| `golang.org/x/net/html` | v0.58.0 (already in framework go.mod) | Token comparison for byte-stable sanitize, as in Journal | Already a cabana dependency [VERIFIED: go.mod] |
|
||||
| `gorm.io/gorm` / `gormigrate/v2` | v1.31.2 / v2.1.7 | Models, raw-SQL migrations in plugins | Framework standard [VERIFIED: go.mod] |
|
||||
| `github.com/goccy/go-yaml` | v1.19.2 | fields.yaml parsing (repeater nested form, reorder config) | CLAUDE.md mandate [VERIFIED: go.mod] |
|
||||
| stdlib `strings.Builder`, `net/url`, `regexp` | Go 1.27 | Sitemap serializer, `Url::to` port, tier patterns (RE2) | The PHP output format must be matched exactly; see Pattern 4 |
|
||||
|
||||
### Core (JS)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `@tiptap/core` | 3.31.4 | `richeditor` in the admin SPA | Same version Journal pins after the 16.2 human package check [VERIFIED: ../sm-journal-plugin/package.json:14-18; npm registry `npm view` 3.31.4, MIT] |
|
||||
| `@tiptap/pm` | 3.31.4 | ProseMirror peer | Required by core [VERIFIED: npm registry] |
|
||||
| `@tiptap/starter-kit` | 3.31.4 | Paragraph, headings, bold/italic/strike, lists, blockquote, hard break, **Link and Underline (new in v3)** | Covers the FAQ HTML (`p`, `ul`, `li`, `strong`, `br`, counted from the current 16 answers) [CITED: tiptap.dev/docs/editor/extensions/functionality/starterkit] |
|
||||
| Nuxt / Vue | 4.4.8 / 3.5.35 | `vue-menu-layer`, `vue-faq-layer` | Same pins as `vue-journal-layer` [VERIFIED: ../vue-journal-layer/package.json] |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `@tiptap/vue-3` | 3.31.4 | Vue binding | Optional. Mounting `Editor` on a `ref` element with core only avoids it; recommend **not** adding it |
|
||||
| `sanitize-html` | 2.18.0 | Client-side mirror in `vue-faq-layer` | Only if the user wants defense in depth in the layer (the Journal layer mirrors its policy this way). Recommended default: server-side only (D-17), with no layer dependency |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Core `richeditor` in cabana + SPA (D-10) | A framework-shipped *plugin field type* on the 16.2 `pact.HasFormFieldTypes` API | Reuses Decode/notices/untouched-skip, but settings and relation forms refuse plugin types and D-10 says "in cabana and the SPA". Stay with the core type and reuse the notices plumbing internally |
|
||||
| Native HTML5 drag-and-drop for the tree page | `sortablejs`/`vue-draggable-plus`, `@atlaskit/pragmatic-drag-and-drop` | The SPA already does native DnD in `FileuploadField.vue` (`draggable="true"`, `@dragstart`) [VERIFIED: admin/src/components/form/fields/FileuploadField.vue:904-910]; no new dependency. Add keyboard move/indent buttons for accessibility |
|
||||
| Hand serializer for sitemap XML | `encoding/xml` | `encoding/xml` escapes `'`/`"` as `'`/`"` in text and `\n` as `
`, and never self-closes `<xhtml:link/>`, so the output cannot match libxml |
|
||||
| bluemonday in the framework | Reuse cabana's partial allowlist walker (`partial_render.go`) | That walker is built for template view models, not stored user HTML; the decision note rejected hand-built gates for this purpose |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# framework
|
||||
go get github.com/microcosm-cc/bluemonday@v1.0.27
|
||||
npm --prefix admin install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4
|
||||
# layers (each repo)
|
||||
pnpm add -D nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
|
||||
```
|
||||
|
||||
**Version verification:** `npm view @tiptap/core version` → `3.31.4` (modified 2026-09-30), `@tiptap/pm` 3.31.4, `@tiptap/starter-kit` 3.31.4, `sanitize-html` 2.18.0; `go list -m -json github.com/microcosm-cc/bluemonday@latest` → `v1.0.27`, `2024-07-04T13:09:59Z`. [VERIFIED: npm registry, Go module proxy]
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| @tiptap/core | npm | version 11 days (pkg years) | 22.4M/wk | github.com/ueberdosis/tiptap | [SUS] (reason: `too-new` version) | Flagged: planner adds `checkpoint:human-verify` (16.2 already approved this exact version for Journal) |
|
||||
| @tiptap/pm | npm | 11 days | 22.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged, same checkpoint |
|
||||
| @tiptap/starter-kit | npm | 11 days | 19.1M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged, same checkpoint |
|
||||
| @tiptap/vue-3 | npm | 11 days | 1.9M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Not recommended (not needed) |
|
||||
| sanitize-html | npm | 11 days | — | github.com/apostrophecms/sanitize-html | [SUS] `too-new` | Optional; only if the layer mirror is chosen |
|
||||
| github.com/microcosm-cc/bluemonday | Go proxy | v1.0.27, 2024-07 | — | github.com/microcosm-cc/bluemonday | not run (seam is npm/pypi/crates) | Approved by precedent (Journal decision note); framework decision note required |
|
||||
|
||||
No `postinstall` scripts on any of the npm packages (`npm view <pkg> scripts.postinstall` empty). **Packages removed due to [SLOP]:** none. **Packages flagged [SUS]:** the TipTap trio (+ `sanitize-html` if used); every flag is "too-new" on a long-established package. The planner inserts one `checkpoint:human-verify` before `npm install` in the admin SPA, as 16.2 did.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
boot (party.activate)
|
||||
plugins ──HasMenuItemTypes──▶ [type registry] ──published on App──┐
|
||||
(Journal: 5 types, dedupe keys, host pattern layer │
|
||||
later pages/blog) │
|
||||
▼
|
||||
Admin SPA ──GET schema/options──▶ cabana ──DependentOptions──▶ menu/sitemap controller
|
||||
repeater rows, dependent dropdown, │ └─▶ registry.TypeInfo(type).References(ctx)
|
||||
richeditor, tree reorder page ├─save──▶ repeater decode → nested field checks
|
||||
│ richeditor → sanitizer (bluemonday) → meta.notices
|
||||
└─reorder POST──▶ tx: lock scoped set, validate tree, write parent_id+sort_order
|
||||
|
||||
Browser/Nuxt SSR ─GET /_menu/api/v1/menus/{code}──▶ sm-menu-plugin ─▶ entries tree (published)
|
||||
└─registry types ─▶ Resolve(ctx, req) ─▶ children
|
||||
─GET /_faq/api/v1/faqs/{slug}?locale──▶ sm-faq-plugin ─▶ Locale(code)→row(active) ─▶ sanitize HTML
|
||||
Search engine ──GET /sitemap.xml──nginx──▶ sm-sitemap-plugin
|
||||
Definition(theme=fixed).data items ─┬─ url item → Url::to
|
||||
└─ registry item → Resolve → (list types: one <url> per alternate)
|
||||
→ festival BeforeAddItem (skip?) → tier priority → serialize (libxml format) → AddItem event
|
||||
→ frontend_url rewrite (loc, xhtml:link href, xsl href) → application/xml
|
||||
─GET /sitemap.xsl──▶ embedded assets/sitemap.xsl (text/xsl; charset=UTF-8)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
The layout follows `plugin-layout-winter-directories.md`: each PHP directory maps to a Go package, and `models/` is a leaf package.
|
||||
|
||||
```
|
||||
summercms.go/
|
||||
├── modules/pact/capabilities.go # + HasMenuItemTypes, MenuItemType, ResolveRequest/ResolvedItem
|
||||
├── modules/<registry>/ # new beach-named module (e.g. "signpost"): Activate, Registry, Patterns, ReferenceOptions
|
||||
├── modules/<sanitizer>/ # new beach-named module (e.g. "seaglass"): bluemonday policy + byte-stable Report
|
||||
├── modules/cabana/field_repeater.go # repeater compile/decode/project
|
||||
├── modules/cabana/field_dependent.go # dependsOn dropdown, options route, save recheck
|
||||
├── modules/cabana/field_richeditor.go # richeditor save gate + notices
|
||||
├── modules/cabana/reorder.go # config_reorder.yaml, GET/POST reorder routes
|
||||
├── modules/party/registry.go # call <registry>.Activate after Register, before Boot
|
||||
└── admin/src/components/form/fields/{RepeaterField,RichEditorField}.vue, views/ReorderView.vue
|
||||
|
||||
../sm-sitemap-plugin/ plugin.go routes.go assets/sitemap.xsl classes/{generator,urlto,serialize,tiers}.go
|
||||
models/{definition,settings}.go models/{definition,settings}/fields.yaml controllers/definitions/
|
||||
console/import.go updates/ lang/{en,pl}/ config/config.yaml README.md go.mod go.work
|
||||
../sm-menu-plugin/ models/{menu,menu_entry}.go controllers/{menus,menuentries}/ controllers/api/ updates/ lang/{en,pl}
|
||||
../sm-faq-plugin/ models/faq.go controllers/faqs/ controllers/api/ updates/ lang/{en,pl}
|
||||
../sm-journal-plugin/ menu_item_types.go + classes/menu_item_types.go (additive)
|
||||
../vue-menu-layer/, ../vue-faq-layer/ nuxt.config.ts lib/*-module.ts app/{components,composables,utils} tests/ testing/
|
||||
../sm-grzybyfunkcjonalne-app/ .gitmodules, go.work, go.mod, summer.yaml, config/{http,…}.yaml, deploy/nginx, scripts/php-export/*.sql, scripts/migrate.sh
|
||||
```
|
||||
|
||||
### PHP port inventory (what "1:1" means)
|
||||
|
||||
| Plugin | Tables (keep names/columns) | Permissions | Admin | Commands |
|
||||
|---|---|---|---|---|
|
||||
| sitemap | `golem15_sitemap_definitions` (`id`, `theme` nullable indexed, `data` mediumtext jsonable, timestamps) [VERIFIED: fonoteka sitemap updates/v1.0.2 + v2.0.0/rename_tables.php:11]; settings (PHP stores them in `system_settings` with item `golem15_sitemap_settings` [VERIFIED: models/Settings.php:13]) → Go settings table | `golem15.sitemap.access_definitions` (developer) [VERIFIED: Plugin.php:42] | Settings entries `definitions` (link) + `settings` (form), category CMS | `sitemap:import {file} {--theme=} {--append}` |
|
||||
| menu | `golem15_menu_menus` (`id`, `name`, `code` unique, `is_active` default true, `css_class`), `golem15_menu_menu_entries` (`id`, `menu_id`, `parent_id` null, `nest_left`, `nest_right`, `nest_depth` NOT NULL, `sort_order` null, `type`, `title`, `url`, `reference`, `code`, `icon`, `css_class`, `target`, `is_published` default true) [VERIFIED: menu updates/v1.0.1, v1.0.2] | `golem15.menu.manage_menu` (developer, publisher) [VERIFIED: Plugin.php:77] | Menus, MenuEntries (list top-level of current menu, form, relation `children`, reorder) | none |
|
||||
| faq | `golem15_faq_faqs` (`id`, `name`, `slug`, `locale_id` NOT NULL, `is_active` default false, `description` text, `content` longText jsonable) [VERIFIED: faq updates/v1.0.1] | `golem15.faq.faqs` (developer, publisher) [VERIFIED: Plugin.php:60] | Top nav "FAQ" order 500 with side menu `faqs` | none |
|
||||
|
||||
The PHP menu type options are, verbatim [VERIFIED: grzyby menu/models/menuentry/fields.yaml:23-27]:
|
||||
```yaml
|
||||
options:
|
||||
link: Link
|
||||
page: Page
|
||||
divider: Divider
|
||||
blog_categories: Active Blog Categories
|
||||
```
|
||||
The Journal PHP type keys are, verbatim [VERIFIED: fonoteka journal/Plugin.php:247-255]:
|
||||
```php
|
||||
'journal-category' => 'golem15.journal::lang.menuitem.journal_category',
|
||||
'all-journal-categories' => 'golem15.journal::lang.menuitem.all_journal_categories',
|
||||
'journal-post' => 'golem15.journal::lang.menuitem.journal_post',
|
||||
'all-journal-posts' => 'golem15.journal::lang.menuitem.all_journal_posts',
|
||||
'category-journal-posts' => 'golem15.journal::lang.menuitem.category_journal_posts',
|
||||
```
|
||||
|
||||
### Pattern 1: Menu-item type registry (D-01)
|
||||
|
||||
**What:** a `pact` interface with typed values. The registry is built in `party.activate` after `Register` and before `Boot`, the same way as `phrasebook.Activate[P interface{ ID() string }](app, plugins)` [VERIFIED: modules/phrasebook/translator.go:86], and it is published as `app.Publish`. Consumers read it with `app.Lookup[*registry.Registry]()`. Types close over a lazy `func() *backpack.App`, as Journal's field types do [VERIFIED: ../sm-journal-plugin/field_types.go].
|
||||
|
||||
```go
|
||||
// modules/pact (proposal, names at planner's discretion)
|
||||
type MenuItemType struct {
|
||||
Key string // "journal-post"; unique across plugins, boot fails on a duplicate
|
||||
Label string // phrase key
|
||||
DefaultPattern string // "/blog/:slug" (layer 1 of D-02)
|
||||
Placeholders []string // ":slug", ":category", ":id", ":year", ":month", ":day"
|
||||
Nesting bool // PHP 'nesting'
|
||||
DynamicItems bool // PHP 'dynamicItems'
|
||||
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` // nil: the type takes no reference
|
||||
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
|
||||
}
|
||||
type MenuItemReference struct { Value, Label string; Items []MenuItemReference } // PHP ["title","items"] tree
|
||||
type MenuItemResolve struct {
|
||||
Type, Reference string
|
||||
Pattern string // already layered: item override > host config > DefaultPattern
|
||||
Nesting bool
|
||||
UnprefixedLocale string // PHP frontend_default_locale; "" = the type's own default
|
||||
}
|
||||
type ResolvedMenuItem struct {
|
||||
Title string
|
||||
URL string // "" for pure list types (all-*), as in PHP
|
||||
MTime *time.Time
|
||||
Alternates []MenuItemAlternate // ORDERED (Locale sort_order); never a map (Pitfall 4)
|
||||
Items []ResolvedMenuItem
|
||||
}
|
||||
type MenuItemAlternate struct { Locale, URL, Title string }
|
||||
type HasMenuItemTypes interface { MenuItemTypes() []MenuItemType }
|
||||
```
|
||||
|
||||
**When to use:** Journal now; pages/blog later. **Don't** put `isActive` in the result: D-16 makes the active state a Nuxt concern.
|
||||
|
||||
### Pattern 2: Three pattern layers (D-02)
|
||||
|
||||
`pattern = item.urlPattern (if non-empty) ?? hostConfig[type] ?? type.DefaultPattern`. Host config is read once at boot from a registry-owned key, for example `menu_items.patterns` as a YAML map from type key to pattern [ASSUMED key name]. Keep the PHP placeholder names: category `:slug`, `:id`; post `:slug`, `:category`, `:id`, `:year`, `:month`, `:day` [VERIFIED: Category.php:126, Post.php:808]. Stored PHP `urlPattern` values use them, and Journal's PHP `buildUrlFromPattern` is an ordered `str_replace` of `:key`. Suggested Journal defaults match the layer's defaults (post `/blog/:slug`, category `/blog/category/:slug`) [VERIFIED: ../vue-journal-layer/app/utils/routes.ts:10-11]. The grzyby host config sets `journal-post: /blog/:slug` and `journal-category: /:slug`, which keeps P16 D-15 [VERIFIED: vue-grzybyfunkcjonalne-app/nuxt.config.ts:20-21 `post: '/blog/:slug'`, `category: '/:category'`].
|
||||
|
||||
### Pattern 3: Journal type semantics to reproduce (headless branch)
|
||||
|
||||
All of this comes from the frozen PHP tree [VERIFIED: fonoteka journal/models/Category.php:330-425, Post.php:1005-1160]:
|
||||
|
||||
- `journal-category`: needs a reference; `url` = the first alternate (or the pattern built from the default slug); `mtime` = `updated_at`. With `nesting`, `items` = the category subtree, **one entry per category with its default URL only** (not per locale).
|
||||
- `all-journal-categories`: no `url`; `items` = every category `orderBy('name')`, **one entry per enabled locale** (same alternates on each).
|
||||
- `journal-post`: needs a reference; single URL, with no published filter.
|
||||
- `all-journal-posts`: `isPublished()` posts `orderBy('title')`, one entry per locale.
|
||||
- `category-journal-posts`: published posts in the category and its descendants (nested set), `orderBy('title')`, one entry per locale.
|
||||
- Alternates: when translate has no enabled locales, `{'x-default': url}`. Otherwise, for each enabled locale in `sort_order`, the prefix is `''` for the unprefixed locale and `'/'.code` for the others; the slug is translated per locale (for posts, the category slug is translated too). The year, month and day placeholders are filled only when the post is published and has `published_at`.
|
||||
|
||||
**Go mapping:** return per-record items (each with ordered alternates) from `Resolve`. The **sitemap** reproduces PHP's per-locale duplication with one rule that matches all five types: *when the resolved top level has no URL (a pure list type), emit one `<url>` per alternate for each child; otherwise emit each child once with its default URL.* The **menu** then never sees per-locale duplicates.
|
||||
|
||||
### Pattern 4: Byte-exact sitemap serializer
|
||||
|
||||
Live PHP output, verbatim prefix [VERIFIED: curl https://grzybyfunkcjonalne.pl/sitemap.xml, 2026-10-11]:
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="https://grzybyfunkcjonalne.pl/sitemap.xsl"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://grzybyfunkcjonalne.pl/</loc><lastmod>2026-10-11T15:45:03+00:00</lastmod><changefreq>always</changefreq><priority>1.0</priority></url>...
|
||||
```
|
||||
An hreflang entry, verbatim [VERIFIED: curl https://www.golem15.com/sitemap.xml]:
|
||||
```
|
||||
<url><loc>https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation</loc><lastmod>2026-02-11T15:30:10+00:00</lastmod><changefreq>always</changefreq><priority>0.6</priority><xhtml:link rel="alternate" hreflang="en" href="https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation"/><xhtml:link rel="alternate" hreflang="pl" href="https://www.golem15.com/blog/inzynieria-ai/agenci-ai-w-automatyzacji-workflow"/></url>
|
||||
```
|
||||
Format facts:
|
||||
|
||||
- `\n` after the XML declaration and after the PI.
|
||||
- Everything else is on one line, ending in `</urlset>\n`.
|
||||
- An empty urlset self-closes: `<urlset xmlns="x"/>`.
|
||||
- `<lastmod>` uses `+00:00`, not `Z`. Use the Go layout `2006-01-02T15:04:05-07:00` in UTC.
|
||||
- `xhtml:link` self-closes with no space before `/>`, and is emitted only when there are more than 1 alternate.
|
||||
- Headers: `Content-Type: application/xml` for the XML and `text/xsl; charset=UTF-8` for the XSL, `Cache-Control: no-cache, private` [VERIFIED: live response headers].
|
||||
- The live XSL is byte-identical to `fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl` [VERIFIED: cmp], so embed it unchanged.
|
||||
|
||||
libxml escaping, measured with local PHP 8.5.10 `DOMDocument::saveXML()` [VERIFIED: local php probe]:
|
||||
- **text nodes:** `&`→`&`, `<`→`<`, `>`→`>`, `\r`→` `; `"`, `'`, `\t`, `\n` and UTF-8 stay literal.
|
||||
- **attributes:** `&`→`&`, `<`→`<`, `>`→`>`, `"`→`"`, `\r`→` `, `\t`→`	`, `\n`→` `; `'` stays literal.
|
||||
|
||||
The generation order follows `Definition::addItemToSet` [VERIFIED: fonoteka sitemap/models/Definition.php]:
|
||||
|
||||
1. Stop at `MAX_URLS = 50000`.
|
||||
2. Fill a missing `url` with `Url::to($item->url)`.
|
||||
3. lastmod = mtime, else the manifest lookup, else **omitted**.
|
||||
4. `BeforeAddItem` (skip if handled).
|
||||
5. Drop an empty URL; `Url::to` again.
|
||||
6. `urlCount++`.
|
||||
7. Tier priority, else the item priority.
|
||||
8. Emit loc, lastmod, changefreq, priority, then the alternates.
|
||||
9. `AddItem` event.
|
||||
|
||||
The route then applies the frontend rewrite with `str_replace(backendUrl, frontendUrl)` inside every `<loc>…</loc>` and every `xhtml:link href`, and on `href="{backendUrl}/sitemap.xsl"`, where `backendUrl = rtrim(url('/'), '/')`.
|
||||
|
||||
### Pattern 4b: `Url::to` port (Winter = Laravel + `buildUrl`)
|
||||
|
||||
These outputs were measured with the vendored Winter router [VERIFIED: local php + grzybyfunkcjonalne.pl/vendor, `Winter\Storm\Router\UrlGenerator`]:
|
||||
|
||||
| Input | Output (`https://api.ex.pl` request) |
|
||||
|---|---|
|
||||
| `/`, `""` | `https://api.ex.pl/` |
|
||||
| `/blog/` | `https://api.ex.pl/blog` |
|
||||
| `/blog/zażółć gęślą` | `https://api.ex.pl/blog/za%C5%BC%C3%B3%C5%82%C4%87%20g%C4%99%C5%9Bl%C4%85` |
|
||||
| `/en/blog/:slug` | `https://api.ex.pl/en/blog/%3Aslug` |
|
||||
| `https://x.pl/q/` (valid URL) | `https://x.pl/q/` (unchanged except buildUrl encoding) |
|
||||
| `/a?b=1&c=2` | `https://api.ex.pl/a?b=1&c=2` |
|
||||
|
||||
The rules:
|
||||
|
||||
- Laravel's `isValidUrl` is `^(#|//|https?://|(mailto|tel|sms):)` or `FILTER_VALIDATE_URL`.
|
||||
- Otherwise the result is `root + '/' + trim(path, '/')`.
|
||||
- Winter then re-encodes each path segment with `rawurlencode(rawurldecode(seg))`, which escapes everything except `A-Za-z0-9-_.~`. That is **not** Go's `url.PathEscape`, which leaves `:@!$&'()*+,;=` alone.
|
||||
- An empty path gets `/`.
|
||||
|
||||
Generate a golden table with local PHP and test the Go port against it.
|
||||
|
||||
### Pattern 5: Repeater (D-07)
|
||||
|
||||
- **YAML:** keep Winter's keys `prompt`, `form.fields`, `maxItems`, `minItems`, `titleFrom`. Refuse `groups` and `style` loudly.
|
||||
- **Nested field types:** allow `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or dependent) and `richeditor`. Refuse `relation`, `relation-manager`, `fileupload`, `widget`, `partial`, `password`, `ml*` and plugin types, so v1 needs no nested file or translation plumbing.
|
||||
- **Column:** a `lagoon.Jsonable[[]map[string]any]` or a typed slice [VERIFIED: modules/lagoon/jsonable.go:21 `type Jsonable[T any] struct`].
|
||||
- **Save:**
|
||||
- Decode an array of objects. Each row keeps only the nested field names and coerces scalars per the nested type.
|
||||
- `required` applies per row. Errors use path keys such as `content.3.answer`.
|
||||
- Each nested `richeditor` is sanitized, and its notices go to `meta.notices`.
|
||||
- **Bypass rule:** today `ProjectWritableFields` drops nested values (`nestedValue` returns true for `[]any`) [VERIFIED: modules/cabana/crud.go `func nestedValue`]. The repeater needs an explicit, schema-driven exemption, not a loosening of that rule.
|
||||
- **Settings forms:** they refuse nested values too (`requireSettingInputs`), so either support the repeater there as well, or keep the sitemap definition on a controller form (Pattern 7, recommended).
|
||||
|
||||
### Pattern 6: Dependent dropdown (D-08)
|
||||
|
||||
- **YAML:** `type: dropdown` with `dependsOn: type` (a sibling field in the same form, or the same repeater row; boot fails if the sibling is missing).
|
||||
- **Options:** a new controller capability, for example `pact.DependentOptionsProvider{ DependentOptions(ctx, field string, values map[string]any) ([]Option, error) }`. The route is `GET .../fields/{field}/dependent-options?values[type]=journal-post`; for a nested field, the field path is `data.reference` [ASSUMED shape]. The existing `pact.DropdownOptionsProvider.DropdownOptions(field string) []Option` takes no `ctx` or database handle [VERIFIED: modules/pact/capabilities.go:574-576], so it cannot serve database-backed references.
|
||||
- **One admin binding:** the registry module exports a single helper, `ReferenceOptions(ctx, typeKey) ([]Option-with-depth, error)`, which flattens the reference tree. Both the menu-entries controller and the definitions controller call it. Nested references need a `depth` (or indent) in the option payload; `pact.Option` has only `Value`/`Label` [VERIFIED: capabilities.go:567-571].
|
||||
- **Save:** recheck that the submitted reference is among the options for the submitted type (the same idea as relation option revalidation, D-18 of P10). An unknown value is a 422 on the field.
|
||||
|
||||
### Pattern 7: Tree reorder page (D-09) and singleton definition (D-11)
|
||||
|
||||
- **Reorder:**
|
||||
- Config: a `config_reorder.yaml` (Winter's `reorder_config.yaml`: `title`, `nameFrom`, `modelClass`) plus Go-side keys for the parent column (default `parent_id`), the sort column (default `sort_order`) and an optional partition column (`menu_id`) [ASSUMED keys].
|
||||
- `GET .../reorder?partition=<id>` returns the scoped tree.
|
||||
- `POST .../reorder` with `{nodes:[{id,parent_id,sort_order}]}` runs in one transaction. It locks the scoped set through `ListExtendQuery`/`FormExtendQuery` plus the partition and requires exactly that set (the file reorder rule "exactly the field's visible files" [VERIFIED: cabana README]). It refuses a cycle, a foreign parent or a parent outside the partition, then writes both columns.
|
||||
- CSRF and `X-Requested-With`, like every write route. The route needs an OpenAPI annotation and a conformance case (`TestPhase10ConformanceInventory` gate).
|
||||
- **Menu entry rules:** PHP's `MenuEntry` used `Sortable` (a global `orderBy sort_order` when no other order is set [VERIFIED: vendor/winter/storm/src/Database/SortableScope.php:16-22]). It set `nest_left`, `nest_right` and `nest_depth` to `0` and `sort_order = max(menu)+1` in `beforeSave`, and inherited `menu_id` from the parent. Keep those rules in a GORM hook, because the `nest_*` columns are NOT NULL.
|
||||
- **Singleton:** PHP's `index()` ran `firstOrCreate(['theme' => …])` and redirected to `update/:id`.
|
||||
- Recommended: a definitions controller with a form only, a `SettingsItem{Controller: ...}` link (already supported [VERIFIED: cabana README "A settings entry whose Controller field is set links to that admin controller"]) and a small generic "singleton form" capability (`firstOrCreate` through a controller hook, SPA opens the record).
|
||||
- Alternative: a `HasSettings` singleton row id 1 with the Definition model. It needs repeater and dependent-dropdown support inside settings forms, and it breaks on copied PHP rows whose id ≠ 1 [VERIFIED: cabana/settings.go Get/Put use `primaryColumn = 1`].
|
||||
|
||||
### Pattern 8: Menu API (D-16)
|
||||
|
||||
`GET /_menu/api/v1/menus/{code}` (optional `?locale=`):
|
||||
|
||||
- **Scope:** the menu must be `is_active`. Entries are filtered by `is_published` and ordered by `sort_order` per parent.
|
||||
- **Built-in entries:** for `url`, return the stored `url`; for `divider`, return no URL.
|
||||
- **Registry entries:**
|
||||
- A single type (`journal-post`, `journal-category`) gets `url` = the alternate for the requested locale, else the default.
|
||||
- A list type (`all-journal-categories`) keeps the entry's own stored `url` and `title` (the PHP partial links the dropdown parent to `entry.url`), and its `items` are the resolved children.
|
||||
- **Mount and limits:** mount like Journal: `r.Group("/_menu/api/v1", surf.Use("throttle:menu-public-api"), …)` plus a `surf.BucketProvider` bucket [VERIFIED: ../sm-journal-plugin/routes.go, plugin.go `Buckets`]. The host adds `_menu/api/*` and `_faq/api/*` to `config/http.yaml` `cors.paths` [VERIFIED: sm-grzybyfunkcjonalne-app/config/http.yaml:4 `- _journal/api/*`].
|
||||
- **Errors:** JSON 404 for an unknown or inactive code.
|
||||
|
||||
### Pattern 9: FAQ API (D-17)
|
||||
|
||||
`GET /_faq/api/v1/faqs/{slug}?locale=pl`:
|
||||
|
||||
1. Resolve the translate `Locale` by `code` (default: `is_default`).
|
||||
2. Select the row by `slug`, `locale_id` and `is_active`, ordered by `id`, first. PHP has no unique index on slug, and its component ignored `is_active`.
|
||||
3. Return `{name, slug, description, content:[{question, answer}]}`.
|
||||
4. Sanitize `description` and every `answer` **on output** with the framework sanitizer, because the D-19 DB copy bypasses the admin save gate (Pitfall 10).
|
||||
|
||||
`Requires()` = `golem15.translate`.
|
||||
|
||||
### Pattern 10: Nuxt layers (D-18)
|
||||
|
||||
Copy the `vue-journal-layer` skeleton:
|
||||
|
||||
- An absolute `currentDir` for every path [VERIFIED: ../vue-journal-layer/nuxt.config.ts].
|
||||
- `runtimeConfig.<x>.apiBase` (server-only, SSR) plus `public.<x>.apiBase` (same-origin).
|
||||
- Tokens CSS first, then base CSS.
|
||||
- `node --test` tests and a `testing/` SSR smoke.
|
||||
- A tag release, pinned as a `git+https://…#vX.Y.Z` dependency.
|
||||
|
||||
Keep both new layers **module-free**: do not declare `@nuxtjs/i18n`, `@nuxtjs/seo` or fonts. The journal layer warns that two copies of `@nuxtjs/i18n` split locale state. Labels come from the API. The menu layer computes the active state from `useRoute()`; the FAQ layer renders `<details>` accordions with `v-html` of server-sanitized HTML.
|
||||
|
||||
For the site app:
|
||||
|
||||
- `extends: ['vue-journal-layer', 'vue-menu-layer', 'vue-faq-layer']`.
|
||||
- `sitemap: { enabled: false }` ("Whether to generate the sitemap", default `true` [CITED: nuxtseo.com/docs/sitemap/api/config]).
|
||||
- `robots: { sitemap: '/sitemap.xml' }` [CITED: nuxtseo.com/docs/robots/api/config].
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **A `map[string]string` for alternates:** Go map order is random and `encoding/json` sorts keys, but PHP emits hreflang and per-locale entries in `sort_order` (Pitfall 4).
|
||||
- **`encoding/xml` or `xml.EscapeText` for the sitemap:** see Pattern 4.
|
||||
- **Switching Journal category content to `richeditor`:** it is already `blogeditor` (see the conflict table).
|
||||
- **A string-keyed festival event for list, info or resolve:** forbidden by D-01. Festival is only for the two sitemap hooks.
|
||||
- **Importing a plugin from the framework:** the registry, the sanitizer and cabana never import Journal, translate or menu.
|
||||
- **Copying PHP rows without remapping `locale_id`:** Go's translate locale ids differ (Pitfall 11).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| HTML sanitizing (richeditor, FAQ output) | Regex gates (`cabana.RenderMarkdown`'s `markdownUnsafeTag` style) | bluemonday v1.0.27 UGC-based policy + the token-equality "byte-stable" comparison from Journal's `classes.SanitizeHTMLReport` | The decision note lists real bypasses of regex gates (`<img src="x"onerror=…>`, `javascript:`, `<svg/onload>`) |
|
||||
| WYSIWYG editing | contenteditable glue | TipTap 3.31.4 StarterKit | Schema, paste handling, undo, link policy |
|
||||
| Tree drag-and-drop | A new DnD library | Native HTML5 DnD as in `FileuploadField.vue`, plus move/indent buttons | Already used; no dependency |
|
||||
| Locale list and translated slugs | Reading `golem15_translate_*` tables in Journal by hand | translate `classes.TranslatedExact` / `models.Locale` (`is_enabled`, `sort_order`) [VERIFIED: ../sm-translate-plugin/classes/translatable.go:73, models/locale.go] | Same storage the admin writes |
|
||||
| Plugin command DB access | Assuming a published `*gorm.DB` | The lookup-or-open helper (`lagoon.OpenFromApp` + `lagoon.Publish`), as `grzyby/console/helpers.go` `lookupDB` and the 14.2 "commandDatabase" pattern do | The generated `main.go` publishes no DB for plugin commands (STATE, Phase 16) |
|
||||
| Rate limiting, CORS | Per-handler limiters | `surf.Use("throttle:<bucket>")` + `surf.BucketProvider`, host `cors.paths` | Existing Journal pattern |
|
||||
|
||||
**Exception (deliberate hand-roll):** the sitemap serializer, the `rawurlencode` and `Url::to` port, and the Journal `str_replace` pattern fill. They exist to match PHP byte for byte, and each needs a golden test generated by the local PHP.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
This phase moves menu, FAQ and sitemap from app code and the PHP DB into plugins.
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| Stored data | PHP (MySQL, live server): `golem15_menu_menus`, `golem15_menu_menu_entries` (types `link`/`page`/`divider`/`blog_categories`), `golem15_faq_faqs` (slug `what-mushrooms-can-give-you`, `locale_id` → PHP locale ids), `golem15_sitemap_definitions` (`data` JSON with PHP-object keys, possibly `cmsPage`, `exists`, `fillable`), `system_settings` item `golem15_sitemap_settings` | **Data migration:** read-only `scripts/php-export/*.sql` → JSONL → Go import commands; map types (D-05), remap `locale_id` by locale code, drop `page`/static-page/`cmsPage` items with a report, bump Postgres sequences after explicit-id inserts |
|
||||
| Live service config | nginx vhost (`deploy/nginx/grzybyfunkcjonalne-staging.conf`): no `/sitemap.xml`, `/sitemap.xsl`, `/_menu/`, `/_faq/` locations yet (only `/gfadmin`, `/_journal/`, storage) [VERIFIED: grep `location`]; Nuxt `@nuxtjs/seo` sitemap enabled via the layer (`sitemap: { sources: ['/api/__sitemap__/journal'] }` [VERIFIED: vue-journal-layer/nuxt.config.ts:62]) | **Config edit:** add four `location` blocks before `location /` with `Host $host` + `X-Forwarded-Proto`; set `sitemap.enabled false` in the app |
|
||||
| OS-registered state | supervisor programs for Go and Nuxt (`deploy/supervisor`): names unchanged | None. Verified by listing `deploy/`; no renamed process |
|
||||
| Secrets/env vars | None new. Journal's site-cache purge token exists; menu and FAQ edits are not purged (see Open Questions) | None, unless a purge is added |
|
||||
| Build artifacts | `modules/boardwalk/dist` (must be rebuilt for the new SPA fields); `vue-grzybyfunkcjonalne-app` pnpm-lock pins `vue-journal-layer#v0.3.0` [VERIFIED: package.json]; app `plugins.gen.go`/`main.go` regenerated by `summer build` after the `summer.yaml` additions; app `go.work` needs three `use` lines | Rebuild dist and commit; tag both layers and pin them; regenerate the build files; commit the `go.work`, `go.mod` and `go.sum` changes |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: `Url::to` trailing-slash and encoding rules
|
||||
**What goes wrong:** the homepage loc becomes `https://host` (missing `/`), `/blog/` keeps its slash, or `:` is left unescaped.
|
||||
**Why it happens:** Laravel trims slashes, and then Winter's `buildUrl` restores the root slash and re-encodes segments with `rawurlencode`.
|
||||
**How to avoid:** port it as in Pattern 4b and test against the PHP-generated golden table.
|
||||
**Warning signs:** a diff against the live sitemap on `/` or on non-ASCII URL items.
|
||||
|
||||
### Pitfall 2: `lastmod` format and fallback
|
||||
**What goes wrong:** Go's `time.RFC3339` prints `Z`, but PHP `date('c')` prints `+00:00` (live output shows `+00:00`). The current grzyby live sitemap still fakes "now" for URL items because it runs an older plugin. The port spec (the fonoteka version) **omits** lastmod when neither an mtime nor a manifest entry exists.
|
||||
**How to avoid:** use layout `2006-01-02T15:04:05-07:00` in UTC. Document that the URL items' lastmod changes against the live grzyby output (expected).
|
||||
|
||||
### Pitfall 3: Per-locale duplication only for list types
|
||||
**What goes wrong:** the sitemap emits one entry per locale for `journal-category` children (PHP does not), or the menu shows each category N times.
|
||||
**How to avoid:** apply the Pattern 3 rule in the sitemap consumer; `Resolve` stays per record.
|
||||
|
||||
### Pitfall 4: Ordered alternates
|
||||
**What goes wrong:** hreflang order differs run to run. golem15.com emits `en` before `pl`, following `sort_order` [VERIFIED: live output; grzyby translate `scopeOrder` orders by `sort_order`].
|
||||
**How to avoid:** use an ordered slice and query locales `ORDER BY sort_order`.
|
||||
|
||||
### Pitfall 5: `orderBy('name'/'title')` collation drift
|
||||
**What goes wrong:** the PHP MySQL `utf8mb4_*_ci` order differs from Postgres ICU `pl-PL` for case and diacritics, so the URL order in the sitemap or menu changes.
|
||||
**How to avoid:** accept a set-equality parity test plus an ordered test on ASCII fixtures, or use the per-query collation from `lagoon-per-query-collation.md`. Flag set-equal vs byte-equal in the acceptance criteria.
|
||||
|
||||
### Pitfall 6: Backend URL behind nginx
|
||||
**What goes wrong:** Go sees `r.TLS == nil` behind TLS termination and builds `http://` locs. The `frontend_url` rewrite then misses, because `backendUrl` differs.
|
||||
**How to avoid:** build the base from the trusted `X-Forwarded-Proto` (surf's trusted proxies) and `Host`, or from `app.url` when set. nginx already sends `Host $host` and `X-Forwarded-Proto $scheme` [VERIFIED: staging vhost].
|
||||
|
||||
### Pitfall 7: PHP-shaped JSON in `data` and `content`
|
||||
**What goes wrong:** Go decoding fails on `"nesting":"1"`, `"priority":0.5` vs `"0.5"`, `"reference":12`, or unexpected keys (`fillable`, `exists`, `code`, `cmsPage`). PHP `json_encode` of `DefinitionItem` objects writes all public properties, and form posts write strings.
|
||||
**How to avoid:** use a tolerant `UnmarshalJSON` (string/number/bool coercion, unknown keys ignored). On the next admin save, write the PHP `toArray()` key set (`changefreq, priority, nesting, type, url, reference, cmsPage, urlPattern`).
|
||||
|
||||
### Pitfall 8: `str_replace` rewrite semantics
|
||||
**What goes wrong:** an "improved" rewrite (prefix-only, URL-parsed) changes output.
|
||||
**How to avoid:** port the three PHP replacements literally: inside every `<loc>` and `xhtml:link href`, plus the exact `href="{backend}/sitemap.xsl"` string. They run after serialization.
|
||||
|
||||
### Pitfall 9: Winter YAML keys cabana does not have
|
||||
**What goes wrong:** the PHP YAML is copied verbatim and boot fails. The affected keys are `tabs:`/`stretch`/`cssClass`/`span: none` (FAQ, definition), `type: hint`/`type: section`/`trigger` (sitemap settings), `select: name` on a relation (FAQ locale), `preset: name` (supported), and a custom widget class (`Golem15\Sitemap\FormWidgets\SitemapItems`).
|
||||
**How to avoid:** flatten tabs into `tab:` keys and turn `select` into `nameFrom`. Map `span: none` to `left`/`right`/`auto`, drop `hint`/`section` into `comment` text, and show tier fields unconditionally. Replace the widget with `type: repeater` on `data`. Journal set the precedent: its fields.yaml is flat [VERIFIED: models/category/fields.yaml]. List every adaptation in each README.
|
||||
|
||||
### Pitfall 10: The DB copy bypasses the save gate
|
||||
**What goes wrong:** FAQ HTML copied from PHP never passes the richeditor sanitizer, so the API serves raw PHP HTML.
|
||||
**How to avoid:** sanitize on API output (and optionally in the import command, reporting what was removed).
|
||||
|
||||
### Pitfall 11: Foreign keys and sequences on the copy
|
||||
**What goes wrong:** FAQ `locale_id` points at PHP locale ids; menu `parent_id`/`menu_id` rely on PHP ids; inserting explicit ids leaves the Postgres `SERIAL` sequences behind, so the next admin create collides.
|
||||
**How to avoid:** remap the locale by `code`. Keep menu ids inside one transaction, then run `SELECT setval(pg_get_serial_sequence('<table>','id'), max(id))`. Refuse to import into non-empty tables unless `--force` is given.
|
||||
|
||||
### Pitfall 12: New framework dependency without a note
|
||||
**What goes wrong:** bluemonday enters the framework `go.mod` and violates CLAUDE.md "A new dependency needs a research doc or a phase/decision note naming it".
|
||||
**How to avoid:** this research names it. Also add `.planning/notes/<date>-framework-html-sanitizer-bluemonday.md` and update the module README Dependencies line in the same commit.
|
||||
|
||||
### Pitfall 13: Env overrides for dashed config keys
|
||||
**What goes wrong:** type keys contain `-` (`journal-post`), so `SUMMER_MENU_ITEMS__PATTERNS__JOURNAL-POST` is not a usable env var name.
|
||||
**How to avoid:** document YAML-file-only overrides for pattern maps, or use a list shape `[{type, pattern}]`.
|
||||
|
||||
### Pitfall 14: Plugin commands without a DB
|
||||
**What goes wrong:** `sitemap:import` or a menu/FAQ import fails with "database is not configured" in a `summer build` host.
|
||||
**How to avoid:** use the lookup-or-open helper in every plugin command (STATE, Phase 16 and 14.2 decisions).
|
||||
|
||||
### Pitfall 15: URL-scheme XSS through menu entries
|
||||
**What goes wrong:** an admin-entered `javascript:` URL is rendered by Nuxt in `<a :href>`, and Vue does not sanitize hrefs.
|
||||
**How to avoid:** validate on save (allow `http`, `https`, `mailto`, `tel`, a relative path and `#`) and filter again in the API. Treat `target` as an enum (`_self`, `_blank`) and add `rel="noopener"` in the layer.
|
||||
|
||||
### Pitfall 16: Admin route conformance gates
|
||||
**What goes wrong:** new cabana routes (dependent options, reorder, singleton) lack swag annotations or conformance cases, and `TestPhase10ConformanceInventory` or `check-admin-openapi.sh` fails.
|
||||
**How to avoid:** annotate in `admin_openapi.go`, add conformance cases, regenerate `schema.d.ts`, and commit dist in the same change.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Sitemap escaping (libxml-compatible)
|
||||
```go
|
||||
// Source: behavior measured with local PHP 8.5.10 DOMDocument::saveXML (this session).
|
||||
var textEsc = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\r", " ")
|
||||
var attrEsc = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """,
|
||||
"\r", " ", "\t", "	", "\n", " ")
|
||||
|
||||
func writeURL(b *strings.Builder, loc, lastmod, changefreq, priority string, alts []pact.MenuItemAlternate) {
|
||||
b.WriteString("<url><loc>"); b.WriteString(textEsc.Replace(loc)); b.WriteString("</loc>")
|
||||
if lastmod != "" { b.WriteString("<lastmod>" + textEsc.Replace(lastmod) + "</lastmod>") }
|
||||
b.WriteString("<changefreq>" + textEsc.Replace(changefreq) + "</changefreq>")
|
||||
b.WriteString("<priority>" + textEsc.Replace(priority) + "</priority>")
|
||||
if len(alts) > 1 {
|
||||
for _, a := range alts {
|
||||
b.WriteString(`<xhtml:link rel="alternate" hreflang="` + attrEsc.Replace(a.Locale) +
|
||||
`" href="` + attrEsc.Replace(urlTo(a.URL)) + `"/>`)
|
||||
}
|
||||
}
|
||||
b.WriteString("</url>")
|
||||
}
|
||||
```
|
||||
|
||||
### rawurlencode (PHP)
|
||||
```go
|
||||
// Escapes everything except A-Z a-z 0-9 - _ . ~ (RFC 3986 unreserved), like PHP rawurlencode.
|
||||
func rawurlencode(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
### Typed sitemap hooks (D-14)
|
||||
```go
|
||||
// BeforeAddItem: UntilHandled; a listener sets Skip to drop the URL (PHP returned false).
|
||||
type BeforeAddItem struct { Item DefinitionItem; Info ItemInfo; Skip bool }
|
||||
func (e *BeforeAddItem) IsHandled() bool { return e.Skip } // festival.Handleable
|
||||
// AddItem: Fire; listeners may append extra child XML (already escaped) to Extra.
|
||||
type AddItem struct { Item DefinitionItem; Info ItemInfo; Extra []string }
|
||||
// usage: handled, err := app.Events.UntilHandled(ctx, &BeforeAddItem{...})
|
||||
```
|
||||
`festival.Bus.UntilHandled` stops at the first handled event or the first error [VERIFIED: modules/festival/bus.go:100-109]. Write `Extra` before `</url>` so listeners can add elements, as the PHP `$urlElement->appendChild` could.
|
||||
|
||||
### Default tier config (D-13, from PHP `getPriorityForUrl`)
|
||||
```yaml
|
||||
# sm-sitemap-plugin config/config.yaml — defaults equal the PHP rules; host config may replace the list.
|
||||
golem15:
|
||||
sitemap:
|
||||
priority_tiers:
|
||||
- { setting: priority_homepage, exact: ["", "/en"] }
|
||||
- { setting: priority_services, pattern: '^/(uslugi|en/services|realizacje|en/work)/[^/]+$' }
|
||||
- { setting: priority_blog, pattern: '^/(blog|en/blog)/[^/]+/[^/]+$' }
|
||||
- { setting: priority_categories, pattern: '^/(uslugi|en/services|realizacje|en/work|blog|en/blog)(/[^/]+)?$' }
|
||||
- { setting: priority_legal, exact: ["/polityka-prywatnosci", "/en/privacy-policy", "/regulamin", "/en/terms", "/o-nas", "/en/about", "/kontakt", "/en/contact", "/laboratorium-ai", "/en/ai-lab", "/laboratorium-ai/zabawa-animacja", "/en/ai-lab/blob-playground"] }
|
||||
```
|
||||
These patterns are copied from fonoteka `Definition.php` `getPriorityForUrl` [VERIFIED: file read this session]. The path is `parse_url(PATH)` with `rtrim('/')`, and the first match wins. The live grzyby priorities `{1.0, 0.5, 0.4}` are consistent with these rules (posts `/blog/<slug>` hit the categories tier 0.4; root categories fall back to item 0.5) [VERIFIED: live grzyby sitemap]. The config key names are [ASSUMED].
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `pages.menuitem.*` string events | Typed `pact` capability + registry | This phase | Compile-time contract for every content plugin |
|
||||
| CMS-page-based URLs (`cmsPage`) | Headless URL patterns, 3 layers | PHP 2.0.3 added `urlPattern`; Go drops `cmsPage` | Copied items lose `cmsPage` (report them) |
|
||||
| Fake `lastmod = now()` (grzyby's older PHP plugin) | Omit lastmod; manifest fallback | fonoteka plugin version | Output changes vs the live grzyby site, intentionally |
|
||||
| Regex HTML gates | bluemonday + byte-stable compare | 2026-10-09 decision note | Framework adopts the same for `richeditor` |
|
||||
| TipTap v2 StarterKit (no Link) | v3 StarterKit includes Link, Underline, ListKeymap, TrailingNode | TipTap v3 | No extra extension packages for FAQ |
|
||||
|
||||
**Deprecated/outdated:** Winter `$guarded = []` on Definition (grzyby copy). The port follows the fonoteka `$fillable = ['theme','data']` + `$guarded = ['*']` (`MassAssignmentTest`). In Go, `Fillable()` returns exactly `theme`, `data`.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | D-10's Journal-category clause should be dropped because 16.2 shipped `blogeditor` | Summary/Conflict | Low; executing it regresses the Blog Editor. **Needs user confirmation** |
|
||||
| A2 | Module names "signpost" (registry) and "seaglass" (sanitizer); config keys `menu_items.patterns`, `golem15.sitemap.priority_tiers` | Patterns 1-2, tiers | Naming only; published config keys are costly once used |
|
||||
| A3 | Pattern placeholders keep PHP names (`:slug`, `:category`, `:id`, …) rather than D-02's illustrative `:post` | Pattern 2 | Copied PHP `urlPattern`s would not fill otherwise |
|
||||
| A4 | Menu items get pattern overrides through a new nullable `url_pattern` column (additive), and nesting defaults to the type's `Nesting` (no column) | Pattern 8 | Schema is "PHP columns + 1"; needs confirmation against "Tables keep their PHP names and columns" |
|
||||
| A5 | The menu built-in key is `url` (PHP stored `link`, mapped on copy) | D-05 | Stored values; costly to change later |
|
||||
| A6 | The singleton definition uses a controller form + settings link + a new "singleton form" capability, not `HasSettings` | Pattern 7 | Alternative is settings-form repeater support |
|
||||
| A7 | The backend URL for the rewrite = `app.url` if set, else the request base from trusted `X-Forwarded-Proto` + `Host` (Winter `url('/')` likely used the request root; not verified for `linkPolicy`) | Pitfall 6 | A wrong base makes the rewrite a no-op; verify with a staging fetch |
|
||||
| A8 | The FAQ layer ships no client sanitizer; the server sanitizes on output | Pattern 9 | Defense-in-depth choice |
|
||||
| A9 | The live grzyby DB holds sitemap/menu type values beyond the four menu options (for example Blog-plugin types); the mapping table must come from a read-only query | Data copy | An unmapped type is dropped silently unless reported |
|
||||
| A10 | Nuxt layers stacking three layers with no module declarations in the new two avoids duplicate-module issues | Pattern 10 | If a layer needs i18n strings, it must rely on the app's i18n |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
Every question below is settled by the phase plans; the resolution line names the plan that carries it.
|
||||
|
||||
1. **D-10 Journal clause (A1).** We know category content is `blogeditor`. Recommendation: confirm with the user and drop the clause; the remaining D-10 scope is FAQ only.
|
||||
**RESOLVED:** the clause is dropped as a recorded deviation: Journal category content stays `blogeditor`. See the 17-01 truth "D-10 resolved deviation", the decision note in 17-01 Task 2, and the 17-03 truth. The core `richeditor` serves FAQ `description` and the repeater `answer` (17-01 Task 2, 17-02, 17-04).
|
||||
2. **Menu item pattern override storage (A4).** D-02 promises a per-item override, but the PHP menu table has no column for it. Recommendation: add a nullable `url_pattern` column in an additive migration, or leave it out for menus (sitemap items have `urlPattern` already).
|
||||
**RESOLVED:** 17-04 adds the nullable `url_pattern` column in the additive migration `202610110003_add_url_pattern_to_golem15_menu_menu_entries`. The form gains a `url_pattern` field, and the API passes the column to signpost as the item override.
|
||||
3. **Live data shapes (A9).** Before writing the type mapping, run read-only queries on the PHP server: `SELECT DISTINCT type FROM golem15_menu_menu_entries;`, `SELECT id, theme, data FROM golem15_sitemap_definitions;`, `SELECT value FROM system_settings WHERE item='golem15_sitemap_settings';`, and `SELECT id, slug, locale_id FROM golem15_faq_faqs;`. Recommendation: a human checkpoint in the integration plan.
|
||||
**RESOLVED:** the 17-05 Task 1 blocking human checkpoint runs the read-only discovery queries and the plugin export SQL. It records the menu code and each DISTINCT type with its mapping, and the import commands receive those mappings as `--map old=new`.
|
||||
4. **Site cache after menu/FAQ edits.** Journal purges the Nuxt swr cache on admin saves; menu and FAQ edits would show only after the TTL (≤300 s). The purger type lives in Journal. Recommendation: accept the TTL now, with a follow-up todo for a framework-level purge service.
|
||||
**RESOLVED:** the TTL is accepted. 17-05 Task 5 writes the todo `site-cache-purge-for-menu-and-faq.md` and documents the delay in DEPLOY.md and the site README.
|
||||
5. **Where the import commands live.** Generic `menu:import-winter` / `faq:import-winter` / `sitemap:import-winter` (JSONL) in the plugins, reusable for other PHP sites, or `grzyby:*` app commands. Recommendation: put them in the plugins (the D-05 mapping is a plugin contract), with `migrate.sh` ordering them after the translate locale step.
|
||||
**RESOLVED:** the commands live in the plugins, each with its export SQL: `sitemap:import-winter` in 17-03, `menu:import-winter` and `faq:import-winter` in 17-04. In 17-05, `scripts/import-companions.sh` runs them in order (FAQ, menu, sitemap), and `migrate.sh` calls it after the Journal and locale steps.
|
||||
6. **Empty definition.** PHP answers `200` with an empty body when there are no items and a 404 page when there is no definition. Recommendation: keep both behaviors (empty `application/xml`; a plain 404).
|
||||
**RESOLVED:** both behaviors are kept, per the 17-03 SC-3 edge truth: a definition with no items answers 200 `application/xml` with an empty body, and a missing definition answers 404.
|
||||
7. **Menu `target` field.** The PHP form lacks it but the template uses it. Recommendation: add a dropdown (`_self`/`_blank`) to the port's fields.yaml, documented as an adaptation.
|
||||
**RESOLVED:** 17-04 adds the `target` dropdown (`_self` by default, `_blank`), validates it on save and documents it in the README's Winter adaptations.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.0 | — |
|
||||
| Node / npm / pnpm | admin SPA, layers | ✓ | 22.23.2 / 12.0.2 / 11.3.0 | — |
|
||||
| Docker daemon | testcontainers Postgres suites | ✓ | 29.7.2 | `-short` |
|
||||
| php CLI + vendored Winter | golden fixtures (escaping, Url::to, tiers) | ✓ | PHP 8.5.10; `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor` | Commit the goldens so CI needs no PHP |
|
||||
| psql, jq, mysql client, xmllint, xsltproc | migrate.sh, XML well-formedness and XSL smoke | ✓ | present | — |
|
||||
| Live PHP sites (reference output) | parity check | ✓ grzybyfunkcjonalne.pl, www.golem15.com (horoskopia 403) | — | Saved fixtures |
|
||||
| Gitea repos sm-{sitemap,menu,faq}-plugin, vue-{menu,faq}-layer | SC-1, D-18 | ✗ (`git ls-remote` "Cannot find repository") | — | **Human step:** create the repos (layers public) before the first push |
|
||||
|
||||
**Missing dependencies with no fallback:** the five new remote repositories (human creation; pushes are gated anyway).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testcontainers Postgres), vitest 3.2.7 (admin SPA), `node --test` (layers) |
|
||||
| Config file | none for Go; `admin/vite.config`/vitest; layer `package.json` `test` script |
|
||||
| Quick run command | `go test -short ./modules/pact ./modules/cabana/... ./modules/<registry>/... ./modules/<sanitizer>/...` |
|
||||
| Full suite command | `bash scripts/check-phase17.sh --all` (new; staged like `check-phase16.2.sh`: go, admin, plugins, layers, docs, postgres) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-2/D-01 | Duplicate type key fails boot; `Lookup` registry; pattern layers | unit | `go test -short ./modules/<registry> -run 'TestRegistry'` | ❌ Wave 0 |
|
||||
| D-04 | Journal 5 types: references, resolve, alternates order, x-default, published filters | unit+pg | `cd ../sm-journal-plugin && go test ./... -run TestMenuItemTypes` | ❌ |
|
||||
| D-07 | Repeater compile refusals, decode, row errors `content.N.answer`, nested richeditor notices | unit | `go test -short ./modules/cabana -run TestRepeater` | ❌ |
|
||||
| D-08 | dependsOn compile, options route, save recheck 422 | unit+conformance | `go test -short ./modules/cabana -run 'TestDependentDropdown|TestPhase10ConformanceInventory'` | ❌ |
|
||||
| D-09 | Reorder: exact-set rule, cycle/foreign parent refusal, partition, both columns written | pg | `go test ./modules/cabana -run TestReorder` | ❌ |
|
||||
| D-10 | richeditor strips and reports; byte-stable when clean; regex-bypass corpus refused | unit | `go test -short ./modules/<sanitizer> ./modules/cabana -run 'TestSanitize|TestRichEditor'` | ❌ |
|
||||
| SC-3 | Golden parity: escaping, Url::to table, tiers, lastmod format, per-locale rule, rewrite, 50k cap, empty definition | unit (goldens from PHP) | `cd ../sm-sitemap-plugin && go test -short ./... -run 'TestSitemapGolden|TestURLTo|TestTiers|TestRewrite'` | ❌ |
|
||||
| D-14 | XmlEscaping/MassAssignment spec ports; BeforeAddItem skip; AddItem extra | unit | same package `-run 'TestXMLEscaping|TestFillable|TestHooks'` | ❌ |
|
||||
| D-16 | Menu API tree, published filter, inactive 404, URL-scheme filter, registry expansion | pg | `cd ../sm-menu-plugin && go test ./...` | ❌ |
|
||||
| D-17 | FAQ API locale default, active only, output sanitized | pg | `cd ../sm-faq-plugin && go test ./...` | ❌ |
|
||||
| Admin SPA | RepeaterField, RichEditorField (untouched value byte-identical), dependent dropdown, ReorderView | vitest | `npm --prefix admin run test` | ❌ |
|
||||
| D-18 | Layer clients and components, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ |
|
||||
| SC-4 | Host boot with three plugins; import commands (type map, locale remap, setval); `/sitemap.xml` from the host | pg/host | `cd ../sm-grzybyfunkcjonalne-app && go test ./...` | partial (boot_test.go exists) |
|
||||
| SC-4 manual | UAT: menu and FAQ render from the API; `/sitemap.xml` via nginx equals the expected golden; robots.txt Sitemap line | manual | on-server smoke (`scripts/smoke.sh` extension) | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick command for the touched repo, plus `go vet ./...`.
|
||||
- **Per wave merge:** full Go suites per repo with Docker, and the admin vitest.
|
||||
- **Phase gate:** `scripts/check-phase17.sh --all` green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `scripts/check-phase17.sh`: staged gate (copy the 16.2 structure, named-test PASS checks).
|
||||
- [ ] PHP golden generator script (for example `../sm-sitemap-plugin/tests/php/gen-goldens.php`, run with the local `php` against `grzybyfunkcjonalne.pl/vendor`) plus the committed `testdata/*.golden`.
|
||||
- [ ] `go.work` for each new plugin repo (as in `sm-journal-plugin/go.work`: `use .` + replaces to `../summercms.go`, `../sm-translate-plugin`).
|
||||
- [ ] Test harness per plugin (copy `admin_harness_test.go` / `TestMain` with `flag.Parse()` + `testing.Short()`).
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (admin) | Existing cabana backend guard; public APIs anonymous by design |
|
||||
| V3 Session Management | no new | — |
|
||||
| V4 Access Control | yes | Permission codes `golem15.sitemap.access_definitions`, `golem15.menu.manage_menu`, `golem15.faq.faqs` on controllers; the reorder and dependent-options routes inherit the controller permissions and scopes (`ListExtendQuery`/`FormExtendQuery`) |
|
||||
| V5 Input Validation | yes | Repeater schema-driven decode, dependent-value recheck, URL scheme allowlist, `target` enum, tier regex compiled at boot (RE2, no ReDoS) |
|
||||
| V6 Cryptography | no | — |
|
||||
| V12 Files | yes | `updates_manifest_path`: read only a regular file, size cap, JSON object only; errors omit lastmod (PHP fails safe) |
|
||||
| V14 Config | yes | Host CORS paths, throttle buckets |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Stored XSS via richeditor/FAQ HTML | Tampering/Elevation | bluemonday policy on save **and** on FAQ API output (the copy bypasses save) |
|
||||
| Stored XSS via menu `url` (`javascript:`) | Tampering | Scheme allowlist on save and in the API; the layer refuses non-allowlisted schemes |
|
||||
| XML injection in sitemap | Tampering | libxml-equivalent escaping (XmlEscapingTest spec) |
|
||||
| Mass assignment on Definition | Tampering | `Fillable()` = `theme`, `data` (MassAssignmentTest spec) |
|
||||
| Reorder IDOR (moving another menu's entries) | Elevation | Exact scoped set + partition check in one locked transaction |
|
||||
| Public API scraping/DoS | DoS | `throttle:menu-public-api` / `faq-public-api` buckets; the sitemap is cheap, but the 50k cap bounds it |
|
||||
| Admin-supplied file path (manifest) | Information disclosure | Only dates are derived; never echo file content |
|
||||
|
||||
Run the security-review agent after the framework and plugin plans (CONTEXT discretion item).
|
||||
|
||||
## Suggested plan split (for the plan-count checkpoint)
|
||||
|
||||
1. **Framework Go (summercms.go):** `pact` registry interface + registry module + `party` hook; sanitizer module (bluemonday, decision note); cabana repeater, dependent dropdown, richeditor gate, tree reorder routes, singleton form; OpenAPI, conformance, READMEs, docs.
|
||||
2. **Admin SPA + framework release:** RepeaterField, RichEditorField (TipTap 3.31.4, human package check), dependent dropdown, ReorderView; `schema.d.ts`, dist, `check-admin-dist`; release gate (human) and a summercms tag (next free after v0.1.22).
|
||||
3. **Journal types + sm-sitemap-plugin:** Journal `HasMenuItemTypes` (additive); the sitemap port (models, settings, definitions singleton, generator, `Url::to`, tiers config, hooks, routes, XSL, `sitemap:import`), PHP goldens, README.
|
||||
4. **sm-menu-plugin + sm-faq-plugin:** models and migrations, admin screens (reorder, dependent reference), public APIs, Winter import commands, READMEs.
|
||||
5. **Site integration:** create the repos (human), `vue-menu-layer` + `vue-faq-layer` (tags, human push), grzyby submodules/`go.work`/`summer.yaml`/CORS/nginx, PHP export SQL + `migrate.sh` steps, remove the app menu/FAQ data, disable the Nuxt sitemap, UAT redeploy and smoke.
|
||||
6. **Unit tests + gate:** fill the test map, `scripts/check-phase17.sh`, validation map.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- In-repo files read this session: `modules/pact/capabilities.go`, `modules/cabana/{form_schema,crud,settings,field_markdown}.go`, `modules/cabana/README.md`, `modules/lagoon/jsonable.go`, `modules/festival/bus.go`, `modules/party/registry.go`, `modules/backpack/app.go`, `modules/phrasebook/translator.go`.
|
||||
- Port sources: fonoteka `plugins/golem15/sitemap/*` and `journal/{Plugin.php, models/Category.php, models/Post.php}`; grzybyfunkcjonalne.pl `plugins/golem15/{menu,faq,translate}`, `themes/arsha/*`, vendor `winter/storm` + `laravel/framework` UrlGenerator.
|
||||
- Siblings: `../sm-journal-plugin`, `../sm-translate-plugin`, `../sm-grzybyfunkcjonalne-app` (config, nginx, scripts), `../vue-journal-layer`.
|
||||
- Live: `https://grzybyfunkcjonalne.pl/sitemap.xml` + `.xsl`, `https://www.golem15.com/sitemap.xml` (fetched 2026-10-11).
|
||||
- Local PHP 8.5.10 probes (DOMDocument escaping, Winter `Url::to`/`buildUrl`).
|
||||
- npm registry (`npm view`), Go module proxy (`go list -m`), MSD package-legitimacy seam.
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- tiptap.dev StarterKit docs (v3 contents).
|
||||
- nuxtseo.com sitemap `enabled` and robots `sitemap` options.
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None used as a basis for recommendations.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Same versions already in use in sibling repos; registry checks run.
|
||||
- Architecture: MEDIUM. The framework API shapes are proposals within locked decisions; the seams they plug into were verified.
|
||||
- Pitfalls: HIGH. Most were reproduced against PHP or found in the code.
|
||||
|
||||
**Research date:** 2026-10-11
|
||||
**Valid until:** 2026-11-10 (stable port; re-check TipTap if planning slips past a month)
|
||||
815
.planning/phases/17-blog-companions/17-UI-SPEC.md
Normal file
815
.planning/phases/17-blog-companions/17-UI-SPEC.md
Normal file
@@ -0,0 +1,815 @@
|
||||
---
|
||||
phase: "17"
|
||||
slug: "blog-companions"
|
||||
status: approved
|
||||
reviewed_at: "2026-10-11"
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: "2026-10-11"
|
||||
---
|
||||
|
||||
# Phase 17 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
|
||||
|
||||
This phase has three visual surfaces:
|
||||
|
||||
1. **Admin SPA (`admin/`, framework).** Four generic cabana features (D-06 to D-10) and one view variant:
|
||||
- the `richeditor` field (HTML WYSIWYG);
|
||||
- the `repeater` field;
|
||||
- the dependent dropdown (`dependsOn`);
|
||||
- the tree reorder page, reached from a new list heading button;
|
||||
- the singleton form, which opens the one sitemap definition directly (D-11).
|
||||
2. **Plugin admin screens** of `sm-faq-plugin`, `sm-menu-plugin` and `sm-sitemap-plugin`. They are YAML only (`fields.yaml`, `columns.yaml`, settings fields) and have no plugin Vue code. They render through the existing list, form and settings views plus the features above.
|
||||
3. **Two Nuxt layers, `vue-menu-layer` and `vue-faq-layer` (D-18).** They are neutral, token-driven render components in the `vue-journal-layer` style. The site app overrides their tokens or components.
|
||||
|
||||
`sitemap.xml` and `sitemap.xsl` have **no new visual design**. The XSL is embedded byte-identical to the PHP `assets/sitemap.xsl`, so the browser view of the sitemap stays the PHP presentation (D-14, RESEARCH Pattern 4).
|
||||
|
||||
The admin SPA already has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no new admin tokens, colours, fonts, radii or shadows.** Every admin value below reuses an existing token or copies a class string from an existing component: `FormView.vue`, `ListView.vue`, `FormField.vue`, `FormGrid.vue`, `DropdownField.vue`, `MarkdownField.vue`, `FileuploadField.vue`, `Button.vue`, `control.ts`, and the 12.2 and 16.3 UI-SPECs.
|
||||
|
||||
Sources:
|
||||
- `17-CONTEXT.md`: D-05, D-07 to D-11, D-13, D-16 to D-18 (locked).
|
||||
- `17-RESEARCH.md`: Patterns 5 to 10, Pitfalls 9, 10 and 15, Assumptions A1, A4 and A6 to A8.
|
||||
- Phase 12.2 and 16.3 UI-SPECs (approved house conventions).
|
||||
- The codebase scan listed above.
|
||||
- The PHP port sources: `faq/models/faq/fields.yaml`; `menu/models/{menu,menuentry}/fields.yaml`, `menuentries/_list_toolbar.php` and `reorder_config.yaml`; `sitemap/classes/definitionitem/fields.yaml`, `models/settings/fields.yaml` and `lang/{en,pl}`; `themes/arsha/partials/g15_menu`, `g15_faq`.
|
||||
- The current site markup the layers replace: `vue-grzybyfunkcjonalne-app/app/components/{SiteHeader,FaqSection}.vue`.
|
||||
|
||||
**Out of this contract:**
|
||||
- Journal's category `content` field stays `type: blogeditor` (the Phase 16.2 Blog Editor). RESEARCH A1 recommends dropping D-10's "switch to richeditor" clause, and this contract specifies no change to it; the user confirms during planning.
|
||||
- The production cutover.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none. There is no shadcn: this is a Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2 and 16.3. The Nuxt layers use plain scoped CSS on their own tokens, like `vue-journal-layer`. |
|
||||
| Preset | not applicable |
|
||||
| Component library | Admin: Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/`. Layers: none (Vue SFCs with native `<details>`, `<nav>` and `<button>`). |
|
||||
| Icon library | Admin: `@lucide/vue` 1.17.0, named imports only. Sizes: 16px in buttons and toolbar buttons, 14px in error and note lines, 20px in the empty-state circle. Navigation icons use the registered names in `admin/src/app/icons.ts`. Layers: no icon library; chevrons are CSS borders, as in the current site header. |
|
||||
| Font | Admin: DM Sans 400/600 for new content; 700 only on the inherited page `h1`, copied verbatim from `FormView` (self-hosted). 13px meta text is always `text-muted`, except error lines (`text-danger`). DM Mono 400 (`font-mono`) only in the richeditor HTML source view. `tabular-nums` on row indexes and counters. Layers: `--menu-font` and `--faq-font` tokens with a system-ui stack (no web font, so the layers stay module-free per RESEARCH Pattern 10). |
|
||||
| WYSIWYG engine (admin) | TipTap 3.31.4: `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, pinned exact. These are new admin SPA dependencies, named by RESEARCH (Standard Stack) and behind a `checkpoint:human-verify` before `npm install`. `@tiptap/vue-3` is not added. |
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-11.
|
||||
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 53 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 3156baa) — 2026-10-11.
|
||||
|
||||
**Primitive presence.** Verified on 2026-10-11 with `node --input-type=module -e "import * as r from 'reka-ui'; ..."` in `admin/`: `ToolbarRoot`, `ToolbarButton`, `ToolbarSeparator`, `PopoverRoot`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`, `CollapsibleRoot`, `CollapsibleTrigger`, `CollapsibleContent` and the `Dialog*` parts are all exported.
|
||||
|
||||
**Icon presence.** Verified the same day with `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` against @lucide/vue@1.17.0. These are all exported: `Bold`, `Italic`, `Underline`, `Heading3`, `List`, `ListOrdered`, `Quote`, `Link`, `Unlink`, `Undo2`, `Redo2`, `Code`, `GripVertical`, `ChevronUp`, `ChevronDown`, `IndentIncrease`, `IndentDecrease`, `ArrowUpDown`, `Plus`, `Trash2`, `CircleAlert`, `Info`, `LoaderCircle`, `RotateCcw`, `ExternalLink`, `X`.
|
||||
|
||||
**Navigation icon names.** These are already registered in `icons.ts`: `list`, `list-ordered`, `circle-question-mark` (also reachable as `icon-question-circle`), `network` (as `icon-sitemap`) and `settings` (as `icon-cog`).
|
||||
|
||||
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
|
||||
|
||||
| Component | Import path | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| ToolbarRoot, ToolbarButton, ToolbarSeparator | `reka-ui` | The richeditor formatting toolbar: roving tabindex and `aria-orientation`. Verified exported. |
|
||||
| PopoverRoot, PopoverTrigger, PopoverContent | `reka-ui` | The richeditor link popover. Verified exported. |
|
||||
| CollapsibleRoot, CollapsibleTrigger, CollapsibleContent | `reka-ui` | Expanding and collapsing a repeater row body. Verified exported. |
|
||||
| AlertDialog* via ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` + `useConfirm` | Confirms removing a non-empty repeater row. FormView already mounts one ConfirmDialog; reuse that instance. Do not build a second confirm. |
|
||||
| Button | `admin/src/components/ui/Button.vue` | Every text button. Variants `primary`/`outline`/`ghost`/`danger`, sizes `md` (42px) and `sm` (38px). |
|
||||
| FormField, FormGrid | `admin/src/components/form/` | Repeater row bodies render a nested `FormGrid` with its own `idPrefix`. Notices and errors use the existing FormField lines and callouts. |
|
||||
| DropdownField | `admin/src/components/form/fields/DropdownField.vue` | Extended, not forked, for `dependsOn` (same native `<select>`, same `ChevronDown`). |
|
||||
| `controlClass`, `controlAttributes` | `admin/src/components/form/control.ts` | Border, background and padding of the richeditor container, the source textarea and the link URL input. |
|
||||
| `.summer-markdown` | `admin/src/styles/main.css` | Content typography inside the richeditor editing area and in the read-only HTML view, reused unchanged. |
|
||||
| `fieldErrors`, `focusField` | `admin/src/components/form/formState.ts` | Map path keys such as `content.3.answer` to nested fields, and focus the first invalid one. |
|
||||
| `showToast` | `admin/src/state/useToasts.ts` | Reorder failure and stale toasts. |
|
||||
| `t`, `tc`, `message` | `admin/src/app/i18n.ts` | All copy. Components contain no literal UI strings. |
|
||||
| `api` (openapi-fetch client) | `admin/src/api/client.ts` | The dependent-options, reorder and singleton routes, called after `npm run gen:api`. |
|
||||
| Native HTML5 drag and drop | the pattern in `FileuploadField.vue` (`draggable="true"`, `@dragstart`) | Pointer reordering of repeater rows and tree nodes. No drag-and-drop library is added (RESEARCH, Alternatives Considered). |
|
||||
|
||||
**New code this phase creates** (the planner names the files):
|
||||
- `admin/src/components/form/fields/RichEditorField.vue`, plus the TipTap glue (suggested `admin/src/components/form/richeditor/`);
|
||||
- `admin/src/components/form/fields/RepeaterField.vue`;
|
||||
- `dependsOn` support inside `DropdownField.vue`;
|
||||
- `admin/src/views/ReorderView.vue` and its route;
|
||||
- a `reorder` heading button in `ListView.vue`;
|
||||
- singleton handling in `FormView.vue`, or a thin `SingletonFormView.vue` wrapper.
|
||||
|
||||
The registry changes:
|
||||
- `richeditor` and `repeater` join `renderers`;
|
||||
- `repeater` joins `groupLabelledTypes`;
|
||||
- neither type is `valueless`.
|
||||
|
||||
Layers (new repos, no component library):
|
||||
- `vue-menu-layer`: `app/components/Menu/Nav.vue` (auto-registers as `MenuNav`), `app/components/Menu/NavItem.vue` (`MenuNavItem`, recursive), `app/composables/useMenu.ts`, `app/composables/useMenuApi.ts`, `app/utils/menuLinks.ts`, `app/assets/css/menu-tokens.css` and `menu-base.css`;
|
||||
- `vue-faq-layer`: `app/components/Faq/Block.vue` (`FaqBlock`), `app/components/Faq/List.vue` (`FaqList`), `app/components/Faq/Item.vue` (`FaqItem`), `app/composables/useFaq.ts`, `app/composables/useFaqApi.ts`, `app/assets/css/faq-tokens.css` and `faq-base.css`.
|
||||
|
||||
The layers deliberately ship no component named `FaqSection`, because the site app already has a `FaqSection.vue`, and a same-name file is the override mechanism. A layer component of that name would be silently replaced.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values for new admin layout in this phase (multiples of 4):
|
||||
|
||||
| Token | Value | Tailwind | Usage |
|
||||
|-------|-------|----------|-------|
|
||||
| xs | 4px | `gap-1`, `p-1` | Toolbar padding and button gap; gap between the icon buttons of a row header or tree row |
|
||||
| sm | 8px | `gap-2`, `px-2` | Repeater row header gap and horizontal padding; tree row gap; link popover stack gap; empty-state stack gap |
|
||||
| md | 16px | `gap-4`, `p-4`, `px-4` | Gap between repeater rows; repeater row body padding; gap from the last row to the add button; tree row base horizontal padding; link popover padding |
|
||||
| lg | 24px | `px-6`, indent step | Tree indent per depth level (`padding-inline-start: calc(16px + depth * 24px)`); empty-state horizontal padding |
|
||||
| xl | 32px | `py-8` | Empty-state vertical padding (repeater, reorder page) |
|
||||
| 2xl | 48px | `min-h-12` | Repeater row header minimum height |
|
||||
| 3xl | 64px | — | Not used by new components |
|
||||
|
||||
Exceptions (inherited from Direction C and binding, not new; the same set accepted in 12.2 and 16.3):
|
||||
- form card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), from `FormView`;
|
||||
- form-grid row gap 22px (`gap-y-[22px]`) and column gap 24px, from `FormGrid`, reused unchanged inside repeater rows;
|
||||
- label-to-control gap 6px (`gap-1.5`), from `FormField`;
|
||||
- control horizontal padding 14px (`px-3.5`) and textarea vertical padding 10px (`py-2.5`), from `MarkdownField`; the richeditor editing area and source textarea copy them;
|
||||
- footer button gap 10px (`gap-2.5`), from `FormView`;
|
||||
- alert block padding 14×18 (`px-[18px] py-3.5`), from the 12.2 alert block;
|
||||
- 2px nudges (`mt-0.5`, `gap-0.5`) in error lines, copied from the existing error-line markup.
|
||||
|
||||
No other off-scale spacing is introduced.
|
||||
|
||||
Fixed component dimensions. These are sizes, not spacing, and are listed so the checker treats them as deliberate:
|
||||
- 32px square icon buttons (`size-8`, `rounded-pager`) in the toolbar, row headers and tree rows;
|
||||
- 44px inputs (`h-input`), and 42px / 38px buttons;
|
||||
- 54px tree row minimum height (`min-h-row`, the existing `--spacing-row` token);
|
||||
- 48px repeater row header minimum height;
|
||||
- richeditor editing area `min-h-[160px]` and `max-h-[480px]`;
|
||||
- link popover width 320px;
|
||||
- 40px empty-state icon circle;
|
||||
- 2px drop-line indicator (`h-0.5`);
|
||||
- 20px toolbar separator height;
|
||||
- 12px skeleton bars with `rounded-[6px]` (the existing list skeleton bar).
|
||||
|
||||
**Layers.** Spacing comes only from tokens: `--menu-space-1..4` and `--faq-space-1..4` = 0.25rem, 0.5rem, 1rem and 1.5rem (4, 8, 16 and 24px at a 16px root). The toggle button is 44×44 (`--menu-toggle-size: 2.75rem`), the touch-target minimum.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
New admin components use exactly three sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`.
|
||||
|
||||
| Role | Size | Weight | Line Height | Used by |
|
||||
|------|------|--------|-------------|---------|
|
||||
| Body | 14px | 400 | 1.5 | Richeditor text, dropdown text, tree row names, link popover input |
|
||||
| Label | 14px | 600 | 1.5 | Field labels (existing `FormField`), repeater row titles, empty-state headings, button text (existing `Button`), link popover label |
|
||||
| Meta | 13px | 400 | 1.5 | Repeater row index, repeater counter line, field comments, error and notice lines, dependent-dropdown state hints, reorder save status, richeditor source textarea (DM Mono), richeditor lossy note, empty-state body |
|
||||
| Page heading | 24px | 700 | 1.5 (inherited), `tracking-[-0.02em]` | Reorder page and singleton form `h1`: the existing `FormView` header class `text-[24px] font-bold tracking-[-0.02em]`, copied verbatim |
|
||||
|
||||
**Weights.** New content uses 400 and 600 only. **700 is the inherited, heading-only weight.** It appears only on the page `h1` (copied verbatim) and never in new components. No 12px, 17px, 18px, 20px or 26px text is introduced.
|
||||
|
||||
**Editor content.** The richeditor editing area and the read-only HTML view render the user's content with the existing `.summer-markdown` content styles. This is content typography, not interface typography, and it is reused unchanged. The toolbar offers only Heading 3, which renders at that class's 16px/600. No new content style is added.
|
||||
|
||||
**Layers.** Their typography is token-driven and limited to three sizes and two weights:
|
||||
|
||||
| Role | Token default | Weight | Line height | Used by |
|
||||
|------|---------------|--------|-------------|---------|
|
||||
| Body | `--menu-font-size` / `--faq-font-size-base` = 1rem (16px) | 400 | `--menu-line-height` 1.5 / `--faq-line-height` 1.7 | Menu links, submenu links, FAQ answers, FAQ description |
|
||||
| Question | `--faq-question-size` = 1.125rem (18px) | 600 | 1.4 | FAQ `summary` |
|
||||
| Title | `--faq-title-size` = 1.5rem (24px) | 600 | 1.25 | `FaqBlock` heading (the FAQ `name`) |
|
||||
|
||||
The active menu link does **not** change weight, so the layout does not shift. Active state is colour plus an underline.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
### Admin
|
||||
|
||||
All colours are existing CSS variables, with light and dark values from `main.css`. Hard-coded hex values are forbidden in new admin components.
|
||||
|
||||
| Role | Value (light / dark) | Usage |
|
||||
|------|----------------------|-------|
|
||||
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; form card; repeater row bodies; richeditor editing area; tree card and rows; popover |
|
||||
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049, `hover` #f1f3f7 / #212b42 | Richeditor toolbar strip; repeater row headers; empty-state circle; row and tree dividers; dashed empty-state borders; meta text; skeleton bars; icon-button hover |
|
||||
| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See the reserved list below |
|
||||
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Repeater row remove button hover; the remove confirm button; invalid borders; error lines; alert blocks; a repeater row with errors |
|
||||
|
||||
Accent reserved for (exhaustive for this phase):
|
||||
1. **`primary` fill:** the page's existing footer Save (unchanged) and the link popover's "Apply link" button (the only primary on that surface). The reorder page has **no** primary fill, because it saves each move.
|
||||
2. **`primary` lines:**
|
||||
- the 2px drop line that marks a "before" or "after" target while a tree node or repeater row is dragged;
|
||||
- the dashed outline of an "inside" drop target;
|
||||
- focused input borders (the existing base style).
|
||||
3. **`sel` tint:**
|
||||
- pressed richeditor toolbar buttons (`aria-pressed="true"`: the active bold, italic, underline, heading, list, quote or link state, and the HTML view toggle);
|
||||
- the "inside" drop-target row;
|
||||
- the drop slot of a dragged repeater row.
|
||||
4. **`ring` (3px):** focus-visible on every new focusable element (the base style), and `:focus-within` on the richeditor container. It is never removed.
|
||||
|
||||
Accent is never used for: the repeater "Add" button (outline), the "Reorder" heading button (outline), row titles, icons at rest, toolbar hover (uses `hover`), tree row hover (uses `hover`), links in editor content, or the dependent dropdown.
|
||||
|
||||
### Layers
|
||||
|
||||
Each layer declares its own tokens. A site without Journal still gets a complete look (D-18). Default values match the `vue-journal-layer` neutral palette, so stacked layers look coherent, but there are no cross-layer variable references. An app overrides any token on `:root:root`, as the Journal README documents.
|
||||
|
||||
| Role | `vue-menu-layer` token (default) | `vue-faq-layer` token (default) | Usage |
|
||||
|------|----------------------------------|---------------------------------|-------|
|
||||
| Dominant (60%) | `--menu-color-bg` #ffffff | `--faq-color-bg` #ffffff | Submenu panel and mobile panel / FAQ item background |
|
||||
| Secondary (30%) | `--menu-color-surface` #f6f8fa, `--menu-color-border` #d1d9e0, `--menu-color-muted` #59636e, `--menu-color-text` #1f2328 | `--faq-color-surface` #f6f8fa, `--faq-color-border` #d1d9e0, `--faq-color-muted` #59636e, `--faq-color-text` #1f2328 | Link hover background, panel and item borders, divider lines, body text |
|
||||
| Accent (10%) | `--menu-color-accent` #0969da | `--faq-color-accent` #0969da | See the reserved list below |
|
||||
| Destructive | none | none | The layers have no destructive action |
|
||||
|
||||
Layer accent reserved for:
|
||||
1. **Menu:** the active link (`aria-current="page"`): text colour plus a 2px underline (`text-decoration-thickness: 2px; text-underline-offset: 0.35em`). Active-trail ancestors get the text colour only.
|
||||
2. **FAQ:** links inside answers and the description (underlined).
|
||||
3. **Both:** the 2px focus-visible outline (`outline: 2px solid var(--…-color-accent); outline-offset: 2px`).
|
||||
|
||||
Accent is never used for menu hover (uses `--menu-color-surface`), open dropdown toggles, FAQ `summary` text, or the FAQ chevron (uses `--faq-color-text`).
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
**Admin framework copy** is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`, in the new blocks `repeater:`, `richeditor:` and `reorder:`, plus new keys in `form:` and `messages.list:`.
|
||||
|
||||
**Plugin copy** lives in each plugin's `lang/{en,pl}/lang.yaml` and keeps the PHP key names. Three rules apply:
|
||||
- Values are corrected where PHP shipped placeholders or typos: "Menuses" becomes "Menus", "Is_published" becomes "Published", and "No description provided yet..." becomes a real description.
|
||||
- A `pl` file is added where PHP had only `en` (FAQ).
|
||||
- Copy never names a consuming application (CLAUDE.md).
|
||||
|
||||
Placeholders use `:name` syntax; plurals are CLDR maps (`one`, `few`, `many`, `other` for Polish). Polish is the design language; English values follow.
|
||||
|
||||
**Layer copy.** The layers ship no site copy. Their only strings are accessibility labels, passed as props with the English defaults listed below; the site app passes `$t(...)` values. Menu titles and FAQ text come from the API.
|
||||
|
||||
### Primary CTAs and key states (admin, framework)
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Primary CTA, repeater add (default) | `repeater.add` "Add item" / "Dodaj pozycję" (`Plus` icon). A field's `prompt:` replaces it. FAQ: `golem15.faq::lang.labels.add_question` "Add question" / "Dodaj pytanie". Sitemap: `golem15.sitemap::lang.item.add_item` "Add item" / "Dodaj pozycję"; the PHP `<u>…</u>` access-key markup is dropped from the value. |
|
||||
| Primary CTA, link popover | `richeditor.link_apply` "Apply link" / "Zastosuj link" |
|
||||
| List heading button | `messages.list.reorder` "Reorder records" / "Zmień kolejność rekordów" (`ArrowUpDown` icon), overridable per controller. Menu entries override it with "Reorder entries" / "Zmień kolejność pozycji". |
|
||||
| Repeater empty heading | `repeater.empty` "No items yet" / "Brak pozycji" |
|
||||
| Repeater empty body | `repeater.empty_hint` "Use “:prompt” below to add the first one." / "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą." `:prompt` is the add button's label. |
|
||||
| Repeater untitled row | `repeater.untitled` "Item :n" / "Pozycja :n" (when the `titleFrom` value is empty) |
|
||||
| Repeater counter (only with `maxItems`) | `repeater.count` "Items: :current of :max" / "Pozycje: :current z :max" |
|
||||
| Repeater row error chip | existing `form.tab_errors` (":count error" / ":count errors"; PL from the existing CLDR map) |
|
||||
| Reorder empty heading | `reorder.empty` "There is nothing to reorder yet" / "Nie ma jeszcze czego porządkować" |
|
||||
| Reorder empty body | `reorder.empty_hint` "Add records first, then come back to set their order." / "Najpierw dodaj rekordy, a potem wróć tutaj, aby ustalić ich kolejność." The header back button is the next step. |
|
||||
| Reorder untitled node | `reorder.untitled` "Untitled (#:id)" / "Bez tytułu (#:id)". Divider menu entries usually have no title. |
|
||||
| Reorder save status | busy: existing `form.saving` "Saving…" / "Zapisywanie…"; done: `reorder.saved` "Order saved" / "Zapisano kolejność" (shown for 2 s) |
|
||||
| Dependent dropdown, sibling empty | `form.dependent_waiting` "Choose “:field” first" / "Najpierw wybierz pole „:field”". `:field` is the sibling's label. |
|
||||
| Dependent dropdown, type takes no value | `form.dependent_not_used` "Not used for this choice" / "Nie dotyczy tego wyboru" |
|
||||
| Dependent dropdown, loading | existing `form.loading_options` "Loading…" / "Wczytywanie…" |
|
||||
| Dependent dropdown, type has no records yet | `form.dependent_empty` "No “:type” records yet. Add one first, or choose another type." / "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ." `:type` is the selected sibling option's label. The existing `form.no_options` stays unchanged for the plain DropdownField. |
|
||||
| Dependent dropdown, value cleared (polite live region) | `form.dependent_cleared` "“:field” was cleared because its options changed." / "Wyczyszczono pole „:field”, bo zmieniły się jego opcje." |
|
||||
| Richeditor HTML view toggle | `richeditor.source` "Edit HTML" / "Edytuj HTML" (`Code` icon, `aria-pressed`) |
|
||||
| Richeditor toolbar label | `richeditor.toolbar` "Formatting" / "Formatowanie" |
|
||||
| Richeditor lossy note | `richeditor.lossy_note` "Some formatting in this text cannot be shown in the editor. Use “Edit HTML” to change it without losing it." / "Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty." |
|
||||
|
||||
### Error states (admin, framework)
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Dependent options failed to load | `form.dependent_failed` "The options could not be loaded." / "Nie udało się wczytać opcji." plus a `form.dependent_retry` "Retry loading options" / "Ponów wczytywanie opcji" button (`RotateCcw`) |
|
||||
| Dependent value rejected on save (422) | `form.dependent_invalid` "Choose one of the listed options." / "Wybierz jedną z dostępnych opcji." (server message, rendered verbatim) |
|
||||
| Repeater required nested field (422) | the server's message under the nested field, verbatim (path key `content.3.answer`) |
|
||||
| Repeater too many or too few rows (422) | `repeater.max_items` "Remove items until there are at most :max." / "Usuń pozycje, aby zostało ich najwyżej :max."; `repeater.min_items` "Add items until there are at least :min." / "Dodaj pozycje, aby było ich co najmniej :min." (server messages under the field label) |
|
||||
| Richeditor editor failed to load | `richeditor.load_failed` "The editor could not be loaded. You can edit the HTML directly." / "Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio." |
|
||||
| Richeditor invalid link URL | `richeditor.link_invalid` "Links must use http, https, mailto, tel or a relative URL." / "Link musi używać http, https, mailto, tel albo adresu względnego." |
|
||||
| Richeditor sanitize notices (after save, warning callout under the field) | `richeditor.removed_elements` one: ":count element was removed: :names" other: ":count elements were removed: :names" / one: "Usunięto :count element: :names" few: "Usunięto :count elementy: :names" many: "Usunięto :count elementów: :names" other: "Usunięto :count elementu: :names". `richeditor.removed_attributes` and `richeditor.removed_comments` follow the same pattern. `richeditor.markup_changed` "The HTML filter changed some markup." / "Filtr HTML zmienił część znaczników." The copy is taken from Journal's `editor.*` keys so both editors speak the same way. |
|
||||
| Reorder load failed | `reorder.load_failed` "The records could not be loaded. Refresh the page to try again." / "Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie." |
|
||||
| Reorder save failed | `reorder.failed` "The new order could not be saved. The previous order is back." / "Nie udało się zapisać nowej kolejności. Przywrócono poprzednią." (danger toast) |
|
||||
| Reorder stale set (server refuses: a record was added or removed meanwhile) | `reorder.stale` "The records changed in the meantime. The list has been reloaded." / "Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie." (danger toast, tree reloads) |
|
||||
| Singleton form load failed | existing `form.load_failed` (unchanged FormView alert) |
|
||||
|
||||
### Destructive actions (admin)
|
||||
|
||||
| Action | Confirmation approach | Copy (EN / PL) |
|
||||
|--------|----------------------|----------------|
|
||||
| Remove a repeater row that has any non-empty value | ConfirmDialog, `danger`. The removal is deferred until the record's Save, so Cancel on the form undoes it and the form becomes dirty. | Button aria-label: `repeater.remove_named` "Remove: :title" / "Usuń: :title". Confirm: `repeater.remove_confirm` "Remove “:title”? The change is saved together with the record." / "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem." Confirm button: `repeater.remove` "Remove item" / "Usuń pozycję". |
|
||||
| Remove an empty repeater row | No dialog: there is nothing to lose. | the same aria-label |
|
||||
| Move or nest a tree node | No dialog. Each move saves at once, and the move buttons reverse any move. | — |
|
||||
| Leave a form with unsaved repeater or richeditor changes | existing unsaved-changes ConfirmDialog (unchanged) | existing `form.unsaved_confirm` / `form.discard` |
|
||||
| Empty the sitemap definition (the PHP `empty_confirm` toolbar action) | **Not ported.** The repeater removes rows one by one, and an accidental "empty all" is costly. Documented as an adaptation in the sitemap README. | — |
|
||||
|
||||
### Accessibility strings (admin, framework)
|
||||
|
||||
These are new keys:
|
||||
- `repeater.move` "Move: :title" / "Przenieś: :title" (drag handle);
|
||||
- `repeater.move_up` "Move up: :title" / "Przenieś wyżej: :title";
|
||||
- `repeater.move_down` "Move down: :title" / "Przenieś niżej: :title";
|
||||
- `repeater.collapse` "Collapse: :title" / "Zwiń: :title" and `repeater.expand` "Expand: :title" / "Rozwiń: :title";
|
||||
- `repeater.collapse_all` "Collapse all" / "Zwiń wszystkie" and `repeater.expand_all` "Expand all" / "Rozwiń wszystkie";
|
||||
- `repeater.moved` ":title moved to position :position of :total." / "Przeniesiono :title na pozycję :position z :total.";
|
||||
- `reorder.move` "Move: :name" / "Przenieś: :name";
|
||||
- `reorder.move_up` "Move up: :name" / "Przenieś wyżej: :name";
|
||||
- `reorder.move_down` "Move down: :name" / "Przenieś niżej: :name";
|
||||
- `reorder.indent` "Move inside the previous item: :name" / "Przenieś do poprzedniej pozycji: :name";
|
||||
- `reorder.outdent` "Move out one level: :name" / "Przenieś poziom wyżej: :name";
|
||||
- `reorder.moved` ":name moved to position :position of :total." / "Przeniesiono :name na pozycję :position z :total.";
|
||||
- `reorder.nested` ":name is now inside :parent." / ":name jest teraz wewnątrz :parent.";
|
||||
- `reorder.unnested` ":name moved out to level :level." / "Przeniesiono :name na poziom :level.";
|
||||
- `reorder.tree` "Order of records" / "Kolejność rekordów" (the list's accessible name).
|
||||
|
||||
Richeditor button labels:
|
||||
- `richeditor.bold` "Bold" / "Pogrubienie";
|
||||
- `richeditor.italic` "Italic" / "Kursywa";
|
||||
- `richeditor.underline` "Underline" / "Podkreślenie";
|
||||
- `richeditor.heading` "Heading" / "Nagłówek";
|
||||
- `richeditor.bullet_list` "Bullet list" / "Lista punktowana";
|
||||
- `richeditor.ordered_list` "Numbered list" / "Lista numerowana";
|
||||
- `richeditor.quote` "Quote" / "Cytat";
|
||||
- `richeditor.link` "Link" / "Link";
|
||||
- `richeditor.unlink` "Remove link" / "Usuń link";
|
||||
- `richeditor.undo` "Undo" / "Cofnij";
|
||||
- `richeditor.redo` "Redo" / "Ponów";
|
||||
- `richeditor.link_url` "Link URL" / "Adres linku".
|
||||
|
||||
Other keys:
|
||||
- the existing `form.close` is reused;
|
||||
- `settings.back` (existing) is the back-button label on the singleton form.
|
||||
|
||||
### Plugin admin copy (keys keep the PHP names)
|
||||
|
||||
| Plugin | Element | Copy (EN / PL) |
|
||||
|--------|---------|----------------|
|
||||
| faq | Navigation | `plugin.name` "FAQ" / "FAQ"; side menu `models.faq.label_plural` "FAQs" / "FAQ"; `plugin.description` "Questions and answers shown on the site." / "Pytania i odpowiedzi wyświetlane na stronie."; permission `permissions.faqs` "Manage FAQs" / "Zarządzanie FAQ" |
|
||||
| faq | Field labels | `labels.name` "Name" / "Nazwa"; `labels.slug` "Slug" / "Slug"; `labels.locale` "Language" / "Język"; `labels.description` "Description" / "Opis"; `labels.is_active` "Active" / "Aktywne"; `labels.content` "Questions" / "Pytania"; `labels.question` "Question" / "Pytanie"; `labels.answer` "Answer" / "Odpowiedź"; `labels.add_question` "Add question" / "Dodaj pytanie" |
|
||||
| faq | Field comment | `labels.is_active_comment` "Only active FAQs are served to the site." / "Strona otrzymuje tylko aktywne FAQ." `labels.locale_comment` "One FAQ per language. Use the same slug in each language." / "Jedno FAQ na język. W każdym języku użyj tego samego sluga." |
|
||||
| menu | Navigation | `plugin.name` "Menus" / "Menu"; side items `models.menu.label_plural` "Menus" / "Menu" and `models.menuentry.label_plural` "Menu entries" / "Pozycje menu"; permission "Manage site menus" / "Zarządzanie menu strony" |
|
||||
| menu | Menu fields | "Name" / "Nazwa"; "Code" / "Kod"; "CSS class" / "Klasa CSS"; "Active" / "Aktywne" |
|
||||
| menu | Entry fields | "Menu" / "Menu"; "Parent" / "Pozycja nadrzędna" (empty option: existing `form.none`); "Type" / "Typ"; "Title" / "Tytuł"; "URL" / "URL"; "Reference" / "Odnośnik"; "Code" / "Kod"; "Icon" / "Ikona"; "CSS class" / "Klasa CSS"; "Opens in" / "Otwieraj w"; "Published" / "Opublikowane" |
|
||||
| menu | Type options (built-ins, D-05) | `url` "Link" / "Link"; `divider` "Divider" / "Separator". Registry types follow in registry order with the owning plugin's labels (below). |
|
||||
| menu | Target options (new field, RESEARCH Open Q 7) | `_self` "Same tab" / "Tej samej karcie"; `_blank` "New tab" / "Nowej karcie" |
|
||||
| menu | URL comment and error | comment `models.menuentry.url_comment` "Used by Link entries. Start with http://, https://, mailto:, tel:, / or #." / "Dla pozycji typu Link. Zacznij od http://, https://, mailto:, tel:, / lub #."; 422 `errors.url_scheme` "Use a link that starts with http://, https://, mailto:, tel:, / or #." / "Podaj link zaczynający się od http://, https://, mailto:, tel:, / lub #." (Pitfall 15) |
|
||||
| menu | Reference comment | `models.menuentry.reference_comment` "The record this entry points to, for types that need one." / "Rekord, do którego prowadzi pozycja, dla typów, które go wymagają." |
|
||||
| journal | Registry type labels (D-04, new Go keys `menuitem.*`) | `journal-category` "Journal category" / "Kategoria (Journal)"; `all-journal-categories` "All journal categories" / "Wszystkie kategorie (Journal)"; `journal-post` "Journal post" / "Wpis (Journal)"; `all-journal-posts` "All journal posts" / "Wszystkie wpisy (Journal)"; `category-journal-posts` "Journal category posts" / "Wpisy kategorii (Journal)". PL values replace the PHP ones, which were wrong ("Kategorie" for one category). |
|
||||
| sitemap | Settings entries (category CMS) | definitions link: `plugin.name` "Sitemap" / "Mapa strony", `plugin.description` "Generate a sitemap.xml file for your website." / "Generuje plik sitemap.xml dla Twojej strony." (PHP values kept); settings form: `settings.label` "Sitemap settings" / "Ustawienia mapy strony", `settings.description` "Frontend URL, locale and priority options." / "Adres frontendu, język i priorytety." |
|
||||
| sitemap | Singleton form title and subtitle | `definition.title` "Sitemap definition" / "Definicja mapy strony"; subtitle link `definition.view` "View sitemap.xml" / "Zobacz sitemap.xml" (`ExternalLink` 14px, new tab) |
|
||||
| sitemap | Item row fields | PHP keys and values: `item.type`, `item.url`, `item.reference`, `item.url_pattern` with `item.url_pattern_comment` ("Use :placeholder syntax, e.g. /blog/:slug" / "Użyj składni :placeholder, np. /blog/:slug"; the PL value is new), `item.allow_nested_items` with comment, `item.priority`, `item.changefreq` and its seven frequency values. `cmsPage` is dropped (D-12). |
|
||||
| sitemap | Settings fields | New keys `settings.frontend_url`, `settings.frontend_default_locale`, `settings.updates_manifest_path`, `settings.enable_priority_tiers`, `settings.priority_*` with the PHP labels. The comments are generic: the PHP comments named one site's paths, so the tier comments become "Used for URLs that match the :tier tier patterns in the host config. Default: :value" / "Dla adresów pasujących do wzorców poziomu „:tier” z konfiguracji hosta. Domyślnie: :value". The `_nginx_hint` and `_priority_section` blocks become field comments and README text (Pitfall 9). |
|
||||
|
||||
### Layer accessibility strings (props with English defaults)
|
||||
|
||||
| Layer | Prop | Default (EN) | Site passes (PL example) |
|
||||
|-------|------|--------------|--------------------------|
|
||||
| menu | `label` (the `<nav>` `aria-label`) | the menu `code` | "Menu główne" |
|
||||
| menu | `openLabel` / `closeLabel` (mobile toggle) | "Open menu" / "Close menu" | "Otwórz menu" / "Zamknij menu" |
|
||||
| menu | `submenuLabel` (toggle of an entry that also has a link) | "Show submenu: :title" | "Pokaż podmenu: :title" |
|
||||
| faq | none: the heading and description come from the API | — | — |
|
||||
|
||||
---
|
||||
|
||||
## Component Contracts
|
||||
|
||||
### 1. RichEditorField (`type: richeditor`, D-10)
|
||||
|
||||
**Structure.** One container: `controlClass(invalid)` minus the `px-3.5` (the parts pad themselves), plus `overflow-hidden`. It shows the input focus treatment through `focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]`, matching every other input. `data-richeditor`. Top to bottom:
|
||||
|
||||
1. **Toolbar.** Reka `ToolbarRoot` with `aria-label` `richeditor.toolbar` and `aria-controls` pointing at the editing area: `flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1`.
|
||||
- Buttons are `ToolbarButton`, 32×32 `size-8 inline-flex items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:opacity-40 disabled:cursor-not-allowed`. Each holds a 16px icon (`aria-hidden`) and has its label as both `aria-label` and `title`. `mousedown.prevent` keeps the editor selection.
|
||||
- Groups are separated by `ToolbarSeparator` (`mx-1 h-5 w-px bg-border`). In order:
|
||||
- Bold (`Bold`), Italic (`Italic`), Underline (`Underline`);
|
||||
- Heading (`Heading3`, toggles H3 ↔ paragraph), Bullet list (`List`), Numbered list (`ListOrdered`), Quote (`Quote`);
|
||||
- Link (`Link`, opens the popover), Remove link (`Unlink`, enabled only inside a link);
|
||||
- Undo (`Undo2`), Redo (`Redo2`).
|
||||
- Then, pushed right with `ml-auto`, the HTML toggle: `Code` icon plus the text `richeditor.source` at 13px. It is a 32px-high button with `px-2` and `aria-pressed`.
|
||||
- Formatting buttons carry `aria-pressed` from the editor state. Undo and Redo are disabled when they cannot run. Link is disabled when the selection is empty and the cursor is not inside a link.
|
||||
2. **Editing area.** The TipTap root: `summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none`, with `role="textbox"`, `aria-multiline="true"`, `aria-labelledby` set to the field label and `aria-describedby` forwarded. Content scrolls inside it, so the toolbar stays visible.
|
||||
3. **HTML view** (when the toggle is pressed). It replaces the editing area with a `<textarea>`: `block w-full min-h-[160px] resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none`, `spellcheck="false"`, holding the current HTML. Every formatting button is disabled in this view. Switching back parses the textarea into the editor.
|
||||
|
||||
**Extensions.** StarterKit 3.31.4 is configured with headings limited to `[3]`. Its Link has `openOnClick: false`, `autolink: false`, and a protocol allowlist of http, https, mailto and tel. Code, CodeBlock and HorizontalRule are off. TipTap's injected CSS is off (`injectCSS: false`), as in Journal.
|
||||
|
||||
**Link popover.** Reka Popover anchored on the Link button: `w-[320px] flex flex-col gap-2 rounded-inner border border-border bg-surface p-4 shadow-menu`.
|
||||
- It holds a `label` (14/600) `richeditor.link_url` and an `input type="url"`, `h-input` with `controlClass(invalid)`, prefilled with the current link's href.
|
||||
- Under it sits the error line (13px `text-danger`, `CircleAlert` 14px).
|
||||
- The button row is `flex justify-end gap-2`: `Button variant="ghost" size="sm"` "Remove link" (only when inside a link) and `Button variant="primary" size="sm"` `richeditor.link_apply`.
|
||||
- Enter applies; Esc closes and returns focus to the Link button.
|
||||
- Client validation accepts `http:`, `https:`, `mailto:`, `tel:`, a path starting with `/`, `#`, `?` or `./`, or a bare relative path. Anything else shows `richeditor.link_invalid` and is not applied. The server sanitizer is still the gate.
|
||||
|
||||
**Value contract.**
|
||||
- The value is an HTML string.
|
||||
- **Untouched rule (binding):** the field emits nothing until the user edits. A loaded value is submitted byte-identical, never re-serialized by TipTap, so imported PHP HTML is never rewritten by merely opening and saving the form (RESEARCH test map, "untouched value byte-identical").
|
||||
- After the first edit the value is `editor.getHTML()`. An editor holding only an empty paragraph emits `""`. The HTML view emits the textarea text as typed.
|
||||
|
||||
**Lossy note.** On load, if the stored HTML contains an element outside this set, a note shows between the toolbar and the editing area: `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `h3`, `ul`, `ol`, `li`, `blockquote`, `a`, `div`, `span`. The check is a case-insensitive comparison of tag names.
|
||||
- Markup: `flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted`, `role="note"`, `Info` 14px (`mt-0.5 shrink-0`) and `richeditor.lossy_note`.
|
||||
- The editor still opens. The untouched rule keeps the stored HTML until the user edits, and the HTML view edits it without loss.
|
||||
|
||||
**Save notices.** The server sanitizes on save (D-10) and returns removal notices in `meta.notices` under the field path. They render through the existing `FormField` warning callout (`summer-callout summer-callout--warning`, `role="status"`) under the field, with no new markup. The editor then shows the sanitized value returned by the save.
|
||||
|
||||
**Loading.** The TipTap code is a lazy chunk. While it loads, the container shows a `h-[200px] rounded-control bg-skel` block with `aria-busy="true"`.
|
||||
- If the chunk fails, the container becomes the HTML view, already toggled on with the toggle hidden, and a 13px muted note `richeditor.load_failed` sits above it. Saving still works.
|
||||
|
||||
**Read-only** (`attributes: readonly: true`, or preview). The sanitized HTML renders in the existing read-only box (`rounded-control border border-border bg-subtle px-3.5 py-2.5`) with `summer-markdown`. An empty value shows a muted "—".
|
||||
|
||||
**Invalid.** A 422 renders through the existing FormField error line. The container border becomes `danger` and the editing area gets `aria-invalid="true"`.
|
||||
|
||||
**Keyboard.**
|
||||
- TipTap defaults: Ctrl/Cmd+B, I and U, Ctrl/Cmd+Z, and Ctrl/Cmd+Shift+Z.
|
||||
- Ctrl/Cmd+K opens the link popover.
|
||||
- Tab leaves the editing area: TipTap does not capture Tab, and lists indent through the toolbar only.
|
||||
- The toolbar is one Tab stop; arrow keys move between its buttons (Reka roving tabindex).
|
||||
|
||||
### 2. RepeaterField (`type: repeater`, D-07)
|
||||
|
||||
**Label.** `repeater` joins `groupLabelledTypes`. The label is a `<span>` naming the field's `role="group"` container through `aria-labelledby`. The field is always full width: the server compiles `span: full`.
|
||||
|
||||
**Visual anchor.** The rows are read first. The only filled element is the page footer's Save, so the outline "Add" button under the rows reads as a secondary step.
|
||||
|
||||
**Layout.** `flex flex-col gap-4`, `data-repeater="<name>"`. Rows come first, then a footer line `flex flex-wrap items-center gap-4` holding the add button and the counter.
|
||||
|
||||
**Row.** `rounded-inner border border-border bg-surface` (`border-danger` when any nested field in the row has an error), `data-repeater-row="<index>"`. A Reka `CollapsibleRoot` holds it, with `open` by default.
|
||||
- **Header** (`CollapsibleTrigger` is *not* the whole header: only the expand button toggles): `flex min-h-12 items-center gap-2 bg-subtle px-2 rounded-t-inner`. When the row is open, the header adds `border-b border-border`; when collapsed, it becomes `rounded-inner`. Left to right:
|
||||
- the drag handle: a 32×32 ghost icon button with `GripVertical` and aria `repeater.move` (hidden when read-only);
|
||||
- the row index `:n.` at 13px muted `tabular-nums`;
|
||||
- the title, `min-w-0 flex-1 truncate`, 14/600, with the full text in `title`. It is the `titleFrom` nested field's value: the option label for a dropdown, text content without tags for a richeditor. When empty it is `repeater.untitled`.
|
||||
- the error chip (only when the row has errors): `inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold text-danger`, with `form.tab_errors`;
|
||||
- an icon-button cluster `flex gap-1`: Move up (`ChevronUp`, aria `repeater.move_up`, disabled on the first row), Move down (`ChevronDown`, aria `repeater.move_down`, disabled on the last row), Expand/Collapse (`ChevronDown` rotated 180° when open, `transition-transform duration-150`, aria `repeater.collapse` or `repeater.expand`, `aria-expanded`), and Remove (`Trash2`, aria `repeater.remove_named`, `hover:bg-danger-soft hover:text-danger`, disabled when the row count equals `minItems`).
|
||||
- All of these are 32×32 `rounded-pager text-muted hover:bg-hover hover:text-text` buttons, always visible, never hover-only.
|
||||
- **Body** (`CollapsibleContent`): `p-4`, holding a nested `FormGrid` built from the repeater's `form.fields`.
|
||||
- The `idPrefix` is `<form idPrefix>-<field>-<rowKey>`. Field ids, label `for`, `aria-describedby`, errors (path `field.<index>.<nested>`) and notices all resolve per row.
|
||||
- The nested grid keeps the 2-column layout and `span` rules of the top-level grid.
|
||||
- Collapsed rows unmount nothing: values stay in the form state. A collapsed row's richeditor is not mounted until it is expanded, so N rows do not start N editors.
|
||||
- **Load state.** All rows are expanded on load, as in Winter. When the repeater has more than one row, a `Button variant="ghost" size="sm"` "Collapse all" / "Expand all" (`repeater.collapse_all` / `repeater.expand_all`) sits at the right of the footer line.
|
||||
|
||||
**Add.**
|
||||
- `Button variant="outline" size="sm" :icon="Plus"` with the field's `prompt` or `repeater.add`, `data-action="repeater-add"`.
|
||||
- It appends one row with nested defaults, expanded, and focuses its first focusable nested control.
|
||||
- It is disabled when the count equals `maxItems`.
|
||||
- The counter `repeater.count` (13px muted `tabular-nums`) shows only when `maxItems` is set.
|
||||
|
||||
**Empty.** With zero rows, the rows area is replaced by `flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center`, `data-repeater-empty`. It holds `repeater.empty` (14/600) and `repeater.empty_hint` (13px muted, `max-w-[420px]`). The add button stays below it.
|
||||
|
||||
**Remove.** A row with any non-empty nested value asks first: `useConfirm` with `repeater.remove_confirm`, `danger`, confirm label `repeater.remove`. An empty row is removed at once. After removal, focus moves to the next row's handle, else the previous row's handle, else the add button.
|
||||
|
||||
**Reorder.**
|
||||
- **Pointer:** drag by the handle. The dragged row is `opacity-50`. The target slot shows a `rounded-inner border-2 border-dashed border-primary bg-sel` placeholder of the row's header height.
|
||||
- **Keyboard:** with the handle focused, ArrowUp moves the row one place earlier and ArrowDown one place later.
|
||||
- **Buttons:** the visible Move up and Move down buttons do the same (WCAG 2.5.7 single-pointer alternative).
|
||||
- Focus stays on the moved row's handle, and a polite `aria-live` region announces `repeater.moved`.
|
||||
- Reordering only changes form state; it is saved with the record.
|
||||
|
||||
**Errors.** A 422 with path keys:
|
||||
- expands every row that has an error;
|
||||
- adds the row error chip and `border-danger`;
|
||||
- renders each message under its nested field;
|
||||
- makes the existing `focusField` focus the first invalid nested control.
|
||||
|
||||
`repeater.max_items` and `repeater.min_items` messages render under the field label as a normal field error.
|
||||
|
||||
**Dirty.** Any add, remove, reorder or nested edit marks the form dirty.
|
||||
|
||||
**Read-only.** There is no handle, move, remove or add, and there are no counters. Rows render their nested fields read-only, all expanded.
|
||||
|
||||
**Nesting limits (server-enforced, D-07).** These nested types are allowed: `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn`) and `richeditor`. A repeater in a repeater fails boot, so there is no nested visual case.
|
||||
|
||||
### 3. Dependent dropdown (`type: dropdown` + `dependsOn`, D-08)
|
||||
|
||||
It is the same control as `DropdownField`: the native `<select>` with `h-input appearance-none pr-10`, `controlClass(invalid)` and the `ChevronDown` 16px. Its options come from the dependent-options route keyed on the sibling's current value. The sibling is in the same form, or in the same repeater row.
|
||||
|
||||
| State | Rendering |
|
||||
|-------|-----------|
|
||||
| Sibling empty | `disabled`; a single option with `form.dependent_waiting` in `text-muted`. Value `null`. |
|
||||
| Loading | `disabled`, `aria-busy="true"`; a single option with `form.loading_options`. |
|
||||
| Type takes no value (the registry type has no references, e.g. `all-journal-categories`, `url`, `divider`) | `disabled`; a single option with `form.dependent_not_used`. Value sent as `null`. |
|
||||
| Loaded, zero options | `disabled`; a single option with `form.dependent_empty` (`:type` = the sibling's selected option label). The same text repeats as a 13px muted line under the control (`aria-describedby`), because a disabled `<select>` hides its option text from some screen readers. |
|
||||
| Loaded | Enabled. The `emptyOption` shows if declared, then the options. Nested references are indented by prefixing the label with four U+00A0 per `depth`, the same flattening Winter used. |
|
||||
| Stored value not among the options | The existing DropdownField rule: the disabled `form.select_placeholder` option is selected, never a wrong choice. The server 422 `form.dependent_invalid` then guides the user. |
|
||||
| Load failed | `disabled`. Under the control, a 13px `text-danger` line (`flex items-center gap-1.5`, `CircleAlert` 14px, `role="alert"`) shows `form.dependent_failed`, followed by `Button variant="ghost" size="sm" :icon="RotateCcw"` `form.dependent_retry`, which refetches. |
|
||||
|
||||
**Behaviour.**
|
||||
- When the sibling's value changes, the options for the new value load. Responses are cached per sibling value for the life of the form view, so switching back does not refetch.
|
||||
- If the current value is not in the new options, it is set to `null`, the form becomes dirty, and the form's single polite live region announces `form.dependent_cleared`.
|
||||
- An in-flight request for a previous sibling value is discarded when it resolves late (last request wins).
|
||||
- **One admin binding:** the menu entry form and the sitemap repeater rows both declare `reference` with `dependsOn: type`. The visuals are identical in both.
|
||||
|
||||
### 4. Reorder page (D-09) and the list "Reorder" button
|
||||
|
||||
**Entry point.**
|
||||
- When the list config declares `reorder` among its buttons, `ListView` renders `Button variant="outline" :icon="ArrowUpDown"` with `messages.list.reorder`, `data-action="reorder"`, in the header button group **before** the primary "New record" button.
|
||||
- It links to the controller's reorder route. When the list is filtered on the reorder partition column, the filter value goes along as `?partition=<id>`.
|
||||
|
||||
**Visual anchor.** The first tree row's name is the anchor. The page has no primary fill, and the save status is muted, so the eye goes from the 24/700 `h1` straight to the tree.
|
||||
|
||||
**Page (`ReorderView.vue`).** It is a `section.flex.w-full.flex-col.gap-5`.
|
||||
- **Header:** the `FormView` header markup copied:
|
||||
- the 40px outline back button (`ArrowLeft` 18px, aria `form.return_to_list`), which returns to the list with its query kept;
|
||||
- the `h1` with the reorder config `title` (24/700, class copied). Menu entries use "Reorder menu entries" / "Zmiana kolejności pozycji menu".
|
||||
- At the right, `flex items-center gap-4`:
|
||||
- the save status (13px muted, `flex items-center gap-1.5`, `aria-live="polite"`): `LoaderCircle` 14px `animate-spin` plus `form.saving` while a save is in flight, `reorder.saved` for 2 s after success, otherwise empty;
|
||||
- when the config declares a partition, the partition `<select>`. It is styled as `DropdownField` (`h-input w-[240px]`) with a visible `label` (14/600, `flex items-center gap-2`) taken from the partition field's label ("Menu" for menu entries), and lists the partition records by name.
|
||||
- Changing the partition updates `?partition=` (replace, not push) and reloads the tree. The initial partition is `?partition`, else the first option.
|
||||
- **Card:** `overflow-hidden rounded-card border border-border bg-surface shadow-card`, holding the tree.
|
||||
|
||||
**Tree.**
|
||||
- A nested `ul` (`role="list"`, `aria-label` `reorder.tree`, `data-reorder-tree`), one `li` per node with its children's `ul` inside it. Nodes are ordered by `sort_order` within each parent.
|
||||
- A row is `flex min-h-row items-center gap-2 border-t border-border pr-4 first:border-t-0 hover:bg-hover`, with `padding-inline-start: calc(16px + depth * 24px)` and `data-reorder-node="<id>"`. Left to right:
|
||||
- the drag handle: a 32×32 ghost icon button with `GripVertical` and aria `reorder.move`;
|
||||
- the name: the `nameFrom` value, 14/400, `min-w-0 flex-1 [overflow-wrap:anywhere]`, or `reorder.untitled` in `text-muted`;
|
||||
- a cluster `flex gap-1` of four 32×32 ghost icon buttons: Move up (`ChevronUp`), Move down (`ChevronDown`), Outdent (`IndentDecrease`) and Indent (`IndentIncrease`), with the `reorder.*` aria-labels. Each is disabled when the move is impossible: first or last among its siblings, already at the root, or no previous sibling to nest under.
|
||||
- Children always move with their parent. There is no collapse: menus and similar trees are short, and the page scrolls.
|
||||
|
||||
**Moves.**
|
||||
- **Pointer drag** by the handle; the dragged row is `opacity-50`. Each hovered row has three zones:
|
||||
- top third: "before" (a 2px `bg-primary` line `h-0.5` at the row's top edge, starting at the target depth's indent);
|
||||
- bottom third: "after" (the same line at the bottom edge);
|
||||
- middle third: "inside, as last child" (the row gets `bg-sel outline-2 outline-dashed outline-primary -outline-offset-2`).
|
||||
- A node cannot drop into its own subtree: no indicator, `cursor-not-allowed`.
|
||||
- **Keyboard on the handle:**
|
||||
- ArrowUp and ArrowDown swap with the previous or next sibling;
|
||||
- ArrowRight nests the node as the last child of its previous sibling;
|
||||
- ArrowLeft moves it out to become the next sibling of its parent.
|
||||
- **Buttons:** the four visible buttons do the same as the keys (WCAG 2.5.7).
|
||||
- After any move, focus stays on the moved node's handle, and a polite live region (separate from the save status) announces `reorder.moved`, `reorder.nested` or `reorder.unnested`.
|
||||
|
||||
**Saving.**
|
||||
- Every move saves without a Save button: one `POST` with the whole scoped node set (`{nodes:[{id,parent_id,sort_order}]}`), debounced 400 ms after the last move, the same as the 12.2 file reorder.
|
||||
- Leaving the page flushes a pending debounce at once and never asks for confirmation.
|
||||
- On failure, the tree reverts to the last saved state and the `reorder.failed` danger toast shows.
|
||||
- When the server refuses a stale set (a node was added, removed or moved out of the partition meanwhile), the `reorder.stale` danger toast shows and the tree reloads.
|
||||
|
||||
**States.**
|
||||
- **Loading:** 5 skeleton rows (`min-h-row`, a 20px square `rounded-[6px] bg-skel` and a `h-3 w-48 rounded-[6px] bg-skel` bar), with `aria-busy="true"` on the card.
|
||||
- **Load failure:** the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `CircleAlert` 16px) with `reorder.load_failed`, in place of the card.
|
||||
- **Empty** (the partition has no nodes, or no partition exists): inside the card, `flex flex-col items-center gap-2 px-6 py-8 text-center`. It holds a 40px `bg-subtle` circle with `ArrowUpDown` 20px muted, `reorder.empty` (14/600) and `reorder.empty_hint` (13px muted, `max-w-[420px]`).
|
||||
- **One node:** every move button is disabled; the handle stays focusable and its arrow keys do nothing.
|
||||
|
||||
**Permissions.** The route inherits the controller's permission. A 403 shows the existing `form.forbidden` alert block in place of the card.
|
||||
|
||||
### 5. Singleton form (sitemap definition, D-11)
|
||||
|
||||
- The sitemap's settings entry links to the definitions controller. That controller declares the singleton capability, so the SPA asks the server for the one record (the server does `firstOrCreate` with the fixed theme key) and renders `FormView` in update mode for it. There is no list screen and no create screen.
|
||||
- **Header:**
|
||||
- the back button aria is `settings.back` and returns to the settings index;
|
||||
- the `h1` is `definition.title`;
|
||||
- the subtitle line is 13px muted (`flex items-center gap-1.5`) with a plain `<a>` `definition.view` to `/sitemap.xml` (`target="_blank" rel="noopener noreferrer"`, underlined, `ExternalLink` 14px `aria-hidden`).
|
||||
- **Footer:**
|
||||
- no Delete button (a singleton cannot be deleted);
|
||||
- Cancel (ghost) and Save and close (outline) both return to the settings index;
|
||||
- Save (primary) stays.
|
||||
- **Body:** one full-width `repeater` field `data` with these row fields (`titleFrom: url`; a registry row with no URL shows `repeater.untitled`):
|
||||
|
||||
| Row field | Type | Span |
|
||||
|-----------|------|------|
|
||||
| type | `dropdown`: `url` "URL" plus the registry types | left |
|
||||
| reference | `dropdown` with `dependsOn: type` | right |
|
||||
| url | `text` | left |
|
||||
| urlPattern | `text`, with comment | right |
|
||||
| priority | `dropdown`, options `0.1` to `1.0` | left |
|
||||
| changefreq | `dropdown`, the 7 frequencies | right |
|
||||
| nesting | `checkbox`, with comment, default on | full |
|
||||
|
||||
- The save toast is the existing `messages.form.saved`. The unsaved-changes guard is unchanged.
|
||||
- **Visual anchor:** the repeater's first row header (14/600 title) is read first after the `h1`. The footer Save is the page's only primary fill, and the "View sitemap.xml" link stays muted.
|
||||
|
||||
### 6. Plugin admin screens (YAML only)
|
||||
|
||||
Winter keys that cabana lacks are adapted as RESEARCH Pitfall 9 lists:
|
||||
- tabs are flattened;
|
||||
- `span: none` and `cssClass` become `left`/`right`;
|
||||
- `select:` on a relation becomes `nameFrom`;
|
||||
- `hint` and `section` become `comment`;
|
||||
- there are no `trigger` conditions, so fields always show and their comments say when they apply.
|
||||
|
||||
Every adaptation is listed in each plugin README.
|
||||
|
||||
**FAQ (`sm-faq-plugin`).**
|
||||
- **Navigation:** top-level "FAQ", icon `circle-question-mark`, order 500, side menu "FAQs" (PHP parity).
|
||||
- **List:** the PHP columns `id`, `name`, `slug`, `is_active` (switch), `created_at` (invisible) and `updated_at`, plus a **`locale` column** (relation, `nameFrom: name`). One slug exists per language, and without the column two rows look identical. This is a recommended adaptation.
|
||||
- **Form:**
|
||||
|
||||
| Field | Type | Span |
|
||||
|-------|------|------|
|
||||
| `name` | text | left |
|
||||
| `slug` | text, `preset: name` | right |
|
||||
| `locale` | relation, `nameFrom: name`, with comment | left |
|
||||
| `is_active` | switch, with comment | right |
|
||||
| `description` | richeditor | full |
|
||||
| `content` | repeater, `prompt` = add_question, `titleFrom: question`; nested `question` (text, required) and `answer` (richeditor) | full |
|
||||
|
||||
**Menu (`sm-menu-plugin`).**
|
||||
- **Navigation:** PHP hung both screens on SiteManager's side menu, and SiteManager is out of scope. The port registers its own top-level "Menus" (icon `list`), with side items "Menus" (`list`) and "Menu entries" (`list-ordered`), permission `golem15.menu.manage_menu`. This is documented as an adaptation.
|
||||
- **Menus list:** the PHP columns. **Menu form:** `name` (left), `code` (right, `preset: name`), `css_class` (left), `is_active` (switch, right).
|
||||
- **Menu entries list:**
|
||||
- the PHP columns;
|
||||
- a `menu` list filter in place of the PHP toolbar's menu `<select>`;
|
||||
- the heading buttons `reorder` (label overridden to "Reorder entries") and `create`;
|
||||
- the PHP toolbar's "Delete selected" stays the existing bulk delete.
|
||||
- **Menu entry form:** flat, with the "Children" tab and partial removed because the reorder page replaces them (D-09):
|
||||
|
||||
| Field | Type | Span |
|
||||
|-------|------|------|
|
||||
| `menu` | dropdown | full |
|
||||
| `parent` | relation, `nameFrom: title`, `emptyOption` = `form.none` | left |
|
||||
| `type` | dropdown, D-05 options | right |
|
||||
| `title` | text | left |
|
||||
| `url` | text, with comment | right |
|
||||
| `reference` | dropdown, `dependsOn: type`, with comment | left |
|
||||
| `code` | text | right |
|
||||
| `icon` | text | left |
|
||||
| `css_class` | text | right |
|
||||
| `target` | dropdown `_self`/`_blank`, default `_self` (new) | left |
|
||||
| `is_published` | switch, default on, `context: update` (PHP parity) | right |
|
||||
|
||||
If the planner adopts RESEARCH A4 (a per-item `url_pattern` column), add `url_pattern` as text with the sitemap's `url_pattern_comment` copy, at span left, after `reference`. The remaining spans then shift to keep left/right pairs.
|
||||
|
||||
**Sitemap (`sm-sitemap-plugin`).**
|
||||
- **Settings index** (category CMS): "Sitemap" (icon `network`, links to the singleton form, §5) and "Sitemap settings" (icon `settings`, the existing `SettingsFormView`), both with permission `golem15.sitemap.access_definitions` (PHP parity).
|
||||
- **Settings form:**
|
||||
|
||||
| Field | Type | Span |
|
||||
|-------|------|------|
|
||||
| `frontend_url` | text | left |
|
||||
| `frontend_default_locale` | text | right |
|
||||
| `updates_manifest_path` | text | full |
|
||||
| `enable_priority_tiers` | checkbox, default on | full |
|
||||
| `priority_homepage`, `priority_services`, `priority_blog`, `priority_categories`, `priority_legal` | text, defaults `1.0` / `0.8` / `0.6` / `0.4` / `0.2` | auto: the 2-column grid places them in pairs |
|
||||
|
||||
Each tier comment ends with "Applies only when priority tiers are enabled." / "Działa tylko przy włączonych poziomach priorytetów." No validation is added beyond PHP, so the output stays 1:1.
|
||||
|
||||
### 7. `vue-menu-layer`
|
||||
|
||||
**Tokens** (`menu-tokens.css`, loaded before `menu-base.css`):
|
||||
- colour: `--menu-color-text`, `--menu-color-muted`, `--menu-color-bg`, `--menu-color-surface`, `--menu-color-border`, `--menu-color-accent` (defaults in §Color);
|
||||
- type: `--menu-font` (the system-ui stack of the Journal layer), `--menu-font-size: 1rem`, `--menu-line-height: 1.5`;
|
||||
- spacing: `--menu-space-1..4`;
|
||||
- radii: `--menu-radius: 4px`, `--menu-radius-panel: 8px`;
|
||||
- layout: `--menu-submenu-min-width: 14rem`, `--menu-toggle-size: 2.75rem`;
|
||||
- shadow: `--menu-shadow: 0 8px 24px rgba(31, 35, 40, 0.12)`.
|
||||
|
||||
**`MenuNav`.**
|
||||
- **Visual anchor:** the active link (`--menu-color-accent` with a 2px underline) is the only accented element. With no active link, the first top-level link leads, and the nav carries no other emphasis.
|
||||
- **Props:** `code` (required), `locale?`, `label?`, `openLabel?`, `closeLabel?`, `submenuLabel?`.
|
||||
- **Data:** `useMenu(code, { locale })`, which calls `useAsyncData` with the key `menu:<code>:<locale>` and is fetched during SSR, so the first paint has the menu.
|
||||
- **Markup:** `<nav class="menu-nav {menu.css_class}" :aria-label>`. Inside it:
|
||||
- the mobile toggle `<button class="menu-nav__toggle" aria-controls aria-expanded>`, 44×44 with three CSS bars and a visually hidden `openLabel`/`closeLabel`;
|
||||
- `<ul class="menu-nav__list">` with one `MenuNavItem` per top-level entry.
|
||||
- **Scoped slot `#item="{ entry, depth, active }"`:** lets an app render an entry its own way, for example with the API's `icon`, which the layer does not render (PHP did not either).
|
||||
- **Slot `#fallback`:** renders when the menu is missing (404), failed, or has no entries. By default nothing renders: no empty `<nav>`.
|
||||
|
||||
**`MenuNavItem` (recursive)**, one `<li class="menu-nav__item">` per entry:
|
||||
|
||||
| Entry | Rendering |
|
||||
|-------|-----------|
|
||||
| Link with a URL on the allowlist (http, https, mailto, tel, `/`, `#`), no children | `NuxtLink` for relative paths; a plain `<a>` for absolute, mailto and tel. `class="menu-nav__link {entry.css_class}"`. With `target="_blank"`, also `rel="noopener noreferrer"`. |
|
||||
| Entry with children and a URL (also list types such as `all-journal-categories`, whose stored URL and title the API keeps) | The link as above, plus a sibling `<button class="menu-nav__subtoggle" aria-expanded aria-controls>` with a CSS chevron and the visually hidden `submenuLabel` (":title" filled), then `<ul class="menu-nav__submenu" :id>` with the children. This is the WAI disclosure navigation pattern: the link still navigates. |
|
||||
| Entry with children and no URL | One `<button class="menu-nav__link menu-nav__subtoggle" aria-expanded aria-controls>` with the title and chevron, then the submenu. |
|
||||
| Leaf with no URL, or a URL outside the allowlist (defence in depth, Pitfall 15) | `<span class="menu-nav__text">` with the title; never an `href`. |
|
||||
| `divider` | `<li class="menu-nav__divider" aria-hidden="true">`: a 1px `--menu-color-border` line, horizontal in panels and a 1px × 1.25rem vertical line at the top level on desktop. |
|
||||
|
||||
**Active state.**
|
||||
- **Matching:** compare `useRoute().path` with each entry URL's path. Both are normalized: decoded, trailing slash removed except on `/`, query and hash ignored. An absolute URL counts only when its host equals `useRequestURL().host`.
|
||||
- **Exact match:** `aria-current="page"` plus `menu-nav__link--active`.
|
||||
- **Ancestors of an active entry:** `menu-nav__item--trail` (text colour only, no `aria-current`).
|
||||
|
||||
**Desktop (≥768px).**
|
||||
- The list is `display:flex; flex-wrap:wrap; align-items:center; gap: var(--menu-space-2)`. Links are `padding: var(--menu-space-2) var(--menu-space-3)`, `border-radius: var(--menu-radius)`, with `--menu-color-surface` background on hover.
|
||||
- **Submenus at depth 1:** an absolutely positioned panel below the item (`inset-inline-start: 0; top: calc(100% + var(--menu-space-2))`), `min-width: var(--menu-submenu-min-width)`, `padding: var(--menu-space-2)`, `--menu-color-bg` background, 1px `--menu-color-border` border, `--menu-radius-panel`, `--menu-shadow`.
|
||||
- **Depth 2 and deeper:** flyouts to the inline end of their parent item (`inset-inline-start: 100%; top: 0`).
|
||||
- **Opening:** a submenu opens on toggle click, and on hover for `(hover: hover)` pointers (PHP parity).
|
||||
- **Closing:** Esc (focus returns to the toggle), focus leaving the item, a pointer-down outside, and every route change.
|
||||
- **Siblings:** opening one submenu closes its open siblings.
|
||||
|
||||
**Mobile (≤767px).** The 767px breakpoint is fixed in `menu-base.css` because custom properties cannot drive media queries; an app changes it by overriding the component or the CSS.
|
||||
- The toggle shows. The list becomes a vertical panel under the bar (`position:absolute; inset-inline:0`, `--menu-color-bg`, bottom border, `--menu-shadow`), hidden until the toggle opens it.
|
||||
- Submenus expand inline as indented accordions (`padding-inline-start: var(--menu-space-3)`); there is no hover opening.
|
||||
- Links are full width with the chevron at the end.
|
||||
- The panel closes on route change and on Esc (focus returns to the toggle).
|
||||
|
||||
**Overrides.** Every visual value is a token. Every component can be replaced by a same-name file in the app. The site app's `SiteHeader.vue` keeps its bar, logo and skip link, and swaps its `MAIN_MENU` loop for `<MenuNav code="main" :label="$t('menu.label')" … />`. It may override the tokens with its `--site-*` palette.
|
||||
|
||||
### 8. `vue-faq-layer`
|
||||
|
||||
**Tokens** (`faq-tokens.css`):
|
||||
- colour: `--faq-color-text`, `--faq-color-muted`, `--faq-color-bg`, `--faq-color-surface`, `--faq-color-border`, `--faq-color-accent`;
|
||||
- type: `--faq-font`, `--faq-font-size-base: 1rem`, `--faq-line-height: 1.7`, `--faq-question-size: 1.125rem`, `--faq-title-size: 1.5rem`;
|
||||
- spacing: `--faq-space-1..4`;
|
||||
- radius: `--faq-radius: 8px`;
|
||||
- shadow: `--faq-shadow: 0 8px 24px rgba(31, 35, 40, 0.08)`.
|
||||
|
||||
**`FaqBlock`.**
|
||||
- **Visual anchor:** the 24px/600 title is read first, then the first question `summary`. When the app hides the heading (`:show-heading="false"`), the first question leads.
|
||||
- **Props:** `slug` (required), `locale?`, `headingLevel` (2 to 4, default 2), `showHeading` (default true), `showDescription` (default true), `exclusive` (default false).
|
||||
- **Data:** `useFaq(slug, { locale })`, keyed `faq:<slug>:<locale>` and fetched during SSR.
|
||||
- **Markup:** `<section class="faq-block" :aria-labelledby>`, containing:
|
||||
- the heading `<h{level} class="faq-block__title">` with the FAQ `name`, at `--faq-title-size`/600/1.25 with `margin-bottom: var(--faq-space-2)`;
|
||||
- `<div class="faq-block__description faq-html" v-html>` with the server-sanitized description, at `margin-bottom: var(--faq-space-4)`, omitted when empty;
|
||||
- `FaqList`.
|
||||
- **When it renders nothing:** on a 404, an error, or zero content items, it renders nothing (no empty heading), unless a `#fallback` slot is given.
|
||||
|
||||
**`FaqList`.**
|
||||
- **Props:** `items: {question, answer}[]`, `exclusive?`, `openFirst?` (default false).
|
||||
- **Markup:** `<div class="faq-list">` (`display:grid; gap: var(--faq-space-3)`) holding one `FaqItem` per item, in API order.
|
||||
|
||||
**`FaqItem`.**
|
||||
- **Container:** `<details class="faq-item">` with a 1px `--faq-color-border` border, `--faq-radius`, `--faq-color-bg` background, and `--faq-shadow` when `[open]`. With `exclusive`, every item in the list shares a `name` attribute, so the native exclusive accordion opens only one at a time; otherwise any number open.
|
||||
- **`<summary class="faq-item__question">`:** `display:flex; align-items:center; justify-content:space-between; gap: var(--faq-space-3)`, `padding: var(--faq-space-3) var(--faq-space-4)`, `--faq-question-size`/600/1.4, `list-style:none` (the WebKit marker hidden too).
|
||||
- A CSS chevron `::after` (`flex-shrink:0`, 0.55rem square, 2px `--faq-color-text` borders, rotated 45° and turned −135° when open, `transition: transform 150ms ease`, off under `prefers-reduced-motion`).
|
||||
- Long questions wrap (`overflow-wrap:anywhere`) and the chevron stays at the end.
|
||||
- The focus ring follows `--faq-color-accent`.
|
||||
- **`<div class="faq-item__answer faq-html" v-html>`:** the server-sanitized answer (D-17; the server sanitizes on output, RESEARCH A8, so there is no client sanitizer), with `padding: 0 var(--faq-space-4) var(--faq-space-4)`.
|
||||
|
||||
**`.faq-html` content styles** (the PHP partial's answer rules, ported):
|
||||
- `p`, `ul` and `ol` have `margin: 0 0 var(--faq-space-3)`, with the last child at margin 0;
|
||||
- `ul` is `list-style: disc` and `ol` is `list-style: decimal`, both with `padding-inline-start: 1.25rem`;
|
||||
- each `li + li` has `margin-top: var(--faq-space-2)`;
|
||||
- `a` is `--faq-color-accent`, underlined;
|
||||
- `h3` is `--faq-question-size`/600;
|
||||
- `blockquote` has a 3px `--faq-color-border` inline-start border, `padding-inline-start: var(--faq-space-3)` and `--faq-color-muted` text.
|
||||
|
||||
**Overrides.** The site app's `FaqSection.vue` keeps its section wrapper and replaces the static JSON with `<FaqBlock slug="what-mushrooms-can-give-you" />`. It may hide the block heading and keep its own (`:show-heading="false"`), and may map tokens to its `--site-*` palette.
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations were resolved with the ui-consideration-probe on 2026-10-11. The probe covered 11 elements:
|
||||
|
||||
| Element | Kinds |
|
||||
|---------|-------|
|
||||
| richeditor | form, interactive-control |
|
||||
| link popover | form |
|
||||
| repeater | form, list-collection |
|
||||
| dependent dropdown | form, interactive-control |
|
||||
| reorder tree | list-collection |
|
||||
| reorder heading button | interactive-control |
|
||||
| singleton form | form |
|
||||
| plugin list and form screens | list-collection, form |
|
||||
| menu nav | nav, list-collection |
|
||||
| FAQ block | list-collection, static-content |
|
||||
| sitemap.xml/xsl | static-content |
|
||||
|
||||
Every applicable pair is covered, backstopped or dismissed with a reason, and 0 are unresolved. Rows group pairs that share a resolution. The copy for empty and error rows lives in the Copywriting Contract.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| empty | Richeditor | ✅ covered | An empty editor shows the bare 160px editing area under the toolbar and emits `""`; read-only shows a muted "—" |
|
||||
| loading | Richeditor | ✅ covered | While the TipTap chunk loads, a 200px `bg-skel` block with `aria-busy="true"` shows |
|
||||
| error | Richeditor (editor load) | ✅ covered | A failed chunk falls back to the HTML textarea with the `richeditor.load_failed` note, and saving still works |
|
||||
| error | Richeditor (save) | ✅ covered | A 422 uses the FormField error line and the `danger` border; sanitize removals show as the existing warning callout under the field, with the `richeditor.removed_*` copy |
|
||||
| partial | Richeditor (imported HTML outside the editor schema) | ✅ covered | The `richeditor.lossy_note` shows; the stored HTML is kept by the untouched rule and is editable in the HTML view |
|
||||
| long-text | Richeditor | ✅ covered | The editing area scrolls inside `max-h-[480px]` and the toolbar stays visible; the HTML textarea is `resize-y` |
|
||||
| populated | Richeditor untouched value | 🧪 backstop | A vitest asserts that loading HTML with `<b>`, `<div>` and attributes and saving without an edit submits the stored string byte-identical |
|
||||
| error | Link popover | ✅ covered | A URL outside the allowlist shows `richeditor.link_invalid` and is not applied |
|
||||
| empty / long-text | Link popover | ➖ dismissed | A single URL input: Apply with an empty URL is the same as Remove link, and long URLs scroll in the 44px input |
|
||||
| empty | Repeater | ✅ covered | The dashed empty block with `repeater.empty` and `repeater.empty_hint`; the add button stays below |
|
||||
| populated | Repeater | ✅ covered | Expanded rows with header (handle, index, title, move/collapse/remove) and a nested FormGrid body |
|
||||
| zero-one-many | Repeater | ✅ covered | Zero: the empty block. One: both move buttons are disabled and "Collapse all" is hidden. Many: `:n.` indexes and the "Collapse all/Expand all" toggle. `maxItems` disables add and shows the counter; `minItems` disables remove |
|
||||
| partial | Repeater rows with missing required values | ✅ covered | The server returns per-row path errors; the affected rows expand with the error chip and `border-danger`, and focus moves to the first invalid nested control |
|
||||
| error | Repeater (count limits) | ✅ covered | `repeater.max_items` and `repeater.min_items` render under the field label |
|
||||
| overflow | Repeater (many rows) | ✅ covered | Rows stack and the page scrolls; "Collapse all" reduces each row to its 48px header; collapsed rows do not mount their richeditors |
|
||||
| long-text | Repeater row title | ✅ covered | One line with `truncate`; the full text is in `title`; an empty title shows `repeater.untitled` |
|
||||
| loading | Repeater | ➖ dismissed | Rows arrive with the record in the form's existing load (FormView skeleton); the repeater has no load of its own |
|
||||
| populated | Repeater remove and reorder semantics | 🧪 backstop | A vitest asserts that removing a non-empty row asks first and an empty one does not, that the arrow keys and buttons reorder and announce `repeater.moved`, and that nothing is sent before Save |
|
||||
| empty | Dependent dropdown (sibling empty) | ✅ covered | Disabled, showing `form.dependent_waiting` |
|
||||
| loading | Dependent dropdown | ✅ covered | Disabled with `aria-busy` and `form.loading_options`; a late response for an old sibling value is discarded |
|
||||
| error | Dependent dropdown | ✅ covered | The `form.dependent_failed` alert line with a Retry button; a 422 on save shows `form.dependent_invalid` |
|
||||
| populated | Dependent dropdown | ✅ covered | Enabled options; nested references are indented by depth |
|
||||
| partial | Dependent dropdown (value no longer offered) | ✅ covered | On a sibling change the value clears and is announced; a stored stale value shows the placeholder, never a wrong option |
|
||||
| empty | Dependent dropdown (type without references, or zero records) | ✅ covered | A type without references: disabled with `form.dependent_not_used`. Zero records: disabled with `form.dependent_empty`, which names the type and gives the next step (add a record or choose another type). In both cases the value is sent as `null` |
|
||||
| overflow / long-text | Dependent dropdown (many or long references) | ✅ covered | A native `<select>`: the browser list scrolls and type-ahead jumps to an option; long labels are clipped by the native control and shown in full in the open list |
|
||||
| loading | Reorder tree | ✅ covered | 5 skeleton rows with `aria-busy` on the card |
|
||||
| error | Reorder tree (load, save, stale, 403) | ✅ covered | The load-failure alert block; a save failure reverts and shows the `reorder.failed` toast; a stale set shows `reorder.stale` and reloads; a 403 shows the `form.forbidden` alert |
|
||||
| empty | Reorder tree | ✅ covered | The empty block with `reorder.empty` and `reorder.empty_hint` inside the card |
|
||||
| populated | Reorder tree | ✅ covered | Indented rows with handle, name and four move buttons, ordered by `sort_order` per parent |
|
||||
| zero-one-many | Reorder tree | ✅ covered | Zero: the empty block. One: all move buttons are disabled. Many: rows separated by `border-t`, with no pagination |
|
||||
| overflow | Reorder tree (deep or long) | ✅ covered | The page scrolls; the indent grows by 24px per level and the name column (`min-w-0 flex-1`) wraps, so deep levels stay readable |
|
||||
| long-text | Reorder node names | ✅ covered | Names wrap with `[overflow-wrap:anywhere]` inside the `min-h-row` row; an empty name shows `reorder.untitled` |
|
||||
| partial | Reorder (divider entries without a title) | ✅ covered | They show `reorder.untitled` with the id in `text-muted` |
|
||||
| populated | Reorder save contract | 🧪 backstop | A vitest asserts that three quick moves send one debounced POST with the full node set, that a failed POST restores the previous tree, and that each keyboard move announces in the live region |
|
||||
| loading / error | Reorder heading button | ➖ dismissed | It is a plain router link with no async state; it renders only when the list config declares `reorder` |
|
||||
| loading / error | Singleton form | ✅ covered | The existing FormView skeleton and `form.load_failed` alert; the server creates the record on first open, so there is no "not found" state in the admin |
|
||||
| empty | Singleton form (new definition) | ✅ covered | A first-time definition shows the repeater empty block with `golem15.sitemap::lang.item.add_item` as the next step |
|
||||
| empty / loading / error / populated / overflow | Plugin list and form screens | ➖ dismissed | They render through the unchanged ListView, FormView and SettingsFormView, whose states are covered by earlier phases; only the new field types above add states |
|
||||
| loading | Menu nav | ✅ covered | Fetched during SSR, so the first paint has the menu; client navigation reuses the keyed `useAsyncData` result, so there is no loading flash |
|
||||
| error / empty | Menu nav | ✅ covered | 404, an error or zero entries render nothing, or the app's `#fallback` slot; there is never an empty `<nav>` |
|
||||
| populated | Menu nav | ✅ covered | Desktop horizontal list with dropdown panels and flyouts; mobile toggle panel with inline accordions; active link and trail per the active-state rule |
|
||||
| zero-one-many | Menu nav (children per entry) | ✅ covered | No children: a plain link. Children: a disclosure toggle plus a submenu. Any depth: recursive, with depth ≥2 as flyouts on desktop and indented on mobile |
|
||||
| overflow | Menu nav (many top-level entries) | ✅ covered | The desktop list uses `flex-wrap` onto a second line; panels use `min-width` and grow with content; the mobile panel scrolls with the page |
|
||||
| long-text | Menu titles | ✅ covered | Top-level links wrap within their padding; submenu links wrap inside the panel (`overflow-wrap:anywhere`) |
|
||||
| error | Menu entry URL scheme | 🧪 backstop | A layer node test asserts that `javascript:` and `data:` URLs render as `<span class="menu-nav__text">` with no `href`, and that `_blank` links carry `rel="noopener noreferrer"` |
|
||||
| loading | FAQ block | ✅ covered | Fetched during SSR, keyed per slug and locale |
|
||||
| error / empty | FAQ block | ✅ covered | 404, an error or zero items render nothing, or the `#fallback` slot; there is never an empty heading |
|
||||
| populated | FAQ block | ✅ covered | Heading, sanitized description, then native `<details>` items, all closed by default (`openFirst` opt-in) |
|
||||
| zero-one-many | FAQ items | ✅ covered | One item renders as a single `<details>`; many stack with `--faq-space-3` gaps; `exclusive` makes them one-at-a-time |
|
||||
| long-text | FAQ questions and answers | ✅ covered | Questions wrap with the chevron pinned at the end; answers flow with the `.faq-html` list and paragraph rules |
|
||||
| populated | FAQ HTML safety | 🧪 backstop | A Go API test (sm-faq-plugin) asserts that `description` and every `answer` are sanitized on output even for rows copied straight into the DB (Pitfall 10) |
|
||||
| empty / populated | sitemap.xml / sitemap.xsl | ➖ dismissed | No new visual design: the XSL is byte-identical to PHP, and the XML output is pinned by PHP golden tests (RESEARCH Pattern 4); an empty definition answers an empty `application/xml` body as in PHP |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (shadcn not used) | not applicable |
|
||||
| third-party registries | none | not applicable |
|
||||
| npm dependencies, admin SPA | `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, all 3.31.4 exact | RESEARCH Package Legitimacy Audit (2026-10-11): `npm view` shows no `postinstall` script on any of them, MIT licence, and source at github.com/ueberdosis/tiptap. They are flagged [SUS] only for "too-new version", on a long-established package; this exact version was already approved by a human package check for Journal in Phase 16.2. The planner adds a `checkpoint:human-verify` before `npm --prefix admin install`. |
|
||||
| npm dependencies, layers | none beyond `nuxt` 4.4.8 / `vue` 3.5.35 / `typescript` 5.9.3 (dev, the same pins as `vue-journal-layer`) | not applicable. No `sanitize-html` and no UI library (RESEARCH A8, Pattern 10). |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [x] Dimension 1 Copywriting: PASS
|
||||
- [x] Dimension 2 Visuals: PASS
|
||||
- [x] Dimension 3 Color: PASS
|
||||
- [x] Dimension 4 Typography: FLAG (700 limited to the inherited h1; accepted)
|
||||
- [x] Dimension 5 Spacing: PASS
|
||||
- [x] Dimension 6 Registry Safety: PASS
|
||||
- [x] Dimension 7 Inventory Provenance: PASS
|
||||
|
||||
**Approval:** approved 2026-10-11
|
||||
95
.planning/phases/17-blog-companions/17-VALIDATION.md
Normal file
95
.planning/phases/17-blog-companions/17-VALIDATION.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: "17"
|
||||
slug: "blog-companions"
|
||||
# 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-11"
|
||||
---
|
||||
|
||||
# Phase 17 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution. Source: `17-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testcontainers Postgres), vitest (admin SPA), `node --test` (Nuxt layers) |
|
||||
| **Config file** | none for Go; `admin/vite.config` (vitest); layer `package.json` `test` script |
|
||||
| **Quick run command** | `go test -short ./modules/pact ./modules/cabana/... ./modules/<registry>/... ./modules/<sanitizer>/...` (plus the touched plugin repo's `go test -short ./...`) |
|
||||
| **Full suite command** | `bash scripts/check-phase17.sh --all` (new; staged like `check-phase16.2.sh`) |
|
||||
| **Estimated runtime** | ~180 seconds (full, with Docker) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** the quick command for the touched repo, plus `go vet ./...`
|
||||
- **After every plan wave:** full Go suites per repo with Docker, plus `npm --prefix admin run test`
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase17.sh --all` must be green
|
||||
- **Max feedback latency:** 60 seconds (quick loop)
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Task IDs filled by the planner (2026-10-11; module names fixed: registry `signpost`, sanitizer `seaglass`). Smoke tests land with the feature task; the full unit tests land in plan 06 (lean rule 2). Plan 06 Task 4 updates File Exists and Status from the gate run.
|
||||
|
||||
| Task ID | Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|-----|----------|-----------|-------------------|-------------|--------|
|
||||
| 17-01-01 / 17-06-01, 17-06-02 | SC-2/D-01..D-03 | Duplicate type key fails boot; DefaultPattern and PatternFrom boot checks; host override checks; pattern layers incl. PatternFrom inheritance; registry before Boot; ReferenceOptions | unit | `go test -count=1 -short ./modules/signpost ./modules/party -run 'TestSignpostSmoke\|TestRegistry\|TestReferenceOptions\|TestActivateMenuItemTypesOrder'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-01 / 17-06-02 | D-08 | dependsOn compile, dependent-options route, save recheck 422 | unit+pg+conformance | `go test -count=1 ./modules/cabana -run 'TestDependentDropdownCompile\|TestCompanionsDependentSmoke\|TestDependentRecheck\|TestPhase10ConformanceInventory'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-02 / 17-06-01, 17-06-02 | D-10 | seaglass byte-stable, removal report, bypass corpus; richeditor save gate; settings/relation refusal | unit+pg | `go test -count=1 ./modules/seaglass ./modules/cabana -run 'TestSeaglassSmoke\|TestSanitize\|TestCheck\|TestRichEditor\|TestCompanionsRichEditorSmoke'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-03 / 17-06-02 | D-07 | Repeater compile refusals, decode, per-row errors, min/max, nested notices and recheck | unit+pg | `go test -count=1 ./modules/cabana -run 'TestRepeater\|TestCompanionsRepeaterSmoke'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-04 / 17-06-02 | D-09, D-11 | Reorder exact set, 409 stale/foreign, 422 cycle/depth, both columns; singleton first-or-create, 404 list/delete | pg | `go test -count=1 ./modules/cabana -run 'TestReorder\|TestSingleton\|TestCompanionsReorderSmoke\|TestCompanionsSingletonSmoke\|TestPhase10OpenAPIConformance'` | ❌ W0 | ⬜ pending |
|
||||
| 17-02-02..04 / 17-06-02 | D-06..D-11 (SPA) | RichEditorField (untouched byte-identical), RepeaterField, dependent dropdown states, ReorderView, singleton form | vitest | `npm --prefix admin run typecheck && npm --prefix admin test` | ❌ W0 | ⬜ pending |
|
||||
| 17-02-05 | D-06 | dist and OpenAPI in sync before the tag | script | `bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check` | ✅ | ⬜ pending |
|
||||
| 17-03-01, 17-03-02 / 17-06-03 | D-04 | Journal 5 types: DefaultPattern on each, list children follow the record type host pattern and the item override, references, nesting, list order, published filters, alternates order, x-default | unit+pg | `go -C ../sm-journal-plugin test -count=1 -run 'TestMenuItemTypes' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-01, 17-03-03 / 17-06-03 | SC-3 | Sitemap golden parity, Url::to table, tiers, rewrite, 50k cap | unit (goldens) | `go -C ../sm-sitemap-plugin test -count=1 -short -run 'TestSitemapGolden\|TestURLTo\|TestTiers\|TestRewrite\|TestURLCap' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-03 / 17-06-03 | D-14 | XmlEscaping and MassAssignment spec ports; BeforeAddItem skip, AddItem extra; manifest; tolerant item JSON | unit | `go -C ../sm-sitemap-plugin test -count=1 -short -run 'TestXMLEscaping\|TestFillable\|TestHooks\|TestManifest\|TestDefinitionItemJSON' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-03, 17-03-04 / 17-06-03 | SC-1, D-11..D-13 | sitemap:import and sitemap:import-winter; singleton admin; settings | pg | `go -C ../sm-sitemap-plugin test -count=1 -run 'TestSitemapImportSmoke\|TestSitemapAdminSmoke\|TestImportWinter' .` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-01..03 / 17-06-03 | SC-1, D-05, D-07..D-10 | FAQ and menu admin, PHP save rules, URL scheme, reorder per menu, Winter imports | pg | `go -C ../sm-faq-plugin test -count=1 ./... && go -C ../sm-menu-plugin test -count=1 ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-05 / 17-06-03 | D-16 | Menu API tree, published filter, URL scheme filter, expansion, locale, url_pattern | pg | `go -C ../sm-menu-plugin test -count=1 -run 'TestMenuAPI\|TestMenuTree\|TestAllowedURL' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-05 / 17-06-03 | D-17 | FAQ API locale default, active only, lowest id, sanitized output (raw-SQL rows) | pg | `go -C ../sm-faq-plugin test -count=1 -run 'TestFaqAPI' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-05-03 / 17-06-04 | D-18 | Layer clients, link safety, active matching, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ W0 | ⬜ pending |
|
||||
| 17-05-02 / 17-06-03 | SC-4, D-19 | Host boot with three plugins; imports of export-shaped fixtures; menu, FAQ and sitemap from the host; CORS | pg/host | `go -C ../sm-grzybyfunkcjonalne-app test -count=1 ./...` | partial | ⬜ pending |
|
||||
| 17-05-04 | SC-4, D-15 | Site app uses MenuNav and FaqBlock, data files removed, Nuxt sitemap off | node test + SSR smoke | `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app smoke:ssr` | ✅ | ⬜ pending |
|
||||
| 17-06-01..04 | SC-5 | Phase gate, fail-closed | script | `bash scripts/check-phase17.sh --all` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `scripts/check-phase17.sh` — staged gate (copy the 16.2 structure, named-test PASS checks)
|
||||
- [ ] PHP golden generator (e.g. `../sm-sitemap-plugin/tests/php/gen-goldens.php`) plus committed `testdata/*.golden`
|
||||
- [ ] `go.work` per new plugin repo (`use .` + replaces to `../summercms.go`, `../sm-translate-plugin`)
|
||||
- [ ] Test harness per plugin (`TestMain` with `flag.Parse()` + `testing.Short()`)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Menu and FAQ render from the API on grzybyfunkcjonalne.pl | SC-4 | Needs the deployed site and nginx routing | Load the homepage and FAQ page, compare with the PHP rendering |
|
||||
| `/sitemap.xml` via nginx equals the expected golden; robots.txt Sitemap line | SC-3/SC-4 | Needs the on-server route | Extend `scripts/smoke.sh`, diff against the golden |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
1
.planning/phases/17-blog-companions/COVERAGE.md
Normal file
1
.planning/phases/17-blog-companions/COVERAGE.md
Normal file
@@ -0,0 +1 @@
|
||||
No external API integration: this phase ports internal WinterCMS plugins (sitemap, menu, FAQ) to SummerCMS and publishes its own read APIs (/_menu/api/v1, /_faq/api/v1, /sitemap.xml) consumed by first-party Nuxt layers; no third-party service, SDK or remote API is called.
|
||||
11
.planning/phases/17-blog-companions/deferred-items.md
Normal file
11
.planning/phases/17-blog-companions/deferred-items.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Phase 17 deferred items
|
||||
|
||||
Out-of-scope findings logged during execution. None blocks the plan that found it.
|
||||
|
||||
## From 17-02 (admin SPA)
|
||||
|
||||
1. **Singleton form subtitle link has no schema key.** The UI-SPEC asks for a "View sitemap.xml" subtitle link on the singleton form, "supplied by the form schema". Plan 01's `cabana.FormView` has no such key (only `singleton`), so FormView's singleton mode shows no subtitle line. Follow-up: an optional `config_form.yaml` key (for example a subtitle link with a label key and a URL) in cabana's form schema, OpenAPI and FormView.
|
||||
2. **Reorder button label cannot be overridden per controller.** `cabana.ListReorder` carries `title` and `partition` only, so the list button always reads `messages.list.reorder` ("Reorder records"). The UI-SPEC's menu entries override ("Reorder entries") needs a list schema key.
|
||||
3. **OpenAPI gaps the SPA works around.** `GET …/fields/{field}/dependent-options` reads `values[<sibling>]` from the query, but the document declares no query (the SPA casts the query). `AdminReorderNode.parent_id`, `AdminReorderRequest.partition` and `AdminReorderTree.partition` are nullable on the wire but typed as plain numbers or objects.
|
||||
4. **npm audit in admin/.** Seven advisories in existing dev and test dependencies (vitest and @vitest/mocker, tinypool, @vue/server-renderer through vue, source-map-js, esbuild). None comes from the TipTap packages added in 17-02.
|
||||
5. **Main bundle size.** `index-*.js` is about 609 kB minified (591 kB before 17-02) and Vite warns above 500 kB. TipTap itself is a separate lazy chunk (`editor-*.js`, about 381 kB).
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
phase: quick-261011-joh
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/sieve/sieve.go
|
||||
- modules/sieve/set.go
|
||||
- modules/sieve/sieve_test.go
|
||||
- modules/sieve/example_test.go
|
||||
- modules/sieve/README.md
|
||||
- README.md
|
||||
- docs/architecture/introduction.md
|
||||
- docs/setup/coming-from-wintercms.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261011-joh]
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "go doc -short ./modules/sieve lists exactly these exported names: Filter, Find, GroupBy, KeyBy, Map, Partition, Reduce, Set, SetOf, TryMap, Uniq, UniqBy. Set has the methods Has, Add and Slice. There is no Pluck (it would be Map under another name) and no Chunk (go1.27.0 already has slices.Chunk). None of the names duplicates a function in the standard library slices or maps packages (D-01, D-03)."
|
||||
- "Every function is eager and returns a new slice or map. It never modifies its input, and its result never shares a backing array with the input, so changing or appending to a result leaves the caller's slice unchanged. Callbacks run once per element, in input order; TryMap and Find stop early (D-02)."
|
||||
- "For nil or empty input, every slice- or map-returning function returns a non-nil empty value, so encoding/json writes [] or {} and never null. Find returns (zero, false), Reduce returns init, and SetOf() returns an empty set that Add can use (D-04)."
|
||||
- "TryMap stops at the first error. It returns (nil, err) with the error unchanged (err == sentinel) and never calls f for the elements after the failing one (D-03)."
|
||||
- "KeyBy keeps the later item when two items share a key. GroupBy keeps input order within each group and returns map[K]S, which keeps the caller's named slice type. Uniq and UniqBy keep the first occurrence in input order. Partition returns (matched, rest), both in input order. Find returns the first match (D-03)."
|
||||
- "The non-test files of modules/sieve import nothing (go list shows no imports), the package has 100.0% statement coverage, and every exported function and every Set method has a runnable Example with an // Output: line."
|
||||
- "modules/sieve/README.md follows the module README structure with the exact summary and **Name:** lines. The root README modules table has the sieve row between postcard and sunrise. docs/architecture/introduction.md and docs/setup/coming-from-wintercms.md mention sieve. TestDocsTree, docs:build --check, go vet ./... and go test -short ./... pass. The work lands as two code commits staged by explicit path, with no co-author trailer and no change to any other module (D-05 to D-08)."
|
||||
artifacts:
|
||||
- path: modules/sieve/sieve.go
|
||||
provides: "Package doc stating the contracts; Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find"
|
||||
contains: "func TryMap["
|
||||
- path: modules/sieve/set.go
|
||||
provides: "type Set[T comparable] map[T]struct{}, SetOf, Set.Has, Set.Add, Set.Slice"
|
||||
contains: "func SetOf["
|
||||
- path: modules/sieve/sieve_test.go
|
||||
provides: "Plain testing-package unit tests at 100.0% statement coverage: nil input, ordering, non-aliasing, input immutability, TryMap early stop, KeyBy later-wins, named slice types, Set behaviour"
|
||||
contains: "func TestTryMap"
|
||||
- path: modules/sieve/example_test.go
|
||||
provides: "A runnable Example for every exported function and Set method; ExampleMap shows the pluck use"
|
||||
contains: "func ExampleMap"
|
||||
- path: modules/sieve/README.md
|
||||
provides: "Module README: H1, summary, import line, Name line, then Overview, Role in the framework, Features, Usage, API reference, Dependencies, Testing"
|
||||
contains: "**Name:** A sand sieve"
|
||||
- path: README.md
|
||||
provides: "Modules table row for sieve, between postcard and sunrise"
|
||||
contains: "[sieve](modules/sieve/README.md)"
|
||||
- path: docs/architecture/introduction.md
|
||||
provides: "Utilities row in the framework modules table"
|
||||
contains: "[sieve](../../modules/sieve/README.md)"
|
||||
- path: docs/setup/coming-from-wintercms.md
|
||||
provides: "A concept-map row mapping Laravel collection methods to sieve, and the rewritten not-provided row for the fluent collection object"
|
||||
contains: "sieve.KeyBy"
|
||||
key_links:
|
||||
- from: modules/sieve/README.md
|
||||
to: internal/docsite (TestDocsTree, docs:build --check)
|
||||
via: "loadModules turns every modules/<name> with a non-test Go file into an API page. splitReadme needs '# sieve' followed by a summary line, and checkSpan resolves every sieve.X code span against the package."
|
||||
pattern: "^# sieve$"
|
||||
- from: README.md
|
||||
to: modules/sieve/README.md
|
||||
via: "The modules table row reuses the summary sentence as the purpose and the Name text as the mnemonic"
|
||||
pattern: "\\[sieve\\]\\(modules/sieve/README.md\\)"
|
||||
- from: docs/setup/coming-from-wintercms.md
|
||||
to: modules/sieve/README.md
|
||||
via: "The concept-map row maps Laravel collection methods to sieve identifiers; the link and identifier checkers resolve them"
|
||||
pattern: "sieve.GroupBy"
|
||||
- from: modules/sieve/example_test.go
|
||||
to: modules/sieve/sieve.go
|
||||
via: "Example functions with // Output: lines run under go test and render in go doc"
|
||||
pattern: "// Output:"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a new framework module, `modules/sieve`: generic, zero-dependency collection helpers for slices and maps, covering what the Go 1.27 standard library `slices` and `maps` packages do not provide. That means Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition and Find, plus a small `Set` type built with `SetOf`. The module ships with a README in the standard module structure, a row in the root README modules table, mentions on the two fitting docs pages, Example tests and full unit tests.
|
||||
|
||||
Purpose: plugin and framework code that ports Laravel collection pipelines (`map`, `filter`, `keyBy`, `groupBy`, `unique`, `partition`, `first`, `pluck`) gets one typed, reviewed implementation instead of each plugin writing its own loops. All results are non-nil, so encoded API bodies keep `[]`/`{}` rather than `null`.
|
||||
|
||||
Output: modules/sieve (sieve.go, set.go, sieve_test.go, example_test.go, README.md), one new root README row, and edits to two docs pages. It lands as two commits: the tracer, then the rest of the API with its docs. No existing module changes.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@README.md
|
||||
@modules/towel/README.md
|
||||
@modules/towel/example_test.go
|
||||
@docs/architecture/introduction.md
|
||||
@docs/setup/coming-from-wintercms.md
|
||||
|
||||
Locked decisions (from the user, cited as D-NN in the tasks):
|
||||
- D-01: The toolchain is Go 1.27 (local go1.27.0). Do not duplicate the standard library: every planned name was checked against `go doc slices` and `go doc maps`. `slices.Chunk` exists (`func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]`), so sieve ships no Chunk, and the README points to `slices.Chunk` instead. The module has zero dependencies: its non-test files import nothing.
|
||||
- D-02: The API is eager and slice-based. Functions return new slices or maps and never mutate their input. iter.Seq variants are out of scope.
|
||||
- D-03: Signatures are idiomatic and generic: Map[S ~[]T, T, U any]; TryMap returns ([]U, error) and stops at the first error; Find returns (T, bool); Partition returns (matched, rest); KeyBy and GroupBy take a key func returning a comparable K; Reduce(s, init, f) returns A; Uniq keeps first-occurrence order, with a UniqBy variant. Pluck and SetOf were left to the planner (see PD-1 and PD-4).
|
||||
- D-04: Nil input gives an empty, non-nil result. Document this the same way everywhere.
|
||||
- D-05: The README structure is H1 name, the one-sentence summary, the import line, the `**Name:**` mnemonic line, then the sections. The root README modules table gets a row, in alphabetical order, whose purpose text reuses the summary and whose third column is the mnemonic.
|
||||
- D-06: Every identifier named in a README or docs page must exist in the package; the docs checker enforces this. Framework READMEs never name a consuming application.
|
||||
- D-07: Commits carry no co-author trailer of any kind. The user's CLAUDE.md rule overrides any attribution reminder. Code and planning docs go in separate commits, and files are staged by explicit path, because other sessions may be working in this repo.
|
||||
- D-08: One plan with 1 to 3 tasks. Example tests and full unit tests land in the same task as the code. No existing module is refactored. Add a docs mention where a fitting page exists. The gates are `go test ./cmd/summer -run TestDocsTree`, `summer docs:build --check`, `go vet ./...` and `go test -short ./...`.
|
||||
|
||||
Planner decisions (Claude's discretion, recorded):
|
||||
- PD-1: Pluck is dropped as redundant. Laravel's `pluck('title')` takes a field name as a string, which in Go would need reflection and would give up compile-time checking. The typed equivalent is Map with a field accessor. The README Usage section says this and shows the Map form, and the Map doc comment and ExampleMap show the pluck use.
|
||||
- PD-2: Chunk is dropped because of D-01. The README names `slices.Chunk` (lazy) and `slices.Collect(slices.Chunk(s, n))` (eager).
|
||||
- PD-3: UniqBy is added. Deduplicating structs by an ID is the common case, and Uniq only works for comparable element types.
|
||||
- PD-4: `type Set[T comparable] map[T]struct{}` is a defined map type, so `len`, `range`, `delete` and `maps.Keys` work on it directly. It has three value-receiver methods: `Has(v T) bool`, `Add(items ...T)` and `Slice() []T`. Slice returns elements in unspecified order and is non-nil even for a nil Set. `SetOf[T comparable](items ...T) Set[T]` is variadic, so `SetOf("a", "b")` and `SetOf(ids...)` both work, including for named slice types. A nil Set answers Has and Slice. Add on a nil Set panics like any nil-map write; document this and point to SetOf.
|
||||
- PD-5: Functions whose element type does not change keep the caller's named slice type. Filter, Uniq, UniqBy and Partition return S, and GroupBy returns map[K]S. Map and TryMap return []U.
|
||||
- PD-6: TryMap returns (nil, err) on failure with err unchanged, not wrapped. A caller who wants the index wraps the error inside f.
|
||||
- PD-7: In KeyBy, a later item overwrites an earlier one with the same key, as Laravel's keyBy does.
|
||||
- PD-8: The README includes a "Role in the framework" section between Overview and Features. All 24 module READMEs have one since commit 602e041 ("docs: add a Role in the framework section to every module README"), so it is part of the standard structure.
|
||||
- PD-9: Docs mentions. docs/architecture/introduction.md gets a new "Utilities" row in the framework modules table, since no existing concern fits. docs/setup/coming-from-wintercms.md gets a concept-map row for Laravel collections, and its "What is not provided" row for Collections is rewritten to cover only the fluent collection object, pointing to sieve. That row currently tells readers to use only `slices` and `maps`, which is out of date once sieve exists.
|
||||
- PD-10: The work lands as two commits. The tracer commit (Task 1) is followed by one commit holding the rest of the API, its tests and README, and both docs edits (Tasks 2 and 3). The docs edits ride in the same change as the API they describe, per CLAUDE.md.
|
||||
|
||||
Exact strings (copy verbatim; the gates match them byte for byte):
|
||||
- SUMMARY (README line 3, and the purpose cell of the root row): Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type.
|
||||
- NAME (README line 7): **Name:** A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps.
|
||||
- ROOT_ROW (one line in README.md, directly after the postcard row and directly before the sunrise row): | [sieve](modules/sieve/README.md) | Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type. | A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps. |
|
||||
- INTRO_ROW (docs/architecture/introduction.md, appended after the "Console and tooling" row): | Utilities | [sieve](../../modules/sieve/README.md) |
|
||||
|
||||
Facts the planner verified (no need to re-derive):
|
||||
- `go version` is go1.27.0. The `go doc slices` functions are All, AppendSeq, Backward, BinarySearch(Func), Chunk, Clip, Clone, Collect, Compact(Func), Compare(Func), Concat, Contains(Func), Delete(Func), Equal(Func), Grow, Index(Func), Insert, IsSorted(Func), Max(Func), Min(Func), Repeat, Replace, Reverse, Sort(Func), SortStableFunc, Sorted(Func), SortedStableFunc and Values. The `go doc maps` functions are All, Clone, Collect, Copy, DeleteFunc, Equal(Func), Insert, Keys and Values. None of them is Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set or SetOf. Compact removes only consecutive duplicates, in place; DeleteFunc filters in place; IndexFunc returns an index, not the element.
|
||||
- The signatures in <interfaces> were compiled and vetted in a scratch module under go1.27.0. Type inference works from the slice argument alone; `SetOf(ids...)` with `type IDs []int` compiles; `GroupBy` on an IDs value yields `map[bool]IDs`; ExampleSet_Has and ExampleSet_Slice, which name methods on a generic type, pass `go vet`'s tests analyzer; `fmt.Println` of a map prints keys sorted, so Example output for KeyBy and GroupBy is deterministic.
|
||||
- `go doc -short ./<pkg>` prints top-level funcs and types. A constructor such as SetOf is listed indented under its type, which the gate's `sed -E 's/^ *(func|type) ([A-Za-z]+).*/\2/'` handles. `go list -f '{{join .Imports " "}}'` prints an empty line for a package with no imports; test imports are not included.
|
||||
- internal/docsite: every modules/<name> directory with a non-test .go file must have a README.md whose first line is `# <name>` followed by a summary line; otherwise TestDocsTree fails. Code spans of the forms `pkg.Ident`, `pkg.Type.Member`, `pkg.Ident(...)` and `pkg.Ident[...]` are checked when pkg is a module package name. `slices.X` and `maps.X` spans are ignored, because they are not modules. Write methods as `sieve.Set.Has`, not `sieve.Set[T].Has`. Go code fences in module READMEs need no src= (only docs/ pages do). docs/ page headings must be plain ASCII with no code or links. Any spelling of the consuming application's name is rejected.
|
||||
- Baseline on master at b906bdd: `go run ./cmd/summer docs:build --check` prints "docs:build: no problems found" in about 4 s, and `go vet ./...` is green. The only untracked path is `.msd/`; never stage it.
|
||||
- Baseline `go test -short ./...` takes about 55 s. Inside the planning sandbox, cabana, conga, lagoon and lighthouse failed only with a Docker socket "permission denied" error on unix:///var/run/docker.sock (testcontainers tests that do not skip under -short). Those are environment failures, not regressions; see Task 3 for how to handle them.
|
||||
- Module tests in this repo use the plain `testing` package (no testify): internal `package <name>` tests plus a `package <name>_test` example_test.go, as in modules/towel.
|
||||
- Commit prefixes are module-scoped, for example `feat(cabana): ...` and `fix(admin): ...`.
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
Exact exported API of modules/sieve (compiled in a scratch module under go1.27.0; parameter names are recommendations):
|
||||
|
||||
```go
|
||||
// sieve.go
|
||||
func Map[S ~[]T, T, U any](s S, f func(T) U) []U
|
||||
func TryMap[S ~[]T, T, U any](s S, f func(T) (U, error)) ([]U, error)
|
||||
func Filter[S ~[]T, T any](s S, keep func(T) bool) S
|
||||
func Reduce[S ~[]T, T, A any](s S, init A, f func(A, T) A) A
|
||||
func KeyBy[S ~[]T, T any, K comparable](s S, key func(T) K) map[K]T
|
||||
func GroupBy[S ~[]T, T any, K comparable](s S, key func(T) K) map[K]S
|
||||
func Uniq[S ~[]T, T comparable](s S) S
|
||||
func UniqBy[S ~[]T, T any, K comparable](s S, key func(T) K) S
|
||||
func Partition[S ~[]T, T any](s S, match func(T) bool) (matched, rest S)
|
||||
func Find[S ~[]T, T any](s S, match func(T) bool) (T, bool)
|
||||
|
||||
// set.go
|
||||
type Set[T comparable] map[T]struct{}
|
||||
func SetOf[T comparable](items ...T) Set[T]
|
||||
func (s Set[T]) Has(v T) bool
|
||||
func (s Set[T]) Add(items ...T)
|
||||
func (s Set[T]) Slice() []T
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the sieve module with Map, wired through its README, the root modules table and the docs checker</name>
|
||||
<files>modules/sieve/sieve.go, modules/sieve/example_test.go, modules/sieve/README.md, README.md</files>
|
||||
<read_first>modules/towel/README.md, modules/towel/example_test.go, README.md (the Modules table, lines 90 to 117), internal/docsite/load.go (loadModules and splitReadme)</read_first>
|
||||
<action>
|
||||
Prove the one path every new module takes through the framework: a Go package, a runnable example, the module README, docs-site ingestion with the identifier check, and the root modules table. Only Map is built here; Task 2 adds the rest.
|
||||
|
||||
1. Create modules/sieve/sieve.go in package sieve, with a package doc comment. Its first sentence is "Package sieve provides generic, dependency-free collection helpers that the standard library slices and maps packages lack." A second paragraph states the contracts per D-02 and D-04. Every function is eager and never modifies its input. Results never share a backing array with the input. Callbacks run once per element in input order. Nil or empty input yields a non-nil empty slice or map, so encoding/json writes [] or {} rather than null. Add only Map, with the signature in interfaces. Allocate the result with make([]U, 0, len(s)), so it is non-nil even for a nil s, and call f once per element in order. Its doc comment says that Map is also the pluck operation, extracting a field through an accessor func (PD-1). The file has no import block at all (D-01).
|
||||
|
||||
2. Create modules/sieve/example_test.go in package sieve_test, importing fmt and git.golem15.com/golem15/summercms/modules/sieve. Add ExampleMap: a local `post` struct with ID int and Title string, three values, then sieve.Map extracting the titles (the pluck use), printed with fmt.Println and checked by an // Output: line. Use only neutral names such as blog and acme (D-06).
|
||||
|
||||
3. Create modules/sieve/README.md in the towel layout (D-05, PD-8):
|
||||
- Line 1 is "# sieve", then a blank line.
|
||||
- Line 3 is the SUMMARY string from context, verbatim, then a blank line.
|
||||
- Line 5 is the import line in a code span, formatted like towel's (import "git.golem15.com/golem15/summercms/modules/sieve"), then a blank line.
|
||||
- Line 7 is the NAME string from context, verbatim.
|
||||
- Then these sections, in this order: Overview, Role in the framework, Features, Usage, API reference, Dependencies, Testing.
|
||||
At this stage:
|
||||
- The API reference table (columns Identifier and Description) has exactly one row, which starts with "| `sieve.Map` |".
|
||||
- Features and Usage cover Map only. Usage has a short Go block in package blog that maps posts to titles.
|
||||
- Dependencies lists three bullets: SummerCMS modules: none; Third-party: none; Standard library: none (the package has no imports).
|
||||
- Testing shows go test ./modules/sieve/... in an sh block and says the tests are in-memory and need no external services.
|
||||
Write no sieve.X code span for any identifier that Task 2 will add. TestDocsTree resolves every such span against the package as it stands at this commit.
|
||||
|
||||
4. Edit README.md at the repo root with Edit, never Write. Insert ROOT_ROW from context as one line directly after the postcard row and directly before the sunrise row of the Modules table. Change nothing else.
|
||||
|
||||
5. Run every check in verify up to and including `git diff --quiet -- go.mod go.sum`; all must pass. Then stage exactly the four paths by name (git add modules/sieve/sieve.go modules/sieve/example_test.go modules/sieve/README.md README.md). Never use git add -A or git add ., and never stage .msd/. Commit with the subject "feat(sieve): add sieve module with Map" and no trailer (D-07). Finally, run the whole verify command, including the commit checks.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/sieve && go test -count=1 -run 'ExampleMap' -v ./modules/sieve | grep -q -- '--- PASS: ExampleMap' && test -z "$(go list -f '{{join .Imports " "}}' ./modules/sieve)" && go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 && go run ./cmd/summer docs:build --check && test "$(sed -n 1p modules/sieve/README.md)" = '# sieve' && test "$(sed -n 3p modules/sieve/README.md)" = 'Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type.' && grep -qxF '**Name:** A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps.' modules/sieve/README.md && grep -qxF '| [sieve](modules/sieve/README.md) | Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type. | A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps. |' README.md && awk '/^\| \[postcard\]/{p=NR} /^\| \[sieve\]/{s=NR} /^\| \[sunrise\]/{u=NR} END{exit !(p && s==p+1 && u==s+1)}' README.md && git diff --quiet -- go.mod go.sum && TRACER=$(git log --format=%H -1 --grep='^feat(sieve): add sieve module with Map$') && test -n "$TRACER" && MSG=$(git log -1 --format=%B "$TRACER") && ! printf '%s' "$MSG" | grep -qi 'co-authored' && FILES=$(git show --name-only --format= "$TRACER") && test "$(printf '%s\n' "$FILES" | LC_ALL=C sort | tr '\n' ' ')" = 'README.md modules/sieve/README.md modules/sieve/example_test.go modules/sieve/sieve.go '</automated>
|
||||
</verify>
|
||||
<done>modules/sieve exists with Map and no imports. ExampleMap passes. The README has the exact summary and Name lines and all seven sections in order. The root README row sits between postcard and sunrise. TestDocsTree, TestDocsBuildRealTree and docs:build --check pass. The commit with the subject "feat(sieve): add sieve module with Map", found by its subject rather than by a HEAD offset, touches exactly those four files and has no trailer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: The rest of the API with full unit tests, Example tests and the complete README</name>
|
||||
<files>modules/sieve/sieve.go, modules/sieve/set.go, modules/sieve/sieve_test.go, modules/sieve/example_test.go, modules/sieve/README.md</files>
|
||||
<read_first>modules/sieve/sieve.go, modules/sieve/example_test.go, modules/sieve/README.md (all from Task 1), modules/towel/context_test.go (test style)</read_first>
|
||||
<behavior>
|
||||
- Map: nil input gives a non-nil, length-0 result. Order is preserved, f is called exactly len(s) times, and changing the result leaves the input unchanged.
|
||||
- TryMap: on success, every element is mapped in order, and nil input gives ([]U{} non-nil, nil). When f fails at index i (cases i = 0 and i = 2 of 4), the result is (nil, sentinel) with err == sentinel and errors.Is true, and f was called exactly i+1 times.
|
||||
- Filter: keeps matches in input order. Nil input, or no matches, gives a non-nil empty result. When every element matches, the result has a new backing array (changing result[0] leaves the input unchanged). The input equals a slices.Clone snapshot afterwards. A named type `type ids []int` comes back as ids (checked at compile time by assigning to an ids variable).
|
||||
- Reduce: sums ints. Nil input returns init unchanged. String concatenation proves left-to-right order ("abc"). The accumulator type may differ from the element type (int elements into a string or map accumulator).
|
||||
- KeyBy: on duplicate keys the later item wins. Nil input gives a non-nil empty map. key is called once per element.
|
||||
- GroupBy: each group keeps input order, the result type is map[K]S for a named S, and nil input gives a non-nil empty map. Appending to one group changes neither the input nor any other group.
|
||||
- Uniq: [3 1 3 2 1] gives [3 1 2]. Input with no duplicates still gets a new backing array. Nil input gives a non-nil empty result, and the input is unchanged.
|
||||
- UniqBy: deduplicating structs by an ID field keeps the first occurrence, in order. key is called once per element.
|
||||
- Partition: matched and rest both keep input order. Nil input gives two non-nil empty slices. When every element matches, rest is non-nil and empty.
|
||||
- Find: returns the first match and true. Without a match, or for nil input, it returns (zero value, false). The predicate is not called after the first match.
|
||||
- Set: SetOf deduplicates, and SetOf() is non-nil and accepts Add. Has reports presence and absence. A nil Set has Has false and a non-nil, length-0 Slice. Add is variadic. Slice holds each element exactly once (sort, then compare). len and delete work directly on a Set.
|
||||
- `go test -cover ./modules/sieve` reports exactly 100.0% of statements.
|
||||
</behavior>
|
||||
<action>
|
||||
Implement the rest of interfaces per D-02, D-03 and D-04 and PD-3 to PD-7, tests first.
|
||||
|
||||
1. RED. Write modules/sieve/sieve_test.go in package sieve (internal tests, plain testing package, no testify, table-driven where it reads naturally), covering every behavior bullet. A compile failure for the missing identifiers counts as the failing run. Confirm it fails with go test ./modules/sieve.
|
||||
|
||||
2. GREEN, in modules/sieve/sieve.go. Add TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition and Find with the exact signatures in interfaces.
|
||||
- Every slice-returning path allocates its own result with make (never s[:0], never an alias of s), so the result is non-nil and independent of the input (D-02, D-04). Map-returning paths use make(map...).
|
||||
- TryMap returns nil and the unwrapped error from f at the first failure (PD-6).
|
||||
- KeyBy lets later items win (PD-7).
|
||||
- Uniq and UniqBy track seen values or keys in a map[T]struct{} or map[K]struct{} and keep the first occurrence.
|
||||
- Find returns the zero value and false when nothing matches.
|
||||
- Each function gets a Go doc comment that states its nil-input result and, where it applies, ordering, early stop and duplicate-key behaviour.
|
||||
- No imports in any non-test file (D-01).
|
||||
|
||||
3. GREEN, in a new file modules/sieve/set.go: Set, SetOf, Has, Add and Slice per PD-4. All methods use value receivers. SetOf sizes its map from len(items). Slice returns make([]T, 0, len(s)) filled by ranging the map. The type's doc comment says that Set is a plain map, that Slice order is unspecified (use slices.Sorted(maps.Keys(set)) for ordered output when T is ordered), and that Add on a nil Set panics, so callers should create sets with SetOf.
|
||||
|
||||
4. Extend modules/sieve/example_test.go with one Example each: ExampleTryMap (strconv.Atoi over strings, once with all valid and once with a bad value, printing the error), ExampleFilter, ExampleReduce, ExampleKeyBy, ExampleGroupBy, ExampleUniq, ExampleUniqBy, ExamplePartition, ExampleFind, ExampleSetOf, ExampleSet_Has, ExampleSet_Add and ExampleSet_Slice. ExampleSet_Slice sorts with slices.Sort before printing. Print maps with fmt.Println, which sorts the keys. Each Example has an // Output: line. Test-file imports such as strconv and slices are fine, because only non-test files must have none.
|
||||
|
||||
5. Complete modules/sieve/README.md, keeping lines 1 to 7 and the seven-section order from Task 1:
|
||||
- Overview: what sieve is and its WinterCMS/Laravel counterpart (collection methods on Eloquent results), plus the four contracts from the package doc.
|
||||
- Role in the framework: a dependency-free leaf with no imports, so any module, plugin or application can use it without import cycles. It complements slices and maps rather than wrapping them. Then a "Use cases:" list: shaping API responses from models, keying records by ID to join two result sets, grouping, deduplicating IDs before an IN query, and set membership checks.
|
||||
- Features: one bullet per function or group, naming each identifier as a sieve.X code span.
|
||||
- Usage: one Go block in package blog with a Post struct (ID, Author, Title, Published) that uses Filter, Map, KeyBy, GroupBy, UniqBy, Partition, Find, TryMap and SetOf. After it, a paragraph explaining why there is no Pluck (PD-1): Laravel's pluck takes a field name as a string, which would need reflection and lose type checking, so the typed equivalent is the inline span `sieve.Map(posts, func(p Post) string { return p.Title })`. Then a short list headed "What the standard library already covers" pointing to slices.Chunk (with slices.Collect(slices.Chunk(s, n)) for an eager [][]T), slices.Contains, slices.IndexFunc, slices.Sort and slices.SortFunc, slices.Min and slices.Max, slices.Compact (consecutive duplicates only, in place), slices.DeleteFunc (in-place filter), and maps.Keys and maps.Values (with slices.Sorted for ordered output), each in a code span (PD-2, D-01).
|
||||
- API reference: one row per identifier, each row starting with "| `sieve.<Id>` |", for exactly these 15: Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set, SetOf, Set.Has, Set.Add, Set.Slice. Each description gives the nil-input result and any ordering or duplicate rule.
|
||||
- Dependencies: still none, none, none.
|
||||
- Testing: the sh block plus a sentence that the tests are in-memory, include runnable examples and cover every statement.
|
||||
Every sieve.X span must resolve (D-06). Do not commit in this task: the change is committed together with Task 3's docs edits (PD-10).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/sieve && go test -count=1 -cover ./modules/sieve | grep -q 'coverage: 100.0% of statements' && test -z "$(go list -f '{{join .Imports " "}}' ./modules/sieve)" && test "$(go doc -short ./modules/sieve | sed -E 's/^ *(func|type) ([A-Za-z]+).*/\2/' | LC_ALL=C sort | tr '\n' ' ')" = 'Filter Find GroupBy KeyBy Map Partition Reduce Set SetOf TryMap Uniq UniqBy ' && go doc -c ./modules/sieve Set.Has >/dev/null && go doc -c ./modules/sieve Set.Add >/dev/null && go doc -c ./modules/sieve Set.Slice >/dev/null && test "$(go test -count=1 -list 'Example.*' ./modules/sieve | grep -c '^Example')" -ge 14 && test "$(grep '^## ' modules/sieve/README.md | tr '\n' '|')" = '## Overview|## Role in the framework|## Features|## Usage|## API reference|## Dependencies|## Testing|' && test "$(for id in Map TryMap Filter Reduce KeyBy GroupBy Uniq UniqBy Partition Find Set SetOf Set.Has Set.Add Set.Slice; do grep -qF "| \`sieve.$id\` |" modules/sieve/README.md || echo "$id"; done)" = '' && grep -qF '`slices.Chunk`' modules/sieve/README.md && go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 && go run ./cmd/summer docs:build --check && git diff --quiet -- go.mod go.sum</automated>
|
||||
</verify>
|
||||
<done>All 12 exported names and the 3 Set methods exist with the signatures in interfaces. Coverage is 100.0%, there are no imports, and every behavior bullet has a passing test. At least 14 Example functions with output pass. The README has all 15 API reference rows, the Pluck explanation and the standard-library list, and TestDocsTree and docs:build --check pass. Nothing is committed yet.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Docs mentions, the full gates and the feature commit</name>
|
||||
<files>docs/architecture/introduction.md, docs/setup/coming-from-wintercms.md</files>
|
||||
<read_first>docs/architecture/introduction.md (the framework modules table, lines 21 to 32), docs/setup/coming-from-wintercms.md (the concept map table and the "What is not provided" table)</read_first>
|
||||
<action>
|
||||
1. In docs/architecture/introduction.md, use Edit to append INTRO_ROW from context as a new last row of the "The framework modules" table, directly after the "Console and tooling" row (PD-9). Change nothing else.
|
||||
|
||||
2. In docs/setup/coming-from-wintercms.md (PD-9, D-06), use Edit:
|
||||
a. Concept map table: insert one row directly after the "Eloquent models" row. The WinterCMS cell names Laravel collections and the methods `map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first` and `pluck` as bare code spans. The SummerCMS cell says these work on plain slices and maps with `sieve.Map`, `sieve.TryMap`, `sieve.Filter`, `sieve.Reduce`, `sieve.KeyBy`, `sieve.GroupBy`, `sieve.Uniq`, `sieve.Partition` and `sieve.Find`. It adds that pluck is `sieve.Map` with a field accessor, unique-by-key is `sieve.UniqBy`, `chunk` is the standard `slices.Chunk`, and sets are `sieve.SetOf`. The Where cell is [sieve](../../modules/sieve/README.md). There is no guide page, so the module reference stands alone.
|
||||
b. "What is not provided" table: rewrite the row whose first cell is Collections. Its first cell becomes the fluent collection object (the Collection class and method chaining). Its second cell says this is not provided, and that you call the [sieve](../../modules/sieve/README.md) functions and the standard `slices` and `maps` packages on plain slices and maps. Keep the table's existing sentence style.
|
||||
Do not add Go code fences: docs/ pages require src= on Go fences. Do not add headings.
|
||||
|
||||
3. Run the verify checks up to and including `go test -short ./...`. If go test -short ./... fails only in cabana, conga, lagoon or lighthouse with a Docker socket "permission denied" error on unix:///var/run/docker.sock (the sandbox blocks the Docker socket; this was observed at planning time and is not a regression), rerun the same command outside the sandbox. If Docker is still unreachable, run the suite without those four packages, record in the SUMMARY which packages were skipped and why, and continue. Any other failure is a real failure: fix it before committing.
|
||||
|
||||
4. Commit Task 2 and Task 3 together (PD-10, D-07). Stage exactly these seven paths by name: docs/architecture/introduction.md, docs/setup/coming-from-wintercms.md, modules/sieve/README.md, modules/sieve/example_test.go, modules/sieve/set.go, modules/sieve/sieve.go and modules/sieve/sieve_test.go. Never use git add -A or git add ., and never stage .msd/. Use the subject "feat(sieve): add the remaining collection helpers and docs mentions". Add a body listing TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set and SetOf, plus the two docs pages, and add no trailer. Then run the whole verify command, including the commit checks and the check that no other module changed (D-08).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -qxF '| Utilities | [sieve](../../modules/sieve/README.md) |' docs/architecture/introduction.md && grep -qF '[sieve](../../modules/sieve/README.md)' docs/setup/coming-from-wintercms.md && grep -qF '`sieve.KeyBy`' docs/setup/coming-from-wintercms.md && grep -qF '`sieve.GroupBy`' docs/setup/coming-from-wintercms.md && grep -qF '`sieve.UniqBy`' docs/setup/coming-from-wintercms.md && grep -qF '`slices.Chunk`' docs/setup/coming-from-wintercms.md && ! grep -q '^| Collections |' docs/setup/coming-from-wintercms.md && go vet ./... && go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 && go run ./cmd/summer docs:build --check && go test -count=1 -cover ./modules/sieve | grep -q 'coverage: 100.0% of statements' && go test -short ./... && git diff --quiet -- go.mod go.sum && TRACER=$(git log --format=%H -1 --grep='^feat(sieve): add sieve module with Map$') && FEAT=$(git log --format=%H -1 --grep='^feat(sieve): add the remaining collection helpers and docs mentions$') && test -n "$TRACER" && test -n "$FEAT" && git merge-base --is-ancestor "$TRACER" "$FEAT" && MSG=$(git log -1 --format=%B "$FEAT") && ! printf '%s' "$MSG" | grep -qi 'co-authored' && FILES=$(git show --name-only --format= "$FEAT") && test "$(printf '%s\n' "$FILES" | LC_ALL=C sort | tr '\n' ' ')" = 'docs/architecture/introduction.md docs/setup/coming-from-wintercms.md modules/sieve/README.md modules/sieve/example_test.go modules/sieve/set.go modules/sieve/sieve.go modules/sieve/sieve_test.go ' && TFILES=$(git show --name-only --format= "$TRACER") && test "$(printf '%s\n' "$TFILES" | LC_ALL=C sort | tr '\n' ' ')" = 'README.md modules/sieve/README.md modules/sieve/example_test.go modules/sieve/sieve.go '</automated>
|
||||
</verify>
|
||||
<done>The architecture intro has the Utilities row. The WinterCMS page maps Laravel collection methods to sieve identifiers, and its not-provided row now covers only the fluent collection object. go vet ./..., TestDocsTree, TestDocsBuildRealTree, docs:build --check, 100.0% sieve coverage and go test -short ./... all pass, with any Docker-sandbox skip recorded in the SUMMARY. There are two feat(sieve) commits, found by subject; the tracer is an ancestor of the feature commit. Neither has a trailer, and each touches exactly its listed files, so no module outside modules/sieve changed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| request data → sieve helpers | Plugin handlers may pass request-derived slices (IDs from a query string, decoded JSON arrays) straight through Map, TryMap, Uniq, KeyBy or SetOf |
|
||||
| sieve results → API responses | Results are encoded by encoding/json into bodies that must stay byte-compatible with the PHP backend |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-joh-01 | Tampering (aliasing: a result sharing the caller's backing array, so a later append or write corrupts cached or shared data) | sieve.go Filter, Uniq, UniqBy, Partition, GroupBy | medium | mitigate | Every result is allocated with make and never re-slices s. Task 2 tests change results (including the all-match and no-duplicate cases) and assert the input still equals a slices.Clone snapshot, and they check that appending to one GroupBy group leaves the input and the other groups unchanged. |
|
||||
| T-joh-02 | Tampering (parity drift: null instead of [] or {} in encoded bodies) | all slice- and map-returning functions, Set.Slice | low | mitigate | Nil or empty input yields non-nil empty results (D-04). Task 2 tests assert non-nil for every function on nil input. The README and doc comments state the rule. |
|
||||
| T-joh-03 | Information Disclosure / Tampering (nondeterministic order leaking into responses) | Set.Slice, KeyBy, GroupBy map results | low | mitigate | Set.Slice is documented as unordered and points to slices.Sorted(maps.Keys(set)). Map results are encoded by encoding/json with sorted keys. Order-preserving functions (Filter, Uniq, UniqBy, Partition, GroupBy groups) have ordering tests. |
|
||||
| T-joh-04 | Denial of Service (large request-derived input) | all functions | low | accept | Output size is at most linear in input size (Map and TryMap 1:1; the others at most n), and allocation is a single pass. Callers bound input size through surf body limits; sieve adds no amplification. |
|
||||
| T-joh-05 | Repudiation / error masking (TryMap swallowing or rewriting errors) | sieve.go TryMap | low | mitigate | TryMap returns f's error unchanged and stops at the first one. Task 2 asserts err == sentinel and the call count. |
|
||||
| T-joh-SC | Tampering | npm/pip/cargo installs | low | accept | No package is added. Each task verifies an empty import list for modules/sieve and `git diff --quiet -- go.mod go.sum`. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... is green and go test -short ./... passes (Docker-sandbox handling as in Task 3).
|
||||
- go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 and go run ./cmd/summer docs:build --check report no problems.
|
||||
- modules/sieve has 100.0% statement coverage, no non-test imports and exactly the exported names in interfaces, with at least 14 passing Examples.
|
||||
- The root README row sits between postcard and sunrise, and the README has the exact summary and Name lines.
|
||||
- Two feat(sieve) commits with explicitly staged files and no trailer; no change outside modules/sieve except README.md and the two docs pages.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
A developer can import git.golem15.com/golem15/summercms/modules/sieve and use Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find and Set/SetOf on plain slices with full type inference and no new dependency. The module's README, the root modules table and two docs pages describe it, the docs checker validates every identifier, and nothing in the standard library is duplicated.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261011-joh-sieve-generic-collection-helpers-module/261011-joh-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
phase: quick-261011-joh
|
||||
plan: 01
|
||||
subsystem: modules/sieve
|
||||
status: complete
|
||||
tags: [go, generics, collections, module, docs]
|
||||
requires: []
|
||||
provides:
|
||||
- "modules/sieve: Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find, Set, SetOf (Set.Has, Set.Add, Set.Slice)"
|
||||
affects:
|
||||
- README.md (modules table)
|
||||
- docs/architecture/introduction.md
|
||||
- docs/setup/coming-from-wintercms.md
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Eager generic helpers over S ~[]T that allocate their own result with make, so results are non-nil and never alias the input"
|
||||
- "Element-preserving functions return the caller's named slice type S (GroupBy returns map[K]S)"
|
||||
key-files:
|
||||
created:
|
||||
- modules/sieve/sieve.go
|
||||
- modules/sieve/set.go
|
||||
- modules/sieve/sieve_test.go
|
||||
- modules/sieve/example_test.go
|
||||
- modules/sieve/README.md
|
||||
modified:
|
||||
- README.md
|
||||
- docs/architecture/introduction.md
|
||||
- docs/setup/coming-from-wintercms.md
|
||||
decisions:
|
||||
- "sieve ships no Pluck (Map with a field accessor) and no Chunk (slices.Chunk exists in Go 1.27)"
|
||||
- "TryMap returns (nil, err) with f's error unwrapped; KeyBy lets the later item win"
|
||||
- "Set is a defined map type with value-receiver Has/Add/Slice; Add on a nil Set panics, so SetOf is the constructor"
|
||||
metrics:
|
||||
duration: 5m
|
||||
completed: 2026-10-11
|
||||
tasks: 3
|
||||
files: 8
|
||||
actuals:
|
||||
tokens: 8300
|
||||
tasks: 3
|
||||
commits: 2
|
||||
plan_head_before: b906bdd48bb361608e6c926113c322e62e5c9238
|
||||
plan_head_after: a0bf8ece050cc404041d1526d6db01d32aa8e1c2
|
||||
---
|
||||
|
||||
# Phase quick-261011-joh Plan 01: sieve generic collection helpers module Summary
|
||||
|
||||
Zero-import generic collection helpers (Map, TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition, Find and a map-backed Set built with SetOf). Every function is eager, never aliases its input, and returns non-nil empty results for nil input, so JSON bodies keep `[]`/`{}`. The package has 100.0% statement coverage and 14 runnable Examples, and is documented in its README, the root modules table and two docs pages.
|
||||
|
||||
## Tasks
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Tracer: sieve module with Map, README, root modules table row | 6933004 | modules/sieve/sieve.go, modules/sieve/example_test.go, modules/sieve/README.md, README.md |
|
||||
| 2 | Rest of the API with unit tests, Examples and the complete README (TDD) | a0bf8ec | modules/sieve/sieve.go, modules/sieve/set.go, modules/sieve/sieve_test.go, modules/sieve/example_test.go, modules/sieve/README.md |
|
||||
| 3 | Docs mentions and full gates | a0bf8ec | docs/architecture/introduction.md, docs/setup/coming-from-wintercms.md |
|
||||
|
||||
Tasks 2 and 3 share one commit, as the plan specifies (PD-10).
|
||||
|
||||
## Verification
|
||||
|
||||
- Tracer gate: after commit 6933004 the Task 1 verify ran again end to end (go vet, ExampleMap, empty import list, TestDocsTree and TestDocsBuildRealTree, docs:build --check, the exact README lines, root row placement between postcard and sunrise, commit file set, no trailer). It passed.
|
||||
- TDD: RED was a compile failure of sieve_test.go (undefined: TryMap, Filter, ...). GREEN came after sieve.go and set.go were added. No refactor step was needed.
|
||||
- `go test -cover ./modules/sieve`: coverage 100.0% of statements. `go list` shows no imports for the package. `go doc -short` lists exactly Filter Find GroupBy KeyBy Map Partition Reduce Set SetOf TryMap Uniq UniqBy, and `go doc` resolves Set.Has, Set.Add and Set.Slice. There are 14 Example functions, and all pass.
|
||||
- `go vet ./...`: green.
|
||||
- `go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree'` and `go run ./cmd/summer docs:build --check`: passed ("no problems found").
|
||||
- `git diff --quiet -- go.mod go.sum`: unchanged.
|
||||
- I also confirmed by hand that the README Usage Go block compiles and vets, using a throwaway directory that was then removed.
|
||||
- Commit checks: both feat(sieve) commits were found by subject, the tracer is an ancestor of the feature commit, neither has a co-author trailer, and each touches exactly its listed files.
|
||||
|
||||
### go test -short ./... and Docker (NOT passing for 5 packages)
|
||||
|
||||
`go test -short ./...` fails in **beachcomber, cabana, conga, lagoon and lighthouse**, and only because testcontainers cannot reach the Docker daemon:
|
||||
|
||||
```
|
||||
docker info: permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
|
||||
```
|
||||
|
||||
- The failure is the same outside the Claude sandbox. `/var/run/docker.sock` is `root:docker` mode 0660, and user `golem` (uid 1002) is not in the `docker` group, so Docker is not reachable from this host session at all.
|
||||
- beachcomber, conga, lagoon and lighthouse fail in TestMain while starting the shared Postgres container. In cabana, 48 FAIL lines all trace to the same Docker error: 44 tests fail directly and 4 are parent tests of Docker-failed subtests. No assertion line in any of these packages is anything other than the Docker error.
|
||||
- The plan named four packages (cabana, conga, lagoon, lighthouse). beachcomber shows the same Docker-only failure.
|
||||
- None of these five packages imports sieve, and this change does not touch them.
|
||||
- The remaining 36 packages pass under `go test -short` with these five excluded (exit 0).
|
||||
- **These five packages were not verified in this run** and should be re-run on a host with Docker access.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written. The only difference is the Docker handling above: beachcomber also needed excluding, and Docker was unreachable outside the sandbox too.
|
||||
|
||||
Note: the commits landed directly on `master`, as the orchestrator instructed (main tree, no worktree isolation). Nothing was pushed or tagged.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No network, auth, file or schema surface was added. The plan's threat register mitigations are covered: tests for non-aliasing and input immutability (T-joh-01), tests that nil input gives non-nil results (T-joh-02), ordering tests and the documented unordered Set.Slice (T-joh-03), and a TryMap test that the sentinel comes back unchanged and f stops being called (T-joh-05).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/sieve/sieve.go, set.go, sieve_test.go, example_test.go, README.md
|
||||
- FOUND: commit 6933004 (feat(sieve): add sieve module with Map)
|
||||
- FOUND: commit a0bf8ec (feat(sieve): add the remaining collection helpers and docs mentions)
|
||||
|
||||
## Follow-up: Docker-backed packages re-run (2026-10-11)
|
||||
|
||||
The Docker failures were a stale login session without the `docker` group. Re-run through `sudo -u golem` (fresh group list): `go test -short` passes in beachcomber, cabana, conga, lagoon (+attach) and lighthouse (+centrifugo). With the other 36 packages, `go test -short ./...` is green.
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
phase: quick-261011-kzi
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/token_guard_test.go
|
||||
- modules/cabana/token_smoke_test.go
|
||||
autonomous: true
|
||||
requirements: [QUICK-261011-kzi]
|
||||
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "In fonoteka.go, TestPhase09SecurityRoutes passes, and scripts/check-phase9.sh --security (run from summercms.go) exits 0."
|
||||
- "phase09AdminRoutes lists all ten post-104a1c9 cabana routes as protected (public=false): PUT /account, POST /account/avatar, DELETE /account/avatar, GET /account/tokens, POST /account/tokens, GET /account/tokens/scopes, DELETE /account/tokens/{id}, GET /field-types, POST /markdown/preview, POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload. Each one was checked against cabana's mount and sits inside the r.GroupRaw(api, []string{\"backend\"}, ...) block. The assertions in TestPhase09SecurityRoutes are unchanged, so the test still fails on any unlisted or unguarded admin route."
|
||||
- "The fonoteka.go commit changes only plugins/golem15/fonoteka/admin_phase09_security_test.go. The uncommitted submodule pointer changes from other sessions (plugins/golem15/feedback, golem, user) stay unstaged and untouched."
|
||||
- "Each compositeGuard owns the in-memory last_used_at throttle; no package-level throttle map is left. Two guards over separate stores (separate test databases) that both hold token ID 7 each stamp last_used_at once. A regression test proves this; it fails against the old process-global map."
|
||||
- "Production behavior is unchanged. Within one guard, two authentications less than a minute apart stamp once, and a stamp older than a minute allows the next one. The backend guard and the cookie-less resolver guard built in one cabana activation share one throttle, so a single-app binary debounces the same way the process-global map did. The SQL UPDATE in gormTokenStore.StampLastUsed stays conditional."
|
||||
- "The verifier's reproduction command (the 11-test -run list from 09-VERIFICATION.md) passes, as do go vet ./..., go test -short ./..., go test -race -short on the cabana token tests and the Docker-backed go test -count=1 ./modules/cabana."
|
||||
artifacts:
|
||||
- path: ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go
|
||||
provides: "phase09AdminRoutes with the ten added backend-guarded routes"
|
||||
contains: "/account/tokens/scopes"
|
||||
- path: modules/cabana/api_tokens.go
|
||||
provides: "stampThrottle type (per-instance last_used_at debounce) and stampLastUsed taking a throttle"
|
||||
contains: "type stampThrottle struct"
|
||||
- path: modules/cabana/token_guard.go
|
||||
provides: "compositeGuard.stamps field, allocated by newCompositeGuard"
|
||||
contains: "stamps *stampThrottle"
|
||||
- path: modules/cabana/token_guard_test.go
|
||||
provides: "TestTokenLastUsedThrottlePerGuard regression test"
|
||||
contains: "func TestTokenLastUsedThrottlePerGuard"
|
||||
key_links:
|
||||
- from: modules/cabana/http.go
|
||||
to: modules/cabana/token_guard.go
|
||||
via: "resolverGuard.stamps = guard.stamps (one throttle per activation, shared by the backend middleware and ResolveBackend)"
|
||||
pattern: "resolverGuard.stamps = guard.stamps"
|
||||
- from: modules/cabana/token_guard.go
|
||||
to: modules/cabana/api_tokens.go
|
||||
via: "authenticateToken calls stampLastUsed with g.stamps instead of a package global"
|
||||
pattern: "stampLastUsed\\(r.Context\\(\\), g.stamps"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the two findings of the Phase 09 re-verification (commit 21fdcfc, `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md`):
|
||||
|
||||
1. BLOCKER. In the sibling app repo fonoteka.go, `TestPhase09SecurityRoutes` fails with "unexpected admin routes", which makes `scripts/check-phase9.sh --security` exit 1. Ten cabana routes were added after 104a1c9 and are missing from fonoteka's `phase09AdminRoutes` inventory. The fix is test-only and follows the precedent commits 549840d and ca746fc in fonoteka.go.
|
||||
2. ADVISORY (Phase 16.3 scope). `TestBackendTokenPostgres` fails with `last_used_at not set` when it runs after the other token tests in one process. The cause is the process-global last_used_at debounce map in `modules/cabana/api_tokens.go`, keyed by token ID. Every fresh test database restarts IDs, so an earlier test's stamp suppresses a later write. The fix moves the throttle state onto the guard instance and keeps production behavior unchanged.
|
||||
|
||||
Planning-time observation (live, 2026-10-11):
|
||||
- Both repos were clean apart from fonoteka.go's three modified submodule pointers and summercms.go's untracked `.msd/`.
|
||||
- `go test ./plugins/golem15/fonoteka -run '^TestPhase09SecurityRoutes$'` fails and lists exactly the ten routes, each with `Middleware:[]string{"backend"}`.
|
||||
- In `modules/cabana/http.go` the ten routes are registered at lines 266-278 and 384, all inside `r.GroupRaw(api, []string{"backend"}, ...)`, which spans lines 262-386.
|
||||
- The verifier's 11-test `-run` command reproduces the stamp failure. `-run Token` alone and the full package run pass, so the failure depends on test order.
|
||||
|
||||
Shape: two independent defect fixes in two repos, with no new layer or architecture. A tracer slice adds nothing here, so both tasks are plain `auto` tasks. Task 2 is test-first. Task 1 runs first because fonoteka.go builds against the summercms.go working tree through go.work, and `check-phase9.sh --security` also runs cabana's `^TestPhase09` tests.
|
||||
|
||||
Purpose: get Phase 09's security gate green again, and remove an order-dependent failure from the Docker-backed cabana suite.
|
||||
Output: one test-only commit in fonoteka.go and one fix commit in summercms.go. No push.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@../fonoteka.go/CLAUDE.md
|
||||
@.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md
|
||||
|
||||
Environment notes:
|
||||
- The login shell is fish. Wrap bash syntax (&&, $(...), for-loops) in `bash -c '...'`.
|
||||
- Docker is available. If the socket is denied, run the tests through `sudo -u golem` (see CLAUDE.md).
|
||||
- Several sessions work in these repos at once. Stage files by explicit path, never `git add -A` or `git commit -a`, and use no Co-Authored-By trailer (user rule, overrides any attribution default).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: List the ten post-Phase 9 cabana routes in fonoteka's Phase 9 route inventory</name>
|
||||
<files>/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go</files>
|
||||
<read_first>
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go (phase09AdminRoutes, lines ~119-177)
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/http.go lines 244-400 (service.mount: the public auth group, the public lang group, the backend group that runs to line ~386, the public adminPrefix group)
|
||||
- `git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go show 549840d ca746fc` (shape of the inventory entries, comment style, commit message style)
|
||||
</read_first>
|
||||
<action>
|
||||
Pre-flight: run `git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go status --short`. The expected state is three modified submodule pointers (plugins/golem15/feedback, plugins/golem15/golem, plugins/golem15/user) owned by other sessions. Leave them alone. If admin_phase09_security_test.go itself already shows uncommitted edits, stop and report rather than overwrite them.
|
||||
|
||||
Confirm the guard before listing anything. For each of the ten routes, find its registration in summercms.go modules/cabana/http.go service.mount and confirm it lies inside the r.GroupRaw(api, []string{"backend"}, ...) block (lines 262-386 at planning time), not in the public auth, lang or adminPrefix groups. At planning time the account, avatar and token routes were at lines 266-272, GET /field-types at 276, POST /markdown/preview at 278 and the upload route at 384. If a route turns out to be outside the backend group, do not list it as protected: stop and report it as a security finding instead.
|
||||
|
||||
Edit phase09AdminRoutes and add exactly these ten entries, all with public=false, in the existing `{"METHOD " + adminAPI("/path"), false},` form:
|
||||
- PUT /account
|
||||
- POST /account/avatar
|
||||
- DELETE /account/avatar
|
||||
- GET /account/tokens
|
||||
- POST /account/tokens
|
||||
- GET /account/tokens/scopes
|
||||
- DELETE /account/tokens/{id}
|
||||
- GET /field-types
|
||||
- POST /markdown/preview
|
||||
- POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload
|
||||
|
||||
Placement, following ca746fc's inline grouping:
|
||||
- Put the My account and token routes, GET /field-types and POST /markdown/preview right after the existing GET /navigation entry, in short commented groups.
|
||||
- My account page: profile update and avatar.
|
||||
- Phase 16.3: personal API token management. These routes are wrapped in interactiveOnly inside the backend group, so a g15_ token never reaches them.
|
||||
- Phase 16.2: plugin field type assets.
|
||||
- Markdown preview: renders a form preview and stores nothing.
|
||||
- Put the upload route after the existing Phase 12.2 fileupload file routes at the end of the slice, with a "Phase 16.2: plugin field type uploads (D-18)" comment. D-18 comes from the doc comment in cabana's field_type_upload.go.
|
||||
|
||||
Do not touch TestPhase09SecurityRoutes, TestPhase09SecurityMatrix, phase09Surface or any assertion. The change is an inventory update only, so the test keeps failing on any unlisted or unguarded admin route. Do not add routes beyond these ten. If the test then reports another unexpected route, stop and report it rather than listing it.
|
||||
|
||||
Run gofmt on the file. Commit in fonoteka.go, staging only this file by path: `git -C <fonoteka.go> add plugins/golem15/fonoteka/admin_phase09_security_test.go`. Use a message in the precedent style, no co-author trailer:
|
||||
- subject: test(quick-261011-kzi): list the post-Phase 9 cabana admin routes in the Phase 9 route inventory
|
||||
- body bullet 1: TestPhase09SecurityRoutes expected the pre-account/token admin surface; the ten account, avatar, token, field-type, markdown preview and field upload routes all carry the backend guard
|
||||
- body bullet 2: Inventory update only: the test still fails on any unguarded or unlisted admin route
|
||||
|
||||
Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && test -z "$(gofmt -l plugins/golem15/fonoteka/admin_phase09_security_test.go)" && go vet ./plugins/golem15/fonoteka && go test -count=1 ./plugins/golem15/fonoteka -run "^TestPhase09Security" && cd ../summercms.go && scripts/check-phase9.sh --security && test "$(git -C ../fonoteka.go show --name-only --format= HEAD | tr -d "[:space:]")" = "plugins/golem15/fonoteka/admin_phase09_security_test.go"'</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- TestPhase09SecurityRoutes and TestPhase09SecurityMatrix pass in fonoteka.go.
|
||||
- `scripts/check-phase9.sh --security` prints "phase9 security passed" and exits 0.
|
||||
- The HEAD commit in fonoteka.go changes exactly one file, admin_phase09_security_test.go, adding ten protected entries (with comments) and no assertion changes.
|
||||
- The three submodule pointer modifications are still unstaged.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Make cabana's token last_used_at throttle belong to the guard instance (regression test first)</name>
|
||||
<files>modules/cabana/api_tokens.go, modules/cabana/token_guard.go, modules/cabana/http.go, modules/cabana/token_guard_test.go, modules/cabana/token_smoke_test.go</files>
|
||||
<read_first>
|
||||
- modules/cabana/api_tokens.go lines 234-292 (tokenStore interface, gormTokenStore.StampLastUsed with its conditional UPDATE, the package-level sync.Map debounce, stampLastUsed)
|
||||
- modules/cabana/token_guard.go lines 40-125 (compositeGuard struct, newCompositeGuard, authenticateToken calling stampLastUsed)
|
||||
- modules/cabana/http.go lines 150-178 (store construction, the registered backend guard `guard`, the cookie-less `resolverGuard` published as *backendResolver)
|
||||
- modules/cabana/token_guard_test.go lines 150-205 (the errStampStore stamp-failure block and TestTokenLastUsedDebounce, which manipulate the global map today)
|
||||
- modules/cabana/token_smoke_test.go lines 223-260 and 315-365 (TestTokenGatesSmoke: memTokenStore with stampN, `guard` also published as the resolver, the debounce block that resets token 101)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestTokenLastUsedThrottlePerGuard (new, internal package cabana, runs under -short).
|
||||
- Setup: build two guards with newCompositeGuard, each over its own memTokenStore, with the same tracerCatalog registry and a stubUsers principal that holds acme.demo.access. Mint a different token for each store, but store both rows with ID 7, BackendUserID 1 and scopes ["demo"].
|
||||
- Authenticate once on guard A, then once on guard B, within the same minute.
|
||||
- Expect storeA.stampN == 1 and storeB.stampN == 1. Against the old process-global map storeB.stampN is 0, so the test is RED before the fix.
|
||||
- Authenticate a second time on guard B. storeB.stampN stays 1, because the debounce still applies within one guard.
|
||||
- TestTokenLastUsedDebounce keeps its intent. Two auths within a minute give one stamp. After the guard's own throttle entry for 42 is backdated by two minutes, the next auth stamps again, giving stampN == 2.
|
||||
- TestTokenGatesSmoke keeps its intent. After the guard's own throttle entry for 101 is cleared, two auths give stampN == 1.
|
||||
- The stamp-failure block (errStampStore) still authenticates successfully. Its fresh guard g2 needs no reset.
|
||||
</behavior>
|
||||
<action>
|
||||
Pre-flight (concurrency, per CLAUDE.md):
|
||||
- Run `git status --short` and `git log --oneline -5 -- modules/cabana` in summercms.go.
|
||||
- If any of the five files listed above has uncommitted edits from another session, stop and report.
|
||||
- At planning time the tree was clean and the last cabana commit was 7ef2e4f.
|
||||
|
||||
RED: add TestTokenLastUsedThrottlePerGuard to modules/cabana/token_guard_test.go as described in the behavior block. Reuse the existing helpers tracerCatalog, memTokenStore (and its put and stampN), stubJWTGuard, stubUsers and mintAPIToken. Run `go test -short -count=1 ./modules/cabana -run '^TestTokenLastUsedThrottlePerGuard$'` and confirm it FAILS on storeB's stamp count before any production change.
|
||||
|
||||
GREEN, in modules/cabana/api_tokens.go:
|
||||
- Replace the package-level debounce variable and its doc comment with an unexported type stampThrottle. It holds a sync.Map from uint token ID to time.Time, in a field named last.
|
||||
- Give stampThrottle a method due(id uint, now time.Time) bool. It keeps today's semantics exactly: Load the previous time; return false if now minus that time is under time.Minute; otherwise Store now and return true. A nil *stampThrottle returns true (no in-memory debounce, since the SQL UPDATE is still conditional), so a zero-value guard never panics.
|
||||
- Change stampLastUsed to take the throttle: stampLastUsed(ctx context.Context, stamps *stampThrottle, store tokenStore, id uint, now time.Time). It returns early for a nil store or id 0 as today, then returns when stamps.due is false, otherwise calls store.StampLastUsed and logs a warning on error, unchanged.
|
||||
- Update the doc comment to say the throttle gates last_used_at writes to at most once per minute per token ID per guard instance, with one instance per cabana activation, and that the SQL UPDATE is additionally conditional.
|
||||
- Do not change gormTokenStore, the tokenStore interface or the UPDATE statement.
|
||||
|
||||
In modules/cabana/token_guard.go:
|
||||
- Add a field stamps *stampThrottle to compositeGuard.
|
||||
- newCompositeGuard keeps its signature and sets stamps to a new &stampThrottle{}. Every existing call site keeps compiling and each guard gets its own state.
|
||||
- authenticateToken calls stampLastUsed(r.Context(), g.stamps, g.store, row.ID, now).
|
||||
|
||||
In modules/cabana/http.go: right after resolverGuard is built with newCompositeGuard, assign resolverGuard.stamps = guard.stamps. Add a one-line comment saying the admin middleware and ResolveBackend share one throttle per activation. Today's process-wide sharing between those two paths is a real production property: one token used on the admin API and on plugin routes still writes at most once a minute. This assignment preserves it, so production behavior is unchanged.
|
||||
|
||||
Adjust the existing tests in package cabana so nothing refers to the removed global:
|
||||
- token_guard_test.go stamp-failure block: drop the reset of token 99. g2 is a fresh guard with a fresh throttle.
|
||||
- TestTokenLastUsedDebounce: drop the initial reset of token 42, because the guard is fresh. Replace the two-minute backdating with a Store on guard.stamps.last for uint(42).
|
||||
- token_smoke_test.go TestTokenGatesSmoke: replace the reset of token 101 with a Delete on guard.stamps.last for uint(101). That `guard` is also the published resolver and already stamped 101 earlier in the test, so the reset is still needed.
|
||||
|
||||
Run `grep -rn "lastStamp" modules/cabana` and confirm it returns nothing. No README or docs/ page mentions this unexported state (checked at planning time), so no documentation change is needed.
|
||||
|
||||
Commit in summercms.go, staging only the five files by explicit path, no co-author trailer:
|
||||
- subject: fix(cabana): scope the token last_used_at throttle to the guard instance
|
||||
- body bullet 1: The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order
|
||||
- body bullet 2: Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token
|
||||
- body bullet 3: TestTokenLastUsedThrottlePerGuard pins the per-guard isolation
|
||||
|
||||
Do not push. Do not edit STATE.md here; the quick-task workflow records state through `msd_run query state.*` handlers.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short -count=1 -race ./modules/cabana -run "Token|ResolveBackend" && go test -short ./... && go test -count=1 ./modules/cabana -run "^(TestAllowsScope|TestBackendTokenGuard|TestBackendTokenPostgres|TestInteractiveOnly|TestResolveBackend|TestScopeCoversAll|TestScopeMatch|TestTokenRouteGates|TestTokenTracerPostgres|TestTokenGatesSmoke|TestAccountTokens)$" && go test -count=1 ./modules/cabana && grep -q "resolverGuard.stamps = guard.stamps" modules/cabana/http.go && grep -q "func TestTokenLastUsedThrottlePerGuard" modules/cabana/token_guard_test.go'</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- TestTokenLastUsedThrottlePerGuard failed before the production change (recorded in the SUMMARY) and passes after it.
|
||||
- The verifier's 11-test reproduction command passes, including TestBackendTokenPostgres.
|
||||
- The Docker-backed `go test -count=1 ./modules/cabana`, `go test -short ./...`, `go vet ./...` and the -race run of the token tests all pass.
|
||||
- No package-level debounce state remains in cabana. compositeGuard owns stamps, and http.go shares the backend guard's throttle with the resolver guard.
|
||||
- The summercms.go HEAD commit touches only the five listed cabana files.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| HTTP client → admin API (`/{prefix}/api/v1/*`) | Untrusted requests. Every non-public route must sit behind the `backend` guard (JWT or g15_ personal token). |
|
||||
| Personal API token → `backend_api_tokens.last_used_at` | Authenticated token use triggers a DB write. The in-memory throttle bounds the write rate. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-kzi-01 | Elevation of privilege | fonoteka `phase09AdminRoutes` inventory | high | mitigate | Before listing each of the ten routes as protected, Task 1 confirms it sits inside cabana's `r.GroupRaw(api, []string{"backend"}, ...)` block. The test assertions stay as they are, so an admin route added later without the guard, or left out of the list, still fails TestPhase09SecurityRoutes. A route found outside the backend group stops the task as a finding. |
|
||||
| T-kzi-02 | Repudiation | `stampLastUsed` / `compositeGuard` | low | mitigate | The per-guard throttle stops one database's token IDs from suppressing another's last_used_at audit stamp. TestTokenLastUsedThrottlePerGuard and the verifier's ordering command pin this. |
|
||||
| T-kzi-03 | Denial of service | last_used_at write rate (`http.go` guard wiring) | low | mitigate | Guards are built once per activation, not per request. `resolverGuard.stamps = guard.stamps` keeps one throttle per app for both auth paths, and gormTokenStore's UPDATE stays conditional on `last_used_at < now - 1 minute`, so the DB write rate is unchanged. |
|
||||
| T-kzi-04 | Tampering | concurrent access to `stampThrottle` | low | mitigate | The state stays in a `sync.Map` with the same Load/Store semantics as before. Task 2 runs the token tests under `-race`. |
|
||||
| T-kzi-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No dependency is added or upgraded in either repo; the package-legitimacy gate does not apply. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- fonoteka.go: `go test -count=1 ./plugins/golem15/fonoteka -run '^TestPhase09Security'` passes.
|
||||
- summercms.go: `scripts/check-phase9.sh --security` exits 0.
|
||||
- summercms.go: `go vet ./...`, `go test -short ./...`, `go test -short -race ./modules/cabana -run 'Token|ResolveBackend'`, the verifier's 11-test command and `go test -count=1 ./modules/cabana` (Docker) all pass.
|
||||
- `git -C ../fonoteka.go status --short` still shows the three submodule pointer modifications, unstaged.
|
||||
- Two new commits, one per repo, with no Co-Authored-By trailer and nothing pushed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The Phase 09 security gate is green again. The fonoteka route inventory matches the assembled admin surface, and every listed protected route was confirmed backend-guarded at its registration.
|
||||
- TestBackendTokenPostgres no longer depends on test order. The throttle state belongs to the guard instance, the single-app production debounce is unchanged and a -short regression test pins the isolation.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/261011-kzi-SUMMARY.md` when done. Record both commit hashes, the observed RED failure of TestTokenLastUsedThrottlePerGuard before the fix, and the output of `check-phase9.sh --security`.
|
||||
</output>
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
phase: quick-261011-kzi
|
||||
plan: 01
|
||||
subsystem: cabana (admin auth) + fonoteka.go Phase 9 security inventory
|
||||
tags: [security, admin-api, api-tokens, tests, phase-09-gap]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "fonoteka phase09AdminRoutes covering the ten post-104a1c9 backend-guarded cabana routes"
|
||||
- "cabana stampThrottle: per-guard last_used_at debounce"
|
||||
affects:
|
||||
- "scripts/check-phase9.sh --security (green again)"
|
||||
- "modules/cabana Docker suite (TestBackendTokenPostgres no longer order-dependent)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "In-memory debounce state owned by the guard instance, shared explicitly between the guards of one activation"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/token_guard_test.go
|
||||
- modules/cabana/token_smoke_test.go
|
||||
decisions:
|
||||
- "The last_used_at throttle belongs to compositeGuard; http.go shares the backend guard's throttle with the cookie-less ResolveBackend guard, so one activation keeps one debounce, as the process-global map did for a single-app binary"
|
||||
- "A nil *stampThrottle never debounces (the SQL UPDATE stays conditional), so a zero-value guard cannot panic"
|
||||
commits: 1
|
||||
plan_head_before: 21fdcfcba6d7a5c192ea449dd5c29a63e841470e
|
||||
plan_head_after: b45af8ebbf67ef90e67b2ad701dddc4e5c7e645a
|
||||
commit_hashes:
|
||||
- "fonoteka.go@e074028 test(quick-261011-kzi): list the post-Phase 9 cabana admin routes in the Phase 9 route inventory"
|
||||
- "summercms.go@b45af8e fix(cabana): scope the token last_used_at throttle to the guard instance"
|
||||
metrics:
|
||||
duration: "~12 min"
|
||||
completed: 2026-10-11
|
||||
actuals:
|
||||
tokens: 4500
|
||||
tasks: 2
|
||||
commits: 2
|
||||
---
|
||||
|
||||
# Quick 261011-kzi: Fix Phase 09 gaps Summary
|
||||
|
||||
Closes both findings of the Phase 09 re-verification. fonoteka's Phase 9 route inventory now lists the ten backend-guarded account, token, field-type, markdown preview and field upload routes. The cabana token `last_used_at` debounce is a per-guard `stampThrottle` instead of a process-global map keyed by token ID.
|
||||
|
||||
## Commits
|
||||
|
||||
| Repo | Hash | Message | Files |
|
||||
|------|------|---------|-------|
|
||||
| fonoteka.go | e074028 | test(quick-261011-kzi): list the post-Phase 9 cabana admin routes in the Phase 9 route inventory | plugins/golem15/fonoteka/admin_phase09_security_test.go |
|
||||
| summercms.go | b45af8e | fix(cabana): scope the token last_used_at throttle to the guard instance | modules/cabana/{api_tokens,token_guard,http,token_guard_test,token_smoke_test}.go |
|
||||
|
||||
The frontmatter `commits: 1` / `plan_head_*` is the measured summercms.go range (21fdcfc..b45af8e). fonoteka.go adds one more commit (7fc790d..e074028), so the total is 2 (`actuals.commits`). Nothing was pushed, and neither commit has a co-author trailer.
|
||||
|
||||
## Task 1: fonoteka Phase 9 route inventory (fonoteka.go@e074028)
|
||||
|
||||
- Checked all ten routes against `modules/cabana/http.go` `service.mount`. They are all registered inside `r.GroupRaw(api, []string{"backend"}, ...)`: the account, avatar and token routes, `/field-types` and `/markdown/preview` next to `/navigation`, and `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` at the end of the group. None is in the public auth, lang or adminPrefix groups, so there was no security finding.
|
||||
- Added the ten `public=false` entries in commented groups (My account, Phase 16.3 tokens, Phase 16.2 field type assets, Markdown preview, Phase 16.2 uploads D-18). No assertion, `phase09Surface` or other test code was changed.
|
||||
- `go test -count=1 ./plugins/golem15/fonoteka -run '^TestPhase09Security'` passes: TestPhase09SecurityRoutes PASS, TestPhase09SecurityMatrix PASS.
|
||||
- `scripts/check-phase9.sh --security` printed `phase9 security passed` and exited 0. It also passed again after the Task 2 cabana change.
|
||||
- The three submodule pointers in fonoteka.go (plugins/golem15/feedback, golem, user) are still modified and unstaged.
|
||||
|
||||
## Task 2: per-guard last_used_at throttle (summercms.go@b45af8e)
|
||||
|
||||
**RED (before the production change):**
|
||||
|
||||
```
|
||||
--- FAIL: TestTokenLastUsedThrottlePerGuard (0.00s)
|
||||
token_guard_test.go:239: stamps A=1 B=0, want 1 each
|
||||
FAIL git.golem15.com/golem15/summercms/modules/cabana 0.114s
|
||||
```
|
||||
|
||||
**GREEN:**
|
||||
- `api_tokens.go`: removed the `lastStamp` package `sync.Map`. Added `type stampThrottle struct { last sync.Map }` with `due(id, now) bool` (same Load/compare/Store semantics; a nil receiver returns true). `stampLastUsed` now takes `stamps *stampThrottle`. `gormTokenStore`, the `tokenStore` interface and the conditional UPDATE are unchanged.
|
||||
- `token_guard.go`: added `compositeGuard.stamps *stampThrottle`. `newCompositeGuard` allocates it with the same signature, and `authenticateToken` calls `stampLastUsed(r.Context(), g.stamps, g.store, row.ID, now)`.
|
||||
- `http.go`: added `resolverGuard.stamps = guard.stamps` (one throttle per activation, shared by the admin middleware and ResolveBackend).
|
||||
- Tests: added TestTokenLastUsedThrottlePerGuard (two guards, separate stores, both token ID 7, one stamp each, and the debounce still holds within guard B). TestTokenLastUsedDebounce backdates through `guard.stamps.last`, TestTokenGatesSmoke resets through `guard.stamps.last.Delete(uint(101))`, and the errStampStore block no longer needs a reset. `grep -rn lastStamp modules/cabana` returns nothing.
|
||||
|
||||
**Verification (all pass):**
|
||||
- `go vet ./...`
|
||||
- `go test -short -count=1 -race ./modules/cabana -run "Token|ResolveBackend"`
|
||||
- `go test -short ./...`
|
||||
- The verifier's 11-test `-run` command, Docker-backed: all PASS, including TestBackendTokenPostgres (0.28s) after TestAccountTokens and TestTokenTracerPostgres
|
||||
- `go test -count=1 ./modules/cabana` (Docker): ok, 81.0s
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written.
|
||||
|
||||
## Deferred Issues
|
||||
|
||||
- Out of scope and not touched: `gofmt -l modules/cabana` flags five files that were already unformatted at HEAD before this task (account_tokens.go, account_tokens_test.go, token_postgres_smoke_test.go, token_routes_test.go, token_scopes_test.go). The five files this task changed are gofmt-clean.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new surface. T-kzi-01 through T-kzi-04 are mitigated as planned (guard placement confirmed for each route, per-guard throttle, shared throttle per activation, sync.Map under -race).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: fonoteka.go@e074028 (changes only admin_phase09_security_test.go)
|
||||
- FOUND: summercms.go@b45af8e (changes only the five cabana files)
|
||||
- FOUND: `type stampThrottle struct` in api_tokens.go, `stamps *stampThrottle` in token_guard.go, `resolverGuard.stamps = guard.stamps` in http.go, `func TestTokenLastUsedThrottlePerGuard` in token_guard_test.go, `/account/tokens/scopes` in admin_phase09_security_test.go
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
phase: quick-261011-mag
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- scripts/check-phase10.sh
|
||||
- admin/tests/form/mlLocale.test.ts
|
||||
- admin/tests/form/MLField.test.ts
|
||||
- admin/tests/form/FileCaptionModal.test.ts
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
autonomous: true
|
||||
requirements: [QUICK-261011-mag]
|
||||
|
||||
estimate:
|
||||
tokens: 40000
|
||||
raw_tokens: 40000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "`bash scripts/check-phase10.sh --hygiene` exits 0 and prints `phase10 hygiene passed` (live at planning time it exits 1 with 7 refusals)."
|
||||
- "`bash scripts/check-phase10.sh --self-test` exits 0 and prints `phase10 self-test passed`. Its plant loop now also plants a `new XMLHttpRequest()` outside admin/src/api/files.ts and an extra file in admin/src/api; each is refused for its own rule (`direct fetch` / `unexpected files in admin/src/api`), never for `imported by no test`."
|
||||
- "The direct-network rule exempts exactly one line: admin/src/api/files.ts `const request = new XMLHttpRequest()` (the upload-progress XHR from 12.2-04, T-12.2-32). The admin/src/api listing rule exempts exactly the path admin/src/api/files.ts. Every other hygiene rule is byte-for-byte unchanged."
|
||||
- "Each of admin/src/components/account/ApiTokenCreateDialog.vue, admin/src/components/account/ApiTokensPanel.vue, admin/src/components/form/fields/FileCaptionModal.vue, admin/src/components/form/fields/MLField.vue and admin/src/components/form/mlLocale.ts is imported directly by a test that mounts or calls it and asserts behavior (no bare import)."
|
||||
- "`npm --prefix admin run typecheck` and the full `npm --prefix admin test` pass."
|
||||
- "admin/src and modules/boardwalk/dist are unchanged by this task, so the embedded dist needs no rebuild; `scripts/check-admin-dist.sh` exits 0."
|
||||
artifacts:
|
||||
- path: scripts/check-phase10.sh
|
||||
provides: "narrow files.ts exceptions in the network and api-listing rules, plus xhr and apifile self-test plants"
|
||||
contains: "admin/src/api/files"
|
||||
- path: admin/tests/form/mlLocale.test.ts
|
||||
provides: "direct unit test of mlLocale.ts"
|
||||
contains: "src/components/form/mlLocale'"
|
||||
- path: admin/tests/form/MLField.test.ts
|
||||
provides: "direct mount test of the MLField locale wrapper"
|
||||
contains: "src/components/form/fields/MLField.vue'"
|
||||
- path: admin/tests/form/FileCaptionModal.test.ts
|
||||
provides: "direct mount test of the caption modal over a stub FileRoutes"
|
||||
contains: "src/components/form/fields/FileCaptionModal.vue'"
|
||||
- path: admin/tests/account/ApiTokensPanel.test.ts
|
||||
provides: "a directly-mounted ApiTokensPanel describe block next to the existing app-level tests"
|
||||
contains: "src/components/account/ApiTokensPanel.vue'"
|
||||
- path: admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
provides: "a directly-mounted ApiTokenCreateDialog describe block next to the existing app-level tests"
|
||||
contains: "src/components/account/ApiTokenCreateDialog.vue'"
|
||||
key_links:
|
||||
- from: scripts/check-phase10.sh hygiene_checks
|
||||
to: admin/tests
|
||||
via: "every-module-imported rule greps `src/<path>'` (a .vue keeps its extension, a .ts drops it)"
|
||||
pattern: "spec=\"src/\\$\\{file%.ts\\}\""
|
||||
- from: scripts/check-phase10.sh run_self_test
|
||||
to: hygiene_checks
|
||||
via: "scratch copy of HYGIENE_DIRS must pass clean, every plant must fail for its own rule"
|
||||
pattern: "for plant in vhtml fetch"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the three gaps in .planning/phases/10-admin-vue-spa/10-VERIFICATION.md. They share one root cause: the Phase 10 hygiene gate has been red since 12.2-04 (ea33296) because later phases added SPA files without updating the gate or adding direct-import tests.
|
||||
|
||||
Live observation at planning time (HEAD d2fa195), `bash scripts/check-phase10.sh --hygiene` exit 1:
|
||||
- `direct fetch outside admin/src/api/client.ts: admin/src/api/files.ts:150: const request = new XMLHttpRequest()`
|
||||
- `unexpected files in admin/src/api: admin/src/api/files.ts`
|
||||
- `admin/src/components/account/ApiTokenCreateDialog.vue is imported by no test`
|
||||
- `admin/src/components/account/ApiTokensPanel.vue is imported by no test`
|
||||
- `admin/src/components/form/fields/FileCaptionModal.vue is imported by no test`
|
||||
- `admin/src/components/form/fields/MLField.vue is imported by no test`
|
||||
- `admin/src/components/form/mlLocale.ts is imported by no test`
|
||||
|
||||
`bash scripts/check-phase10.sh --self-test` exit 1: `self-test hygiene rejected the clean scratch copy` (a consequence of the seven refusals above).
|
||||
|
||||
The fix is decided per failure:
|
||||
1. **files.ts (two refusals): the gate is stale, so the gate changes.** Both rules assume the Phase 10 world, where admin/src/api held only schema.d.ts, client.ts and types.ts and nothing uploaded files. files.ts holds the FileRoutes adapters. They are API adapters and belong in api/. Every JSON, blob and thumbnail call already goes through the typed `api` client; files.ts has no `fetch(` left. The one XMLHttpRequest exists because fetch has no upload-progress events. That XHR sends X-Requested-With (T-12.2-32, asserted by admin/tests/smoke/deferred.smoke.test.ts) and refreshes through client.ts `refreshSession`. All wire shapes come from the generated aliases in ./types, so SC-4 holds in substance. Moving the XHR into client.ts was rejected: the same call would sit in a different file just to satisfy a filename rule, and the api-listing rule would still flag the adapters. The gate gets narrow, line-anchored exceptions instead, the same pattern as the MarkdownField (27711b7) and listState (9cac45c) precedents. New self-test plants prove the narrowed rules still fail closed.
|
||||
2. **Five untested modules: the rule is current, so the tests change.** The 10-05 rule that every SPA module is imported by a test is still valid. The account components are only exercised through mountApp; the others only through their wrappers or formState re-exports. Each gets a direct, behavior-asserting test. Nothing in admin/src changes, so the committed dist stays valid.
|
||||
3. **--self-test: no separate change.** It goes green once 1 and 2 land. It also gains the two plants from 1.
|
||||
|
||||
Purpose: restore `scripts/check-phase10.sh --hygiene` and `--self-test` to green so the Phase 10 acceptance gate is meaningful again and 10-VERIFICATION can return to human_needed.
|
||||
Output: one gate script edit, three new SPA test files, two extended SPA test files.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@./CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-admin-vue-spa/10-VERIFICATION.md
|
||||
@scripts/check-phase10.sh
|
||||
|
||||
Key facts for the executor (verified at planning time, no need to re-derive):
|
||||
- hygiene_checks network rule (scripts/check-phase10.sh around line 360) pipes `grep -rnE '(^|[^A-Za-z0-9_.])fetch\(|XMLHttpRequest\(|axios' admin/src` through `grep -v '^admin/src/api/client.ts:'`. Its output format is `path:line:content`.
|
||||
- api-listing rule (around line 364) is `find admin/src/api -type f ! -name schema.d.ts ! -name client.ts ! -name types.ts`, run from the tree root, so it prints `admin/src/api/<name>`.
|
||||
- every-module rule (around line 388) builds `spec="src/${file%.ts}"` and greps admin/tests `*.ts` for `"$spec'"`. Therefore a `.vue` import must read `'../../src/components/.../X.vue'` (extension kept, single quotes), and the .ts module must be imported as `'../../src/components/form/mlLocale'` (no extension, single quotes).
|
||||
- The app-name rule also scans admin/tests with `pl[yý]tarium|fonoteka|albumy|kolekcj|winyl|p[lł]yt[aęy]` (case-insensitive). Test strings must not match it. Use neutral data (`Hello`/`Cześć`, `acme`, `gadgets`).
|
||||
- admin/tsconfig.json has `noUnusedLocals: true`. vue-tsc fails on an import the test never uses.
|
||||
- Test harness: admin/tests/helpers.ts exports `API`, `mockApi(routes)`, `requestsTo`, `resetState`, `keydown`, `wait`, `mountApp`. admin/tests/form/FileuploadField.test.ts shows the pattern for mounting a component directly with `mount` + `mockApi`. admin/tests/form/MLFields.test.ts shows providing `FORM_ENABLED_LOCALES` (from src/components/form/formContext).
|
||||
- FieldControlProps (src/components/form/control.ts): field, modelValue, controlId, optional invalid, describedBy, labels, source, recordId.
|
||||
- FileCaptionModal props: open, file (FileItem|null), routes (FileRoutes|null), preview (string|null), idPrefix. Emits: update:open, saved(file), closed. It renders through a reka-ui portal into document.body with `[data-caption-modal]`, `[data-caption-title]`, `[data-caption-description]`, `[data-action="save-caption"]`. Save calls `routes.update(file.id, { title, description })`. On data it emits saved, then update:open false. A 422 keeps the dialog open and shows the detail messages. Any other failure posts a danger toast.
|
||||
- ApiTokensPanel props: `confirm: ReturnType<typeof useConfirm>` (src/components/ui/confirm). On mount it calls `GET /account/tokens`, then renders `[data-token-row="<id>"]`, `[data-tokens-empty]` or `[data-tokens-error]`. ApiTokenCreateDialog props: open, confirm. Emits update:open, created, closed. It loads `GET /account/tokens/scopes` and posts `POST /account/tokens`. It renders `[data-token-dialog]`, `[data-action="create-token-submit"]`, `[data-token-plaintext]`, `[data-action="hide-token"]`. The existing tests in admin/tests/account/ already define TOKENS, SCOPES, envelope, existingToken, createdToken, PLAIN and scopes fixtures. Reuse them.
|
||||
- admin/node_modules is installed (vitest and vue-tsc present).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Narrow the check-phase10.sh network and api-listing rules for the files.ts upload XHR, with fail-closed self-test plants</name>
|
||||
<files>scripts/check-phase10.sh</files>
|
||||
<action>
|
||||
Edit only `hygiene_checks` and `run_self_test` in scripts/check-phase10.sh. Do NOT touch admin/src/api/files.ts or admin/src/api/client.ts. The objective explains why: the gate is stale, and the code is sound.
|
||||
|
||||
(a) Network rule: keep the existing `grep -v '^admin/src/api/client.ts:'` filter. Chain a second filter, `grep -vE`, that drops only a line matching, anchored at both ends: path `admin/src/api/files.ts`, a numeric line number, optional leading whitespace, the exact statement `const request = new XMLHttpRequest()` and optional trailing whitespace. Escape the dots and parentheses as in the MarkdownField allowlist two rules above. Keep the `|| true` and the unchanged `fail "direct fetch outside admin/src/api/client.ts: $hits"` message, because the self-test matches on `direct fetch`. Put a one-line comment above the rule in the style of the MarkdownField comment. It should say: uploads in admin/src/api/files.ts use XMLHttpRequest because only XHR reports upload progress (12.2-04); that XHR sends X-Requested-With (T-12.2-32) and refreshes through client.ts; every other XHR, fetch or axios call is still refused.
|
||||
|
||||
(b) api-listing rule: add `! -path admin/src/api/files.ts` to the find. Do not use `! -name files.ts`; it would also exempt a nested files.ts. Update the comment above it so the directory holds the generated schema, the client, the aliases and the FileRoutes adapters (files.ts, 12.2-04). Keep the fail message `unexpected files in admin/src/api` unchanged.
|
||||
|
||||
(c) run_self_test plant loop: extend `for plant in vhtml fetch appname storage` with two more plants.
|
||||
- `xhr`: write `export const open = () => new XMLHttpRequest()` to `$scratch/admin/src/__plant/plant.ts`, with want=`direct fetch`. The default scratch test import already covers it.
|
||||
- `apifile`: write `export {}` to `$scratch/admin/src/api/__plant.ts`, write the scratch test `$scratch/admin/tests/__plant.test.ts` as `import '../src/api/__plant'` so that the import rule is satisfied, and set want=`unexpected files in admin/src/api`.
|
||||
At the top of each iteration, also remove `$scratch/admin/src/api/__plant.ts` next to the existing `rm -rf` of `__plant` and the scratch test, so the clean-copy state holds between plants. Update the comment above the scratch block that lists what gets planted.
|
||||
|
||||
Leave the `vhtml`, `fetch`, `appname` and `storage` plants, `HYGIENE_DIRS`, the other rules and all non-hygiene stages unchanged.
|
||||
|
||||
Commit on its own: `fix(scripts): allow the files.ts upload XHR in check-phase10 hygiene`. Stage only `scripts/check-phase10.sh` by path. Other sessions may have uncommitted edits, so never use `git add -A`. No co-author trailer (user rule).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && bash -n scripts/check-phase10.sh && out="$(bash scripts/check-phase10.sh --hygiene 2>&1 || true)"; echo "$out"; ! grep -q "admin/src/api/files.ts" <<<"$out" && [ "$(grep -v "imported by no test" <<<"$out" | grep -c "refuse:")" -eq 0 ] && grep -q "for plant in vhtml fetch appname storage xhr apifile" scripts/check-phase10.sh && grep -q -- "! -path admin/src/api/files.ts" scripts/check-phase10.sh'</automated>
|
||||
</verify>
|
||||
<done>The hygiene stage no longer refuses admin/src/api/files.ts. The only refusals left are the five `imported by no test` lines that Tasks 2 and 3 close. The script parses (`bash -n`), and the self-test loop names the xhr and apifile plants. The change is committed alone.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Direct tests for mlLocale.ts, MLField.vue and FileCaptionModal.vue</name>
|
||||
<files>admin/tests/form/mlLocale.test.ts, admin/tests/form/MLField.test.ts, admin/tests/form/FileCaptionModal.test.ts</files>
|
||||
<behavior>
|
||||
- mlLocale: isMLFieldType is true for mltext, mltextarea and mlmarkdown, and false for text and undefined. isMLField is true for a translatable field with an element, false without an element, false without translatable:true, and false for undefined. localeRecord keeps only string values, drops numbers and null, and returns {} for null, an array or a string. broadcastMLLocale('pl') dispatches a window CustomEvent named ML_LOCALE_CHANGE with detail 'pl'. broadcastMLLocale('') dispatches nothing.
|
||||
- MLField: mounted with inner=TextField, innerType='text', mlType='mltext', FORM_ENABLED_LOCALES=['en','pl'] and modelValue {en:'Hello', pl:'Cześć'}. The root carries data-ml-field="mltext", the locale select lists en and pl, and the input shows 'Hello'. Typing emits update:modelValue with the whole map, with only en changed. Choosing pl shows 'Cześć' and broadcasts, so a second mounted MLField switches to pl. A window ML_LOCALE_CHANGE event switches the locale. With contentLocale=true, an inner stub that declares a contentLocale prop receives the active locale. Without it, the stub receives no contentLocale.
|
||||
- FileCaptionModal: mounted open with a FileItem and a stub FileRoutes whose methods are vi.fn(). The title and description inputs are prefilled from the file. Save calls routes.update(file.id, {title, description}) with the edited values and emits saved(file), then update:open false. A 422 whose details name title keeps the dialog open, shows that message and emits no saved. A 500 posts a danger toast (state/useToasts toasts) and keeps the typed input.
|
||||
</behavior>
|
||||
<action>
|
||||
Create three new Vitest files under admin/tests/form/. They test existing code, so there is no RED phase. The tests must pass against the current admin/src, and admin/src must not change. If a behavior above turns out to differ from the code, assert what the code does and record the discrepancy in the SUMMARY. Do not edit the component: that would force a dist rebuild and is outside this task.
|
||||
|
||||
mlLocale.test.ts imports `ML_LOCALE_CHANGE`, `broadcastMLLocale`, `isMLField`, `isMLFieldType` and `localeRecord` from `'../../src/components/form/mlLocale'`: single quotes, no extension, because that exact string is what the gate looks for. Listen with window.addEventListener and remove the listener afterwards.
|
||||
|
||||
MLField.test.ts imports `MLField from '../../src/components/form/fields/MLField.vue'` and `TextField from '../../src/components/form/fields/TextField.vue'`. Mount with `mount` from @vue/test-utils, providing `FORM_ENABLED_LOCALES` as admin/tests/form/MLFields.test.ts does. Use `enableAutoUnmount(afterEach)` so the window listeners are removed. For the contentLocale cases, define a small inner stub with defineComponent that declares `contentLocale` as a prop and renders it in a data attribute.
|
||||
|
||||
FileCaptionModal.test.ts imports `FileCaptionModal from '../../src/components/form/fields/FileCaptionModal.vue'`. Build a FileItem literal typed through `'../../src/api/types'`, or take one entry from `deferredFilesFixture` in admin/tests/fixtures/typed.ts. Build a `FileRoutes` stub typed through `'../../src/api/files'`, so no HTTP is involved. Mount with `attachTo: document.body` and query `document.body` for the portal content. Call `resetState()` from ../helpers in beforeEach, as the sibling tests do. The language bundle is not loaded, so visible texts are their keys; assert on data attributes and emitted events, not translated text.
|
||||
|
||||
All test strings must stay clear of the gate's app-name pattern (see context).
|
||||
|
||||
Commit after Task 3 together with the account tests as one `test(admin): ...` commit, or as its own commit. Either way, stage these files by path only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && npm --prefix admin test -- tests/form/mlLocale.test.ts tests/form/MLField.test.ts tests/form/FileCaptionModal.test.ts && npm --prefix admin run typecheck && out="$(bash scripts/check-phase10.sh --hygiene 2>&1 || true)"; ! grep -qE "(mlLocale\.ts|MLField\.vue|FileCaptionModal\.vue) is imported by no test" <<<"$out"'</automated>
|
||||
</verify>
|
||||
<done>Three new test files pass in Vitest and under vue-tsc. The hygiene stage no longer reports mlLocale.ts, MLField.vue or FileCaptionModal.vue as untested. admin/src is untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Direct-mount tests for ApiTokensPanel and ApiTokenCreateDialog, then run the full gate</name>
|
||||
<files>admin/tests/account/ApiTokensPanel.test.ts, admin/tests/account/ApiTokenCreateDialog.test.ts</files>
|
||||
<behavior>
|
||||
- ApiTokensPanel mounted directly with `confirm: useConfirm()`. With GET /account/tokens answering [existingToken], `[data-token-row="11"]` renders with the token name. With an empty list, `[data-tokens-empty]` renders. With a 500, `[data-tokens-error]` renders. Each case makes exactly one GET to the tokens endpoint.
|
||||
- ApiTokenCreateDialog mounted directly with `open: true` and `confirm: useConfirm()`. `[data-token-dialog]` renders in document.body and the scopes from GET /account/tokens/scopes are offered. Filling the name, picking a scope and submitting sends one POST /account/tokens. The plaintext PLAIN then appears in `[data-token-plaintext]` and `created` is emitted.
|
||||
</behavior>
|
||||
<action>
|
||||
Extend the two existing files. Do not create sibling files. Add a new `describe('... mounted directly')` block to each and leave the existing mountApp-based tests unchanged.
|
||||
|
||||
In ApiTokensPanel.test.ts, add `mount` to the existing `@vue/test-utils` import. Add `import ApiTokensPanel from '../../src/components/account/ApiTokensPanel.vue'` (single quotes, extension kept) and `import { useConfirm } from '../../src/components/ui/confirm'`. Add `mockApi` to the ../helpers import. Reuse TOKENS, envelope and existingToken.
|
||||
|
||||
In ApiTokenCreateDialog.test.ts, add the same `mount`, `useConfirm` and `mockApi` imports. Add `import ApiTokenCreateDialog from '../../src/components/account/ApiTokenCreateDialog.vue'`. Reuse TOKENS, SCOPES, envelope, scopes, createdToken and PLAIN. Copy the field-filling and submit steps from the file's existing `step 1` and `step 2` describes; do not invent selectors. The dialog portals into document.body, so mount with `attachTo: document.body`.
|
||||
|
||||
Every new import must be used, because noUnusedLocals is on. Keep test data neutral (see context). Do not change admin/src.
|
||||
|
||||
Then run the full verification below. If `--self-test` fails on a plant for the wrong reason, fix the plant in scripts/check-phase10.sh, as a follow-up to the Task 1 commit. Do not loosen a rule.
|
||||
|
||||
Commit the five test files as `test(admin): import the untested SPA modules directly for check-phase10 hygiene`. Stage the five paths explicitly; no co-author trailer. Leave other sessions' uncommitted files (STATE.md, state.json, .msd/) unstaged.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && bash scripts/check-phase10.sh --hygiene && bash scripts/check-phase10.sh --self-test && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-dist.sh && git diff --quiet HEAD -- admin/src modules/boardwalk/dist'</automated>
|
||||
</verify>
|
||||
<done>`check-phase10.sh --hygiene` prints `phase10 hygiene passed` and `--self-test` prints `phase10 self-test passed`, both exit 0. The full Vitest suite and vue-tsc pass, the dist drift gate passes, and admin/src and the dist are unchanged. The tests are committed separately from the gate change.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| SPA → admin API (cookie-authenticated) | Every browser request must carry X-Requested-With (CSRF, T-10 / T-12.2-32) and go through the typed client or its sanctioned upload path |
|
||||
| Phase gate → future commits | check-phase10.sh hygiene is the mechanical guard for SC-4; an over-broad exception silently admits untyped or unguarded network calls |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-261011-mag-01 | Tampering | check-phase10.sh network rule exception | medium | mitigate | The exception is anchored at both ends to one path and one exact statement (`const request = new XMLHttpRequest()` in admin/src/api/files.ts). The new self-test `xhr` plant proves an XHR in any other file is still refused with `direct fetch`. |
|
||||
| T-261011-mag-02 | Tampering | check-phase10.sh api-listing exception | low | mitigate | The exemption uses `! -path admin/src/api/files.ts` (exact path, not `-name`). The new `apifile` plant proves any other file added to admin/src/api is refused. |
|
||||
| T-261011-mag-03 | Spoofing (CSRF) | admin/src/api/files.ts upload XHR | high | mitigate | The exception is justified only because the XHR sets X-Requested-With. That property stays asserted by admin/tests/smoke/deferred.smoke.test.ts ("uploads to record 0 with the form key", T-12.2-32) and server-side TestPhase10CSRF. The full `npm --prefix admin test` in Task 3 re-runs it. |
|
||||
| T-261011-mag-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs. The tests use the already-installed admin/node_modules from the committed lockfile. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase10.sh --hygiene`: exit 0
|
||||
- `bash scripts/check-phase10.sh --self-test`: exit 0 (clean copy passes; vhtml, fetch, appname, storage, xhr and apifile plants each refused for their own rule)
|
||||
- `npm --prefix admin run typecheck` and `npm --prefix admin test`: pass
|
||||
- `scripts/check-admin-dist.sh`: exit 0; `git diff --quiet HEAD -- admin/src modules/boardwalk/dist`
|
||||
- Optional for the re-verifier, not required here (needs Docker and the fonoteka.go sibling): `bash scripts/check-phase10.sh --all`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The three 10-VERIFICATION gaps are closed by live evidence. The SC-4 hygiene stage is green with exactly two narrow, documented, self-tested exceptions for the files.ts upload path. All five later-phase SPA modules are imported directly by behavior-asserting tests. `--self-test` passes. No production code or embedded dist changed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1/261011-mag-SUMMARY.md` when done. Record under decisions why files.ts is exempted in the gate rather than restructured, citing T-12.2-32 and the MarkdownField/listState precedents.
|
||||
</output>
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
phase: quick-261011-mag
|
||||
plan: 01
|
||||
subsystem: admin-spa / phase gates
|
||||
tags: [check-phase10, hygiene, vitest, admin]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "scripts/check-phase10.sh hygiene: narrow files.ts upload-XHR and api-listing exceptions with xhr/apifile self-test plants"
|
||||
- "direct tests for mlLocale.ts, MLField.vue, FileCaptionModal.vue, ApiTokensPanel.vue, ApiTokenCreateDialog.vue"
|
||||
affects: [phase-10 verification]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: ["line-anchored gate allowlist (MarkdownField/listState precedent)", "self-test plant per narrowed rule"]
|
||||
key-files:
|
||||
created:
|
||||
- admin/tests/form/mlLocale.test.ts
|
||||
- admin/tests/form/MLField.test.ts
|
||||
- admin/tests/form/FileCaptionModal.test.ts
|
||||
modified:
|
||||
- scripts/check-phase10.sh
|
||||
- admin/tests/account/ApiTokensPanel.test.ts
|
||||
- admin/tests/account/ApiTokenCreateDialog.test.ts
|
||||
decisions:
|
||||
- "admin/src/api/files.ts is exempted in the gate rather than restructured: its single XMLHttpRequest exists because only XHR reports upload progress (12.2-04), it sends X-Requested-With (T-12.2-32, asserted by tests/smoke/deferred.smoke.test.ts) and refreshes through client.ts; moving it into client.ts would only satisfy a filename rule. The exception follows the MarkdownField (27711b7) and listState (9cac45c) precedents: one line-anchored statement and one exact path, each proven fail-closed by a new self-test plant."
|
||||
metrics:
|
||||
duration: "~10 min"
|
||||
completed: 2026-10-11
|
||||
actuals:
|
||||
tokens: 5750
|
||||
tasks: 3
|
||||
commits: 2
|
||||
plan_head_before: d2fa195a91343fc9d1bd38969c0e830992f41c51
|
||||
plan_head_after: 1541cd4167bb9a92ffba897403a967030f37b93d
|
||||
---
|
||||
|
||||
# Quick 261011-mag Plan 01: check-phase10 hygiene gaps Summary
|
||||
|
||||
The Phase 10 hygiene gate is green again. It now has two narrow exceptions for the upload XHR in admin/src/api/files.ts, each guarded by a self-test plant that proves the rule still refuses everything else. Five later-phase SPA modules that no test imported now have direct, behavior-asserting tests. Nothing in admin/src or the embedded dist changed.
|
||||
|
||||
## Tasks
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
| ---- | ---- | ------ | ----- |
|
||||
| 1 | Narrow the network and api-listing rules for the files.ts upload XHR, add xhr/apifile plants | 42b5cf8 | scripts/check-phase10.sh |
|
||||
| 2 | Direct tests for mlLocale.ts, MLField.vue, FileCaptionModal.vue | 1541cd4 | admin/tests/form/{mlLocale,MLField,FileCaptionModal}.test.ts |
|
||||
| 3 | Direct-mount tests for ApiTokensPanel and ApiTokenCreateDialog, full gate run | 1541cd4 | admin/tests/account/{ApiTokensPanel,ApiTokenCreateDialog}.test.ts |
|
||||
|
||||
Tasks 2 and 3 share one `test(admin)` commit, as the plan allowed.
|
||||
|
||||
## Gate changes (scripts/check-phase10.sh)
|
||||
|
||||
- Network rule: a second filter, `grep -vE '^admin/src/api/files\.ts:[0-9]+:[[:space:]]*const request = new XMLHttpRequest\(\)[[:space:]]*$'`, follows the existing client.ts filter. The fail message is unchanged.
|
||||
- api-listing rule: `! -path admin/src/api/files.ts` (exact path, not `-name`). The comment now names the FileRoutes adapters.
|
||||
- Self-test: the plant loop is now `vhtml fetch appname storage xhr apifile`. `xhr` plants `new XMLHttpRequest()` in `admin/src/__plant/plant.ts` and expects `direct fetch`. `apifile` plants `admin/src/api/__plant.ts` (imported by the scratch test) and expects `unexpected files in admin/src/api`. Each iteration also removes `admin/src/api/__plant.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash scripts/check-phase10.sh --hygiene`: `phase10 hygiene passed` (exit 0)
|
||||
- `bash scripts/check-phase10.sh --self-test`: `phase10 self-test passed` (exit 0)
|
||||
- `npm --prefix admin run typecheck`: exit 0
|
||||
- `npm --prefix admin test`: 83 files, 1279 tests passed
|
||||
- `scripts/check-admin-dist.sh`: `modules/boardwalk/dist matches a fresh build` (exit 0)
|
||||
- `git diff --quiet HEAD -- admin/src modules/boardwalk/dist`: unchanged
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Behavior differing from the plan text (code asserted as is, component untouched)
|
||||
|
||||
**1. ApiTokenCreateDialog loads scopes only on an open transition.** The plan said to mount it with `open: true`. Its `watch(() => props.open, ...)` is not `immediate`, so a dialog mounted already open never calls `GET /account/tokens/scopes`. ApiTokensPanel always mounts it closed and then opens it. The direct test does the same: it mounts with `open: false`, then calls `setProps({ open: true })`. No source change was made, which also avoids a dist rebuild.
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] FileCaptionModal test fixture typing.** Spreading `deferredFilesFixture.data[0]` made every field optional under `noUncheckedIndexedAccess`, so vue-tsc failed. The fix uses a literal `FileItem` with the same values. Commit 1541cd4.
|
||||
|
||||
**2. [Rule 1 - Bug] Vue reactive-component warning in MLField test.** test-utils makes props reactive, so passing `inner` as a prop triggered Vue's "Component that was made a reactive object" warning. The inner component is now wrapped in `markRaw`. Commit 1541cd4.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: admin/tests/form/mlLocale.test.ts, admin/tests/form/MLField.test.ts, admin/tests/form/FileCaptionModal.test.ts
|
||||
- FOUND: commits 42b5cf8, 1541cd4
|
||||
@@ -165,9 +165,9 @@
|
||||
}
|
||||
],
|
||||
"next": {
|
||||
"command": "/msd-progress --next",
|
||||
"command": "/msd:progress --next",
|
||||
"label": "Advance to the next step",
|
||||
"reason": "Phase 16.3 of 40 · executing"
|
||||
"reason": "Phase 14.2 of 40 · executing"
|
||||
},
|
||||
"updated_at": "2026-10-11T00:07:52.680Z"
|
||||
"updated_at": "2026-10-11T15:48:22.885Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Let the sm-user-plugin console commands open their own database
|
||||
date: 2026-10-07
|
||||
priority: low
|
||||
area: sm-user-plugin
|
||||
---
|
||||
|
||||
`user:token:create`, `user:token:list`, `user:token:revoke` (`console/api_tokens.go`) and `user:require-password-change` (`console/require_password_change.go`) only look up a published `*gorm.DB`. The generated application `main` boots the plugins before any command runs, and only `serve`, the `migrate*` commands and the queue commands open and publish the database themselves, so in the real binary these commands stop with `golem15.user: database is not configured` (or `user:require-password-change: database is not configured`). Their tests pass because the test harness publishes the database first.
|
||||
|
||||
Found in Phase 14.2 research (gap G1): the same gap stopped `fonoteka:reindex`. Plan 14.2-01 fixed the Fonoteka plugin's own commands (`fonoteka:reindex`, `fonoteka:oauth-client`, `fonoteka:prune-notifications`, and the new `fonoteka:dev-seed`) with a helper that returns the published handle when there is one and otherwise opens `database.dsn` with `lagoon.OpenFromApp` and publishes it with `lagoon.Publish`, so `lagoon.OnDatabase` hooks still run. The seed mints its own API token, so the local stack does not need `user:token:create`.
|
||||
|
||||
Suggested fix, in the sm-user-plugin repository: the same published-else-open-and-publish pattern in the user plugin's command helper. It is additive and non-breaking (no route, payload, table, migration ID or config key changes). It is deferred here because sm-user-plugin is a shared core plugin and Phase 14.2 must not modify it; commit and push the plugin first, then bump the submodule pointers in the applications as separate commits. A framework helper (for example in lagoon) that every plugin command can call would remove the duplication; that needs its own decision.
|
||||
260
CLAUDE.md
260
CLAUDE.md
@@ -1,229 +1,77 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
SummerCMS in Go: a rewrite of the WinterCMS/OctoberCMS content management framework for the Golem15 stack. Read `README.md` first, then `.planning/notes/` for decisions and `.planning/research/` for ecosystem findings.
|
||||
SummerCMS in Go: a content management framework modelled on WinterCMS/OctoberCMS, for the Golem15 stack. An application built on it compiles into one binary: framework modules, the application's plugins, the embedded admin SPA and console commands. Read `README.md` first (module table, quick start, development commands), then `.planning/PROJECT.md` and `.planning/notes/` for decisions.
|
||||
|
||||
The reference implementation is WinterCMS. The Golem15 starter lives at `../examples/golem15-wintercms-starter`, and the v1 port target (Płytarium) at `/media/nvme/dev/golem15/fonoteka`.
|
||||
The core value: a WinterCMS-shaped app is ported plugin by plugin to a single Go binary without its frontend noticing. When porting endpoints, the existing frontend's requests (Nuxt app, MCP server) define the contract; response shapes are not "improved" during a port.
|
||||
|
||||
## GSD workflow rules (lean mode)
|
||||
References: the WinterCMS starter at `../examples/golem15-wintercms-starter`, the first port target Płytarium at `/media/nvme/dev/golem15/fonoteka`.
|
||||
|
||||
These rules apply to every GSD phase in this project and override defaults:
|
||||
## Commands
|
||||
|
||||
1. **Lean planning.** Prefer fewer, larger plans per phase. Skip optional agents unless a phase touches security or the plugin API.
|
||||
2. **Checkpoint on plan count.** Before writing plans for a phase, present the suggested number of plans with a one-line scope for each and wait for confirmation. The user adjusts the number; only then write PLAN.md files.
|
||||
3. **Unit tests are always the last plan of a phase.** Every phase ends with a dedicated plan that brings full unit test coverage for that phase's code. Earlier plans in the phase may include smoke tests but must not be blocked on coverage.
|
||||
4. **Go conventions.** Standard library first (net/http ServeMux, html/template, encoding/json). Add a dependency only when the research doc or a phase decision names it. Keep `go vet` and `go test ./...` green at every commit.
|
||||
5. **Compiled plugins.** Plugins are Go modules registered at build time. Do not introduce runtime plugin loading (stdlib `plugin`, yaegi) without a decision note.
|
||||
6. **API parity is the acceptance test.** When porting Płytarium endpoints, the existing Nuxt app's requests define the contract. Do not "improve" response shapes during the port.
|
||||
```sh
|
||||
go vet ./...
|
||||
go test -short ./... # fast loop, no Docker
|
||||
go test ./... # also the testcontainers Postgres/Mailpit tests (needs Docker)
|
||||
go test -short ./modules/cabana -run TestName # one test
|
||||
cd examples/hello && go test ./... # separate workspace module, not covered by the root ./...
|
||||
|
||||
## Commit rules
|
||||
go install ./cmd/summer # the CLI
|
||||
go test ./cmd/summer -run TestDocsTree # docs checker
|
||||
summer docs:build --check # docs checker (identifiers, links, anchors, src= snippets, command names)
|
||||
summer docs:sync # refresh src= code copies in docs/ after changing their source
|
||||
|
||||
- Never add co-author tags to commit messages.
|
||||
- One logical change per commit. Planning docs and code in separate commits.
|
||||
npm --prefix admin ci
|
||||
npm --prefix admin run test | typecheck | build | dev
|
||||
scripts/check-admin-openapi.sh [--check] # swag annotations in cabana -> admin/openapi/admin.json -> admin/src/api/schema.d.ts
|
||||
scripts/check-admin-dist.sh # committed modules/boardwalk/dist must match a fresh admin build
|
||||
scripts/check-phaseN.sh # per-phase verification gates
|
||||
```
|
||||
|
||||
## Documentation
|
||||
Docker-backed tests skip under `-short`; a `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()` (go test passes `-test.short=true`, so scanning `os.Args` for `-test.short` misses it). If the Docker socket is denied although the user is in the `docker` group, the shell session predates the group; run the tests through `sudo -u golem` to pick up a fresh group list. Keep `go vet` and `go test ./...` green at every commit.
|
||||
|
||||
- Any change to a package under `modules/` that touches its exported API, config keys, CLI commands or dependencies must update that module's `README.md` in the same change (the same commit or PR).
|
||||
- A new module ships with a `README.md` that follows the standard structure: H1 name, one-sentence summary, import line, then Overview, Features, Usage, API reference, the optional Configuration and CLI commands sections, Dependencies and Testing. It also gets a row in the root `README.md` modules table, whose purpose text reuses the summary sentence.
|
||||
- Framework READMEs never name a consuming application. Say "the application" or "host application", and use neutral example names such as `blog` or `acme`.
|
||||
- Every identifier named in a README or a docs page must exist in the package. The docs checker verifies this automatically; `go doc ./modules/<name> <Identifier>` remains the manual check.
|
||||
- A change to a module's exported API, config keys or CLI commands also updates the affected pages under `docs/` in the same change. `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` check identifiers, internal links and anchors, `src=` snippets, command names and consuming-application names across `docs/` and every module README.
|
||||
- Config keys named in README or docs pages are not checked automatically yet (deferred in Phase 11.1); review them by hand.
|
||||
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**SummerCMS (Go)**
|
||||
|
||||
SummerCMS is a Go rewrite of the WinterCMS/OctoberCMS content management framework for the Golem15 stack. It keeps what makes WinterCMS productive for us (plugins that extend each other, YAML-driven admin forms and lists, models/controllers/components, scaffolding commands, a headless API layer) and drops the parts that do not survive a compiled language (runtime plugin autoloading, PHP-style mutable magic). It is built by Golem15 developers and AI agents, for Golem15 projects that today run on the WinterCMS starter.
|
||||
|
||||
v1 is a headless backend that runs one real project, Płytarium (`fonoteka`), with its existing Nuxt 4 app and MCP server unchanged.
|
||||
|
||||
**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.
|
||||
|
||||
### Constraints
|
||||
|
||||
- **Tech stack**: Go 1.27, standard library first (net/http ServeMux, html/template, encoding/json); a dependency is added only when the research doc or a phase decision names it — keeps the binary boring and the dependency tree auditable
|
||||
- **Data**: GORM on Postgres only — chosen for Eloquent-like DX and a line-by-line model port; River needs Postgres
|
||||
- **Compatibility**: `vue-fonoteka-app` and `fonoteka-mcp` must run unchanged; the Nuxt app's requests define the contract and response shapes are not improved during the port
|
||||
- **Realtime**: keep the Centrifugo server and port only the publisher and token issuing — the Nuxt client connects to Centrifugo directly
|
||||
- **Plugins**: compiled at build time; no runtime plugin loading without a decision note
|
||||
- **Two repositories**: `summercms.go` is the framework only (the `summer` packages, the CLI, the admin SPA shell, the parity harness tooling) and knows nothing about Płytarium; `fonoteka.go`, a sibling directory in the meta repo, is the application: a go.work workspace holding the ported plugins (user, websockets, translate, feedback, sitemap, fonoteka) and the app binary, requiring the framework by module path with a local replace during development. Roadmap phases name which repo each plan writes to; planning docs stay in `summercms.go/.planning`
|
||||
- **Workflow**: lean planning (few, large plans per phase), a plan-count checkpoint before PLAN.md files are written, unit tests as the last plan of every phase, `go vet` and `go test ./...` green at every commit
|
||||
- **Commits**: no co-author tags; one logical change per commit; planning docs and code in separate commits
|
||||
- **Core plugin contracts**: the PHP user, blog, pages and payment plugins are shared across many projects; the Go ports must preserve their contracts and the PHP originals are not changed as part of this project
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
<!-- GSD:stack-start source:research/STACK.md -->
|
||||
## Technology Stack
|
||||
|
||||
## Recommended Stack
|
||||
### Core Technologies
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| Go | 1.27 (Aug 2026) | Language/runtime | Already decided. Generic methods clean up repository/query-builder APIs; `encoding/json` is now json/v2-backed (stricter: rejects duplicate keys, invalid UTF-8) — matters for hand-authored `fields.yaml`/`columns.yaml` round-tripped through JSON for the admin SPA. |
|
||||
| GORM | v1.31.2 (Jun 22, 2026) | ORM, Postgres only | Already decided. Current stable line; v1.31.x added generics-based `Count` etc. Confirmed via pkg.go.dev version list. HIGH confidence. |
|
||||
| gorm.io/driver/postgres | v1.6.3 (Sep 14, 2026) | Postgres driver for GORM | Latest patch release, two days before this research. Its `go.mod` pins `github.com/jackc/pgx/v5 v5.10.0` — GORM's "Postgres driver" is pgx under the hood, not lib/pq. This is the crux of the River pool-sharing question below. HIGH confidence (read go.mod directly). |
|
||||
| River | v0.47.0 (confirmed via multiple independent 2026 dependabot PRs bumping to this version; GitHub releases page fetch returned stale cached 2024 dates — do not trust that page directly, cross-checked against `riverdriver`/`riverpgxv5`/`riverdatabasesql` sub-package publish dates of Apr–Jul 2026) | Postgres-backed job queue | Already decided. MEDIUM-HIGH confidence on the exact patch version; HIGH confidence it's the current v0.4x line and actively maintained. |
|
||||
| zitadel/oidc | v3.51.0 (Sep 14, 2026) | OAuth2/OIDC provider | Already decided, and confirmed correct: **v4 exists only as `v4.0.0-next.4` (pre-release, Jul 30, 2026)**. v3 is the maintained stable line (v3.51.0 shipped two days before this research, newer than the v4 pre-release). Stay on v3; do not chase v4 until it ships stable. Flagged explicitly per the quality gate — this does not contradict the existing decision, it confirms it. |
|
||||
| golang-jwt/jwt | v5.3.1 (Jan 28, 2026), import path `github.com/golang-jwt/jwt/v5` | JWT for the SPA and for signing Centrifugo connection/subscription tokens | Already decided. One library covers both jobs — see Centrifugo section. |
|
||||
| go-playground/validator | v10.30.4 (Sep 3, 2026), import path `.../validator/v10` | Struct-tag validation from YAML `rules:` | Already decided. Current. |
|
||||
| cobra | v1.10.2 (Dec 3, 2025) | CLI framework | Already decided. Current stable. |
|
||||
| gocloud.dev | v0.46.0 (Jun 2, 2026) | Blob storage abstraction | Already decided. Current. |
|
||||
| Vue 3 + TypeScript | (frontend, out of Go versioning scope) | Minimal admin SPA | Already decided. Types generated from OpenAPI — see OpenAPI section. |
|
||||
### Supporting Libraries
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| go-gormigrate/gormigrate/v2 | latest tag as of May 26, 2026 (1.2k stars, actively maintained, PostgreSQL 18 in its own CI matrix) | Migrations, up/down, per-plugin | **Recommended primary migration tool.** See "Migration tooling" below for why this beats goose and atlas for this project's shape. |
|
||||
| pressly/goose | v3.27.3 (Jul 27, 2026, 11.5k stars) | Alternative migration tool | Use instead of gormigrate only if a future plugin needs pure-SQL migrations independent of `*gorm.DB`, or a non-GORM connection. Not recommended as primary — see rationale below. |
|
||||
| jackc/pgx/v5 | v5.10.0 (pinned by gorm.io/driver/postgres's own go.mod) | Postgres driver, shared by GORM and River | Not a separate app-level choice — it's already there transitively through GORM's driver. River's own driver (`riverpgxv5`) also needs pgx v5. Pin the same `pgx/v5` version across `go.sum` (Go's module resolution does this automatically via MVS; just don't force a divergent replace directive). |
|
||||
| koanf/v2 (`github.com/knadh/koanf/v2`) | v2.3.4 (Mar 21, 2026) | Layered config with plugin namespaces | Already decided per go-ecosystem.md. See "Config format" below for the HOCON question. |
|
||||
| koanf providers: `providers/file`, `providers/env`, `providers/confmap` | ships alongside koanf/v2, versioned independently as sub-modules | Base file + env-var overlay + programmatic plugin defaults | `file` provider loads `base.yaml`/`<env>.yaml`; `env` provider overlays `SUMMER_`-prefixed env vars with a transform func mapping `SUMMER_DB__HOST` → `db.host`; `confmap` lets each plugin register its own namespaced defaults (`plugins.<name>.*`) before the file/env layers are merged on top. |
|
||||
| koanf/parsers/yaml | tracks koanf/v2 | YAML parsing for koanf | Wraps `goccy/go-yaml` as of koanf's current release — **not** `gopkg.in/yaml.v3`, which is why the direct fields/columns parsing question below matters independently. |
|
||||
| goccy/go-yaml | v1.19.2 (Jan 8, 2026) | Parse `fields.yaml` / `columns.yaml` | **Use this, not `gopkg.in/yaml.v3`.** See "What NOT to Use" — yaml.v3's upstream repo (`go-yaml/yaml`) was archived by its maintainer on Apr 1, 2025 and is explicitly marked unmaintained. goccy/go-yaml passes 355/402 cases of the YAML test suite vs 295/402 for yaml.v3, has an AST/tokenizer API useful for round-tripping comments if `summer make:*` scaffolds YAML files, and is what koanf itself has moved to. HIGH confidence — this is a load-bearing finding, not a style preference. |
|
||||
| swaggo/swag | v1.16.6 (Jul 29, 2026, stable; v2.0.0-rc6 exists but is not production-ready) | Generate OpenAPI from annotated net/http handlers | **Recommended primary.** See "OpenAPI generation" below. |
|
||||
| openapi-typescript | current npm release (JS ecosystem, not Go-versioned) | Generate TS types for the Vue admin SPA from the OpenAPI doc swag produces | Already decided (types generated from OpenAPI). Feeds directly off swag's output JSON/YAML. |
|
||||
| typesense-go | v3.2.0 (Mar 27, 2025), confirms Typesense server API v28 support | Typesense client | Already decided. This is the most recent tagged release found; no newer tag surfaced in this research pass — treat the exact patch as MEDIUM confidence (worth a re-check at implementation time since it is over a year old relative to today) but the library itself is the only real Go client and is the correct pick. |
|
||||
| centrifugal/gocent/v3 | current release, import path `github.com/centrifugal/gocent/v3` | Centrifugo HTTP API client (publish/broadcast/presence) | Official client from the Centrifugo org. Small (87 stars is normal for a niche official SDK, not a red flag — same maintainers as Centrifugo itself). Given it wraps ~4 REST calls, hand-rolling a thin `net/http` client is a legitimate stdlib-first alternative if the team wants zero non-decided dependencies; gocent is the pragmatic default. |
|
||||
| — (no separate library for Centrifugo tokens) | — | Sign Centrifugo connection/subscription JWTs | Centrifugo does not need its own token library — it verifies plain JWTs signed with an HMAC secret (or RSA/ECDSA). **Use the already-decided `golang-jwt/jwt/v5` to sign these tokens too**, with claims shaped per Centrifugo's connection/subscription token spec. One JWT library, two token types (SPA auth, Centrifugo realtime auth). |
|
||||
| go-i18n/v2 (`github.com/nicksnyder/go-i18n/v2`) | v2.6.1 (Jan 1, 2026) | CLDR-plural i18n | Already decided. CLDR v48 as of this release (up from CLDR 44 in older v2.3.0 — make sure any tutorial/blog post referencing go-i18n is checked against the current release, plural-rule edge cases have shifted). Also replaced an unmaintained YAML dependency internally in a recent release — another confirmation that the Go YAML-library churn described above is a live, current issue, not stale training-data noise. |
|
||||
| testcontainers-go + testcontainers-go/modules/postgres | v0.44.0 (Aug 7, 2026) | Spin up real Postgres in tests | For GORM model tests, migration up/down tests, and River job tests that need real Postgres behavior (JSON columns, `LISTEN`/`NOTIFY`, constraint behavior) rather than SQLite-in-CI approximations. |
|
||||
| stretchr/testify | v1.12.1 (Aug 17, 2026) | Assertions in Go tests | Standard choice; use `assert`/`require` for readability in the API-parity diff tests, not as a BDD framework — keep tests as plain `func Test...(*testing.T)`. |
|
||||
| air-verse/air | v1.67.4 (Aug 1, 2026), actively maintained | Dev watch-rebuild loop | This is the "watch loop that rebuilds the compiled-plugin binary on change" the plugin architecture already calls for. Config via `.air.toml`; point `cmd`/`bin` at `go build -o ./tmp/summer ./cmd/summer && ./tmp/summer serve`. |
|
||||
| golangci-lint | v2.13.2 (Aug 27, 2026) | Linting | v2's config schema (`version: "2"` in `.golangci.yml`) is a breaking change from v1 — do not copy a v1 config from an older Go project without migrating it. |
|
||||
### Development Tools
|
||||
| Tool | Purpose | Notes |
|
||||
|------|---------|-------|
|
||||
| air | live rebuild in dev | Config as above; combine with the plugin system's own `plugins.go` regeneration step so editing a plugin's route/model file triggers both codegen and rebuild. |
|
||||
| golangci-lint v2 | static analysis in CI and pre-commit | Enable `govet`, `staticcheck`, `errcheck`, `revive` at minimum; the stdlib-first constraint makes `depguard` worth configuring to fail CI if an undecided dependency is imported. |
|
||||
| testcontainers-go | integration tests against real Postgres | Gate these behind a build tag or `-short` skip so unit tests stay fast; the phase-ending "unit tests" plan (per this repo's lean-mode rule) should still run everywhere, with testcontainers tests as a separate, slower suite. |
|
||||
| golden-file API parity tests | diff Go backend responses against recorded PHP responses | Not a library — a pattern: record Płytarium's actual JSON responses per route as fixtures (`testdata/parity/<route>.golden.json`), replay the same requests against the Go backend in `httptest.Server`, diff with `testify/assert.JSONEq` or a small custom normalizer for non-deterministic fields (timestamps, IDs). This is the literal implementation of the PROJECT.md requirement "an API parity test suite replays the Nuxt app's and MCP server's requests against both backends and diffs responses." |
|
||||
## Installation
|
||||
# Core (already decided, versions confirmed this session)
|
||||
# Migrations
|
||||
# Config
|
||||
# YAML for fields.yaml / columns.yaml (NOT gopkg.in/yaml.v3)
|
||||
# i18n
|
||||
# OpenAPI
|
||||
# Search / realtime clients
|
||||
# Dev / test dependencies
|
||||
# Dev tools (not go.mod dependencies)
|
||||
# golangci-lint installed via its install script, pinned to v2.13.2, not go install
|
||||
## Deep Dives on the Milestone's Specific Questions
|
||||
### GORM: plain structs, not gorm gen or the new GORM CLI
|
||||
### Migration tooling: gormigrate, not goose or atlas
|
||||
- **gormigrate** (`go-gorm/gormigrate/v2`) defines migrations as `{ID, Migrate(tx *gorm.DB) error, Rollback(tx *gorm.DB) error}` structs, operates directly on the shared `*gorm.DB`, and — confirmed by reading the source (`gormigrate.go`) directly rather than trusting a README excerpt — exposes `RollbackLast()` and `RollbackTo(id)` as first-class methods, plus `MigrateTo(id)`. This is a literal implementation of "drop the last migration and fix it": run `RollbackLast()`, edit the migration's `Migrate`/`Rollback` funcs, run `Migrate()` again. No separate migration-file format, no separate driver, no SQL string embedding required (though `tx.Exec(...)` is available inside a migration when raw SQL is easier than `Migrator()` calls).
|
||||
- **goose** supports Go-code migrations (not just `.sql` files) via `goose.NewGoMigration`, which is the reason it was flagged as a candidate. But it registers migrations into its own provider backed by an `embed.FS` per source tree; making per-plugin migration sets compose cleanly (each plugin contributing its own ordered slice, aggregated by the kernel at boot from the generated plugin import list) is more natural with gormigrate's plain `[]*gormigrate.Migration` slices than with goose's filesystem-and-provider model. goose is still the right tool if a future plugin needs raw-SQL migrations decoupled from `*gorm.DB` entirely (e.g. a plugin that talks to Postgres via bare `pgx` for performance reasons) — keep it as the named fallback, not the default.
|
||||
- **atlas** is a declarative schema-diff tool (desired-state HCL/SQL compared against actual state, migration generated automatically). It is the better fit when GORM's own struct tags are treated as the single source of truth and hand-written data-backfill migrations are rare. This project's port needs custom, hand-authored up/down logic per PHP migration (27 of them, with real data semantics, not just DDL) — atlas's diffing model fights that instead of helping it, and it adds a second DSL and a separate binary. Not recommended for v1.
|
||||
### River + GORM: share one `*sql.DB`, not one `pgxpool.Pool`
|
||||
### zitadel/oidc v3 storage interface: what to implement
|
||||
- **`AuthStorage`** — the core of the authorization-code + PKCE flow: `CreateAuthRequest`, `AuthRequestByID`, `AuthRequestByCode`, `SaveAuthCode`, `DeleteAuthRequest`, `CreateAccessToken`, `CreateAccessAndRefreshTokens`, `TokenRequestByRefreshToken`, `TerminateSession`, `RevokeToken`, `GetRefreshTokenInfo`, `SigningKey`, `SignatureAlgorithms`, `KeySet`. This is the interface Płytarium's `OAuthAuthCode`, `OAuthClient`, `OAuthRefreshToken` models map onto directly.
|
||||
- **`OPStorage`** — `GetClientByClientID`, `AuthorizeClientIDSecret`, `SetUserinfoFromToken`, `SetIntrospectionFromToken`, `GetPrivateClaimsFromScopes`, `GetKeyByIDAndClientID`, `ValidateJWTProfileScopes`. `SetUserinfoFromScopes` exists but is explicitly documented as deprecated in favor of the optional `CanSetUserinfoFromRequest` interface — implement the newer one, leave the old one empty.
|
||||
- **`ClientCredentialsStorage`** (optional) — needed only if the client-credentials grant is used (worth checking whether the MCP server or ChatGPT connector needs it; Płytarium's OAuth models list doesn't obviously call for it, flag as an open question for the phase that implements this).
|
||||
- **`TokenExchangeStorage`** / **`TokenExchangeTokensVerifierStorage`** (optional) — RFC 8693 token exchange; almost certainly out of scope for a straight port unless the existing PHP OAuth server implements it (check `wavepath.org/plugins/golem15/oauthserver` for this before assuming it's unneeded).
|
||||
- **Optional `Can*` interfaces** (`CanTerminateSessionFromRequest`, `CanSetUserinfoFromRequest`, and others in the same file) let the request object itself (not just IDs) reach the storage implementation — worth implementing from the start rather than the older, more limited required methods, since they carry richer context.
|
||||
### Config: koanf + YAML, not a literal HOCON parser
|
||||
### go-i18n + CLDR plurals
|
||||
### YAML parsing: goccy/go-yaml, confirmed necessary not just preferred
|
||||
### OpenAPI generation: swaggo/swag (code-first, comment-driven), not Huma, not hand-written
|
||||
### Testing stack
|
||||
- **testcontainers-go** (v0.44.0, Aug 7, 2026) + its `modules/postgres` for real-Postgres integration tests: GORM migrations up/down, River job processing (needs real `LISTEN`/`NOTIFY`), JSON column round-tripping. Gate behind a build tag/`-short` so the fast unit-test loop stays fast.
|
||||
- **testify** (v1.12.1, Aug 17, 2026) for assertions only (`assert`/`require`) — keep tests as plain stdlib `func TestX(t *testing.T)`, no BDD DSL, consistent with stdlib-first.
|
||||
- **Golden-file API parity tests**: no library needed. Record real Płytarium PHP responses as JSON fixtures per route, replay identical requests against the Go backend via `httptest.NewServer`, diff with a normalizer that ignores non-deterministic fields (timestamps, generated IDs) before comparing. This directly implements the PROJECT.md acceptance test.
|
||||
### Dev loop and linting
|
||||
- **air** (v1.67.4, Aug 1, 2026) for the watch-rebuild loop the compiled-plugin architecture needs to "feel like WinterCMS's drop-in loop" (go-ecosystem.md's own phrase). Point its build command at the full `go build` of the `summer` binary plus the plugin-import-list regeneration step, not just a bare `go build ./...`.
|
||||
- **golangci-lint v2.13.2** (Aug 27, 2026). v2's `.golangci.yml` schema (`version: "2"`) is a breaking change from v1 configs — do not reuse a v1 config verbatim. Configure `depguard` to enforce the stdlib-first / decided-dependency-only constraint at CI time, not just by convention.
|
||||
## Alternatives Considered
|
||||
| Recommended | Alternative | When to Use Alternative |
|
||||
|--------------|-------------|--------------------------|
|
||||
| gormigrate | goose | A future plugin needs raw-SQL migrations independent of `*gorm.DB`/GORM entirely. |
|
||||
| gormigrate | atlas | Schema is treated as pure declarative state (GORM struct tags as source of truth) with rare hand-authored data migrations — not this project's shape for v1. |
|
||||
| plain GORM structs | go-gorm/cli or gorm.io/gen | A later, non-ported plugin wants compile-time-checked queries and is willing to add a codegen step. |
|
||||
| swaggo/swag | huma + humago | A later, non-parity API surface where request/response shape isn't constrained by an existing contract, and the team accepts Huma's handler-shape convention project-wide. |
|
||||
| swaggo/swag | hand-written OpenAPI + oapi-codegen | A small, from-scratch API (not a 154-route port) where authoring the spec first is cheaper than annotating existing handlers. |
|
||||
| YAML (via koanf) | literal HOCON via a Go parser | Never, for this project — no maintained Go HOCON library exists at the bar this project applies elsewhere. |
|
||||
| gocent/v3 | hand-rolled Centrifugo HTTP client | Team wants zero additional non-decided dependencies; Centrifugo's HTTP API is small enough (~4 calls) that this is a legitimate, if slightly more work, stdlib-first option. |
|
||||
## What NOT to Use
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|--------------|
|
||||
| `gopkg.in/yaml.v3` (`go-yaml/yaml`) | Archived by its maintainer Apr 1, 2025; README explicitly says "THIS PROJECT IS UNMAINTAINED." No further fixes, security patches, or Go-compatibility work will land. | `goccy/go-yaml` v1.19.2 |
|
||||
| `gorm.io/gen` or `go-gorm/cli` for v1 model code | Adds a codegen layer that works against the explicit goal of a simple, reviewable, line-by-line Eloquent-to-GORM port of 25 models. | Plain GORM structs and methods |
|
||||
| `atlas` for v1 migrations | Declarative schema-diff model fights hand-authored, data-aware up/down migrations for 27 real PHP migrations with actual data semantics, and adds a second DSL/binary. | gormigrate |
|
||||
| Sharing a raw `pgxpool.Pool` directly between GORM's postgres driver and River's `riverpgxv5` driver as two independent pools | Two separate pools against the same database is wasteful and defeats the point of "share one pool"; naively wiring them both to the *same* pool object isn't how either library's constructor is designed to be used. | One shared `*sql.DB` (via `pgx/v5/stdlib`) for both GORM and River's queries, plus one small separate `pgxpool.Pool` used only for River's `LISTEN`/`NOTIFY` wake-ups |
|
||||
| zitadel/oidc v4 | Only exists as `v4.0.0-next.4`, a pre-release from Jul 30, 2026 — not production-ready, and older than the current v3.51.0 stable release. | zitadel/oidc v3 (`/v3`), already decided |
|
||||
| Huma for the v1 parity port specifically | Requires restructuring every ported handler into Huma's typed Input/Output convention rather than ordinary `net/http` handlers, right when byte-compatible parity with an existing contract is the entire point. | swaggo/swag, annotating existing handlers |
|
||||
| swaggo/swag v2 (`v2.0.0-rc6`) | Still a release candidate as of Sep 13, 2026; OpenAPI 3.1 support and dependency cleanup are still in flux. | swaggo/swag v1.16.6 |
|
||||
| A literal HOCON parser in Go (`gurkankaymak/hocon`, `go-hocon/hocon`) | Both are tiny, single-or-zero-star projects with unclear or no active maintenance — far below this project's ecosystem-depth bar for every other pick. | koanf + YAML, achieving the same layering design without literal HOCON syntax |
|
||||
| golangci-lint v1-style config on a v2 install | v2 changed the `.golangci.yml` schema; a copied v1 config will not behave as expected. | A `version: "2"` config written against v2's current schema |
|
||||
## Stack Patterns by Variant
|
||||
- Use goose's Go-code migrations for that plugin specifically
|
||||
- Because gormigrate assumes `*gorm.DB`-shaped `Migrate`/`Rollback` funcs; a plugin that deliberately bypasses GORM for a hot path has no natural `*gorm.DB` to hand it
|
||||
- Consider Huma instead of hand-annotated swag comments, and `go-gorm/cli` instead of plain structs
|
||||
- Because the byte-compatible-parity constraint that rules both out for v1 doesn't apply to code with no existing contract to match
|
||||
## Version Compatibility
|
||||
| Package A | Compatible With | Notes |
|
||||
|-----------|------------------|-------|
|
||||
| gorm.io/gorm v1.31.2 | gorm.io/driver/postgres v1.6.3 | Driver's own go.mod targets this gorm version; keep them bumped together. |
|
||||
| gorm.io/driver/postgres v1.6.3 | jackc/pgx/v5 v5.10.0 | Pinned directly in the driver's go.mod — this is the pgx version that ends up in `go.sum` for the whole binary via GORM. |
|
||||
| River v0.47.0 | jackc/pgx/v5 (River's own `riverpgxv5` driver requires v5; `riverdatabasesql` driver works with any `database/sql`-registered driver, including pgx's `stdlib` registration) | Go's module resolution (MVS) will pick one pgx/v5 version for the whole build; do not force a divergent `replace` for either GORM's or River's sake — let them converge. |
|
||||
| zitadel/oidc v3.51.0 | go-jose/go-jose/v4 (imported directly in `pkg/op/storage.go`) | v3's storage interfaces use `jose.SignatureAlgorithm` and `*jose.JSONWebKey` types from go-jose v4 in method signatures — a storage implementation's key-handling code will import this directly. |
|
||||
| koanf/v2 v2.3.4 | koanf/parsers/yaml (wraps goccy/go-yaml) | Confirms goccy/go-yaml is already in the dependency tree via koanf; using it directly for `fields.yaml`/`columns.yaml` doesn't add a new library, just a direct dependency on one already present transitively. |
|
||||
| go-i18n/v2 v2.6.1 | golang.org/x/text v0.32.0+ | go-i18n's own recent release notes cite bumping to this x/text version alongside the CLDR v48 update. |
|
||||
| golangci-lint v2.13.2 | `.golangci.yml` schema `version: "2"` | Not backward compatible with v1-style config files without migration. |
|
||||
## Sources
|
||||
- pkg.go.dev version pages for `gorm.io/gorm`, `gorm.io/driver/postgres`, `github.com/golang-jwt/jwt/v5`, `github.com/go-playground/validator/v10`, `github.com/spf13/cobra`, `gocloud.dev`, `github.com/goccy/go-yaml`, `github.com/stretchr/testify`, `github.com/testcontainers/testcontainers-go` — versions and dates, fetched 2026-09-16. HIGH confidence.
|
||||
- `raw.githubusercontent.com/go-gorm/postgres/master/go.mod` — read directly for the pgx/v5 pin. HIGH confidence.
|
||||
- `riverqueue.com/docs/gorm` — official River+GORM integration guide, the source for the shared-`*sql.DB` pattern. HIGH confidence.
|
||||
- `raw.githubusercontent.com/zitadel/oidc/main/pkg/op/storage.go` — read directly for interface definitions. HIGH confidence.
|
||||
- github.com/zitadel/oidc releases page — v3.51.0 vs v4.0.0-next.4 dates. HIGH confidence.
|
||||
- `raw.githubusercontent.com/go-gormigrate/gormigrate/master/gormigrate.go` and `README.md` — read directly for `RollbackLast`/`RollbackTo`/`MigrateTo` API and usage pattern. HIGH confidence.
|
||||
- github.com/go-yaml/yaml repo page — archived status and "UNMAINTAINED" README notice, read directly. HIGH confidence.
|
||||
- WebSearch results on koanf's yaml parser migrating to goccy/go-yaml — MEDIUM confidence (WebSearch-sourced, not independently re-verified against koanf's own go.mod, but corroborated by two independent search results and by go-i18n's own release notes mentioning a similar unmaintained-YAML-dependency swap).
|
||||
- github.com/swaggo/swag releases page, huma.rocks docs (`bring-your-own-router`, `humago` adapter), oapi-codegen GitHub — OpenAPI approach comparison. HIGH confidence on version/maintenance facts, MEDIUM-HIGH (judgment call) on the recommendation itself.
|
||||
- github.com/gurkankaymak/hocon, github.com/go-hocon/hocon — star counts and activity, read directly. HIGH confidence these are not viable options.
|
||||
- github.com/typesense/typesense-go releases — v3.2.0, Mar 27 2025. MEDIUM confidence this is still the latest tag; worth a fresh check at implementation time given the gap since this research date.
|
||||
- github.com/centrifugal/gocent repo page — v3 module path, star count. MEDIUM confidence (couldn't confirm an exact latest tag/date from the page content retrieved).
|
||||
- github.com/air-verse/air releases — v1.67.4, Aug 1 2026. HIGH confidence.
|
||||
- golangci-lint changelog/release references — v2.13.2, Aug 27 2026, v2 config schema change. HIGH confidence.
|
||||
<!-- GSD:stack-end -->
|
||||
|
||||
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||
## Conventions
|
||||
|
||||
Conventions not yet established. Will populate as patterns emerge during development.
|
||||
<!-- GSD:conventions-end -->
|
||||
|
||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||
## Architecture
|
||||
|
||||
Architecture not yet mapped. Follow existing patterns found in the codebase.
|
||||
<!-- GSD:architecture-end -->
|
||||
- **One Go module**, `git.golem15.com/golem15/summercms`; each package is imported as `.../modules/<name>`. Module names follow a beach theme, and each README carries a `**Name:**` mnemonic; a new module gets a fitting beach name, a README in the standard structure and a row in the root `README.md` table.
|
||||
- **Compiled plugins.** A plugin implements capability interfaces from `pact` (routes, config, migrations, middleware, commands, admin screens, translations, mail, jobs). `summer build` reads the application's `summer.yaml`, generates `plugins.gen.go` and `main.go` (`internal/build`) and builds the binary. `party` orders plugins by `Requires()` and runs `Register(app)` then `Boot(app)`; `backpack.App` is the per-instance container (config, typed service registry via `app.Lookup[T]()`, event bus, activated plugins). No runtime plugin loading (stdlib `plugin`, yaegi) without a decision note.
|
||||
- **Request path.** `surf` assembles plugin routes and named middleware onto a stdlib `net/http` ServeMux; `bouncer` authenticates (JWT, and personal API tokens resolved via cabana); `towel` carries actor/locale through `context.Context`; `wire` keeps JSON byte-compatible with the PHP (Laravel) backend.
|
||||
- **Admin.** Plugins describe lists, forms, filters and relations in WinterCMS-style YAML (`columns.yaml`, `fields.yaml`). `cabana` compiles them at boot and serves a JSON admin API (swag-annotated, see `admin_openapi.go`); `boardwalk` embeds the Vue 3 SPA from `admin/`, whose build output is committed in `modules/boardwalk/dist`. Before tagging a release that changes `admin/`, rebuild, commit the dist and pass `check-admin-dist.sh` (`.planning/notes/2026-10-07-embedded-admin-release-gate.md`).
|
||||
- **Data.** `lagoon`: GORM on Postgres only, per-plugin gormigrate migrations, model helpers, attachments (`lagoon/attach`). `conga`: River jobs on the same pool plus the `summer_jobs` record. YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3` (archived).
|
||||
- **Dependencies.** Standard library first. A new dependency needs a research doc or a phase/decision note naming it (`.planning/research/STACK.md` has the decided set and versions).
|
||||
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
## Sibling repositories
|
||||
|
||||
No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.
|
||||
<!-- GSD:skills-end -->
|
||||
The meta repo (`..`) holds the framework and its consumers side by side. Consumers `require` a tagged framework version (`vX.Y.Z` tags on `master`) and `replace` it with `../summercms.go` during development:
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## GSD Workflow Enforcement
|
||||
- `fonoteka.go`: Płytarium application (go.work workspace; app-specific plugins stay in-repo).
|
||||
- `sm-*-app`: other applications (`sm-grzybyfunkcjonalne-app`, `sm-summercmsio-app`, `sm-bm-app`).
|
||||
- `sm-*-plugin`: shared core plugins in their own repos, module path = repo path, mounted in apps as git submodules at `plugins/<vendor>/<name>` (`.planning/notes/core-plugins-own-repos.md`). Commit and push the plugin first, then the pointer bump in the app as a separate commit.
|
||||
- `vue-journal-layer`: public Nuxt layer consumed by site apps as a pnpm git dependency pinned by tag.
|
||||
|
||||
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
|
||||
Framework code never names a consuming application (READMEs, docs, examples use neutral names like `blog` or `acme`).
|
||||
|
||||
Use these entry points:
|
||||
- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
|
||||
- `/gsd-debug` for investigation and bug fixing
|
||||
- `/gsd-execute-phase` for planned phase work
|
||||
## Documentation rules
|
||||
|
||||
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
|
||||
<!-- GSD:workflow-end -->
|
||||
- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's `README.md` and the affected `docs/` pages in the same commit.
|
||||
- Module README structure: H1 name, one-sentence summary, import line, `**Name:**` mnemonic, then Overview, Features, Usage, API reference, optional Configuration and CLI commands, Dependencies, Testing. The root table's purpose text reuses the summary sentence.
|
||||
- Every identifier in a README or docs page must exist in the package (the docs checker enforces it). Config keys are not checked automatically; review them by hand.
|
||||
|
||||
<!-- GSD:profile-start -->
|
||||
## Developer Profile
|
||||
## MSD workflow
|
||||
|
||||
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-claude-profile` -- do not edit manually.
|
||||
<!-- GSD:profile-end -->
|
||||
Work goes through MSD (`/msd-*` commands; the older GSD tooling is retired): `/msd-quick` for small tasks (`.planning/quick/`), `/msd-fast` for trivial edits, `/msd-debug`, and `/msd-discuss-phase` → `/msd-plan-phase` → `/msd-execute-phase` for roadmap phases (`.planning/ROADMAP.md`, `.planning/phases/`). Urgent work is inserted as decimal phases (`/msd-phase --insert`). Don't edit the repo outside an MSD workflow unless the user says so.
|
||||
|
||||
Project rules that override MSD defaults:
|
||||
|
||||
1. **Lean planning.** Few, large plans per phase. Before writing PLAN.md files, present the suggested plan count with a one-line scope each and wait for confirmation.
|
||||
2. **Unit tests are the last plan of every phase.** Earlier plans may carry smoke tests but are not blocked on coverage.
|
||||
3. **State through handlers.** Update `.planning/STATE.md` with `msd_run query state.*` handlers, not raw edits.
|
||||
4. **g15office sync.** Todos and phase milestone tasks are mirrored in g15office (`.planning/G15OFFICE.md`); post a short comment when a phase advances.
|
||||
|
||||
## Commits and concurrency
|
||||
|
||||
- No co-author tags. One logical change per commit; planning docs and code in separate commits.
|
||||
- Several Claude sessions often work in this repo at once (different phases, quick tasks). Stage files explicitly by path (never `git add -A`), leave other sessions' uncommitted edits (often `STATE.md`, `state.json`) alone, and check for in-flight edits before touching shared files such as cabana.
|
||||
- Don't push, tag or deploy without the user's go-ahead; deploys are documented per application (for example `../sm-grzybyfunkcjonalne-app/DEPLOY.md`).
|
||||
|
||||
@@ -108,6 +108,9 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. | The guest list and the order people arrive in; it registers plugins and boots them in dependency order. |
|
||||
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. | What you pack to speak the local language on holiday; it holds the translations. |
|
||||
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. | What you send home in summer; it delivers the application's mail. |
|
||||
| [seaglass](modules/seaglass/README.md) | HTML sanitizer for stored rich text: a bluemonday allowlist policy that returns clean HTML byte for byte and reports what it removed from the rest. | Glass the sea has worn smooth; rough HTML goes in and only the safe, smooth part comes out. |
|
||||
| [sieve](modules/sieve/README.md) | Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type. | A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps. |
|
||||
| [signpost](modules/signpost/README.md) | Menu item type registry that collects the typed menu item types plugins register at boot, layers their URL patterns with host overrides and resolves menu and sitemap items through the owning plugin. | A beach signpost points the way to places; the registry tells menus and sitemaps where each item leads. |
|
||||
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework. | The start of a summer day; it starts a new application, or updates one, in one command. |
|
||||
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. | Protects you from exposure; it keeps secrets out of the logs. |
|
||||
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. | Catching the incoming waves; it routes incoming HTTP requests to their handlers. |
|
||||
|
||||
@@ -223,6 +223,43 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminDependentOption": {
|
||||
"properties": {
|
||||
"depth": {
|
||||
"type": "integer"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"depth",
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminDependentOptionsResult": {
|
||||
"properties": {
|
||||
"applies": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"options": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminDependentOption"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"applies",
|
||||
"options"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminFileCaptionRequest": {
|
||||
"properties": {
|
||||
"description": {
|
||||
@@ -384,6 +421,125 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminReorderNode": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"parent_id",
|
||||
"sort_order"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminReorderNodeInput": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"parent_id",
|
||||
"sort_order"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminReorderPartition": {
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminReorderPartitionOption"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"options",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminReorderPartitionOption": {
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminReorderRequest": {
|
||||
"properties": {
|
||||
"nodes": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminReorderNodeInput"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"partition": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodes",
|
||||
"partition"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminReorderTree": {
|
||||
"properties": {
|
||||
"nodes": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.AdminReorderNode"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"partition": {
|
||||
"$ref": "#/components/schemas/cabana.AdminReorderPartition"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodes",
|
||||
"partition",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminRoleSummary": {
|
||||
"properties": {
|
||||
"code": {
|
||||
@@ -403,6 +559,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminSingletonResult": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.BulkAction": {
|
||||
"properties": {
|
||||
"confirm": {
|
||||
@@ -620,6 +787,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminDependentOptionsResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminDependentOptionsResult"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminLoginData": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -695,6 +877,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminReorderTree": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminReorderTree"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_AdminSingletonResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.AdminSingletonResult"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_BulkActionResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -1105,6 +1317,10 @@
|
||||
"description": "Deferrable is true on a relation-manager field whose relation can be\nmanaged before the record is first saved (RelationSchema.Deferrable):\nthe SPA shows it on the create screen.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dependsOn": {
|
||||
"description": "DependsOn names the sibling field (same form, or same repeater row)\nwhose value selects a dropdown's options; the options come from the\ndependent-options route (D-08).",
|
||||
"type": "string"
|
||||
},
|
||||
"displayFormat": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1133,6 +1349,14 @@
|
||||
"description": "FirstDay is the first day of the week, 0 (Sunday) to 6.",
|
||||
"type": "integer"
|
||||
},
|
||||
"form": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.RepeaterForm"
|
||||
}
|
||||
],
|
||||
"description": "Form holds the nested fields of a `type: repeater` field, one set per\nrow (D-07)."
|
||||
},
|
||||
"format": {
|
||||
"description": "Format is a datepicker's WinterCMS (PHP date) display format;\nDisplayFormat is the same format in the SPA's moment-style tokens.",
|
||||
"type": "string"
|
||||
@@ -1162,6 +1386,10 @@
|
||||
"description": "MaxFilesize is the largest accepted file in megabytes.",
|
||||
"type": "number"
|
||||
},
|
||||
"maxItems": {
|
||||
"description": "MaxItems and MinItems bound a repeater's row count, checked on save.",
|
||||
"type": "integer"
|
||||
},
|
||||
"mimeTypes": {
|
||||
"description": "MimeTypes are the allowed MIME patterns (or extensions) of a\nfileupload field.",
|
||||
"items": {
|
||||
@@ -1173,6 +1401,9 @@
|
||||
"description": "MinDate and MaxDate bound a datepicker's calendar date (YYYY-MM-DD),\nchecked again on save.",
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime) or the\npermissioneditor mode (radio or checkbox).",
|
||||
"type": "string"
|
||||
@@ -1245,6 +1476,10 @@
|
||||
],
|
||||
"description": "ThumbOptions carries the preview thumbnail mode."
|
||||
},
|
||||
"titleFrom": {
|
||||
"description": "TitleFrom names the nested field whose value titles a repeater row.",
|
||||
"type": "string"
|
||||
},
|
||||
"translatable": {
|
||||
"description": "Translatable is true when a plugin field type's value is a map of\ncontent locale to string.",
|
||||
"type": "boolean"
|
||||
@@ -1445,6 +1680,10 @@
|
||||
}
|
||||
],
|
||||
"description": "Redirects are the raw Winter config_form.yaml targets; the SPA maps\nthem onto its routes."
|
||||
},
|
||||
"singleton": {
|
||||
"description": "Singleton is true when the controller edits exactly one record\n(pact.AdminSingleton, D-11): the admin opens it through the singleton\nroute instead of a list.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1673,6 +1912,34 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.ListReorder": {
|
||||
"properties": {
|
||||
"partition": {
|
||||
"$ref": "#/components/schemas/cabana.ListReorderPartition"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.ListReorderPartition": {
|
||||
"properties": {
|
||||
"column": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"column"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.ListSchema": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
@@ -1737,6 +2004,14 @@
|
||||
"recordsPerPage": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reorder": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.ListReorder"
|
||||
}
|
||||
],
|
||||
"description": "Reorder is set when the controller has a tree reorder page\n(config_reorder.yaml, D-09)."
|
||||
},
|
||||
"rowActions": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.RowAction"
|
||||
@@ -2231,6 +2506,20 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RepeaterForm": {
|
||||
"properties": {
|
||||
"fields": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.FormField"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fields"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RowAction": {
|
||||
"properties": {
|
||||
"label": {
|
||||
@@ -4046,6 +4335,100 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": {
|
||||
"get": {
|
||||
"description": "Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[\u003csibling\u003e]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. For a dropdown inside a repeater row, {field} is `\u003crepeater\u003e.\u003cfield\u003e` and `values[\u003csibling\u003e]` is the row's sibling value. A field that is not a dependsOn dropdown of the controller's form answers 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Dropdown field name, or repeater.field for a dropdown in a repeater row",
|
||||
"in": "path",
|
||||
"name": "field",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminDependentOptionsResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Dependent dropdown options",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
"get": {
|
||||
"description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.",
|
||||
@@ -4370,9 +4753,236 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/reorder": {
|
||||
"get": {
|
||||
"description": "The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A controller without a reorder page answers 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Partition id",
|
||||
"in": "query",
|
||||
"name": "partition",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminReorderTree"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Load a reorder tree",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. The answer is the new tree.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminReorderRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The partition and every node with its new parent and sort value",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminReorderTree"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Conflict"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request Entity Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Save a reorder tree",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).",
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route. A `type: richeditor` field's value is a string of HTML, sanitized on every save; what the sanitizer removed is reported in the save response's `meta.notices`. A `type: repeater` field carries `form.fields` (the fields of one row), `prompt`, `maxItems`, `minItems` and `titleFrom`; its value in a record response and in a save body is a list of row objects keyed by the nested field names, and a row's errors are keyed `\u003cfield\u003e.\u003cindex\u003e.\u003cnested\u003e`.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -4632,6 +5242,91 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/singleton": {
|
||||
"get": {
|
||||
"description": "The id of the one record a pact.AdminSingleton controller edits, created by the controller's SingletonRecord when it does not exist yet. The admin opens that record's form. The form schema of such a controller carries `singleton: true`, and its list, create, bulk and delete routes answer 404. A controller that is not a singleton answers 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminSingletonResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Open a singleton record",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
|
||||
"post": {
|
||||
"description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.",
|
||||
|
||||
535
admin/package-lock.json
generated
535
admin/package-lock.json
generated
@@ -10,6 +10,9 @@
|
||||
"@fontsource/dm-sans": "5.3.0",
|
||||
"@internationalized/date": "3.12.4",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"reka-ui": "2.9.10",
|
||||
"vue": "3.5.35",
|
||||
@@ -1537,6 +1540,375 @@
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
|
||||
"integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz",
|
||||
"integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
|
||||
"integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz",
|
||||
"integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
|
||||
"integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz",
|
||||
"integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
|
||||
"integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz",
|
||||
"integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz",
|
||||
"integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz",
|
||||
"integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz",
|
||||
"integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz",
|
||||
"integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
|
||||
"integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz",
|
||||
"integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz",
|
||||
"integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz",
|
||||
"integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz",
|
||||
"integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz",
|
||||
"integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz",
|
||||
"integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
|
||||
"integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz",
|
||||
"integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz",
|
||||
"integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz",
|
||||
"integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz",
|
||||
"integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.42.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz",
|
||||
"integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/extension-blockquote": "3.31.4",
|
||||
"@tiptap/extension-bold": "3.31.4",
|
||||
"@tiptap/extension-bullet-list": "3.31.4",
|
||||
"@tiptap/extension-code": "3.31.4",
|
||||
"@tiptap/extension-code-block": "3.31.4",
|
||||
"@tiptap/extension-document": "3.31.4",
|
||||
"@tiptap/extension-dropcursor": "3.31.4",
|
||||
"@tiptap/extension-gapcursor": "3.31.4",
|
||||
"@tiptap/extension-hard-break": "3.31.4",
|
||||
"@tiptap/extension-heading": "3.31.4",
|
||||
"@tiptap/extension-horizontal-rule": "3.31.4",
|
||||
"@tiptap/extension-italic": "3.31.4",
|
||||
"@tiptap/extension-link": "3.31.4",
|
||||
"@tiptap/extension-list": "3.31.4",
|
||||
"@tiptap/extension-list-item": "3.31.4",
|
||||
"@tiptap/extension-list-keymap": "3.31.4",
|
||||
"@tiptap/extension-ordered-list": "3.31.4",
|
||||
"@tiptap/extension-paragraph": "3.31.4",
|
||||
"@tiptap/extension-strike": "3.31.4",
|
||||
"@tiptap/extension-text": "3.31.4",
|
||||
"@tiptap/extension-underline": "3.31.4",
|
||||
"@tiptap/extensions": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -3033,6 +3405,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||
@@ -3232,6 +3610,12 @@
|
||||
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -3380,6 +3764,145 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
|
||||
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
|
||||
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
|
||||
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.12",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.6",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
|
||||
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.12",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -3497,6 +4020,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
@@ -4199,6 +4728,12 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"@fontsource/dm-sans": "5.3.0",
|
||||
"@internationalized/date": "3.12.4",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"reka-ui": "2.9.10",
|
||||
"vue": "3.5.35",
|
||||
|
||||
415
admin/src/api/schema.d.ts
vendored
415
admin/src/api/schema.d.ts
vendored
@@ -1568,6 +1568,81 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Dependent dropdown options
|
||||
* @description Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[<sibling>]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. For a dropdown inside a repeater row, {field} is `<repeater>.<field>` and `values[<sibling>]` is the row's sibling value. A field that is not a dependsOn dropdown of the controller's form answers 404.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
/** @description Dropdown field name, or repeater.field for a dropdown in a repeater row */
|
||||
field: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminDependentOptionsResult"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1812,6 +1887,180 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/reorder": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Load a reorder tree
|
||||
* @description The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A controller without a reorder page answers 404.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Partition id */
|
||||
partition?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminReorderTree"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Save a reorder tree
|
||||
* @description Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. The answer is the new tree.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The partition and every node with its new parent and sort value */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminReorderRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminReorderTree"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Request Entity Too Large */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1821,7 +2070,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Admin form schema
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route. A `type: richeditor` field's value is a string of HTML, sanitized on every save; what the sanitizer removed is reported in the save response's `meta.notices`. A `type: repeater` field carries `form.fields` (the fields of one row), `prompt`, `maxItems`, `minItems` and `titleFrom`; its value in a record response and in a save body is a list of row objects keyed by the nested field names, and a row's errors are keyed `<field>.<index>.<nested>`.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -2027,6 +2276,79 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/singleton": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Open a singleton record
|
||||
* @description The id of the one record a pact.AdminSingleton controller edits, created by the controller's SingletonRecord when it does not exist yet. The admin opens that record's form. The form schema of such a controller carries `singleton: true`, and its list, create, bulk and delete routes answer 404. A controller that is not a singleton answers 404.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminSingletonResult"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4981,6 +5303,15 @@ export interface components {
|
||||
thumb_url?: string;
|
||||
url: string;
|
||||
};
|
||||
"cabana.AdminDependentOption": {
|
||||
depth: number;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
"cabana.AdminDependentOptionsResult": {
|
||||
applies: boolean;
|
||||
options: components["schemas"]["cabana.AdminDependentOption"][];
|
||||
};
|
||||
"cabana.AdminFileCaptionRequest": {
|
||||
description?: string;
|
||||
title?: string;
|
||||
@@ -5029,11 +5360,43 @@ export interface components {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
"cabana.AdminReorderNode": {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id: number;
|
||||
sort_order: number;
|
||||
};
|
||||
"cabana.AdminReorderNodeInput": {
|
||||
id: number;
|
||||
parent_id: number;
|
||||
sort_order: number;
|
||||
};
|
||||
"cabana.AdminReorderPartition": {
|
||||
label: string;
|
||||
options: components["schemas"]["cabana.AdminReorderPartitionOption"][];
|
||||
value: number;
|
||||
};
|
||||
"cabana.AdminReorderPartitionOption": {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
"cabana.AdminReorderRequest": {
|
||||
nodes: components["schemas"]["cabana.AdminReorderNodeInput"][];
|
||||
partition: number;
|
||||
};
|
||||
"cabana.AdminReorderTree": {
|
||||
nodes: components["schemas"]["cabana.AdminReorderNode"][];
|
||||
partition: components["schemas"]["cabana.AdminReorderPartition"];
|
||||
title: string;
|
||||
};
|
||||
"cabana.AdminRoleSummary": {
|
||||
code: string;
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
"cabana.AdminSingletonResult": {
|
||||
id: number;
|
||||
};
|
||||
"cabana.BulkAction": {
|
||||
confirm?: string;
|
||||
label?: string;
|
||||
@@ -5086,6 +5449,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.AdminActionResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminDependentOptionsResult": {
|
||||
data: components["schemas"]["cabana.AdminDependentOptionsResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminLoginData": {
|
||||
data: components["schemas"]["cabana.AdminLoginData"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -5106,6 +5473,14 @@ export interface components {
|
||||
data: components["schemas"]["cabana.AdminRecord"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminReorderTree": {
|
||||
data: components["schemas"]["cabana.AdminReorderTree"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_AdminSingletonResult": {
|
||||
data: components["schemas"]["cabana.AdminSingletonResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_BulkActionResult": {
|
||||
data: components["schemas"]["cabana.BulkActionResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -5224,6 +5599,12 @@ export interface components {
|
||||
* the SPA shows it on the create screen.
|
||||
*/
|
||||
deferrable?: boolean;
|
||||
/**
|
||||
* @description DependsOn names the sibling field (same form, or same repeater row)
|
||||
* whose value selects a dropdown's options; the options come from the
|
||||
* dependent-options route (D-08).
|
||||
*/
|
||||
dependsOn?: string;
|
||||
displayFormat?: string;
|
||||
/**
|
||||
* @description Element is the custom-element tag of a field whose type a plugin
|
||||
@@ -5237,6 +5618,11 @@ export interface components {
|
||||
fill?: string[];
|
||||
/** @description FirstDay is the first day of the week, 0 (Sunday) to 6. */
|
||||
firstDay?: number;
|
||||
/**
|
||||
* @description Form holds the nested fields of a `type: repeater` field, one set per
|
||||
* row (D-07).
|
||||
*/
|
||||
form?: components["schemas"]["cabana.RepeaterForm"];
|
||||
/**
|
||||
* @description Format is a datepicker's WinterCMS (PHP date) display format;
|
||||
* DisplayFormat is the same format in the SPA's moment-style tokens.
|
||||
@@ -5253,6 +5639,8 @@ export interface components {
|
||||
maxFiles?: number;
|
||||
/** @description MaxFilesize is the largest accepted file in megabytes. */
|
||||
maxFilesize?: number;
|
||||
/** @description MaxItems and MinItems bound a repeater's row count, checked on save. */
|
||||
maxItems?: number;
|
||||
/**
|
||||
* @description MimeTypes are the allowed MIME patterns (or extensions) of a
|
||||
* fileupload field.
|
||||
@@ -5263,6 +5651,7 @@ export interface components {
|
||||
* checked again on save.
|
||||
*/
|
||||
minDate?: string;
|
||||
minItems?: number;
|
||||
/**
|
||||
* @description Mode is the fileupload mode (image or file, default file), the
|
||||
* datepicker mode (date, datetime or time, default datetime) or the
|
||||
@@ -5306,6 +5695,8 @@ export interface components {
|
||||
tab?: string;
|
||||
/** @description ThumbOptions carries the preview thumbnail mode. */
|
||||
thumbOptions?: components["schemas"]["cabana.ThumbOptions"];
|
||||
/** @description TitleFrom names the nested field whose value titles a repeater row. */
|
||||
titleFrom?: string;
|
||||
/**
|
||||
* @description Translatable is true when a plugin field type's value is a map of
|
||||
* content locale to string.
|
||||
@@ -5380,6 +5771,12 @@ export interface components {
|
||||
* them onto its routes.
|
||||
*/
|
||||
redirects: components["schemas"]["cabana.FormRedirects"];
|
||||
/**
|
||||
* @description Singleton is true when the controller edits exactly one record
|
||||
* (pact.AdminSingleton, D-11): the admin opens it through the singleton
|
||||
* route instead of a list.
|
||||
*/
|
||||
singleton?: boolean;
|
||||
};
|
||||
"cabana.LangBundle": {
|
||||
[key: string]: components["schemas"]["cabana.MessageForms"];
|
||||
@@ -5446,6 +5843,14 @@ export interface components {
|
||||
};
|
||||
total: number;
|
||||
};
|
||||
"cabana.ListReorder": {
|
||||
partition?: components["schemas"]["cabana.ListReorderPartition"];
|
||||
title: string;
|
||||
};
|
||||
"cabana.ListReorderPartition": {
|
||||
column: string;
|
||||
label?: string;
|
||||
};
|
||||
"cabana.ListSchema": {
|
||||
/** @description Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets). */
|
||||
assets: components["schemas"]["cabana.ControllerAssets"];
|
||||
@@ -5469,6 +5874,11 @@ export interface components {
|
||||
perPageOptions: number[];
|
||||
recordUrl?: string;
|
||||
recordsPerPage: number;
|
||||
/**
|
||||
* @description Reorder is set when the controller has a tree reorder page
|
||||
* (config_reorder.yaml, D-09).
|
||||
*/
|
||||
reorder?: components["schemas"]["cabana.ListReorder"];
|
||||
rowActions: components["schemas"]["cabana.RowAction"][];
|
||||
searchPrompt?: string;
|
||||
searchTerm: string;
|
||||
@@ -5611,6 +6021,9 @@ export interface components {
|
||||
view: components["schemas"]["cabana.RelationPanel"];
|
||||
viewForm?: components["schemas"]["cabana.FormField"][];
|
||||
};
|
||||
"cabana.RepeaterForm": {
|
||||
fields: components["schemas"]["cabana.FormField"][];
|
||||
};
|
||||
"cabana.RowAction": {
|
||||
label?: string;
|
||||
name: string;
|
||||
|
||||
@@ -68,6 +68,26 @@ export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
|
||||
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
|
||||
/** One save notice of a plugin field type: field, content locale and message. */
|
||||
export type FieldNotice = Schemas['cabana.FieldNotice']
|
||||
/** A dependent dropdown's options for one sibling value (D-08): applies false takes no value. */
|
||||
export type AdminDependentOptionsResult = Schemas['cabana.AdminDependentOptionsResult']
|
||||
/** One dependent option; depth indents a choice taken from a tree. */
|
||||
export type AdminDependentOption = Schemas['cabana.AdminDependentOption']
|
||||
/** The tree reorder page (D-09): title, partition and the scoped nodes. */
|
||||
export type AdminReorderTree = Schemas['cabana.AdminReorderTree']
|
||||
/** One node of a reorder tree; parent_id is null at the top level. */
|
||||
export type AdminReorderNode = Schemas['cabana.AdminReorderNode']
|
||||
/** One node of a reorder save. */
|
||||
export type AdminReorderNodeInput = Schemas['cabana.AdminReorderNodeInput']
|
||||
/** Body of POST …/reorder: the whole scoped node set and the partition. */
|
||||
export type AdminReorderRequest = Schemas['cabana.AdminReorderRequest']
|
||||
/** The partition of a reorder page: its label, the choices and the one shown. */
|
||||
export type AdminReorderPartition = Schemas['cabana.AdminReorderPartition']
|
||||
/** A list schema's tree reorder page (title, partition column). */
|
||||
export type ListReorder = Schemas['cabana.ListReorder']
|
||||
/** The id of a singleton controller's one record (D-11). */
|
||||
export type AdminSingletonResult = Schemas['cabana.AdminSingletonResult']
|
||||
/** A repeater's row form: the nested fields of one row (D-07). */
|
||||
export type RepeaterForm = Schemas['cabana.RepeaterForm']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import LoginView from '../views/LoginView.vue'
|
||||
import ListView from '../views/ListView.vue'
|
||||
import FormView from '../views/FormView.vue'
|
||||
import PreviewView from '../views/PreviewView.vue'
|
||||
import ReorderView from '../views/ReorderView.vue'
|
||||
import SettingsFormView from '../views/SettingsFormView.vue'
|
||||
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
||||
import AccountView from '../views/AccountView.vue'
|
||||
@@ -36,7 +37,7 @@ export function safeRedirect(value: unknown): string | null {
|
||||
return value
|
||||
}
|
||||
|
||||
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview'])
|
||||
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview', 'reorder'])
|
||||
|
||||
/**
|
||||
* The controller a route shows, or '' for every other screen (settings,
|
||||
@@ -72,6 +73,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
||||
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
||||
// The tree reorder page of a list with config_reorder.yaml (D-09).
|
||||
{ path: '/:vendor/:plugin/:controller/reorder', name: 'reorder', component: ReorderView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
||||
// The read-only record screen of a form with a preview block (D-11).
|
||||
{
|
||||
|
||||
@@ -61,7 +61,9 @@ const plaintextInput = ref<HTMLInputElement | null>(null)
|
||||
const copyButton = ref<HTMLElement | null>(null)
|
||||
|
||||
const starChecked = computed(() => checked.value.has('*'))
|
||||
const canSubmit = computed(() => !scopesLoading.value && !scopesFailed.value && scopes.value.length > 0 && !busy.value)
|
||||
const canSubmit = computed(
|
||||
() => !scopesLoading.value && !scopesFailed.value && scopes.value.length > 0 && checked.value.size > 0 && !busy.value,
|
||||
)
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { t, tc } from '../../app/i18n'
|
||||
import { keyField } from './formState'
|
||||
|
||||
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
|
||||
// Messages of keys that are not form fields are listed here, so no server
|
||||
@@ -14,9 +15,11 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const count = computed(() => Object.keys(props.errors).length)
|
||||
// A repeater row path (`content.3.answer`) belongs to its field: it renders
|
||||
// under the nested control, not here.
|
||||
const orphans = computed(() =>
|
||||
Object.entries(props.errors)
|
||||
.filter(([name]) => !props.fieldNames.includes(name))
|
||||
.filter(([name]) => !props.fieldNames.includes(name) && !props.fieldNames.includes(keyField(name)))
|
||||
.flatMap(([, messages]) => messages),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -53,7 +53,7 @@ const describedBy = computed(() =>
|
||||
<span v-if="labelsGroup && field.label" :id="`${controlId}-label`" class="font-semibold">
|
||||
{{ field.label }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</span>
|
||||
<label v-else-if="!selfLabelled && !labelsGroup" :for="controlId" class="font-semibold">
|
||||
<label v-else-if="!selfLabelled && !labelsGroup" :id="`${controlId}-label`" :for="controlId" class="font-semibold">
|
||||
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</label>
|
||||
<FieldRenderer
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
|
||||
import FormField from './FormField.vue'
|
||||
import { FIELD_SCOPE } from './formContext'
|
||||
import { RELATION_MANAGER } from './registry'
|
||||
|
||||
// Two-column form grid (design screen 4): span left -> column 1, right ->
|
||||
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
|
||||
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
|
||||
defineProps<{
|
||||
// The grid is the scope its fields read siblings from (a dependent dropdown,
|
||||
// D-08); a repeater row's grid passes `scopePath`, the repeater's name, so a
|
||||
// nested field's routes address it as `<repeater>.<field>`.
|
||||
const props = defineProps<{
|
||||
fields: FormFieldSchema[]
|
||||
values: AdminRecord
|
||||
errors: Record<string, string[]>
|
||||
@@ -16,9 +21,17 @@ defineProps<{
|
||||
source?: ControllerParams | null
|
||||
recordId?: number | null
|
||||
idPrefix?: string
|
||||
/** The repeater whose row this grid renders. */
|
||||
scopePath?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
||||
|
||||
provide(FIELD_SCOPE, {
|
||||
fields: computed(() => props.fields),
|
||||
values: computed(() => props.values),
|
||||
path: (name: string) => (props.scopePath ? `${props.scopePath}.${name}` : name),
|
||||
})
|
||||
|
||||
function spanClass(field: FormFieldSchema): string {
|
||||
// The relation manager always takes the whole row (design screen 5).
|
||||
if (field.type === RELATION_MANAGER) {
|
||||
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
/** Types rendered by their own control in its read-only mode. */
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor'])
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater'])
|
||||
|
||||
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
||||
const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { FormOption } from '../../../api/types'
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ChevronDown, CircleAlert, RotateCcw } from '@lucide/vue'
|
||||
import { api } from '../../../api/client'
|
||||
import type { AdminDependentOptionsResult, FormOption } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { controlClass, type FieldControlProps } from '../control'
|
||||
import { DEPENDENT_CACHE, FIELD_SCOPE, FORM_FEEDBACK } from '../formContext'
|
||||
|
||||
// type: dropdown. With `dependsOn` (D-08, UI-SPEC "Dependent dropdown") the
|
||||
// options come from GET …/fields/{field}/dependent-options for the sibling's
|
||||
// current value (same form, or same repeater row through the row's grid
|
||||
// scope), and the control shows the waiting, loading, not-used, empty,
|
||||
// loaded and failed states. Answers are cached per sibling value for the
|
||||
// form view and a late answer for an old sibling value is dropped. After the
|
||||
// sibling changes, a value the new options no longer offer is cleared to
|
||||
// null (the form becomes dirty) and announced in the form's live region; on
|
||||
// load a stored value no option matches only shows the placeholder.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value'] | null] }>()
|
||||
|
||||
const EMPTY = 'empty'
|
||||
const PLACEHOLDER = 'placeholder'
|
||||
const INDENT = ' '.repeat(4)
|
||||
|
||||
const options = computed(() => props.field.options ?? [])
|
||||
type Dependent = 'waiting' | 'loading' | 'unused' | 'empty' | 'ready' | 'failed'
|
||||
|
||||
const scope = inject(FIELD_SCOPE, null)
|
||||
const feedback = inject(FORM_FEEDBACK, null)
|
||||
const cache = inject(DEPENDENT_CACHE, null) ?? new Map<string, AdminDependentOptionsResult>()
|
||||
|
||||
const sibling = computed(() => props.field.dependsOn ?? '')
|
||||
const dependent = computed(() => sibling.value !== '')
|
||||
const state = ref<Dependent>('waiting')
|
||||
const loaded = ref<FormOption[]>([])
|
||||
|
||||
/** The sibling's value as the route takes it: a string, or null when empty. */
|
||||
const siblingValue = computed<string | null>(() => {
|
||||
const value = scope?.values.value[sibling.value]
|
||||
if (value === null || value === undefined || value === '' || typeof value === 'object') {
|
||||
return null
|
||||
}
|
||||
return String(value)
|
||||
})
|
||||
const siblingField = computed(() => scope?.fields.value.find((field) => field.name === sibling.value))
|
||||
const siblingLabel = computed(() => siblingField.value?.label || sibling.value)
|
||||
const siblingOptionLabel = computed(() => {
|
||||
const option = (siblingField.value?.options ?? []).find((item) => same(item.value, siblingValue.value))
|
||||
return option?.label ?? siblingValue.value ?? ''
|
||||
})
|
||||
|
||||
const options = computed<FormOption[]>(() => (dependent.value ? loaded.value : (props.field.options ?? [])))
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (a === null || a === undefined || b === null || b === undefined) {
|
||||
@@ -20,6 +59,112 @@ function same(a: unknown, b: unknown): boolean {
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
function hasValue(): boolean {
|
||||
return props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== ''
|
||||
}
|
||||
|
||||
function clearValue(): void {
|
||||
if (!hasValue()) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', null)
|
||||
feedback?.announce(t('backend::lang.form.dependent_cleared', { field: props.field.label || props.field.name }))
|
||||
}
|
||||
|
||||
function adopt(result: AdminDependentOptionsResult, changed: boolean): void {
|
||||
if (!result.applies) {
|
||||
loaded.value = []
|
||||
state.value = 'unused'
|
||||
if (changed) {
|
||||
clearValue()
|
||||
}
|
||||
return
|
||||
}
|
||||
loaded.value = result.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: INDENT.repeat(Math.max(0, option.depth)) + option.label,
|
||||
}))
|
||||
state.value = loaded.value.length > 0 ? 'ready' : 'empty'
|
||||
if (changed && hasValue() && !loaded.value.some((option) => same(option.value, props.modelValue))) {
|
||||
clearValue()
|
||||
}
|
||||
}
|
||||
|
||||
let sequence = 0
|
||||
// Whether the request that failed followed a sibling edit: Retry keeps it.
|
||||
let failedAfterChange = false
|
||||
|
||||
/** Loads the options for the sibling's current value; `changed` after a sibling edit. */
|
||||
async function load(changed: boolean): Promise<void> {
|
||||
const current = ++sequence
|
||||
const value = siblingValue.value
|
||||
if (value === null) {
|
||||
loaded.value = []
|
||||
state.value = 'waiting'
|
||||
if (changed) {
|
||||
clearValue()
|
||||
}
|
||||
return
|
||||
}
|
||||
const path = scope?.path(props.field.name) ?? props.field.name
|
||||
const key = `${path}\u0000${value}`
|
||||
const cached = cache.get(key)
|
||||
if (cached) {
|
||||
adopt(cached, changed)
|
||||
return
|
||||
}
|
||||
if (!props.source) {
|
||||
failedAfterChange = changed
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
state.value = 'loading'
|
||||
try {
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options', {
|
||||
params: {
|
||||
path: { ...props.source, field: path },
|
||||
// values[<sibling>]=<value>: a dynamic key the OpenAPI document does not list.
|
||||
query: { [`values[${sibling.value}]`]: value } as never,
|
||||
},
|
||||
})
|
||||
if (current !== sequence) {
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
failedAfterChange = changed
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
cache.set(key, data.data)
|
||||
adopt(data.data, changed)
|
||||
} catch {
|
||||
if (current === sequence) {
|
||||
failedAfterChange = changed
|
||||
state.value = 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry(): void {
|
||||
void load(failedAfterChange)
|
||||
}
|
||||
|
||||
watch(siblingValue, (next, previous) => {
|
||||
if (dependent.value && next !== previous) {
|
||||
void load(true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (dependent.value) {
|
||||
void load(false)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sequence++
|
||||
})
|
||||
|
||||
// The <select> works on indexes so option values keep their JSON type
|
||||
// (string, number or boolean) when they are sent back.
|
||||
const selected = computed(() => {
|
||||
@@ -27,13 +172,37 @@ const selected = computed(() => {
|
||||
if (index >= 0) {
|
||||
return String(index)
|
||||
}
|
||||
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
|
||||
if (props.field.emptyOption !== undefined && !hasValue()) {
|
||||
return EMPTY
|
||||
}
|
||||
return PLACEHOLDER
|
||||
})
|
||||
|
||||
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
|
||||
/** The single disabled option text of a dependent state other than ready. */
|
||||
const stateText = computed(() => {
|
||||
switch (state.value) {
|
||||
case 'waiting':
|
||||
return t('backend::lang.form.dependent_waiting', { field: siblingLabel.value })
|
||||
case 'loading':
|
||||
return t('backend::lang.form.loading_options')
|
||||
case 'unused':
|
||||
return t('backend::lang.form.dependent_not_used')
|
||||
case 'empty':
|
||||
return t('backend::lang.form.dependent_empty', { type: siblingOptionLabel.value })
|
||||
case 'failed':
|
||||
return t('backend::lang.form.dependent_failed')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
const blocked = computed(() => dependent.value && state.value !== 'ready')
|
||||
const muted = computed(() => blocked.value || selected.value === EMPTY || selected.value === PLACEHOLDER)
|
||||
const hintId = computed(() => `${props.controlId}-dependent`)
|
||||
const describedBy = computed(() =>
|
||||
[props.describedBy ?? '', dependent.value && (state.value === 'empty' || state.value === 'failed') ? hintId.value : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
@@ -49,33 +218,52 @@ function onChange(event: Event): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<select
|
||||
:id="controlId"
|
||||
:name="field.name"
|
||||
:value="selected"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
|
||||
class="h-input appearance-none pr-10"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
|
||||
<!-- A stored value no option matches shows the placeholder, never a
|
||||
wrong choice (also next to an emptyOption). -->
|
||||
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
|
||||
{{ t('backend::lang.form.select_placeholder') }}
|
||||
</option>
|
||||
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5" :data-dependent="dependent ? state : undefined">
|
||||
<div class="relative">
|
||||
<select
|
||||
:id="controlId"
|
||||
:name="field.name"
|
||||
:value="blocked ? 'state' : selected"
|
||||
:disabled="blocked || undefined"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-busy="dependent && state === 'loading' ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
|
||||
class="h-input appearance-none pr-10 disabled:cursor-not-allowed"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="blocked" value="state" class="text-muted">{{ stateText }}</option>
|
||||
<template v-else>
|
||||
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
|
||||
<!-- A stored value no option matches shows the placeholder, never a
|
||||
wrong choice (also next to an emptyOption). -->
|
||||
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
|
||||
{{ t('backend::lang.form.select_placeholder') }}
|
||||
</option>
|
||||
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="dependent && state === 'empty'" :id="hintId" data-dependent-hint class="text-[13px] text-muted">
|
||||
{{ stateText }}
|
||||
</p>
|
||||
<div v-else-if="dependent && state === 'failed'" class="flex flex-wrap items-center gap-2">
|
||||
<p :id="hintId" role="alert" class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.form.dependent_failed') }}</span>
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" :icon="RotateCcw" data-action="dependent-retry" @click="retry">
|
||||
{{ t('backend::lang.form.dependent_retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
465
admin/src/components/form/fields/RepeaterField.vue
Normal file
465
admin/src/components/form/fields/RepeaterField.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue'
|
||||
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from '@lucide/vue'
|
||||
import type { AdminRecord, FormField } from '../../../api/types'
|
||||
import { t, tc } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { type FieldControlProps } from '../control'
|
||||
import { FORM_FEEDBACK } from '../formContext'
|
||||
import FormGrid from '../FormGrid.vue'
|
||||
import PreviewField from '../PreviewField.vue'
|
||||
import { inertFragment } from '../richeditor/lossy'
|
||||
|
||||
// type: repeater (D-07, UI-SPEC RepeaterField): a list of rows, each a
|
||||
// nested FormGrid of the repeater's form.fields. Rows add, remove (asking
|
||||
// first when the row holds a value), reorder by drag, by ArrowUp/ArrowDown on
|
||||
// the handle and by the move buttons, and collapse; every change only edits
|
||||
// the form value, which the record's Save sends. Row errors and notices come
|
||||
// from the form's feedback under `<field>.<index>.<nested>`. A collapsed row
|
||||
// unmounts its body (its richeditor is not running); its values stay in the
|
||||
// form value. Read-only shows every row expanded through PreviewField.
|
||||
// The expand button is a plain aria-expanded/aria-controls disclosure and
|
||||
// the body a v-if: it mounts in the same tick, so a 422 that opens a row can
|
||||
// focus the nested control at once.
|
||||
//
|
||||
// Only the registry imports this module, so the registry always evaluates
|
||||
// first and this module's FormGrid -> registry import cycle is safe.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: AdminRecord[]] }>()
|
||||
|
||||
const feedback = inject(FORM_FEEDBACK, null)
|
||||
|
||||
const nested = computed<FormField[]>(() => props.field.form?.fields ?? [])
|
||||
const readOnly = computed(() => !!props.field.readOnly)
|
||||
const rows = computed<AdminRecord[]>(() =>
|
||||
Array.isArray(props.modelValue)
|
||||
? props.modelValue.map((row) => (row !== null && typeof row === 'object' && !Array.isArray(row) ? (row as AdminRecord) : {}))
|
||||
: [],
|
||||
)
|
||||
const maxItems = computed(() => (typeof props.field.maxItems === 'number' ? props.field.maxItems : null))
|
||||
const minItems = computed(() => (typeof props.field.minItems === 'number' ? props.field.minItems : 0))
|
||||
const addLabel = computed(() => props.field.prompt || t('backend::lang.repeater.add'))
|
||||
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||
|
||||
// Stable keys per row: v-for identity, element ids and the expanded state.
|
||||
let counter = 0
|
||||
const keys = ref<string[]>([])
|
||||
const collapsed = ref<Set<string>>(new Set())
|
||||
let lastEmitted: unknown = undefined
|
||||
|
||||
function newKey(): string {
|
||||
counter += 1
|
||||
return `r${counter}`
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value === lastEmitted && keys.value.length === rows.value.length) {
|
||||
return
|
||||
}
|
||||
// A value from outside (load, the save's answer): keep the keys when the
|
||||
// row count is unchanged, so ids and expanded rows stay put.
|
||||
if (keys.value.length !== rows.value.length) {
|
||||
keys.value = rows.value.map(() => newKey())
|
||||
collapsed.value = new Set()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function commit(next: AdminRecord[], nextKeys: string[]): void {
|
||||
keys.value = nextKeys
|
||||
lastEmitted = next
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === '' || value === false || (Array.isArray(value) && value.length === 0)
|
||||
}
|
||||
|
||||
function defaults(): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of nested.value) {
|
||||
if (field.default !== undefined) {
|
||||
out[field.name] = field.default
|
||||
} else if (field.type === 'checkbox' || field.type === 'switch') {
|
||||
out[field.name] = false
|
||||
} else {
|
||||
out[field.name] = null
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether removing the row loses something: a value other than empty or the default. */
|
||||
function holdsValue(row: AdminRecord): boolean {
|
||||
return nested.value.some((field) => {
|
||||
const value = row[field.name]
|
||||
if (isEmptyValue(value)) {
|
||||
return false
|
||||
}
|
||||
return field.default === undefined || String(field.default) !== String(value)
|
||||
})
|
||||
}
|
||||
|
||||
function plainText(html: string): string {
|
||||
return (inertFragment(html).textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function title(index: number): string {
|
||||
const row = rows.value[index] ?? {}
|
||||
const source = nested.value.find((field) => field.name === props.field.titleFrom)
|
||||
const value = source ? row[source.name] : undefined
|
||||
let text = ''
|
||||
if (source && !isEmptyValue(value) && typeof value !== 'object') {
|
||||
if (source.type === 'dropdown') {
|
||||
text = (source.options ?? []).find((option) => String(option.value) === String(value))?.label ?? String(value)
|
||||
} else if (source.type === 'richeditor') {
|
||||
text = plainText(String(value))
|
||||
} else {
|
||||
text = String(value).trim()
|
||||
}
|
||||
}
|
||||
return text !== '' ? text : t('backend::lang.repeater.untitled', { n: index + 1 })
|
||||
}
|
||||
|
||||
function rowMap(map: Record<string, string[]> | undefined, index: number): Record<string, string[]> {
|
||||
const prefix = `${props.field.name}.${index}.`
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [key, lines] of Object.entries(map ?? {})) {
|
||||
if (key.startsWith(prefix) && key.length > prefix.length) {
|
||||
out[key.slice(prefix.length)] = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const rowErrors = computed(() => rows.value.map((_, index) => rowMap(feedback?.errors.value, index)))
|
||||
const rowNotices = computed(() => rows.value.map((_, index) => rowMap(feedback?.notices.value, index)))
|
||||
const errorCount = (index: number) => Object.keys(rowErrors.value[index] ?? {}).length
|
||||
|
||||
// A 422 opens every row with errors, so focusError reaches the control.
|
||||
watch(rowErrors, (all) => {
|
||||
const open = all.flatMap((errors, index) => (Object.keys(errors).length > 0 ? [keys.value[index]!] : []))
|
||||
if (open.length > 0) {
|
||||
const next = new Set(collapsed.value)
|
||||
for (const key of open) {
|
||||
next.delete(key)
|
||||
}
|
||||
collapsed.value = next
|
||||
}
|
||||
})
|
||||
|
||||
function isOpen(key: string): boolean {
|
||||
return readOnly.value || !collapsed.value.has(key)
|
||||
}
|
||||
|
||||
function setOpen(key: string, open: boolean): void {
|
||||
const next = new Set(collapsed.value)
|
||||
if (open) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
collapsed.value = next
|
||||
}
|
||||
|
||||
const anyOpen = computed(() => keys.value.some((key) => !collapsed.value.has(key)))
|
||||
|
||||
function toggleAll(): void {
|
||||
collapsed.value = anyOpen.value ? new Set(keys.value) : new Set()
|
||||
}
|
||||
|
||||
// Elements by row key: handles keep focus after a move or a removal.
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const addButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
function handleOf(key: string): HTMLElement | null {
|
||||
return root.value?.querySelector<HTMLElement>(`[data-repeater-handle="${key}"]`) ?? null
|
||||
}
|
||||
|
||||
function onNested(index: number, name: string, value: unknown): void {
|
||||
const next = rows.value.map((row, at) => (at === index ? { ...row, [name]: value } : row))
|
||||
commit(next, [...keys.value])
|
||||
feedback?.clearError(`${props.field.name}.${index}.${name}`)
|
||||
}
|
||||
|
||||
async function add(): Promise<void> {
|
||||
if (readOnly.value || (maxItems.value !== null && rows.value.length >= maxItems.value)) {
|
||||
return
|
||||
}
|
||||
const key = newKey()
|
||||
commit([...rows.value, defaults()], [...keys.value, key])
|
||||
await nextTick()
|
||||
const row = root.value?.querySelector(`[data-repeater-key="${key}"]`)
|
||||
row?.querySelector<HTMLElement>('[data-repeater-body] input, [data-repeater-body] select, [data-repeater-body] textarea, [data-repeater-body] [contenteditable="true"]')?.focus()
|
||||
}
|
||||
|
||||
async function remove(index: number): Promise<void> {
|
||||
const row = rows.value[index]
|
||||
if (!row || readOnly.value || rows.value.length <= minItems.value) {
|
||||
return
|
||||
}
|
||||
if (holdsValue(row) && feedback) {
|
||||
const name = title(index)
|
||||
const ok = await feedback.confirm({
|
||||
message: t('backend::lang.repeater.remove_confirm', { title: name }),
|
||||
confirmLabel: t('backend::lang.repeater.remove'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const nextKeys = keys.value.filter((_, at) => at !== index)
|
||||
commit(
|
||||
rows.value.filter((_, at) => at !== index),
|
||||
nextKeys,
|
||||
)
|
||||
await nextTick()
|
||||
const focusKey = nextKeys[index] ?? nextKeys[index - 1]
|
||||
const handle = focusKey ? handleOf(focusKey) : null
|
||||
if (handle) {
|
||||
handle.focus()
|
||||
} else {
|
||||
addButton.value?.$el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function move(from: number, to: number): Promise<void> {
|
||||
const total = rows.value.length
|
||||
if (readOnly.value || from === to || from < 0 || to < 0 || from >= total || to >= total) {
|
||||
return
|
||||
}
|
||||
const nextRows = [...rows.value]
|
||||
const nextKeys = [...keys.value]
|
||||
const [row] = nextRows.splice(from, 1)
|
||||
const [key] = nextKeys.splice(from, 1)
|
||||
nextRows.splice(to, 0, row!)
|
||||
nextKeys.splice(to, 0, key!)
|
||||
commit(nextRows, nextKeys)
|
||||
await nextTick()
|
||||
handleOf(key!)?.focus()
|
||||
feedback?.announce(t('backend::lang.repeater.moved', { title: title(to), position: to + 1, total }))
|
||||
}
|
||||
|
||||
function onHandleKeydown(event: KeyboardEvent, index: number): void {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
void move(index, event.key === 'ArrowUp' ? index - 1 : index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Native drag and drop by the handle (as FileuploadField): the dragged row
|
||||
// fades and a dashed slot marks where it lands.
|
||||
const dragging = ref<number | null>(null)
|
||||
const slot = ref<number | null>(null)
|
||||
|
||||
function onDragStart(event: DragEvent, index: number): void {
|
||||
dragging.value = index
|
||||
slot.value = null
|
||||
event.dataTransfer?.setData('text/plain', keys.value[index] ?? '')
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, index: number): void {
|
||||
if (dragging.value === null) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const box = target.getBoundingClientRect()
|
||||
slot.value = event.clientY > box.top + box.height / 2 ? index + 1 : index
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
if (dragging.value === null || slot.value === null) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const from = dragging.value
|
||||
const to = slot.value > from ? slot.value - 1 : slot.value
|
||||
onDragEnd()
|
||||
void move(from, to)
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
dragging.value = null
|
||||
slot.value = null
|
||||
}
|
||||
|
||||
const iconButton =
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
role="group"
|
||||
:aria-labelledby="labelledBy"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:data-repeater="field.name"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
v-if="rows.length === 0"
|
||||
data-repeater-empty
|
||||
class="flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center"
|
||||
>
|
||||
<p class="font-semibold">{{ t('backend::lang.repeater.empty') }}</p>
|
||||
<p v-if="!readOnly" class="max-w-[420px] text-[13px] text-muted">
|
||||
{{ t('backend::lang.repeater.empty_hint', { prompt: addLabel }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-for="(row, index) in rows" :key="keys[index]">
|
||||
<div
|
||||
v-if="dragging !== null && slot === index && slot !== dragging && slot !== dragging + 1"
|
||||
data-repeater-slot
|
||||
class="h-12 rounded-inner border-2 border-dashed border-primary bg-sel"
|
||||
/>
|
||||
<div
|
||||
:data-repeater-row="index"
|
||||
:data-repeater-key="keys[index]"
|
||||
:class="[errorCount(index) > 0 ? 'border-danger' : 'border-border', dragging === index ? 'opacity-50' : '']"
|
||||
class="rounded-inner border bg-surface"
|
||||
@dragover="onDragOver($event, index)"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div
|
||||
:class="isOpen(keys[index]!) ? 'rounded-t-inner border-b border-border' : 'rounded-inner'"
|
||||
class="flex min-h-12 items-center gap-2 bg-subtle px-2"
|
||||
>
|
||||
<button
|
||||
v-if="!readOnly"
|
||||
type="button"
|
||||
draggable="true"
|
||||
:data-repeater-handle="keys[index]"
|
||||
:aria-label="t('backend::lang.repeater.move', { title: title(index) })"
|
||||
:class="iconButton"
|
||||
class="cursor-grab"
|
||||
@keydown="onHandleKeydown($event, index)"
|
||||
@dragstart="onDragStart($event, index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<GripVertical :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="text-[13px] text-muted tabular-nums">{{ index + 1 }}.</span>
|
||||
<span data-repeater-title class="min-w-0 flex-1 truncate font-semibold" :title="title(index)">{{ title(index) }}</span>
|
||||
<span
|
||||
v-if="errorCount(index) > 0"
|
||||
data-repeater-errors
|
||||
class="inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold text-danger"
|
||||
>
|
||||
{{ tc('backend::lang.form.tab_errors', errorCount(index)) }}
|
||||
</span>
|
||||
<div v-if="!readOnly" class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-up"
|
||||
:aria-label="t('backend::lang.repeater.move_up', { title: title(index) })"
|
||||
:disabled="index === 0"
|
||||
:class="iconButton"
|
||||
@click="move(index, index - 1)"
|
||||
>
|
||||
<ChevronUp :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-down"
|
||||
:aria-label="t('backend::lang.repeater.move_down', { title: title(index) })"
|
||||
:disabled="index === rows.length - 1"
|
||||
:class="iconButton"
|
||||
@click="move(index, index + 1)"
|
||||
>
|
||||
<ChevronDown :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-toggle"
|
||||
:aria-label="t(isOpen(keys[index]!) ? 'backend::lang.repeater.collapse' : 'backend::lang.repeater.expand', { title: title(index) })"
|
||||
:aria-expanded="isOpen(keys[index]!) ? 'true' : 'false'"
|
||||
:aria-controls="`${controlId}-${keys[index]}-body`"
|
||||
:class="iconButton"
|
||||
@click="setOpen(keys[index]!, !isOpen(keys[index]!))"
|
||||
>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
:class="isOpen(keys[index]!) ? 'rotate-180' : ''"
|
||||
class="transition-transform duration-150"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="repeater-remove"
|
||||
:aria-label="t('backend::lang.repeater.remove_named', { title: title(index) })"
|
||||
:disabled="rows.length <= minItems"
|
||||
:class="iconButton"
|
||||
class="hover:bg-danger-soft hover:text-danger"
|
||||
@click="remove(index)"
|
||||
>
|
||||
<Trash2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isOpen(keys[index]!)" :id="`${controlId}-${keys[index]}-body`" data-repeater-body class="p-4">
|
||||
<dl v-if="readOnly" class="m-0 grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
|
||||
<PreviewField
|
||||
v-for="item in nested"
|
||||
:key="item.name"
|
||||
:class="item.span === 'left' ? 'min-[600px]:col-start-1' : item.span === 'right' ? 'min-[600px]:col-start-2' : 'col-span-full'"
|
||||
:field="{ ...item, readOnly: true }"
|
||||
:value="row[item.name]"
|
||||
:source="source"
|
||||
:record-id="recordId"
|
||||
:id-prefix="`${controlId}-${keys[index]}`"
|
||||
/>
|
||||
</dl>
|
||||
<FormGrid
|
||||
v-else
|
||||
:fields="nested"
|
||||
:values="row"
|
||||
:errors="rowErrors[index] ?? {}"
|
||||
:notices="rowNotices[index]"
|
||||
:source="source"
|
||||
:record-id="recordId"
|
||||
:id-prefix="`${controlId}-${keys[index]}`"
|
||||
:scope-path="field.name"
|
||||
@update="(name: string, value: unknown) => onNested(index, name, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="dragging !== null && slot === rows.length && slot !== dragging + 1"
|
||||
data-repeater-slot
|
||||
class="h-12 rounded-inner border-2 border-dashed border-primary bg-sel"
|
||||
@dragover.prevent
|
||||
@drop="onDrop"
|
||||
/>
|
||||
|
||||
<div v-if="!readOnly" class="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
ref="addButton"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="Plus"
|
||||
data-action="repeater-add"
|
||||
:disabled="maxItems !== null && rows.length >= maxItems"
|
||||
@click="add"
|
||||
>
|
||||
{{ addLabel }}
|
||||
</Button>
|
||||
<span v-if="maxItems !== null" data-repeater-count class="text-[13px] text-muted tabular-nums">
|
||||
{{ t('backend::lang.repeater.count', { current: rows.length, max: maxItems }) }}
|
||||
</span>
|
||||
<Button v-if="rows.length > 1" variant="ghost" size="sm" class="ml-auto" data-action="repeater-toggle-all" @click="toggleAll">
|
||||
{{ t(anyOpen ? 'backend::lang.repeater.collapse_all' : 'backend::lang.repeater.expand_all') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
424
admin/src/components/form/fields/RichEditorField.vue
Normal file
424
admin/src/components/form/fields/RichEditorField.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, type Component, type ComponentPublicInstance } from 'vue'
|
||||
import { Bold, Code, Heading3, Info, Italic, Link, List, ListOrdered, Quote, Redo2, Underline, Undo2, Unlink } from '@lucide/vue'
|
||||
import { ToolbarButton, ToolbarRoot, ToolbarSeparator } from 'reka-ui'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlClass, type FieldControlProps } from '../control'
|
||||
import { displayHtml } from '../richeditor/html'
|
||||
import { isLossy } from '../richeditor/lossy'
|
||||
import LinkPopover from '../richeditor/LinkPopover.vue'
|
||||
import type { Editor } from '../richeditor/editor'
|
||||
|
||||
// type: richeditor (D-10, UI-SPEC RichEditorField): an HTML WYSIWYG on
|
||||
// TipTap, which loads as its own chunk (a skeleton meanwhile; when the chunk
|
||||
// fails, the HTML view with richeditor.load_failed). Value contract:
|
||||
// - untouched rule: nothing is emitted until the administrator edits, so a
|
||||
// loaded value is saved byte-identical and never re-serialized by TipTap;
|
||||
// - after an edit in the editor the value is its HTML, "" for an empty
|
||||
// paragraph; the HTML view emits the textarea text as typed;
|
||||
// - a new value from outside (a save's answer, the sanitized HTML) replaces
|
||||
// the document and makes the field untouched again.
|
||||
// Read-only (preview) shows the value through displayHtml, never as stored.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
type EditorModule = typeof import('../richeditor/editor')
|
||||
type Status = 'loading' | 'ready' | 'failed'
|
||||
|
||||
const readOnly = computed(() => !!props.field.readOnly)
|
||||
const stored = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const display = computed(() => (readOnly.value ? displayHtml(stored.value) : ''))
|
||||
|
||||
const status = ref<Status>('loading')
|
||||
const source = ref(false)
|
||||
const sourceText = ref('')
|
||||
const lossy = ref(isLossy(stored.value))
|
||||
const mount = ref<HTMLElement | null>(null)
|
||||
const linkButton = ref<ComponentPublicInstance | null>(null)
|
||||
const editor = shallowRef<Editor | null>(null)
|
||||
// Bumped on every editor transaction, so the toolbar state recomputes.
|
||||
const tick = ref(0)
|
||||
const linkOpen = ref(false)
|
||||
const linkHref = ref('')
|
||||
|
||||
let module: EditorModule | null = null
|
||||
let disposed = false
|
||||
// The last value this field emitted: its echo through modelValue is not an
|
||||
// outside change.
|
||||
let lastEmitted: string | null = null
|
||||
// The HTML view's text when it opened: leaving it unchanged re-parses nothing.
|
||||
let sourceOpenedWith = ''
|
||||
|
||||
const labelId = computed(() => `${props.controlId}-label`)
|
||||
const containerClass = computed(() => [
|
||||
controlClass(props.invalid).replace(' px-3.5', ''),
|
||||
'overflow-hidden focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]',
|
||||
])
|
||||
|
||||
function areaAttributes(): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
class: 'summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none',
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-labelledby': labelId.value,
|
||||
'data-richeditor-area': '',
|
||||
}
|
||||
if (!source.value) {
|
||||
out.id = props.controlId
|
||||
}
|
||||
if (props.describedBy) {
|
||||
out['aria-describedby'] = props.describedBy
|
||||
}
|
||||
if (props.invalid) {
|
||||
out['aria-invalid'] = 'true'
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function setValue(value: string): void {
|
||||
lastEmitted = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function onEditorChange(): void {
|
||||
if (editor.value && module) {
|
||||
setValue(module.editorValue(editor.value))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
module = await import('../richeditor/editor')
|
||||
} catch {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
status.value = 'failed'
|
||||
sourceText.value = stored.value
|
||||
sourceOpenedWith = stored.value
|
||||
source.value = true
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
status.value = 'ready'
|
||||
await nextTick()
|
||||
if (disposed || !mount.value) {
|
||||
return
|
||||
}
|
||||
editor.value = module.createRichEditor({
|
||||
element: mount.value,
|
||||
content: stored.value,
|
||||
attributes: areaAttributes(),
|
||||
onChange: onEditorChange,
|
||||
onTransaction: () => {
|
||||
tick.value++
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
editor.value?.destroy()
|
||||
editor.value = null
|
||||
})
|
||||
|
||||
// A value from outside (load, the save's answer): show it, untouched again.
|
||||
watch(stored, (value) => {
|
||||
if (value === lastEmitted) {
|
||||
return
|
||||
}
|
||||
lastEmitted = null
|
||||
lossy.value = isLossy(value)
|
||||
if (source.value) {
|
||||
sourceText.value = value
|
||||
sourceOpenedWith = value
|
||||
}
|
||||
if (editor.value && module) {
|
||||
module.loadContent(editor.value, value)
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.invalid, () => props.describedBy, source], () => {
|
||||
editor.value?.setOptions({ editorProps: { attributes: areaAttributes() } })
|
||||
})
|
||||
|
||||
interface Tool {
|
||||
key: string
|
||||
icon: Component
|
||||
/** Full phrase key: the SPA key check reads literals only. */
|
||||
label: string
|
||||
run: (editor: Editor) => void
|
||||
active?: (editor: Editor) => boolean
|
||||
enabled?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
const groups: Tool[][] = [
|
||||
[
|
||||
{ key: 'bold', icon: Bold, label: 'backend::lang.richeditor.bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
||||
{ key: 'italic', icon: Italic, label: 'backend::lang.richeditor.italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
||||
{
|
||||
key: 'underline',
|
||||
icon: Underline,
|
||||
label: 'backend::lang.richeditor.underline',
|
||||
run: (e) => e.chain().focus().toggleUnderline().run(),
|
||||
active: (e) => e.isActive('underline'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'heading',
|
||||
icon: Heading3,
|
||||
label: 'backend::lang.richeditor.heading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 3 }),
|
||||
},
|
||||
{
|
||||
key: 'bullet_list',
|
||||
icon: List,
|
||||
label: 'backend::lang.richeditor.bullet_list',
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
active: (e) => e.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
key: 'ordered_list',
|
||||
icon: ListOrdered,
|
||||
label: 'backend::lang.richeditor.ordered_list',
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
active: (e) => e.isActive('orderedList'),
|
||||
},
|
||||
{ key: 'quote', icon: Quote, label: 'backend::lang.richeditor.quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
||||
],
|
||||
]
|
||||
|
||||
const history: Tool[] = [
|
||||
{ key: 'undo', icon: Undo2, label: 'backend::lang.richeditor.undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
|
||||
{ key: 'redo', icon: Redo2, label: 'backend::lang.richeditor.redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
|
||||
]
|
||||
|
||||
/** The toolbar's view of the editor; recomputed after every transaction. */
|
||||
const view = computed(() => {
|
||||
void tick.value
|
||||
const current = editor.value
|
||||
if (!current || source.value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
editor: current,
|
||||
inLink: current.isActive('link'),
|
||||
hasSelection: !current.state.selection.empty,
|
||||
}
|
||||
})
|
||||
|
||||
function pressed(tool: Tool): 'true' | 'false' | undefined {
|
||||
if (!tool.active) {
|
||||
return undefined
|
||||
}
|
||||
return view.value && tool.active(view.value.editor) ? 'true' : 'false'
|
||||
}
|
||||
|
||||
function disabled(tool: Tool): boolean {
|
||||
const state = view.value
|
||||
return !state || (tool.enabled !== undefined && !tool.enabled(state.editor))
|
||||
}
|
||||
|
||||
function run(tool: Tool): void {
|
||||
if (view.value && !disabled(tool)) {
|
||||
tool.run(view.value.editor)
|
||||
}
|
||||
}
|
||||
|
||||
const linkEnabled = computed(() => !!view.value && (view.value.hasSelection || view.value.inLink))
|
||||
|
||||
function openLink(): void {
|
||||
if (!view.value || !linkEnabled.value) {
|
||||
return
|
||||
}
|
||||
const href = view.value.editor.getAttributes('link').href
|
||||
linkHref.value = typeof href === 'string' ? href : ''
|
||||
linkOpen.value = true
|
||||
}
|
||||
|
||||
function applyLink(url: string): void {
|
||||
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
linkOpen.value = false
|
||||
}
|
||||
|
||||
function removeLink(): void {
|
||||
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
linkOpen.value = false
|
||||
}
|
||||
|
||||
function onLinkClosed(changed: boolean): void {
|
||||
linkOpen.value = false
|
||||
if (changed) {
|
||||
editor.value?.commands.focus()
|
||||
return
|
||||
}
|
||||
const element = linkButton.value?.$el as HTMLElement | undefined
|
||||
element?.focus()
|
||||
}
|
||||
|
||||
function toggleSource(): void {
|
||||
if (!source.value) {
|
||||
linkOpen.value = false
|
||||
sourceText.value = stored.value
|
||||
sourceOpenedWith = stored.value
|
||||
source.value = true
|
||||
return
|
||||
}
|
||||
// Re-parse only when the text changed; the value stays that text until
|
||||
// the next edit in the editor.
|
||||
if (editor.value && module && sourceText.value !== sourceOpenedWith) {
|
||||
module.loadContent(editor.value, sourceText.value)
|
||||
}
|
||||
source.value = false
|
||||
}
|
||||
|
||||
function onSourceInput(event: Event): void {
|
||||
sourceText.value = (event.target as HTMLTextAreaElement).value
|
||||
setValue(sourceText.value)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k' && view.value) {
|
||||
event.preventDefault()
|
||||
openLink()
|
||||
}
|
||||
}
|
||||
|
||||
const toolClass =
|
||||
'inline-flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="readOnly"
|
||||
:id="controlId"
|
||||
data-richeditor-readonly
|
||||
class="rounded-control border border-border bg-subtle px-3.5 py-2.5"
|
||||
>
|
||||
<div v-if="display !== ''" class="summer-markdown" v-html="display" />
|
||||
<span v-else class="text-muted">{{ t('backend::lang.list.empty_value') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'loading'" data-richeditor-loading aria-busy="true" class="h-[200px] rounded-control bg-skel" />
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<p v-if="status === 'failed'" data-richeditor-load-failed class="text-[13px] text-muted">
|
||||
{{ t('backend::lang.richeditor.load_failed') }}
|
||||
</p>
|
||||
<div :data-richeditor="field.name" :class="containerClass" @keydown="onKeydown">
|
||||
<ToolbarRoot
|
||||
v-if="status === 'ready'"
|
||||
:aria-label="t('backend::lang.richeditor.toolbar')"
|
||||
:aria-controls="controlId"
|
||||
class="flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1"
|
||||
>
|
||||
<template v-for="(group, index) in groups" :key="index">
|
||||
<ToolbarSeparator v-if="index > 0" class="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
v-for="tool in group"
|
||||
:key="tool.key"
|
||||
:data-tool="tool.key"
|
||||
:aria-label="t(tool.label)"
|
||||
:title="t(tool.label)"
|
||||
:aria-pressed="pressed(tool)"
|
||||
:disabled="disabled(tool)"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="run(tool)"
|
||||
>
|
||||
<component :is="tool.icon" :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</template>
|
||||
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
|
||||
<LinkPopover
|
||||
:open="linkOpen"
|
||||
:href="linkHref"
|
||||
:in-link="!!view?.inLink"
|
||||
:id-prefix="controlId"
|
||||
@update:open="(open) => { if (!open) linkOpen = false }"
|
||||
@apply="applyLink"
|
||||
@remove="removeLink"
|
||||
@closed="onLinkClosed"
|
||||
>
|
||||
<ToolbarButton
|
||||
ref="linkButton"
|
||||
data-tool="link"
|
||||
:aria-label="t('backend::lang.richeditor.link')"
|
||||
:title="t('backend::lang.richeditor.link')"
|
||||
:aria-pressed="view?.inLink ? 'true' : 'false'"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="linkOpen ? 'true' : 'false'"
|
||||
:disabled="!linkEnabled"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="openLink"
|
||||
>
|
||||
<Link :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</LinkPopover>
|
||||
<ToolbarButton
|
||||
data-tool="unlink"
|
||||
:aria-label="t('backend::lang.richeditor.unlink')"
|
||||
:title="t('backend::lang.richeditor.unlink')"
|
||||
:disabled="!view?.inLink"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="removeLink"
|
||||
>
|
||||
<Unlink :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
v-for="tool in history"
|
||||
:key="tool.key"
|
||||
:data-tool="tool.key"
|
||||
:aria-label="t(tool.label)"
|
||||
:title="t(tool.label)"
|
||||
:disabled="disabled(tool)"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="run(tool)"
|
||||
>
|
||||
<component :is="tool.icon" :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
data-tool="source"
|
||||
:aria-pressed="source ? 'true' : 'false'"
|
||||
class="ml-auto inline-flex h-8 items-center gap-1.5 rounded-pager px-2 text-[13px] text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text"
|
||||
@mousedown.prevent
|
||||
@click="toggleSource"
|
||||
>
|
||||
<Code :size="16" aria-hidden="true" />
|
||||
{{ t('backend::lang.richeditor.source') }}
|
||||
</ToolbarButton>
|
||||
</ToolbarRoot>
|
||||
<p
|
||||
v-if="lossy && status === 'ready'"
|
||||
role="note"
|
||||
data-richeditor-lossy
|
||||
class="flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted"
|
||||
>
|
||||
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.richeditor.lossy_note') }}</span>
|
||||
</p>
|
||||
<div v-show="!source" ref="mount" />
|
||||
<textarea
|
||||
v-if="source"
|
||||
:id="controlId"
|
||||
data-richeditor-source
|
||||
:name="field.name"
|
||||
:value="sourceText"
|
||||
spellcheck="false"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="block min-h-[160px] w-full resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none"
|
||||
@input="onSourceInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@
|
||||
// provider (settings pages) gets the defaults of the injecting control.
|
||||
import { ref, type InjectionKey, type Ref } from 'vue'
|
||||
import type { FileRoutes } from '../../api/files'
|
||||
import type { AdminRecord } from '../../api/types'
|
||||
import type { AdminDependentOptionsResult, AdminRecord, FormField } from '../../api/types'
|
||||
|
||||
/** Read-only view of the form's current values. */
|
||||
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
|
||||
@@ -113,3 +113,39 @@ export const FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'
|
||||
|
||||
/** How long a widget waits for its custom element to be defined. */
|
||||
export const WIDGET_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* The record form's feedback (Phase 17, D-07, D-08): errors and save notices
|
||||
* keyed by field name or row path (`<repeater>.<index>.<nested>`), the form's
|
||||
* single polite live region, and its confirm dialog. FormView provides it; a
|
||||
* control without it (settings pages) announces nothing and never asks.
|
||||
*/
|
||||
export interface FormFeedback {
|
||||
errors: Readonly<Ref<Record<string, string[]>>>
|
||||
notices: Readonly<Ref<Record<string, string[]>>>
|
||||
/** Drops the error lines of one key: the administrator edited that value. */
|
||||
clearError(key: string): void
|
||||
/** Announces text in the form's polite live region. */
|
||||
announce(text: string): void
|
||||
/** Asks through the form's ConfirmDialog; resolves true on confirm. */
|
||||
confirm(request: { message: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>
|
||||
}
|
||||
|
||||
export const FORM_FEEDBACK: InjectionKey<FormFeedback> = Symbol('summer.form.feedback')
|
||||
|
||||
/**
|
||||
* The fields a FormGrid renders and their values (D-08): a dependent
|
||||
* dropdown reads its sibling here. A repeater row's grid provides the row's
|
||||
* fields and values, and `path` names a field for its routes
|
||||
* (`<repeater>.<field>` in a row).
|
||||
*/
|
||||
export interface FieldScope {
|
||||
fields: Readonly<Ref<readonly FormField[]>>
|
||||
values: Readonly<Ref<AdminRecord>>
|
||||
path(name: string): string
|
||||
}
|
||||
|
||||
export const FIELD_SCOPE: InjectionKey<FieldScope> = Symbol('summer.form.fieldScope')
|
||||
|
||||
/** Dependent options per field path and sibling value, for the life of a form view. */
|
||||
export const DEPENDENT_CACHE: InjectionKey<Map<string, AdminDependentOptionsResult>> = Symbol('summer.form.dependentCache')
|
||||
|
||||
@@ -296,3 +296,65 @@ export function focusField(prefix: string, name: string): boolean {
|
||||
element.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
/** The top-level field of an error or notice key: `content` for `content.3.answer`. */
|
||||
export function keyField(key: string): string {
|
||||
const dot = key.indexOf('.')
|
||||
return dot < 0 ? key : key.slice(0, dot)
|
||||
}
|
||||
|
||||
/** Whether a field has errors on itself or, for a repeater, on any row path. */
|
||||
export function fieldHasErrors(errors: Record<string, string[]>, name: string): boolean {
|
||||
return Object.keys(errors).some((key) => key === name || key.startsWith(`${name}.`))
|
||||
}
|
||||
|
||||
/** The entries of one repeater row (`<field>.<index>.<nested>`) keyed by nested name. */
|
||||
export function rowEntries(map: Record<string, string[]>, field: string, index: number): Record<string, string[]> {
|
||||
const prefix = `${field}.${index}.`
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [key, lines] of Object.entries(map)) {
|
||||
if (key.startsWith(prefix) && key.length > prefix.length) {
|
||||
out[key.slice(prefix.length)] = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The first error key of a field in the server's order: itself, else its first row path. */
|
||||
export function firstErrorKey(errors: Record<string, string[]>, name: string): string | undefined {
|
||||
if (errors[name]) {
|
||||
return name
|
||||
}
|
||||
return Object.keys(errors).find((key) => key.startsWith(`${name}.`))
|
||||
}
|
||||
|
||||
const NAME = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* Focuses the control of an error key. A row path (`content.3.answer`)
|
||||
* focuses the nested control of that repeater row, found through the row's
|
||||
* data attributes and the nested label's `for`; a plain name is focusField.
|
||||
*/
|
||||
export function focusError(prefix: string, key: string): boolean {
|
||||
const parts = key.split('.')
|
||||
if (parts.length < 3) {
|
||||
return focusField(prefix, parts[0] ?? key)
|
||||
}
|
||||
const [field, index, nested] = parts as [string, string, string]
|
||||
if (!NAME.test(field) || !/^\d+$/.test(index) || !NAME.test(nested)) {
|
||||
return focusField(prefix, field)
|
||||
}
|
||||
const cell = document.querySelector(`[data-repeater="${field}"] [data-repeater-row="${index}"] [data-field="${nested}"]`)
|
||||
const target = cell?.querySelector('label[for]')?.getAttribute('for')
|
||||
const control =
|
||||
(target ? document.getElementById(target) : null) ??
|
||||
cell?.querySelector<HTMLElement>('input, select, textarea, [contenteditable="true"], button')
|
||||
if (!control) {
|
||||
return focusField(prefix, field)
|
||||
}
|
||||
if (!control.matches('input, select, textarea, button, [tabindex], [contenteditable="true"]')) {
|
||||
control.setAttribute('tabindex', '-1')
|
||||
}
|
||||
control.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
// MLField locale switcher around it, when the type is translatable), holds a
|
||||
// form value and is labelled as a group. Look such fields up with rendererForField and
|
||||
// isRegisteredField, which know the field, not only its type.
|
||||
// Phase 17 adds the richeditor control (D-10): an HTML string value edited
|
||||
// with a lazily loaded TipTap editor, sent unchanged until the administrator
|
||||
// edits it. The repeater control (D-07) holds a list of row objects, each
|
||||
// a nested FormGrid, labelled as a group. It renders FormGrid, so it sits on
|
||||
// an import cycle with this module; only this module imports it, so this
|
||||
// module always evaluates first and the binding is complete here.
|
||||
import type { Component } from 'vue'
|
||||
import type { FormField } from '../../api/types'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
@@ -34,6 +40,8 @@ import PasswordField from './fields/PasswordField.vue'
|
||||
import MLPluginField from './fields/MLPluginField.vue'
|
||||
import PluginField from './fields/PluginField.vue'
|
||||
import PermissionEditorField from './fields/PermissionEditorField.vue'
|
||||
import RepeaterField from './fields/RepeaterField.vue'
|
||||
import RichEditorField from './fields/RichEditorField.vue'
|
||||
import RelationManager from '../relation/RelationManager.vue'
|
||||
import RelationField from './fields/RelationField.vue'
|
||||
import SwitchField from './fields/SwitchField.vue'
|
||||
@@ -79,6 +87,8 @@ const renderers = new Map<string, Component>([
|
||||
['mltext', MLTextField],
|
||||
['mltextarea', MLTextareaField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
['richeditor', RichEditorField],
|
||||
['repeater', RepeaterField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
@@ -98,7 +108,16 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
||||
* Types whose control is a group rather than one focusable element: the
|
||||
* visible label is a span the group points at, not a label for an input.
|
||||
*/
|
||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown'])
|
||||
const groupLabelledTypes = new Set<string>([
|
||||
'widget',
|
||||
'partial',
|
||||
'fileupload',
|
||||
'permissioneditor',
|
||||
'mltext',
|
||||
'mltextarea',
|
||||
'mlmarkdown',
|
||||
'repeater',
|
||||
])
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||
|
||||
133
admin/src/components/form/richeditor/LinkPopover.vue
Normal file
133
admin/src/components/form/richeditor/LinkPopover.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from 'reka-ui'
|
||||
import { t } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { controlClass } from '../control'
|
||||
import { isAllowedLink } from './html'
|
||||
|
||||
// The richeditor link popover (UI-SPEC RichEditorField, "Link popover"),
|
||||
// anchored on the toolbar's Link button (the default slot). Enter applies,
|
||||
// Esc closes; on close the field decides where focus goes (`closed` tells it
|
||||
// whether a link was applied or removed). A URL outside the client policy
|
||||
// (http, https, mailto, tel or relative) shows richeditor.link_invalid and
|
||||
// is not applied; the server sanitizer is still the gate.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** The current link's href, or "" when the selection is not in a link. */
|
||||
href: string
|
||||
/** Whether the selection is inside a link: offers "Remove link". */
|
||||
inLink: boolean
|
||||
/** Prefix of the element ids. */
|
||||
idPrefix: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
apply: [url: string]
|
||||
remove: []
|
||||
/** Esc or a click outside asks to close. */
|
||||
'update:open': [open: boolean]
|
||||
/** The popover has closed; `changed` is true after apply or remove. */
|
||||
closed: [changed: boolean]
|
||||
}>()
|
||||
|
||||
const url = ref('')
|
||||
const invalid = ref(false)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
let changed = false
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
url.value = props.href
|
||||
invalid.value = false
|
||||
changed = false
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
},
|
||||
)
|
||||
|
||||
function apply(): void {
|
||||
const value = url.value.trim()
|
||||
if (!isAllowedLink(value)) {
|
||||
invalid.value = true
|
||||
input.value?.focus()
|
||||
return
|
||||
}
|
||||
changed = true
|
||||
emit('apply', value)
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
changed = true
|
||||
emit('remove')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
// The popover sits in a form: Enter applies the link, it never submits.
|
||||
event.preventDefault()
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean): void {
|
||||
emit('update:open', open)
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
// There is no trigger to return to: the field focuses the Link button or
|
||||
// the editor itself.
|
||||
event.preventDefault()
|
||||
emit('closed', changed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot :open="open" @update:open="onOpenChange">
|
||||
<PopoverAnchor as-child>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-richeditor-link
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
class="z-50 flex w-[320px] flex-col gap-2 rounded-inner border border-border bg-surface p-4 text-text shadow-menu"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<label :for="`${idPrefix}-link-url`" class="font-semibold">{{ t('backend::lang.richeditor.link_url') }}</label>
|
||||
<input
|
||||
:id="`${idPrefix}-link-url`"
|
||||
ref="input"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="invalid ? `${idPrefix}-link-error` : undefined"
|
||||
@input="invalid = false"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<p v-if="invalid" :id="`${idPrefix}-link-error`" class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.richeditor.link_invalid') }}</span>
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button v-if="inLink" variant="ghost" size="sm" data-action="richeditor-unlink" @click="remove">
|
||||
{{ t('backend::lang.richeditor.unlink') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" data-action="richeditor-link-apply" @click="apply">
|
||||
{{ t('backend::lang.richeditor.link_apply') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
71
admin/src/components/form/richeditor/editor.ts
Normal file
71
admin/src/components/form/richeditor/editor.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// The TipTap editor of the richeditor field (D-10). RichEditorField loads
|
||||
// this module with a dynamic import, so TipTap is its own chunk and a form
|
||||
// without a richeditor never downloads it. The editor mounts on an element
|
||||
// the field owns (no @tiptap/vue-3) with StarterKit configured as the
|
||||
// UI-SPEC says: headings level 3 only; no code, code block or horizontal
|
||||
// rule; links that never open on click, never autolink and take only the
|
||||
// policy URLs; no trailing node (it would append a paragraph and count as an
|
||||
// edit); TipTap's injected CSS off, the content uses .summer-markdown.
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { LINK_SCHEMES, isAllowedLink } from './html'
|
||||
|
||||
export type { Editor }
|
||||
|
||||
export interface RichEditorOptions {
|
||||
/** The element the editing area mounts in. */
|
||||
element: HTMLElement
|
||||
/** The stored HTML, parsed once; the field keeps the string itself. */
|
||||
content: string
|
||||
/** Attributes of the editing area (class, role, aria-*). */
|
||||
attributes: Record<string, string>
|
||||
/** Called after every transaction that changed the document. */
|
||||
onChange: () => void
|
||||
/** Called after every transaction (toolbar state). */
|
||||
onTransaction: () => void
|
||||
}
|
||||
|
||||
/** Builds the field's editor on options.element. */
|
||||
export function createRichEditor(options: RichEditorOptions): Editor {
|
||||
return new Editor({
|
||||
element: options.element,
|
||||
injectCSS: false,
|
||||
content: options.content,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [3] },
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
trailingNode: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
protocols: [...LINK_SCHEMES],
|
||||
HTMLAttributes: { target: null, rel: null, class: null },
|
||||
isAllowedUri: (url) => isAllowedLink(url),
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: { attributes: options.attributes },
|
||||
onUpdate: ({ transaction, appendedTransactions }) => {
|
||||
if (transaction.docChanged || appendedTransactions.some((tr) => tr.docChanged)) {
|
||||
options.onChange()
|
||||
}
|
||||
},
|
||||
onTransaction: () => options.onTransaction(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the document with html without an update event and outside the
|
||||
* undo history, so neither the untouched rule nor undo sees the load.
|
||||
*/
|
||||
export function loadContent(editor: Editor, html: string): void {
|
||||
editor.chain().setMeta('addToHistory', false).setContent(html, { emitUpdate: false }).run()
|
||||
}
|
||||
|
||||
/** The editor's value: its HTML, or "" when it holds only an empty paragraph. */
|
||||
export function editorValue(editor: Editor): string {
|
||||
return editor.isEmpty ? '' : editor.getHTML()
|
||||
}
|
||||
104
admin/src/components/form/richeditor/html.ts
Normal file
104
admin/src/components/form/richeditor/html.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Link and display policy of the richeditor field (D-10, T-17-10, T-17-11).
|
||||
// The server sanitizer (seaglass) is the gate on every save; these client
|
||||
// checks keep the editor from offering what the gate would remove and keep a
|
||||
// stored value that predates the gate from running in the admin.
|
||||
import { EDITOR_TAGS, inertFragment } from './lossy'
|
||||
|
||||
/** Link schemes the editor applies; anything else must be a relative URL. */
|
||||
export const LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']
|
||||
|
||||
// Browsers ignore ASCII control characters, spaces and DEL inside a scheme,
|
||||
// so "java\tscript:" is read with them removed.
|
||||
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
||||
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
||||
|
||||
/**
|
||||
* Whether url may be a link href: an http, https, mailto or tel URL, or a
|
||||
* relative one (a path starting with /, #, ? or ./, or a bare relative path).
|
||||
*/
|
||||
export function isAllowedLink(url: string | null | undefined): boolean {
|
||||
if (url == null) {
|
||||
return false
|
||||
}
|
||||
const cleaned = url.replace(URL_IGNORED, '')
|
||||
if (cleaned === '') {
|
||||
return false
|
||||
}
|
||||
const scheme = URL_SCHEME.exec(cleaned)
|
||||
if (scheme) {
|
||||
return LINK_SCHEMES.includes(scheme[1]!.toLowerCase())
|
||||
}
|
||||
try {
|
||||
new URL(cleaned, 'https://admin.invalid/')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Elements dropped with their content when a value is displayed. */
|
||||
const DROPPED = new Set([
|
||||
'script',
|
||||
'style',
|
||||
'template',
|
||||
'noscript',
|
||||
'iframe',
|
||||
'frame',
|
||||
'frameset',
|
||||
'object',
|
||||
'embed',
|
||||
'applet',
|
||||
'svg',
|
||||
'math',
|
||||
'title',
|
||||
'head',
|
||||
'meta',
|
||||
'link',
|
||||
'base',
|
||||
])
|
||||
|
||||
function clean(parent: ParentNode): void {
|
||||
for (const node of Array.from(parent.childNodes)) {
|
||||
if (node.nodeType === Node.COMMENT_NODE) {
|
||||
node.remove()
|
||||
continue
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
continue
|
||||
}
|
||||
const element = node as Element
|
||||
const name = element.localName.toLowerCase()
|
||||
if (DROPPED.has(name)) {
|
||||
element.remove()
|
||||
continue
|
||||
}
|
||||
clean(element)
|
||||
if (!EDITOR_TAGS.has(name)) {
|
||||
// Another element keeps its text: its children take its place.
|
||||
element.replaceWith(...Array.from(element.childNodes))
|
||||
continue
|
||||
}
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const keep = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
|
||||
if (!keep) {
|
||||
element.removeAttribute(attribute.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-only rendering of a stored value: only the editor's elements,
|
||||
* only allowed link hrefs, no other attribute, no comment. It is what may be
|
||||
* bound as HTML in the admin; the stored value itself is never bound.
|
||||
*/
|
||||
export function displayHtml(html: string): string {
|
||||
if (html === '') {
|
||||
return ''
|
||||
}
|
||||
const fragment = inertFragment(html)
|
||||
clean(fragment)
|
||||
const holder = document.createElement('div')
|
||||
holder.append(fragment)
|
||||
return holder.innerHTML
|
||||
}
|
||||
52
admin/src/components/form/richeditor/lossy.ts
Normal file
52
admin/src/components/form/richeditor/lossy.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// What the richeditor's TipTap schema can show (UI-SPEC RichEditorField,
|
||||
// "Lossy note"). Stored HTML with any other element still opens: the field
|
||||
// keeps the stored string until the administrator edits (the untouched
|
||||
// rule), and the HTML view edits it without loss. This module has no TipTap
|
||||
// import, so the field can decide before the editor chunk loads.
|
||||
|
||||
/** Elements the editor shows; compared by lower-cased tag name. */
|
||||
export const EDITOR_TAGS: ReadonlySet<string> = new Set([
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'h3',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'a',
|
||||
'div',
|
||||
'span',
|
||||
])
|
||||
|
||||
/**
|
||||
* Parses html into an inert template: nothing in it runs or loads (no
|
||||
* scripts, no image requests), so stored markup can be inspected safely.
|
||||
*/
|
||||
export function inertFragment(html: string): DocumentFragment {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
return template.content
|
||||
}
|
||||
|
||||
/** The distinct lower-cased tag names of html's elements, in document order. */
|
||||
export function tagNames(html: string): string[] {
|
||||
if (html === '') {
|
||||
return []
|
||||
}
|
||||
const out = new Set<string>()
|
||||
for (const element of Array.from(inertFragment(html).querySelectorAll('*'))) {
|
||||
out.add(element.localName.toLowerCase())
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
/** Whether html holds an element the editor cannot show (the lossy note). */
|
||||
export function isLossy(html: string): boolean {
|
||||
return tagNames(html).some((name) => !EDITOR_TAGS.has(name))
|
||||
}
|
||||
86
admin/src/components/reorder/ReorderBranch.vue
Normal file
86
admin/src/components/reorder/ReorderBranch.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { ChevronDown, ChevronUp, GripVertical, IndentDecrease, IndentIncrease } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { REORDER_PAGE } from './context'
|
||||
import type { Move, TreeNode } from './tree'
|
||||
|
||||
// One level of the reorder tree (UI-SPEC "Tree"): a list item per node with
|
||||
// its row (drag handle, name, move up, move down, outdent, indent) and its
|
||||
// children's list inside it. The page owns the tree and every move.
|
||||
defineProps<{ nodes: TreeNode[]; depth: number }>()
|
||||
|
||||
const page = inject(REORDER_PAGE)!
|
||||
|
||||
const iconButton =
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
const moves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action: string }> = [
|
||||
{ move: 'up', icon: ChevronUp, label: 'backend::lang.reorder.move_up', action: 'reorder-up' },
|
||||
{ move: 'down', icon: ChevronDown, label: 'backend::lang.reorder.move_down', action: 'reorder-down' },
|
||||
{ move: 'outdent', icon: IndentDecrease, label: 'backend::lang.reorder.outdent', action: 'reorder-outdent' },
|
||||
{ move: 'indent', icon: IndentIncrease, label: 'backend::lang.reorder.indent', action: 'reorder-indent' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="m-0 list-none p-0">
|
||||
<li v-for="(node, index) in nodes" :key="node.id">
|
||||
<div
|
||||
:data-reorder-node="node.id"
|
||||
:class="[
|
||||
depth === 0 && index === 0 ? '' : 'border-t border-border',
|
||||
page.dropTarget.value?.id === node.id && page.dropTarget.value.zone === 'inside'
|
||||
? 'bg-sel outline-2 -outline-offset-2 outline-primary outline-dashed'
|
||||
: 'hover:bg-hover',
|
||||
page.dragging.value === node.id ? 'opacity-50' : '',
|
||||
page.blocked.value === node.id ? 'cursor-not-allowed' : '',
|
||||
]"
|
||||
class="relative flex min-h-row items-center gap-2 pr-4"
|
||||
:style="{ paddingInlineStart: `calc(16px + ${depth} * 24px)` }"
|
||||
@dragover="page.onDragOver($event, node.id)"
|
||||
@dragleave="page.onDragLeave(node.id)"
|
||||
@drop="page.onDrop($event)"
|
||||
>
|
||||
<span
|
||||
v-if="page.dropTarget.value?.id === node.id && page.dropTarget.value.zone !== 'inside'"
|
||||
data-reorder-line
|
||||
:class="page.dropTarget.value.zone === 'before' ? 'top-0' : 'bottom-0'"
|
||||
class="pointer-events-none absolute right-0 h-0.5 bg-primary"
|
||||
:style="{ left: `calc(16px + ${depth} * 24px)` }"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
draggable="true"
|
||||
:data-reorder-handle="node.id"
|
||||
:aria-label="t('backend::lang.reorder.move', { name: page.nameOf(node) })"
|
||||
:class="iconButton"
|
||||
class="cursor-grab"
|
||||
@keydown="page.onHandleKeydown($event, node.id)"
|
||||
@dragstart="page.onDragStart($event, node.id)"
|
||||
@dragend="page.onDragEnd"
|
||||
>
|
||||
<GripVertical :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="min-w-0 flex-1 [overflow-wrap:anywhere]" :class="node.name === '' ? 'text-muted' : ''">
|
||||
{{ page.nameOf(node) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="item in moves"
|
||||
:key="item.move"
|
||||
type="button"
|
||||
:data-action="item.action"
|
||||
:aria-label="t(item.label, { name: page.nameOf(node) })"
|
||||
:disabled="!page.canMove(node.id, item.move)"
|
||||
:class="iconButton"
|
||||
@click="page.move(node.id, item.move)"
|
||||
>
|
||||
<component :is="item.icon" :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ReorderBranch v-if="node.children.length > 0" :nodes="node.children" :depth="depth + 1" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
23
admin/src/components/reorder/context.ts
Normal file
23
admin/src/components/reorder/context.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// What ReorderView shares with every level of its tree (ReorderBranch).
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import type { Move, TreeNode, Zone } from './tree'
|
||||
|
||||
export interface ReorderPage {
|
||||
/** The node being dragged, or null. */
|
||||
dragging: Readonly<Ref<number | null>>
|
||||
/** The row and zone a drop would land in, or null. */
|
||||
dropTarget: Readonly<Ref<{ id: number; zone: Zone } | null>>
|
||||
/** The row under the pointer that refuses the drop (the dragged node's own subtree). */
|
||||
blocked: Readonly<Ref<number | null>>
|
||||
nameOf(node: TreeNode): string
|
||||
canMove(id: number, move: Move): boolean
|
||||
move(id: number, move: Move): void
|
||||
onHandleKeydown(event: KeyboardEvent, id: number): void
|
||||
onDragStart(event: DragEvent, id: number): void
|
||||
onDragOver(event: DragEvent, id: number): void
|
||||
onDragLeave(id: number): void
|
||||
onDrop(event: DragEvent): void
|
||||
onDragEnd(): void
|
||||
}
|
||||
|
||||
export const REORDER_PAGE: InjectionKey<ReorderPage> = Symbol('summer.reorder.page')
|
||||
173
admin/src/components/reorder/tree.ts
Normal file
173
admin/src/components/reorder/tree.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// The tree of the reorder page (D-09, UI-SPEC "Reorder page"): built from
|
||||
// the flat nodes GET …/reorder answers, edited by the page's moves and sent
|
||||
// back flat by POST …/reorder. Children always move with their parent.
|
||||
import type { AdminReorderNode, AdminReorderNodeInput } from '../../api/types'
|
||||
|
||||
export interface TreeNode {
|
||||
id: number
|
||||
name: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
/** Where a dragged node lands relative to the row under the pointer. */
|
||||
export type Zone = 'before' | 'after' | 'inside'
|
||||
|
||||
/** The keyboard and button moves: siblings, nest under the previous one, out one level. */
|
||||
export type Move = 'up' | 'down' | 'indent' | 'outdent'
|
||||
|
||||
/** A node as sent: the server's AdminReorderNodeInput, with a null parent at the top. */
|
||||
export type FlatNode = Omit<AdminReorderNodeInput, 'parent_id'> & { parent_id: number | null }
|
||||
|
||||
/** The nested tree of flat nodes: by sort order, then id; an unknown parent puts a node at the top. */
|
||||
export function buildTree(nodes: readonly AdminReorderNode[]): TreeNode[] {
|
||||
const byId = new Map<number, TreeNode>()
|
||||
for (const node of nodes) {
|
||||
byId.set(node.id, { id: node.id, name: node.name ?? '', children: [] })
|
||||
}
|
||||
const ordered = [...nodes].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id)
|
||||
const roots: TreeNode[] = []
|
||||
for (const flat of ordered) {
|
||||
const node = byId.get(flat.id)!
|
||||
const parentId = (flat.parent_id as number | null | undefined) ?? null
|
||||
const parent = parentId !== null ? byId.get(parentId) : undefined
|
||||
if (parent && parent !== node) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
// Nodes caught in a parent cycle reach no root: they join the top level.
|
||||
const reached = new Set<number>()
|
||||
const walk = (list: TreeNode[]) => {
|
||||
for (const node of list) {
|
||||
reached.add(node.id)
|
||||
walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(roots)
|
||||
for (const flat of ordered) {
|
||||
if (!reached.has(flat.id)) {
|
||||
const node = byId.get(flat.id)!
|
||||
for (const other of byId.values()) {
|
||||
other.children = other.children.filter((child) => child.id !== node.id)
|
||||
}
|
||||
roots.push(node)
|
||||
walk([node])
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
/** The tree as sent: pre-order, sort_order counting from 1, parent null at the top. */
|
||||
export function flatten(tree: readonly TreeNode[]): FlatNode[] {
|
||||
const out: FlatNode[] = []
|
||||
const walk = (list: readonly TreeNode[], parent: number | null) => {
|
||||
for (const node of list) {
|
||||
out.push({ id: node.id, parent_id: parent, sort_order: out.length + 1 })
|
||||
walk(node.children, node.id)
|
||||
}
|
||||
}
|
||||
walk(tree, null)
|
||||
return out
|
||||
}
|
||||
|
||||
export function cloneTree(tree: readonly TreeNode[]): TreeNode[] {
|
||||
return tree.map((node) => ({ id: node.id, name: node.name, children: cloneTree(node.children) }))
|
||||
}
|
||||
|
||||
export interface Located {
|
||||
node: TreeNode
|
||||
list: TreeNode[]
|
||||
index: number
|
||||
parent: TreeNode | null
|
||||
/** 0 at the top level. */
|
||||
depth: number
|
||||
}
|
||||
|
||||
export function locate(tree: TreeNode[], id: number, parent: TreeNode | null = null, depth = 0): Located | null {
|
||||
for (let index = 0; index < tree.length; index++) {
|
||||
const node = tree[index]!
|
||||
if (node.id === id) {
|
||||
return { node, list: tree, index, parent, depth }
|
||||
}
|
||||
const found = locate(node.children, id, node, depth + 1)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether id is node itself or anywhere below it. */
|
||||
export function contains(node: TreeNode, id: number): boolean {
|
||||
return node.id === id || node.children.some((child) => contains(child, id))
|
||||
}
|
||||
|
||||
/** Whether a move can run: first or last among its siblings, at the top, or no previous sibling. */
|
||||
export function canMove(tree: TreeNode[], id: number, move: Move): boolean {
|
||||
const at = locate(tree, id)
|
||||
if (!at) {
|
||||
return false
|
||||
}
|
||||
switch (move) {
|
||||
case 'up':
|
||||
case 'indent':
|
||||
return at.index > 0
|
||||
case 'down':
|
||||
return at.index < at.list.length - 1
|
||||
case 'outdent':
|
||||
return at.parent !== null
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a move on tree in place; false when it cannot run. */
|
||||
export function applyMove(tree: TreeNode[], id: number, move: Move): boolean {
|
||||
if (!canMove(tree, id, move)) {
|
||||
return false
|
||||
}
|
||||
const at = locate(tree, id)!
|
||||
switch (move) {
|
||||
case 'up':
|
||||
at.list.splice(at.index, 1)
|
||||
at.list.splice(at.index - 1, 0, at.node)
|
||||
return true
|
||||
case 'down':
|
||||
at.list.splice(at.index, 1)
|
||||
at.list.splice(at.index + 1, 0, at.node)
|
||||
return true
|
||||
case 'indent': {
|
||||
const previous = at.list[at.index - 1]!
|
||||
at.list.splice(at.index, 1)
|
||||
previous.children.push(at.node)
|
||||
return true
|
||||
}
|
||||
case 'outdent': {
|
||||
const parent = locate(tree, at.parent!.id)!
|
||||
at.list.splice(at.index, 1)
|
||||
parent.list.splice(parent.index + 1, 0, at.node)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether node `dragged` may land on `target` in zone: never in its own subtree. */
|
||||
export function canDrop(tree: TreeNode[], dragged: number, target: number): boolean {
|
||||
const at = locate(tree, dragged)
|
||||
return !!at && !contains(at.node, target)
|
||||
}
|
||||
|
||||
/** Moves `dragged` before, after or inside (as last child of) `target`, in place. */
|
||||
export function applyDrop(tree: TreeNode[], dragged: number, target: number, zone: Zone): boolean {
|
||||
if (!canDrop(tree, dragged, target)) {
|
||||
return false
|
||||
}
|
||||
const from = locate(tree, dragged)!
|
||||
from.list.splice(from.index, 1)
|
||||
const to = locate(tree, target)!
|
||||
if (zone === 'inside') {
|
||||
to.node.children.push(from.node)
|
||||
} else {
|
||||
to.list.splice(zone === 'before' ? to.index : to.index + 1, 0, from.node)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -150,7 +150,7 @@ onBeforeUnmount(cancelClose)
|
||||
<template>
|
||||
<nav
|
||||
:aria-label="t('backend::lang.nav.plugins')"
|
||||
class="flex w-rail shrink-0 flex-col items-center gap-1.5 border-r border-side-border bg-side pt-3.5 pb-3"
|
||||
class="sticky top-0 flex h-screen w-rail shrink-0 flex-col items-center gap-1.5 self-start overflow-y-auto border-r border-side-border bg-side pt-3.5 pb-3"
|
||||
>
|
||||
<span class="mb-3.5 flex size-10 items-center justify-center rounded-inner bg-accent-soft" title="SummerCMS">
|
||||
<Sun :size="22" class="text-accent" aria-hidden="true" />
|
||||
|
||||
@@ -36,7 +36,7 @@ function collapse(): void {
|
||||
<template>
|
||||
<aside
|
||||
:aria-label="t('backend::lang.nav.sections')"
|
||||
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
|
||||
class="sticky top-0 flex h-screen w-panel shrink-0 flex-col gap-0.5 self-start overflow-y-auto border-r border-border bg-surface px-3 py-3.5"
|
||||
>
|
||||
<div class="mb-3.5 flex h-10 items-center justify-between gap-2 pl-2">
|
||||
<span class="truncate text-[16px] font-bold">{{ entry.label }}</span>
|
||||
|
||||
@@ -13,7 +13,18 @@ import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import { FORM_ASSETS, FORM_ENABLED_LOCALES, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
|
||||
import {
|
||||
DEPENDENT_CACHE,
|
||||
FORM_ASSETS,
|
||||
FORM_ENABLED_LOCALES,
|
||||
FORM_FEEDBACK,
|
||||
FORM_LOCALE,
|
||||
FORM_PATCH,
|
||||
FORM_SESSION,
|
||||
FORM_VALUES,
|
||||
createUploadGate,
|
||||
} from '../components/form/formContext'
|
||||
import type { AdminDependentOptionsResult } from '../api/types'
|
||||
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
@@ -22,7 +33,9 @@ import {
|
||||
contextAllows,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
fieldHasErrors,
|
||||
firstErrorKey,
|
||||
focusError,
|
||||
initialValues,
|
||||
isMLField,
|
||||
mergeMLValue,
|
||||
@@ -124,7 +137,7 @@ const tabs = computed<TabItem[]>(() => {
|
||||
out.push(tab)
|
||||
}
|
||||
}
|
||||
if (errors.value[field.name]) {
|
||||
if (fieldHasErrors(errors.value, field.name)) {
|
||||
tab.errors += 1
|
||||
}
|
||||
}
|
||||
@@ -157,14 +170,31 @@ const title = computed(() => {
|
||||
return message(forms, undefined, { name: recordName.value })
|
||||
})
|
||||
|
||||
// A singleton controller (D-11, pact.AdminSingleton) edits its one record,
|
||||
// opened from the settings index: no Delete, and back, Cancel and Save and
|
||||
// close return to the settings index.
|
||||
const singleton = computed(() => mode === 'update' && !!schema.value?.singleton)
|
||||
const SETTINGS_PATH = '/settings'
|
||||
|
||||
const subtitle = computed(() =>
|
||||
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
|
||||
mode === 'update' && !singleton.value ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
|
||||
)
|
||||
|
||||
// An update form of a record that has a preview screen is entered from it,
|
||||
// so back and Cancel return there (UI-SPEC S3); a delete still goes to the list.
|
||||
const backToPreview = computed(() => mode === 'update' && !!schema.value?.preview)
|
||||
const backPath = computed(() => (backToPreview.value ? `${listPath}/${recordId}/preview` : listPath))
|
||||
const backToPreview = computed(() => mode === 'update' && !singleton.value && !!schema.value?.preview)
|
||||
const backPath = computed(() => {
|
||||
if (singleton.value) {
|
||||
return SETTINGS_PATH
|
||||
}
|
||||
return backToPreview.value ? `${listPath}/${recordId}/preview` : listPath
|
||||
})
|
||||
const backLabel = computed(() => {
|
||||
if (singleton.value) {
|
||||
return t('backend::lang.settings.back')
|
||||
}
|
||||
return t(backToPreview.value ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')
|
||||
})
|
||||
|
||||
const dirty = computed(
|
||||
() =>
|
||||
@@ -281,6 +311,36 @@ provide(FORM_PATCH, update)
|
||||
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
||||
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
|
||||
provide(FORM_ASSETS, () => assetsReady)
|
||||
// The form's single polite live region (dependent dropdown clears, repeater
|
||||
// moves) and the feedback nested controls read: errors and notices by row
|
||||
// path, the confirm dialog (Phase 17, D-07, D-08).
|
||||
const announcement = ref('')
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function announce(text: string): void {
|
||||
// Emptied first, so the same text is announced again.
|
||||
announcement.value = ''
|
||||
if (announceTimer !== null) {
|
||||
clearTimeout(announceTimer)
|
||||
}
|
||||
announceTimer = setTimeout(() => {
|
||||
announceTimer = null
|
||||
announcement.value = text
|
||||
}, 50)
|
||||
}
|
||||
provide(FORM_FEEDBACK, {
|
||||
errors: computed(() => errors.value),
|
||||
notices: computed(() => notices.value),
|
||||
clearError: (key: string) => {
|
||||
if (errors.value[key]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[key]
|
||||
errors.value = next
|
||||
}
|
||||
},
|
||||
announce,
|
||||
confirm: (request) => confirm.ask(request),
|
||||
})
|
||||
provide(DEPENDENT_CACHE, new Map<string, AdminDependentOptionsResult>())
|
||||
provide(FORM_SESSION, {
|
||||
key: sessionKey,
|
||||
recordId: recordId ?? 0,
|
||||
@@ -297,7 +357,8 @@ provide(FORM_SESSION, {
|
||||
/** 422: messages under fields, the first invalid field (schema order) focused. */
|
||||
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
||||
errors.value = fieldErrors(error?.details)
|
||||
const invalid = fields.value.filter((field) => errors.value[field.name])
|
||||
// A repeater row path (content.3.answer) counts for its field.
|
||||
const invalid = fields.value.filter((field) => fieldHasErrors(errors.value, field.name))
|
||||
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
|
||||
if (!first) {
|
||||
return
|
||||
@@ -306,7 +367,12 @@ async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<vo
|
||||
activeTab.value = tabOf(first)
|
||||
}
|
||||
await nextTick()
|
||||
focusField(ID_PREFIX, first.name)
|
||||
const key = firstErrorKey(errors.value, first.name) ?? first.name
|
||||
if (!focusError(ID_PREFIX, key)) {
|
||||
// A repeater row the errors opened mounts its body one tick later.
|
||||
await nextTick()
|
||||
focusError(ID_PREFIX, key)
|
||||
}
|
||||
}
|
||||
|
||||
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
|
||||
@@ -388,7 +454,7 @@ async function onSave(): Promise<void> {
|
||||
async function onSaveAndClose(): Promise<void> {
|
||||
const record = await save()
|
||||
if (record) {
|
||||
await go(redirectTarget('redirectClose', record.data.id))
|
||||
await go(singleton.value ? SETTINGS_PATH : redirectTarget('redirectClose', record.data.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +533,9 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
window.removeEventListener(ML_LOCALE_CHANGE, onMLLocale)
|
||||
if (announceTimer !== null) {
|
||||
clearTimeout(announceTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// The header's last breadcrumb is this record's title (design: Top header).
|
||||
@@ -482,7 +551,7 @@ void load()
|
||||
<Button
|
||||
variant="outline"
|
||||
data-action="back"
|
||||
:aria-label="t(backToPreview ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')"
|
||||
:aria-label="backLabel"
|
||||
class="size-10! px-0!"
|
||||
@click="onLeave"
|
||||
>
|
||||
@@ -527,7 +596,7 @@ void load()
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||
>
|
||||
<Button
|
||||
v-if="mode === 'update'"
|
||||
v-if="mode === 'update' && !singleton"
|
||||
variant="danger"
|
||||
data-action="delete"
|
||||
:icon="Trash2"
|
||||
@@ -547,6 +616,8 @@ void load()
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<p class="sr-only" aria-live="polite" data-form-live>{{ announcement }}</p>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Plus, SearchX } from '@lucide/vue'
|
||||
import { ArrowUpDown, Plus, SearchX } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, BulkActionResult, ListMeta, ListQuery, ListSchema } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
@@ -79,6 +79,18 @@ const subtitle = computed(() => (meta.value ? message(messages.value?.recordCoun
|
||||
|
||||
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
|
||||
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
|
||||
// The tree reorder page (D-09): offered when the list declares it; a list
|
||||
// filtered on the reorder partition column opens that partition.
|
||||
const reorderLink = computed(() => {
|
||||
const reorder = schema.value?.reorder
|
||||
if (!reorder) {
|
||||
return null
|
||||
}
|
||||
const column = reorder.partition?.column
|
||||
const filter = column ? schema.value?.filters.find((item) => item.column === column || item.name === column) : undefined
|
||||
const value = filter ? query.value.filter?.[filter.name] : undefined
|
||||
return { path: `${basePath}/reorder`, query: value !== undefined && value !== '' ? { partition: String(value) } : {} }
|
||||
})
|
||||
// Delete and the registered actions the admin may run, in declared order
|
||||
// (D-12). An action missing from toolbarActions is not rendered at all.
|
||||
const toolbarActions = computed(() => schema.value?.toolbarActions ?? [])
|
||||
@@ -408,7 +420,10 @@ async function onAction(name: string): Promise<void> {
|
||||
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
|
||||
<p v-if="subtitle" data-record-count class="text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="headingButtons.length > 0" class="flex items-center gap-2.5">
|
||||
<div v-if="headingButtons.length > 0 || reorderLink" class="flex items-center gap-2.5">
|
||||
<Button v-if="reorderLink" data-action="reorder" variant="outline" :icon="ArrowUpDown" :to="reorderLink">
|
||||
{{ t('backend::lang.messages.list.reorder') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-for="button in headingButtons"
|
||||
:key="button"
|
||||
|
||||
390
admin/src/views/ReorderView.vue
Normal file
390
admin/src/views/ReorderView.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, provide, ref, watch, watchEffect } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ArrowUpDown, CircleAlert, LoaderCircle } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminReorderPartition, AdminReorderRequest } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { t } from '../app/i18n'
|
||||
import ReorderBranch from '../components/reorder/ReorderBranch.vue'
|
||||
import { REORDER_PAGE } from '../components/reorder/context'
|
||||
import {
|
||||
applyDrop,
|
||||
applyMove,
|
||||
canDrop,
|
||||
canMove,
|
||||
cloneTree,
|
||||
flatten,
|
||||
locate,
|
||||
buildTree,
|
||||
type Move,
|
||||
type TreeNode,
|
||||
type Zone,
|
||||
} from '../components/reorder/tree'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// The tree reorder page (D-09, UI-SPEC "Reorder page"), reached from a list
|
||||
// whose schema declares `reorder`. Rows move by pointer drag (before, after
|
||||
// or inside a row, never into the node's own subtree), by the arrow keys on
|
||||
// the handle and by the four buttons. There is no Save button: every move is
|
||||
// saved by one POST with the whole scoped node set, 400 ms after the last
|
||||
// move, and a pending save is flushed when the page is left. A failed save
|
||||
// restores the last saved tree; a 409 (the records changed meanwhile)
|
||||
// reloads it.
|
||||
const SAVE_DEBOUNCE_MS = 400
|
||||
const SAVED_SHOWN_MS = 2000
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const path = {
|
||||
vendor: String(route.params.vendor ?? ''),
|
||||
plugin: String(route.params.plugin ?? ''),
|
||||
controller: String(route.params.controller ?? ''),
|
||||
}
|
||||
const controllerId = controllerIdFromParams(path)
|
||||
const listPath = controllerPath(controllerId) ?? '/'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'failed' | 'forbidden'
|
||||
|
||||
const status = ref<Status>('loading')
|
||||
const title = ref('')
|
||||
const partition = ref<AdminReorderPartition | null>(null)
|
||||
const tree = ref<TreeNode[]>([])
|
||||
// The tree as last saved (or loaded): a failed save goes back to it.
|
||||
let savedTree: TreeNode[] = []
|
||||
const saving = ref(0)
|
||||
const savedShown = ref(false)
|
||||
const announcement = ref('')
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let generation = 0
|
||||
|
||||
const requestedPartition = computed(() => {
|
||||
const raw = route.query.partition
|
||||
const value = Number(Array.isArray(raw) ? raw[0] : raw)
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
})
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++generation
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const query = requestedPartition.value !== undefined ? { partition: requestedPartition.value } : undefined
|
||||
const { data, response } = await api.GET('/{vendor}/{plugin}/{controller}/reorder', { params: { path, query } })
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
status.value = response.status === 403 ? 'forbidden' : 'failed'
|
||||
return
|
||||
}
|
||||
title.value = data.data.title
|
||||
partition.value = (data.data.partition as AdminReorderPartition | null) ?? null
|
||||
tree.value = buildTree(data.data.nodes ?? [])
|
||||
savedTree = cloneTree(tree.value)
|
||||
status.value = 'ready'
|
||||
} catch {
|
||||
if (current === generation) {
|
||||
status.value = 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showSaved(): void {
|
||||
savedShown.value = true
|
||||
if (savedTimer !== null) {
|
||||
clearTimeout(savedTimer)
|
||||
}
|
||||
savedTimer = setTimeout(() => {
|
||||
savedTimer = null
|
||||
savedShown.value = false
|
||||
}, SAVED_SHOWN_MS)
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
saveTimer = null
|
||||
const sent = cloneTree(tree.value)
|
||||
const body = { nodes: flatten(sent), partition: partition.value?.value ?? null } as unknown as AdminReorderRequest
|
||||
savedShown.value = false
|
||||
saving.value++
|
||||
try {
|
||||
const { data, response } = await api.POST('/{vendor}/{plugin}/{controller}/reorder', { params: { path }, body })
|
||||
if (data) {
|
||||
savedTree = sent
|
||||
showSaved()
|
||||
return
|
||||
}
|
||||
if (response.status === 409) {
|
||||
showToast(t('backend::lang.reorder.stale'), 'danger')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
tree.value = cloneTree(savedTree)
|
||||
showToast(t('backend::lang.reorder.failed'), 'danger')
|
||||
} catch {
|
||||
tree.value = cloneTree(savedTree)
|
||||
showToast(t('backend::lang.reorder.failed'), 'danger')
|
||||
} finally {
|
||||
saving.value--
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave(): void {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
saveTimer = setTimeout(() => void save(), SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** Sends a pending save at once (leaving the page, changing the partition). */
|
||||
function flush(): void {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer)
|
||||
void save()
|
||||
}
|
||||
}
|
||||
|
||||
function announce(text: string): void {
|
||||
announcement.value = ''
|
||||
if (announceTimer !== null) {
|
||||
clearTimeout(announceTimer)
|
||||
}
|
||||
announceTimer = setTimeout(() => {
|
||||
announceTimer = null
|
||||
announcement.value = text
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function nameOf(node: TreeNode): string {
|
||||
return node.name !== '' ? node.name : t('backend::lang.reorder.untitled', { id: node.id })
|
||||
}
|
||||
|
||||
/** After a move: save later, keep focus on the moved handle, announce where it went. */
|
||||
async function moved(id: number, kind: 'moved' | 'nested' | 'unnested'): Promise<void> {
|
||||
scheduleSave()
|
||||
const at = locate(tree.value, id)
|
||||
if (!at) {
|
||||
return
|
||||
}
|
||||
const name = nameOf(at.node)
|
||||
if (kind === 'nested' && at.parent) {
|
||||
announce(t('backend::lang.reorder.nested', { name, parent: nameOf(at.parent) }))
|
||||
} else if (kind === 'unnested') {
|
||||
announce(t('backend::lang.reorder.unnested', { name, level: at.depth + 1 }))
|
||||
} else {
|
||||
announce(t('backend::lang.reorder.moved', { name, position: at.index + 1, total: at.list.length }))
|
||||
}
|
||||
await nextTick()
|
||||
document.querySelector<HTMLElement>(`[data-reorder-handle="${id}"]`)?.focus()
|
||||
}
|
||||
|
||||
function move(id: number, kind: Move): void {
|
||||
const next = cloneTree(tree.value)
|
||||
if (!applyMove(next, id, kind)) {
|
||||
return
|
||||
}
|
||||
tree.value = next
|
||||
void moved(id, kind === 'indent' ? 'nested' : kind === 'outdent' ? 'unnested' : 'moved')
|
||||
}
|
||||
|
||||
const KEY_MOVES: Record<string, Move> = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }
|
||||
|
||||
function onHandleKeydown(event: KeyboardEvent, id: number): void {
|
||||
const kind = KEY_MOVES[event.key]
|
||||
if (kind) {
|
||||
event.preventDefault()
|
||||
move(id, kind)
|
||||
}
|
||||
}
|
||||
|
||||
const dragging = ref<number | null>(null)
|
||||
const dropTarget = ref<{ id: number; zone: Zone } | null>(null)
|
||||
const blocked = ref<number | null>(null)
|
||||
|
||||
function onDragStart(event: DragEvent, id: number): void {
|
||||
dragging.value = id
|
||||
dropTarget.value = null
|
||||
event.dataTransfer?.setData('text/plain', String(id))
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, id: number): void {
|
||||
if (dragging.value === null) {
|
||||
return
|
||||
}
|
||||
if (!canDrop(tree.value, dragging.value, id)) {
|
||||
// Into its own subtree: no indicator, and no preventDefault, so no drop.
|
||||
blocked.value = id
|
||||
dropTarget.value = null
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
blocked.value = null
|
||||
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const offset = event.clientY - box.top
|
||||
const zone: Zone = offset < box.height / 3 ? 'before' : offset > (box.height * 2) / 3 ? 'after' : 'inside'
|
||||
dropTarget.value = { id, zone }
|
||||
}
|
||||
|
||||
function onDragLeave(id: number): void {
|
||||
if (blocked.value === id) {
|
||||
blocked.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
const dragged = dragging.value
|
||||
const target = dropTarget.value
|
||||
onDragEnd()
|
||||
if (dragged === null || !target) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const next = cloneTree(tree.value)
|
||||
if (!applyDrop(next, dragged, target.id, target.zone)) {
|
||||
return
|
||||
}
|
||||
tree.value = next
|
||||
void moved(dragged, target.zone === 'inside' ? 'nested' : 'moved')
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
dragging.value = null
|
||||
dropTarget.value = null
|
||||
blocked.value = null
|
||||
}
|
||||
|
||||
provide(REORDER_PAGE, {
|
||||
dragging,
|
||||
dropTarget,
|
||||
blocked,
|
||||
nameOf,
|
||||
canMove: (id: number, kind: Move) => canMove(tree.value, id, kind),
|
||||
move,
|
||||
onHandleKeydown,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
})
|
||||
|
||||
function onPartition(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
flush()
|
||||
void router.replace({ path: route.path, query: { ...route.query, partition: value } })
|
||||
}
|
||||
|
||||
watch(requestedPartition, (next, previous) => {
|
||||
if (next !== previous) {
|
||||
void load()
|
||||
}
|
||||
})
|
||||
|
||||
function back(): void {
|
||||
void router.push(listPath)
|
||||
}
|
||||
|
||||
// Leaving never asks: the pending save goes out at once.
|
||||
onBeforeRouteLeave(() => {
|
||||
flush()
|
||||
return true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
flush()
|
||||
for (const timer of [savedTimer, announceTimer]) {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
clearRecordCrumb()
|
||||
})
|
||||
|
||||
watchEffect(() => setRecordCrumb(status.value === 'ready' ? title.value : ''))
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex w-full flex-col gap-5">
|
||||
<header class="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-action="back"
|
||||
:aria-label="t('backend::lang.form.return_to_list')"
|
||||
class="size-10! px-0!"
|
||||
@click="back"
|
||||
>
|
||||
<ArrowLeft :size="18" aria-hidden="true" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ title }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<p data-reorder-status aria-live="polite" class="flex items-center gap-1.5 text-[13px] text-muted">
|
||||
<template v-if="saving > 0">
|
||||
<LoaderCircle :size="14" class="animate-spin" aria-hidden="true" />
|
||||
{{ t('backend::lang.form.saving') }}
|
||||
</template>
|
||||
<template v-else-if="savedShown">{{ t('backend::lang.reorder.saved') }}</template>
|
||||
</p>
|
||||
<label v-if="partition" class="flex items-center gap-2 font-semibold">
|
||||
{{ partition.label }}
|
||||
<select
|
||||
data-reorder-partition
|
||||
:value="String(partition.value)"
|
||||
class="h-input w-[240px] appearance-none rounded-control border border-border-strong bg-surface px-3.5 font-normal text-text"
|
||||
@change="onPartition"
|
||||
>
|
||||
<option v-for="option in partition.options" :key="option.value" :value="String(option.value)">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="status === 'failed'" role="alert" class="flex items-center gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
|
||||
{{ t('backend::lang.reorder.load_failed') }}
|
||||
</p>
|
||||
<p v-else-if="status === 'forbidden'" role="alert" class="flex items-center gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
|
||||
{{ t('backend::lang.form.forbidden') }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
data-reorder-card
|
||||
class="overflow-hidden rounded-card border border-border bg-surface shadow-card"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
>
|
||||
<template v-if="status === 'loading'">
|
||||
<div v-for="row in 5" :key="row" data-reorder-skeleton class="flex min-h-row items-center gap-3 border-t border-border px-4 first:border-t-0">
|
||||
<span class="size-5 rounded-[6px] bg-skel" />
|
||||
<span class="h-3 w-48 rounded-[6px] bg-skel" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="tree.length === 0" data-reorder-empty class="flex flex-col items-center gap-2 px-6 py-8 text-center">
|
||||
<span class="flex size-10 items-center justify-center rounded-full bg-subtle text-muted">
|
||||
<ArrowUpDown :size="20" aria-hidden="true" />
|
||||
</span>
|
||||
<p class="font-semibold">{{ t('backend::lang.reorder.empty') }}</p>
|
||||
<p class="max-w-[420px] text-[13px] text-muted">{{ t('backend::lang.reorder.empty_hint') }}</p>
|
||||
</div>
|
||||
<ReorderBranch v-else :nodes="tree" :depth="0" :aria-label="t('backend::lang.reorder.tree')" data-reorder-tree />
|
||||
</div>
|
||||
|
||||
<p class="sr-only" aria-live="polite" data-reorder-live>{{ announcement }}</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import type { SettingsEntry } from '../api/types'
|
||||
import { parseControllerId } from '../app/controllerRoutes'
|
||||
import { t } from '../app/i18n'
|
||||
import { iconFor } from '../app/icons'
|
||||
import { settingsGroups, settingsPath } from '../state/useSettings'
|
||||
@@ -7,6 +11,37 @@ import { settingsGroups, settingsPath } from '../state/useSettings'
|
||||
// Settings index (D-21): every permitted settings page, grouped by category,
|
||||
// with its icon, label and description. The server filters the list. A
|
||||
// singleton entry opens its settings form; a link entry opens its controller.
|
||||
// A link to a singleton controller (D-11) opens that controller's one record:
|
||||
// GET …/singleton answers its id (a controller that is not a singleton
|
||||
// answers 404 and the link opens its list).
|
||||
const router = useRouter()
|
||||
const opening = ref<string | null>(null)
|
||||
|
||||
async function open(event: MouseEvent, entry: SettingsEntry, navigate: (event: MouseEvent) => unknown): Promise<void> {
|
||||
const params = entry.controller ? parseControllerId(entry.controller) : null
|
||||
if (!params) {
|
||||
void navigate(event)
|
||||
return
|
||||
}
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
// A new tab or window opens the link as it is.
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (opening.value !== null) {
|
||||
return
|
||||
}
|
||||
opening.value = entry.code
|
||||
const target = settingsPath(entry)
|
||||
try {
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/singleton', { params: { path: params } })
|
||||
await router.push(data ? `${target}/${data.data.id}` : target)
|
||||
} catch {
|
||||
await router.push(target)
|
||||
} finally {
|
||||
opening.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,17 +63,25 @@ import { settingsGroups, settingsPath } from '../state/useSettings'
|
||||
<RouterLink
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.code"
|
||||
v-slot="{ href, navigate }"
|
||||
:to="settingsPath(entry)"
|
||||
:data-settings="entry.code"
|
||||
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
|
||||
custom
|
||||
>
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
|
||||
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="font-semibold">{{ entry.label }}</span>
|
||||
<span v-if="entry.description" class="text-[13px] text-muted">{{ entry.description }}</span>
|
||||
</span>
|
||||
<a
|
||||
:href="href"
|
||||
:data-settings="entry.code"
|
||||
:aria-busy="opening === entry.code ? 'true' : undefined"
|
||||
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
|
||||
@click="open($event, entry, navigate)"
|
||||
>
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
|
||||
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="font-semibold">{{ entry.label }}</span>
|
||||
<span v-if="entry.description" class="text-[13px] text-muted">{{ entry.description }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
494
admin/tests/account/ApiTokenCreateDialog.test.ts
Normal file
494
admin/tests/account/ApiTokenCreateDialog.test.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import ApiTokenCreateDialog from '../../src/components/account/ApiTokenCreateDialog.vue'
|
||||
import { useConfirm } from '../../src/components/ui/confirm'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { API, keydown, mockApi, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const TOKENS = `${API}/account/tokens`
|
||||
const SCOPES = `${API}/account/tokens/scopes`
|
||||
|
||||
const envelope = (data: unknown, status = 200) => ({ status, body: { data, meta: {} } })
|
||||
|
||||
const PLAIN = 'g15_0123456789abcdef0123456789abcdef01234567'
|
||||
|
||||
const createdToken = {
|
||||
id: 12,
|
||||
name: 'CI runner',
|
||||
hint: 'c3d4',
|
||||
scopes: [
|
||||
{
|
||||
name: 'admin:read',
|
||||
label: 'Read-only, whole admin',
|
||||
description: 'View everything',
|
||||
read_only: true,
|
||||
known: true,
|
||||
},
|
||||
],
|
||||
created_at: '2026-01-10T12:00:00Z',
|
||||
last_used_at: '',
|
||||
expires_at: '2026-04-10T12:00:00Z',
|
||||
revoked_at: '',
|
||||
expired: false,
|
||||
token: PLAIN,
|
||||
}
|
||||
|
||||
const scopes = [
|
||||
{
|
||||
name: '*',
|
||||
label: 'Full access',
|
||||
description: 'Everything',
|
||||
read_only: false,
|
||||
known: true,
|
||||
},
|
||||
{
|
||||
name: 'admin:read',
|
||||
label: 'Read-only, whole admin',
|
||||
description: 'View everything',
|
||||
read_only: true,
|
||||
known: true,
|
||||
},
|
||||
]
|
||||
|
||||
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
|
||||
let release!: (reply: Reply) => void
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { route: () => pending, release }
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function localDatePlus(days: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
function tokenRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${TOKENS}`]: envelope([]),
|
||||
[`GET ${SCOPES}`]: envelope(scopes),
|
||||
[`POST ${TOKENS}`]: envelope(createdToken, 201),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const panel = () => document.body.querySelector<HTMLElement>('[data-api-tokens]')
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-token-dialog]')
|
||||
const confirmDialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
|
||||
async function click(selector: string, root: ParentNode = document.body): Promise<void> {
|
||||
const el = root.querySelector<HTMLElement>(selector)
|
||||
el?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openCreate(routes: Record<string, Route> = {}) {
|
||||
const mounted = await mountApp('/account', tokenRoutes(routes), { attach: true })
|
||||
await flushPromises()
|
||||
await click('[data-action="create-token"]', panel()!)
|
||||
await flushPromises()
|
||||
return mounted
|
||||
}
|
||||
|
||||
async function fillName(value: string): Promise<void> {
|
||||
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
|
||||
nameInput.value = value
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function pickAdminRead(): Promise<void> {
|
||||
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
||||
scope.checked = true
|
||||
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openCreateAtStep2(routes: Record<string, Route> = {}): Promise<void> {
|
||||
await openCreate(routes)
|
||||
await fillName('Agent')
|
||||
await pickAdminRead()
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
expect(dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!.value).toBe(PLAIN)
|
||||
}
|
||||
|
||||
function plaintextRetained(): boolean {
|
||||
if (document.body.innerHTML.includes(PLAIN)) return true
|
||||
if (document.body.textContent?.includes(PLAIN)) return true
|
||||
for (const toast of toasts.value) {
|
||||
if (toast.text.includes(PLAIN)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ApiTokenCreateDialog plaintext lifetime', () => {
|
||||
it('clears plaintext from the DOM and toasts after Done (hide)', async () => {
|
||||
await openCreateAtStep2()
|
||||
await click('[data-action="hide-token"]', dialog()!)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears plaintext after Esc-confirm close and reopening starts at step 1', async () => {
|
||||
await openCreateAtStep2()
|
||||
keydown(dialog()!, 'Escape')
|
||||
await flushPromises()
|
||||
if (!confirmDialog()) {
|
||||
await click('[data-action="close"]', dialog()!)
|
||||
}
|
||||
expect(confirmDialog()).toBeTruthy()
|
||||
await click('[data-action="confirm"]', confirmDialog()!)
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeNull()
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
|
||||
await click('[data-action="create-token"]', panel()!)
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeTruthy()
|
||||
expect(dialog()!.querySelector('[data-token-plaintext]')).toBeNull()
|
||||
expect(dialog()!.querySelector('#api-token-name')).toBeTruthy()
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
})
|
||||
|
||||
it('copy success announces; clipboard failure selects and alerts', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
await openCreateAtStep2()
|
||||
await click('[data-action="copy-token"]', dialog()!)
|
||||
expect(writeText).toHaveBeenCalledWith(PLAIN)
|
||||
expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain(
|
||||
'Skopiowano token do schowka.',
|
||||
)
|
||||
|
||||
writeText.mockRejectedValueOnce(new Error('denied'))
|
||||
await click('[data-action="copy-token"]', dialog()!)
|
||||
expect(dialog()!.querySelector('[role="alert"]')!.textContent).toContain(
|
||||
'Nie udało się skopiować automatycznie',
|
||||
)
|
||||
expect(document.activeElement).toBe(dialog()!.querySelector('[data-token-plaintext]'))
|
||||
})
|
||||
|
||||
it('Esc before copying asks and cancel keeps step 2', async () => {
|
||||
await openCreateAtStep2()
|
||||
keydown(dialog()!, 'Escape')
|
||||
await flushPromises()
|
||||
if (!confirmDialog()) {
|
||||
await click('[data-action="close"]', dialog()!)
|
||||
}
|
||||
expect(confirmDialog()).toBeTruthy()
|
||||
await click('[data-action="cancel"]', confirmDialog()!)
|
||||
expect(dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!.value).toBe(PLAIN)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiTokenCreateDialog step 1', () => {
|
||||
it('focuses the name input on open', async () => {
|
||||
await openCreate()
|
||||
expect(document.activeElement).toBe(dialog()!.querySelector('#api-token-name'))
|
||||
})
|
||||
|
||||
it('shows scopes skeleton and disables submit while pending', async () => {
|
||||
const pending = deferred()
|
||||
await openCreate({ [`GET ${SCOPES}`]: pending.route })
|
||||
expect(dialog()!.querySelector('[data-scope-picker]')!.querySelectorAll('[aria-hidden="true"]').length).toBe(3)
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!.disabled).toBe(true)
|
||||
pending.release(envelope(scopes))
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('[data-scope="admin:read"]')).toBeTruthy()
|
||||
// No scope selected yet — submit stays disabled (WR-02).
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows scopes failure alert when the scopes request fails', async () => {
|
||||
await openCreate({
|
||||
[`GET ${SCOPES}`]: { status: 500, body: { error: { code: 'x', message: 'fail', details: {} } } },
|
||||
})
|
||||
expect(dialog()!.textContent).toContain('Nie udało się wczytać zakresów')
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows scopes failure alert when the scopes list is empty', async () => {
|
||||
await openCreate({ [`GET ${SCOPES}`]: envelope([]) })
|
||||
expect(dialog()!.textContent).toContain('Nie udało się wczytać zakresów')
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('star checked disables others; unchecking restores without picks; no preselection', async () => {
|
||||
await openCreate()
|
||||
const star = dialog()!.querySelector<HTMLInputElement>('[data-scope="*"] input')!
|
||||
const admin = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
||||
expect(star.checked).toBe(false)
|
||||
expect(admin.checked).toBe(false)
|
||||
admin.checked = true
|
||||
admin.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
star.checked = true
|
||||
star.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(admin.disabled).toBe(true)
|
||||
expect(admin.checked).toBe(false)
|
||||
expect(dialog()!.textContent).toContain('Pełny dostęp obejmuje wszystkie pozostałe zakresy.')
|
||||
star.checked = false
|
||||
star.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(admin.disabled).toBe(false)
|
||||
expect(admin.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('preselects 90d and previews 30, 90, 365 and never', async () => {
|
||||
await openCreate()
|
||||
const segments = [...dialog()!.querySelectorAll<HTMLElement>('[data-expiry-segment]')]
|
||||
expect(segments.map((el) => el.getAttribute('value') ?? el.textContent)).toBeTruthy()
|
||||
const checked = dialog()!.querySelector('[data-expiry-segment][data-state="checked"]')
|
||||
expect(checked?.textContent).toContain('90 dni')
|
||||
expect(dialog()!.textContent).toContain(`Wygasa ${localDatePlus(90)}.`)
|
||||
|
||||
const clickSegment = async (label: string) => {
|
||||
const el = segments.find((s) => s.textContent?.includes(label))
|
||||
el?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
await clickSegment('30 dni')
|
||||
expect(dialog()!.textContent).toContain(`Wygasa ${localDatePlus(30)}.`)
|
||||
await clickSegment('365 dni')
|
||||
expect(dialog()!.textContent).toContain(`Wygasa ${localDatePlus(365)}.`)
|
||||
await clickSegment('Bez terminu')
|
||||
expect(dialog()!.textContent).toContain('Ten token nie wygasa')
|
||||
})
|
||||
|
||||
it('renders 422 for name and expires with aria-invalid and focuses first control', async () => {
|
||||
await openCreate({
|
||||
[`POST ${TOKENS}`]: {
|
||||
status: 422,
|
||||
body: {
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
message: 'Validation failed',
|
||||
details: {
|
||||
name: ['Podaj nazwę tokenu.'],
|
||||
expires: ['Wybierz poprawny termin ważności.'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await pickAdminRead()
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
expect(dialog()!.textContent).toContain('Podaj nazwę tokenu.')
|
||||
expect(dialog()!.querySelector('#api-token-name')!.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement).toBe(dialog()!.querySelector('#api-token-name'))
|
||||
})
|
||||
|
||||
it('busy disables fieldset, buttons, Esc and overlay and ignores a second submit', async () => {
|
||||
const pending = deferred()
|
||||
let posts = 0
|
||||
await openCreate({
|
||||
[`POST ${TOKENS}`]: () => {
|
||||
posts += 1
|
||||
return pending.route()
|
||||
},
|
||||
})
|
||||
await fillName('Busy')
|
||||
await pickAdminRead()
|
||||
const submit = dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!
|
||||
submit.click()
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('fieldset')!.disabled).toBe(true)
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-cancel"]')!.disabled).toBe(
|
||||
true,
|
||||
)
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="close"]')!.disabled).toBe(true)
|
||||
expect(dialog()!.textContent).toContain('Tworzenie…')
|
||||
submit.click()
|
||||
await flushPromises()
|
||||
expect(posts).toBe(1)
|
||||
keydown(dialog()!, 'Escape')
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeTruthy()
|
||||
dialog()!.querySelector('[data-dialog-overlay]')?.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeTruthy()
|
||||
pending.release(envelope(createdToken, 201))
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('non-422 failure toast keeps input; discard closes and returns focus to Create', async () => {
|
||||
await openCreate({
|
||||
[`POST ${TOKENS}`]: {
|
||||
status: 500,
|
||||
body: { error: { code: 'x', message: 'fail', details: {} } },
|
||||
},
|
||||
})
|
||||
await fillName('Keep me')
|
||||
await pickAdminRead()
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
expect(toasts.value[0]).toMatchObject({
|
||||
text: 'Nie udało się utworzyć tokenu. Spróbuj ponownie.',
|
||||
tone: 'danger',
|
||||
})
|
||||
expect(dialog()!.querySelector<HTMLInputElement>('#api-token-name')!.value).toBe('Keep me')
|
||||
await click('[data-action="create-token-cancel"]', dialog()!)
|
||||
expect(dialog()).toBeNull()
|
||||
await flushPromises()
|
||||
const create = panel()!.querySelector('[data-action="create-token"]')
|
||||
expect(document.activeElement === create || panel()!.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('posts selected scopes and never leaves plaintext after Done', async () => {
|
||||
const { calls } = await openCreate()
|
||||
await fillName('CI')
|
||||
await pickAdminRead()
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
const [post] = requestsTo(calls, 'POST', TOKENS)
|
||||
expect(await post!.clone().json()).toEqual({
|
||||
name: 'CI',
|
||||
expires: '90d',
|
||||
scopes: ['admin:read'],
|
||||
})
|
||||
await click('[data-action="hide-token"]', dialog()!)
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiTokenCreateDialog step 2', () => {
|
||||
it('copy success flashes for two seconds and announces politely', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
await openCreateAtStep2()
|
||||
await click('[data-action="copy-token"]', dialog()!)
|
||||
expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Skopiowano')
|
||||
expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain(
|
||||
'Skopiowano token do schowka.',
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Kopiuj token')
|
||||
})
|
||||
|
||||
it('missing clipboard selects the input and shows the alert', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
await openCreateAtStep2()
|
||||
await click('[data-action="copy-token"]', dialog()!)
|
||||
expect(dialog()!.querySelector('[role="alert"]')!.textContent).toContain(
|
||||
'Nie udało się skopiować automatycznie',
|
||||
)
|
||||
expect(document.activeElement).toBe(dialog()!.querySelector('[data-token-plaintext]'))
|
||||
})
|
||||
|
||||
it('manual copy event counts as copied so Esc closes without confirm', async () => {
|
||||
await openCreateAtStep2()
|
||||
const input = dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!
|
||||
input.dispatchEvent(new Event('copy', { bubbles: true }))
|
||||
await flushPromises()
|
||||
keydown(dialog()!, 'Escape')
|
||||
await flushPromises()
|
||||
expect(confirmDialog()).toBeNull()
|
||||
expect(dialog()).toBeNull()
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
})
|
||||
|
||||
it('overlay never closes step 2; after copy Esc closes at once; Done needs no confirm', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
await openCreateAtStep2()
|
||||
const overlay = document.body.querySelector('[data-dialog-overlay]')
|
||||
overlay?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeTruthy()
|
||||
expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy()
|
||||
|
||||
await click('[data-action="copy-token"]', dialog()!)
|
||||
keydown(dialog()!, 'Escape')
|
||||
await flushPromises()
|
||||
expect(confirmDialog()).toBeNull()
|
||||
expect(dialog()).toBeNull()
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
|
||||
await openCreateAtStep2()
|
||||
await click('[data-action="hide-token"]', dialog()!)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(plaintextRetained()).toBe(false)
|
||||
})
|
||||
|
||||
it('focuses Copy token when entering step 2', async () => {
|
||||
await openCreateAtStep2()
|
||||
const copy = dialog()!.querySelector('[data-action="copy-token"]')!
|
||||
const active = document.activeElement
|
||||
// Button may put data-action on the <button> or a wrapper; accept either.
|
||||
expect(
|
||||
active === copy ||
|
||||
copy.contains(active) ||
|
||||
active?.closest('[data-action="copy-token"]') === copy ||
|
||||
active?.textContent?.includes('Kopiuj token') === true,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiTokenCreateDialog mounted directly', () => {
|
||||
// The panel mounts the dialog closed and opens it; opening loads the scopes.
|
||||
async function openDialog() {
|
||||
const calls = mockApi(tokenRoutes())
|
||||
const wrapper = mount(ApiTokenCreateDialog, {
|
||||
props: { open: false, confirm: useConfirm() },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
it('opens with the scopes from one GET', async () => {
|
||||
const { calls } = await openDialog()
|
||||
expect(dialog()).toBeTruthy()
|
||||
expect(requestsTo(calls, 'GET', SCOPES)).toHaveLength(1)
|
||||
expect(dialog()!.querySelector('[data-scope="*"]')).toBeTruthy()
|
||||
expect(dialog()!.querySelector('[data-scope="admin:read"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('creates a token with one POST, shows the plaintext and emits created', async () => {
|
||||
const { wrapper, calls } = await openDialog()
|
||||
await fillName('CI runner')
|
||||
await pickAdminRead()
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
|
||||
const posts = requestsTo(calls, 'POST', TOKENS)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(await posts[0]!.clone().json()).toEqual({ name: 'CI runner', expires: '90d', scopes: ['admin:read'] })
|
||||
expect(dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!.value).toBe(PLAIN)
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
|
||||
await click('[data-action="hide-token"]', dialog()!)
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import ApiTokensPanel from '../../src/components/account/ApiTokensPanel.vue'
|
||||
import { useConfirm } from '../../src/components/ui/confirm'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { API, keydown, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
import { API, keydown, mockApi, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const TOKENS = `${API}/account/tokens`
|
||||
const SCOPES = `${API}/account/tokens/scopes`
|
||||
@@ -29,6 +31,42 @@ const existingToken = {
|
||||
expired: false,
|
||||
}
|
||||
|
||||
const starToken = {
|
||||
...existingToken,
|
||||
id: 12,
|
||||
name: 'Full agent',
|
||||
hint: 'c3d4',
|
||||
scopes: [
|
||||
{
|
||||
name: '*',
|
||||
label: 'Full access',
|
||||
description: 'Everything',
|
||||
read_only: false,
|
||||
known: true,
|
||||
},
|
||||
],
|
||||
last_used_at: '2026-02-01T08:30:00Z',
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
const unknownScopeToken = {
|
||||
...existingToken,
|
||||
id: 14,
|
||||
name: 'Legacy',
|
||||
hint: 'zzzz',
|
||||
scopes: [
|
||||
{
|
||||
name: 'gone.plugin',
|
||||
label: '',
|
||||
description: '',
|
||||
read_only: false,
|
||||
known: false,
|
||||
},
|
||||
],
|
||||
expired: true,
|
||||
expires_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const revokedToken = {
|
||||
...existingToken,
|
||||
id: 13,
|
||||
@@ -39,7 +77,7 @@ const revokedToken = {
|
||||
|
||||
const createdToken = {
|
||||
...existingToken,
|
||||
id: 12,
|
||||
id: 15,
|
||||
name: 'CI runner',
|
||||
hint: 'c3d4',
|
||||
token: 'g15_0123456789abcdef0123456789abcdef01234567',
|
||||
@@ -166,6 +204,44 @@ describe('ApiTokensPanel', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows skeleton with aria-busy on toggle reload', async () => {
|
||||
const pending = deferred()
|
||||
let first = true
|
||||
await openAccount({
|
||||
[`GET ${TOKENS}`]: () => {
|
||||
if (first) {
|
||||
first = false
|
||||
return envelope([existingToken])
|
||||
}
|
||||
return pending.route()
|
||||
},
|
||||
})
|
||||
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
|
||||
toggle.checked = true
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(panel()!.querySelector('[data-token-list]')!.getAttribute('aria-busy')).toBe('true')
|
||||
expect(toggle.disabled).toBe(false)
|
||||
pending.release(envelope([existingToken, revokedToken]))
|
||||
await flushPromises()
|
||||
expect(panel()!.querySelector('[data-token-row="13"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows empty block when revoked toggle is on and list is empty', async () => {
|
||||
await openAccount(
|
||||
{
|
||||
[`GET ${TOKENS}`]: (request) =>
|
||||
envelope(queryOf(request).get('revoked') === '1' ? [] : []),
|
||||
},
|
||||
[],
|
||||
)
|
||||
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
|
||||
toggle.checked = true
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the alert and hides the toggle when the load fails', async () => {
|
||||
await openAccount({
|
||||
[`GET ${TOKENS}`]: { status: 500, body: { error: { code: 'x', message: 'fail', details: {} } } },
|
||||
@@ -176,6 +252,37 @@ describe('ApiTokensPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('row anatomy', () => {
|
||||
it('renders name, hint aria-label, chips and meta line order', async () => {
|
||||
await openAccount({}, [existingToken, starToken, unknownScopeToken, revokedToken])
|
||||
const row = panel()!.querySelector('[data-token-row="11"]')!
|
||||
expect(row.textContent).toContain('Blog agent')
|
||||
const hint = row.querySelector('span.font-mono')!
|
||||
expect(hint.getAttribute('aria-label')).toBe('Token kończący się na a1b2')
|
||||
expect(hint.textContent).toContain('g15_…a1b2')
|
||||
expect(row.querySelector('[data-token-scopes]')!.textContent).toContain('Read-only, whole admin')
|
||||
expect(row.textContent).toMatch(/Utworzono .* · Nieużywany · Wygasa /)
|
||||
|
||||
const starRow = panel()!.querySelector('[data-token-row="12"]')!
|
||||
expect(starRow.querySelector('[data-token-scopes]')!.textContent).toContain('Pełny dostęp')
|
||||
expect(starRow.textContent).toContain('Bez terminu ważności')
|
||||
expect(starRow.textContent).toMatch(/Ostatnio użyto /)
|
||||
|
||||
const unknownRow = panel()!.querySelector('[data-token-row="14"]')!
|
||||
const unknownChip = unknownRow.querySelector('[data-token-scopes] span')!
|
||||
expect(unknownChip.className).toContain('border-dashed')
|
||||
expect(unknownChip.getAttribute('title')).toContain('nie jest już zarejestrowany')
|
||||
expect(unknownChip.textContent).toContain('gone.plugin (nieznany)')
|
||||
expect(unknownRow.textContent).toContain('Wygasły')
|
||||
expect(unknownRow.textContent).toMatch(/Wygasł /)
|
||||
|
||||
const revokedRow = panel()!.querySelector('[data-token-row="13"]')!
|
||||
expect(revokedRow.textContent).toContain('Unieważniony')
|
||||
expect(revokedRow.textContent).toMatch(/Unieważniono /)
|
||||
expect(revokedRow.querySelector('[data-action="revoke-token"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke', () => {
|
||||
it('runs DELETE through the shared confirm and toasts success', async () => {
|
||||
const { calls } = await openAccount({
|
||||
@@ -183,9 +290,16 @@ describe('ApiTokensPanel', () => {
|
||||
})
|
||||
await click('[data-action="revoke-token"]', panel()!)
|
||||
expect(confirmDialog()).toBeTruthy()
|
||||
expect(confirmDialog()!.textContent).toContain('Unieważnić token „Blog agent”?')
|
||||
expect(confirmDialog()!.textContent).toContain('Tej operacji nie można cofnąć')
|
||||
await click('[data-action="confirm"]', confirmDialog()!)
|
||||
expect(requestsTo(calls, 'DELETE', TOKEN_ID)).toHaveLength(1)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Unieważniono token', tone: 'success' })
|
||||
await flushPromises()
|
||||
const createBtn =
|
||||
panel()!.querySelector<HTMLElement>('[data-action="create-token"]') ??
|
||||
panel()!.querySelector('button')
|
||||
expect(document.activeElement === createBtn || panel()!.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('toasts gone on 404 and reloads', async () => {
|
||||
@@ -208,6 +322,22 @@ describe('ApiTokensPanel', () => {
|
||||
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the row when revoke fails with a non-404 error', async () => {
|
||||
await openAccount({
|
||||
[`DELETE ${TOKEN_ID}`]: {
|
||||
status: 500,
|
||||
body: { error: { code: 'x', message: 'fail', details: {} } },
|
||||
},
|
||||
})
|
||||
await click('[data-action="revoke-token"]', panel()!)
|
||||
await click('[data-action="confirm"]', confirmDialog()!)
|
||||
expect(toasts.value[0]).toMatchObject({
|
||||
text: 'Nie udało się unieważnić tokenu. Spróbuj ponownie.',
|
||||
tone: 'danger',
|
||||
})
|
||||
expect(panel()!.querySelector('[data-token-row="11"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('requests revoked=1 and hides revoke on revoked rows', async () => {
|
||||
const { calls } = await openAccount({
|
||||
[`GET ${TOKENS}`]: (request) =>
|
||||
@@ -254,6 +384,21 @@ describe('ApiTokensPanel', () => {
|
||||
expect(await post!.clone().json()).toEqual({ name: 'Full', expires: '90d', scopes: ['*'] })
|
||||
})
|
||||
|
||||
it('keeps submit disabled until a scope is selected', async () => {
|
||||
await openCreateDialog()
|
||||
await fillName('No scope')
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!.disabled).toBe(
|
||||
true,
|
||||
)
|
||||
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
||||
scope.checked = true
|
||||
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-submit"]')!.disabled).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a 422 on scopes and focuses the first scope checkbox', async () => {
|
||||
await openCreateDialog({
|
||||
[`POST ${TOKENS}`]: {
|
||||
@@ -267,7 +412,11 @@ describe('ApiTokensPanel', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await fillName('No scope')
|
||||
await fillName('Scoped')
|
||||
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
||||
scope.checked = true
|
||||
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
expect(dialog()!.textContent).toContain('Wybierz co najmniej jeden zakres.')
|
||||
expect(document.activeElement).toBe(dialog()!.querySelector('[data-scope="*"] input'))
|
||||
@@ -312,7 +461,6 @@ describe('ApiTokensPanel', () => {
|
||||
expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy()
|
||||
keydown(dialog()!, 'Escape')
|
||||
await flushPromises()
|
||||
// Reka Escape may be handled as escape-key-down; fall back to the close button.
|
||||
if (!confirmDialog()) {
|
||||
await click('[data-action="close"]', dialog()!)
|
||||
}
|
||||
@@ -325,3 +473,38 @@ describe('ApiTokensPanel', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiTokensPanel mounted directly', () => {
|
||||
async function mountPanel(list: Route) {
|
||||
const calls = mockApi({ [`GET ${TOKENS}`]: list, [`GET ${SCOPES}`]: envelope(scopes) })
|
||||
const wrapper = mount(ApiTokensPanel, { props: { confirm: useConfirm() }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
it('lists the tokens from one GET', async () => {
|
||||
const { wrapper, calls } = await mountPanel(envelope([existingToken]))
|
||||
const row = wrapper.find('[data-token-row="11"]')
|
||||
expect(row.exists()).toBe(true)
|
||||
expect(row.text()).toContain('Blog agent')
|
||||
expect(wrapper.find('[data-tokens-empty]').exists()).toBe(false)
|
||||
expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the empty block for an empty list', async () => {
|
||||
const { wrapper, calls } = await mountPanel(envelope([]))
|
||||
expect(wrapper.find('[data-tokens-empty]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-token-row]').exists()).toBe(false)
|
||||
expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the error block when the list fails', async () => {
|
||||
const { wrapper, calls } = await mountPanel({
|
||||
status: 500,
|
||||
body: { error: { code: 'internal', message: 'fail', details: {} } },
|
||||
})
|
||||
expect(wrapper.find('[data-tokens-error]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-token-row]').exists()).toBe(false)
|
||||
expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
141
admin/tests/fixtures/companions.form-schema.json
vendored
Normal file
141
admin/tests/fixtures/companions.form-schema.json
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Link",
|
||||
"modelClass": "Link",
|
||||
"fields": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"label": "Title",
|
||||
"span": "left"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"type": "dropdown",
|
||||
"label": "Type",
|
||||
"span": "right",
|
||||
"options": [
|
||||
{
|
||||
"value": "url",
|
||||
"label": "Link"
|
||||
},
|
||||
{
|
||||
"value": "post",
|
||||
"label": "Post"
|
||||
},
|
||||
{
|
||||
"value": "category",
|
||||
"label": "Category"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"type": "dropdown",
|
||||
"label": "Reference",
|
||||
"span": "left",
|
||||
"dependsOn": "type"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "richeditor",
|
||||
"label": "Description",
|
||||
"comment": "Shown under the title."
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"type": "repeater",
|
||||
"label": "Questions",
|
||||
"span": "full",
|
||||
"prompt": "Add question",
|
||||
"titleFrom": "question",
|
||||
"maxItems": 3,
|
||||
"form": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "question",
|
||||
"type": "text",
|
||||
"label": "Question",
|
||||
"span": "left",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"type": "dropdown",
|
||||
"label": "Kind",
|
||||
"span": "right",
|
||||
"options": [
|
||||
{
|
||||
"value": "post",
|
||||
"label": "Post"
|
||||
},
|
||||
{
|
||||
"value": "url",
|
||||
"label": "Link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"type": "dropdown",
|
||||
"label": "Target",
|
||||
"span": "left",
|
||||
"dependsOn": "kind"
|
||||
},
|
||||
{
|
||||
"name": "visible",
|
||||
"type": "checkbox",
|
||||
"label": "Visible",
|
||||
"span": "right",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": {
|
||||
"other": "New link"
|
||||
},
|
||||
"update": {
|
||||
"other": "Edit link"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this record?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Record deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Link details"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit link"
|
||||
}
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/companions/links",
|
||||
"create": {
|
||||
"redirect": "acme/companions/links/:id",
|
||||
"redirectClose": "acme/companions/links"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/companions/links",
|
||||
"redirectClose": "acme/companions/links"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": []
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
204
admin/tests/fixtures/lang.json
vendored
204
admin/tests/fixtures/lang.json
vendored
@@ -252,6 +252,27 @@
|
||||
"backend::lang.form.deleting": {
|
||||
"other": "Usuwanie…"
|
||||
},
|
||||
"backend::lang.form.dependent_cleared": {
|
||||
"other": "Wyczyszczono pole „:field”, bo zmieniły się jego opcje."
|
||||
},
|
||||
"backend::lang.form.dependent_empty": {
|
||||
"other": "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ."
|
||||
},
|
||||
"backend::lang.form.dependent_failed": {
|
||||
"other": "Nie udało się wczytać opcji."
|
||||
},
|
||||
"backend::lang.form.dependent_invalid": {
|
||||
"other": "Wybierz jedną z dostępnych opcji."
|
||||
},
|
||||
"backend::lang.form.dependent_not_used": {
|
||||
"other": "Nie dotyczy tego wyboru"
|
||||
},
|
||||
"backend::lang.form.dependent_retry": {
|
||||
"other": "Ponów wczytywanie opcji"
|
||||
},
|
||||
"backend::lang.form.dependent_waiting": {
|
||||
"other": "Najpierw wybierz pole „:field”"
|
||||
},
|
||||
"backend::lang.form.discard": {
|
||||
"other": "Porzuć zmiany"
|
||||
},
|
||||
@@ -267,6 +288,9 @@
|
||||
"backend::lang.form.error_title": {
|
||||
"other": "Nie udało się zapisać."
|
||||
},
|
||||
"backend::lang.form.forbidden": {
|
||||
"other": "Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane."
|
||||
},
|
||||
"backend::lang.form.load_failed": {
|
||||
"other": "Nie udało się wczytać formularza."
|
||||
},
|
||||
@@ -447,6 +471,9 @@
|
||||
"many": ":count rekordów",
|
||||
"other": ":count rekordu"
|
||||
},
|
||||
"backend::lang.messages.list.reorder": {
|
||||
"other": "Zmień kolejność rekordów"
|
||||
},
|
||||
"backend::lang.messages.list.selected": {
|
||||
"other": "Zaznaczono :count"
|
||||
},
|
||||
@@ -609,6 +636,183 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.reorder.empty": {
|
||||
"other": "Nie ma jeszcze czego porządkować"
|
||||
},
|
||||
"backend::lang.reorder.empty_hint": {
|
||||
"other": "Najpierw dodaj rekordy, a potem wróć tutaj, aby ustalić ich kolejność."
|
||||
},
|
||||
"backend::lang.reorder.failed": {
|
||||
"other": "Nie udało się zapisać nowej kolejności. Przywrócono poprzednią."
|
||||
},
|
||||
"backend::lang.reorder.indent": {
|
||||
"other": "Przenieś do poprzedniej pozycji: :name"
|
||||
},
|
||||
"backend::lang.reorder.load_failed": {
|
||||
"other": "Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie."
|
||||
},
|
||||
"backend::lang.reorder.move": {
|
||||
"other": "Przenieś: :name"
|
||||
},
|
||||
"backend::lang.reorder.move_down": {
|
||||
"other": "Przenieś niżej: :name"
|
||||
},
|
||||
"backend::lang.reorder.move_up": {
|
||||
"other": "Przenieś wyżej: :name"
|
||||
},
|
||||
"backend::lang.reorder.moved": {
|
||||
"other": "Przeniesiono :name na pozycję :position z :total."
|
||||
},
|
||||
"backend::lang.reorder.nested": {
|
||||
"other": ":name jest teraz wewnątrz :parent."
|
||||
},
|
||||
"backend::lang.reorder.outdent": {
|
||||
"other": "Przenieś poziom wyżej: :name"
|
||||
},
|
||||
"backend::lang.reorder.saved": {
|
||||
"other": "Zapisano kolejność"
|
||||
},
|
||||
"backend::lang.reorder.stale": {
|
||||
"other": "Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie."
|
||||
},
|
||||
"backend::lang.reorder.tree": {
|
||||
"other": "Kolejność rekordów"
|
||||
},
|
||||
"backend::lang.reorder.unnested": {
|
||||
"other": "Przeniesiono :name na poziom :level."
|
||||
},
|
||||
"backend::lang.reorder.untitled": {
|
||||
"other": "Bez tytułu (#:id)"
|
||||
},
|
||||
"backend::lang.repeater.add": {
|
||||
"other": "Dodaj pozycję"
|
||||
},
|
||||
"backend::lang.repeater.collapse": {
|
||||
"other": "Zwiń: :title"
|
||||
},
|
||||
"backend::lang.repeater.collapse_all": {
|
||||
"other": "Zwiń wszystkie"
|
||||
},
|
||||
"backend::lang.repeater.count": {
|
||||
"other": "Pozycje: :current z :max"
|
||||
},
|
||||
"backend::lang.repeater.empty": {
|
||||
"other": "Brak pozycji"
|
||||
},
|
||||
"backend::lang.repeater.empty_hint": {
|
||||
"other": "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą."
|
||||
},
|
||||
"backend::lang.repeater.expand": {
|
||||
"other": "Rozwiń: :title"
|
||||
},
|
||||
"backend::lang.repeater.expand_all": {
|
||||
"other": "Rozwiń wszystkie"
|
||||
},
|
||||
"backend::lang.repeater.max_items": {
|
||||
"other": "Usuń pozycje, aby zostało ich najwyżej :max."
|
||||
},
|
||||
"backend::lang.repeater.min_items": {
|
||||
"other": "Dodaj pozycje, aby było ich co najmniej :min."
|
||||
},
|
||||
"backend::lang.repeater.move": {
|
||||
"other": "Przenieś: :title"
|
||||
},
|
||||
"backend::lang.repeater.move_down": {
|
||||
"other": "Przenieś niżej: :title"
|
||||
},
|
||||
"backend::lang.repeater.move_up": {
|
||||
"other": "Przenieś wyżej: :title"
|
||||
},
|
||||
"backend::lang.repeater.moved": {
|
||||
"other": "Przeniesiono :title na pozycję :position z :total."
|
||||
},
|
||||
"backend::lang.repeater.remove": {
|
||||
"other": "Usuń pozycję"
|
||||
},
|
||||
"backend::lang.repeater.remove_confirm": {
|
||||
"other": "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem."
|
||||
},
|
||||
"backend::lang.repeater.remove_named": {
|
||||
"other": "Usuń: :title"
|
||||
},
|
||||
"backend::lang.repeater.untitled": {
|
||||
"other": "Pozycja :n"
|
||||
},
|
||||
"backend::lang.richeditor.bold": {
|
||||
"other": "Pogrubienie"
|
||||
},
|
||||
"backend::lang.richeditor.bullet_list": {
|
||||
"other": "Lista punktowana"
|
||||
},
|
||||
"backend::lang.richeditor.heading": {
|
||||
"other": "Nagłówek"
|
||||
},
|
||||
"backend::lang.richeditor.italic": {
|
||||
"other": "Kursywa"
|
||||
},
|
||||
"backend::lang.richeditor.link": {
|
||||
"other": "Link"
|
||||
},
|
||||
"backend::lang.richeditor.link_apply": {
|
||||
"other": "Zastosuj link"
|
||||
},
|
||||
"backend::lang.richeditor.link_invalid": {
|
||||
"other": "Link musi używać http, https, mailto, tel albo adresu względnego."
|
||||
},
|
||||
"backend::lang.richeditor.link_url": {
|
||||
"other": "Adres linku"
|
||||
},
|
||||
"backend::lang.richeditor.load_failed": {
|
||||
"other": "Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio."
|
||||
},
|
||||
"backend::lang.richeditor.lossy_note": {
|
||||
"other": "Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty."
|
||||
},
|
||||
"backend::lang.richeditor.markup_changed": {
|
||||
"other": "Filtr HTML zmienił część znaczników."
|
||||
},
|
||||
"backend::lang.richeditor.ordered_list": {
|
||||
"other": "Lista numerowana"
|
||||
},
|
||||
"backend::lang.richeditor.quote": {
|
||||
"other": "Cytat"
|
||||
},
|
||||
"backend::lang.richeditor.redo": {
|
||||
"other": "Ponów"
|
||||
},
|
||||
"backend::lang.richeditor.removed_attributes": {
|
||||
"one": "Usunięto :count atrybut: :names",
|
||||
"few": "Usunięto :count atrybuty: :names",
|
||||
"many": "Usunięto :count atrybutów: :names",
|
||||
"other": "Usunięto :count atrybutu: :names"
|
||||
},
|
||||
"backend::lang.richeditor.removed_comments": {
|
||||
"one": "Usunięto :count komentarz HTML",
|
||||
"few": "Usunięto :count komentarze HTML",
|
||||
"many": "Usunięto :count komentarzy HTML",
|
||||
"other": "Usunięto :count komentarza HTML"
|
||||
},
|
||||
"backend::lang.richeditor.removed_elements": {
|
||||
"one": "Usunięto :count element: :names",
|
||||
"few": "Usunięto :count elementy: :names",
|
||||
"many": "Usunięto :count elementów: :names",
|
||||
"other": "Usunięto :count elementu: :names"
|
||||
},
|
||||
"backend::lang.richeditor.source": {
|
||||
"other": "Edytuj HTML"
|
||||
},
|
||||
"backend::lang.richeditor.toolbar": {
|
||||
"other": "Formatowanie"
|
||||
},
|
||||
"backend::lang.richeditor.underline": {
|
||||
"other": "Podkreślenie"
|
||||
},
|
||||
"backend::lang.richeditor.undo": {
|
||||
"other": "Cofnij"
|
||||
},
|
||||
"backend::lang.richeditor.unlink": {
|
||||
"other": "Usuń link"
|
||||
},
|
||||
"backend::lang.settings.back": {
|
||||
"other": "Wróć do ustawień"
|
||||
},
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -7,6 +7,7 @@ import type { components } from '../../src/api/schema'
|
||||
import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import companionsFormSchemaJson from './companions.form-schema.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import extensionListSchemaJson from './extension.list-schema.json'
|
||||
import extensionPartialJson from './extension.partial.json'
|
||||
@@ -46,6 +47,8 @@ export const deferredRelationSchemaFixture: {
|
||||
} = deferredRelationSchemaJson
|
||||
/** A protected file list: a captioned image, a pending image and a PDF (Phase 12.2). */
|
||||
export const deferredFilesFixture: S['cabana.Envelope-array_cabana_FileItem'] = deferredFilesJson
|
||||
/** A link form with the Phase 17 field types: richeditor, repeater and a dependent dropdown. */
|
||||
export const companionsFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = companionsFormSchemaJson
|
||||
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
||||
|
||||
128
admin/tests/form/DependentDropdown.test.ts
Normal file
128
admin/tests/form/DependentDropdown.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the dependent dropdown (D-08). Plan 06
|
||||
// completes the coverage.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
const REFERENCES = `${BASE}/fields/reference/dependent-options`
|
||||
|
||||
const answers: Record<string, Reply> = {
|
||||
post: {
|
||||
body: {
|
||||
data: {
|
||||
applies: true,
|
||||
options: [
|
||||
{ value: '7', label: 'Hello world', depth: 0 },
|
||||
{ value: '8', label: 'Nested post', depth: 1 },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
},
|
||||
category: { body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } },
|
||||
url: { body: { data: { applies: false, options: [] }, meta: {} } },
|
||||
}
|
||||
|
||||
function references(request: Request): Reply {
|
||||
return answers[queryOf(request).get('values[type]') ?? ''] ?? { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
||||
}
|
||||
|
||||
function routes(type: string, reference: string | null, overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', type, reference, rows: [] }, meta: {} } },
|
||||
[`GET ${REFERENCES}`]: references,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('DependentDropdown', () => {
|
||||
it('DependentDropdown loads options for the sibling value and clears a stale value', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('post', '7', {
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: { data: { id: 1, ...body }, meta: {} } }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
// textContent: text() trims the U+00A0 depth indent.
|
||||
const optionTexts = () => select().findAll('option').map((option) => (option.element.textContent ?? '').replace(/^ +| +$/g, ''))
|
||||
|
||||
// Loaded for the stored sibling value, depth indented, stored value kept.
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(1)
|
||||
expect(queryOf(requestsTo(calls, 'GET', REFERENCES)[0]!).get('values[type]')).toBe('post')
|
||||
expect(select().attributes('disabled')).toBeUndefined()
|
||||
expect(optionTexts()).toEqual(['Hello world', ' Nested post'])
|
||||
expect(select().element.selectedOptions[0]!.text).toBe('Hello world')
|
||||
|
||||
// The sibling changes: new options, the stale value is cleared and announced.
|
||||
await wrapper.find('#field-type').setValue('2')
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', REFERENCES)[1]!).get('values[type]')).toBe('category')
|
||||
expect(optionTexts()).toEqual(['Wybierz…', 'News'])
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-form-live]').text()).toBe('Wyczyszczono pole „Reference”, bo zmieniły się jego opcje.')
|
||||
})
|
||||
|
||||
// A type without references: disabled, not used.
|
||||
await wrapper.find('#field-type').setValue('0')
|
||||
await flushPromises()
|
||||
expect(select().attributes('disabled')).toBeDefined()
|
||||
expect(optionTexts()).toEqual(['Nie dotyczy tego wyboru'])
|
||||
|
||||
// Back to a type seen before: answered from the cache, no new request.
|
||||
await wrapper.find('#field-type').setValue('1')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(3)
|
||||
expect(optionTexts()).toEqual(['Wybierz…', 'Hello world', ' Nested post'])
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(body).toMatchObject({ type: 'post', reference: null })
|
||||
})
|
||||
|
||||
it('waits for the sibling, and offers a retry when loading fails', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('', null, {
|
||||
[`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', type: null, reference: null, rows: [] }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
expect(select.attributes('disabled')).toBeDefined()
|
||||
expect(select.text()).toBe('Najpierw wybierz pole „Type”')
|
||||
expect(requestsTo(calls, 'GET', REFERENCES)).toHaveLength(0)
|
||||
|
||||
answers.post = { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
|
||||
try {
|
||||
await wrapper.find('#field-type').setValue('1')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-field="reference"] [role="alert"]').text()).toBe('Nie udało się wczytać opcji.')
|
||||
} finally {
|
||||
answers.post = { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }] }, meta: {} } }
|
||||
}
|
||||
await wrapper.find('[data-action="dependent-retry"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find<HTMLSelectElement>('#field-reference').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('#field-reference').text()).toContain('Hello world')
|
||||
})
|
||||
})
|
||||
138
admin/tests/form/FileCaptionModal.test.ts
Normal file
138
admin/tests/form/FileCaptionModal.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// FileCaptionModal (Phase 12.2, D-09; UI-SPEC section 3, caption modal)
|
||||
// mounted on its own over a stub FileRoutes, so no HTTP is involved. Save
|
||||
// sends title and description through routes.update; a 422 stays in the
|
||||
// dialog under its inputs; any other failure keeps the input and posts a
|
||||
// danger toast. The language bundle has no fileupload strings, so the test
|
||||
// asserts on data attributes and events rather than texts.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { FileCallResult, FileRoutes } from '../../src/api/files'
|
||||
import type { FileItem } from '../../src/api/types'
|
||||
import FileCaptionModal from '../../src/components/form/fields/FileCaptionModal.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const FILE: FileItem = {
|
||||
id: 41,
|
||||
file_name: 'front.png',
|
||||
file_size: 120400,
|
||||
content_type: 'image/png',
|
||||
title: 'Front',
|
||||
description: 'The front side',
|
||||
sort_order: 41,
|
||||
pending: false,
|
||||
created_at: '2026-10-02T10:00:00Z',
|
||||
}
|
||||
|
||||
function routesStub(update: FileRoutes['update']): FileRoutes {
|
||||
return {
|
||||
list: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
update: vi.fn(update),
|
||||
remove: vi.fn(),
|
||||
reorder: vi.fn(),
|
||||
download: vi.fn(),
|
||||
thumb: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function ok(item: FileItem): FileCallResult<FileItem> {
|
||||
return { data: item, status: 200, error: null }
|
||||
}
|
||||
|
||||
async function mountModal(routes: FileRoutes) {
|
||||
const wrapper = mount(FileCaptionModal, {
|
||||
props: { open: true, file: FILE, routes, preview: null, idPrefix: 'gadget' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function el<T extends Element>(selector: string): T {
|
||||
const found = document.body.querySelector<T>(selector)
|
||||
if (!found) {
|
||||
throw new Error(`missing ${selector}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function type(selector: string, value: string): void {
|
||||
const input = el<HTMLInputElement | HTMLTextAreaElement>(selector)
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
el<HTMLButtonElement>('[data-action="save-caption"]').click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('FileCaptionModal mounted directly', () => {
|
||||
it('prefills title and description from the file', async () => {
|
||||
await mountModal(routesStub(async () => ok(FILE)))
|
||||
expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull()
|
||||
expect(el<HTMLInputElement>('[data-caption-title]').value).toBe('Front')
|
||||
expect(el<HTMLTextAreaElement>('[data-caption-description]').value).toBe('The front side')
|
||||
expect(document.body.textContent).toContain(FILE.file_name)
|
||||
})
|
||||
|
||||
it('saves the edited caption through routes.update and closes', async () => {
|
||||
const saved: FileItem = { ...FILE, title: 'Front cover', description: 'Shown first' }
|
||||
const routes = routesStub(async () => ok(saved))
|
||||
const wrapper = await mountModal(routes)
|
||||
type('[data-caption-title]', 'Front cover')
|
||||
type('[data-caption-description]', 'Shown first')
|
||||
await save()
|
||||
|
||||
expect(routes.update).toHaveBeenCalledTimes(1)
|
||||
expect(routes.update).toHaveBeenCalledWith(FILE.id, { title: 'Front cover', description: 'Shown first' })
|
||||
expect(wrapper.emitted('saved')).toEqual([[saved]])
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
expect(toasts.value[0]?.tone).toBe('success')
|
||||
})
|
||||
|
||||
it('keeps the dialog open on a 422 and shows the field message', async () => {
|
||||
const routes = routesStub(async () => ({
|
||||
data: null,
|
||||
status: 422,
|
||||
error: { code: 'validation_failed', message: 'Invalid', details: { title: ['Title is too long.'] } },
|
||||
}))
|
||||
const wrapper = await mountModal(routes)
|
||||
type('[data-caption-title]', 'x'.repeat(300))
|
||||
await save()
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined()
|
||||
expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull()
|
||||
expect(el('#gadget-caption-title-error').textContent).toContain('Title is too long.')
|
||||
expect(el<HTMLInputElement>('[data-caption-title]').getAttribute('aria-invalid')).toBe('true')
|
||||
expect(toasts.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('posts a danger toast on any other failure and keeps the typed input', async () => {
|
||||
const routes = routesStub(async () => ({
|
||||
data: null,
|
||||
status: 500,
|
||||
error: { code: 'internal', message: 'Server exploded', details: {} },
|
||||
}))
|
||||
const wrapper = await mountModal(routes)
|
||||
type('[data-caption-title]', 'Kept title')
|
||||
await save()
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined()
|
||||
expect(toasts.value[0]).toMatchObject({ tone: 'danger', text: 'Server exploded' })
|
||||
expect(el<HTMLInputElement>('[data-caption-title]').value).toBe('Kept title')
|
||||
})
|
||||
})
|
||||
120
admin/tests/form/MLField.test.ts
Normal file
120
admin/tests/form/MLField.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// MLField, the locale-switching wrapper of the ML controls, mounted on its own
|
||||
// with the enabled locales FormView would provide: it edits only the active
|
||||
// locale, emits the whole map, follows ML_LOCALE_CHANGE from every other ML
|
||||
// control and hands content-locale only to an inner control that asks for it.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, markRaw, ref, type Component } from 'vue'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import MLField from '../../src/components/form/fields/MLField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||
import { ML_LOCALE_CHANGE } from '../../src/components/form/mlLocale'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
|
||||
|
||||
function field(): FormField {
|
||||
return { name: 'title', label: 'Title', type: 'mltext' } as FormField
|
||||
}
|
||||
|
||||
function mountML(props: { inner?: Component; contentLocale?: boolean; modelValue?: unknown; controlId?: string } = {}) {
|
||||
return mount(MLField, {
|
||||
props: {
|
||||
field: field(),
|
||||
modelValue: props.modelValue ?? { en: 'Hello', pl: 'Cześć' },
|
||||
controlId: props.controlId ?? 'f-title',
|
||||
// markRaw: test-utils props are reactive, a component prop must not be.
|
||||
inner: markRaw(props.inner ?? TextField),
|
||||
innerType: 'text',
|
||||
mlType: 'mltext',
|
||||
contentLocale: props.contentLocale,
|
||||
},
|
||||
attachTo: document.body,
|
||||
global: { provide: { [FORM_ENABLED_LOCALES as symbol]: enabledLocales } },
|
||||
})
|
||||
}
|
||||
|
||||
/** An inner control that declares content-locale and shows what it got. */
|
||||
const LocaleProbe = defineComponent({
|
||||
props: {
|
||||
field: { type: Object, required: true },
|
||||
modelValue: { type: null, default: '' },
|
||||
controlId: { type: String, default: '' },
|
||||
contentLocale: { type: String, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('span', { 'data-probe': '', 'data-content-locale': props.contentLocale ?? 'none' }, String(props.modelValue))
|
||||
},
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
enabledLocales.value = ['en', 'pl']
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('MLField mounted directly', () => {
|
||||
it('renders the ML type, the enabled locales and the first locale text', () => {
|
||||
const wrapper = mountML()
|
||||
expect(wrapper.attributes('data-ml-field')).toBe('mltext')
|
||||
const options = wrapper.findAll('[data-ml-locale] option').map((option) => option.attributes('value'))
|
||||
expect(options).toEqual(['en', 'pl'])
|
||||
expect((wrapper.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('en')
|
||||
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Hello')
|
||||
})
|
||||
|
||||
it('emits the whole map with only the active locale changed', async () => {
|
||||
const wrapper = mountML()
|
||||
await wrapper.find('input').setValue('Hi there')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[{ en: 'Hi there', pl: 'Cześć' }]])
|
||||
})
|
||||
|
||||
it('switches locale from the select and broadcasts it to every other ML control', async () => {
|
||||
const first = mountML({ controlId: 'f-one' })
|
||||
const second = mountML({ controlId: 'f-two' })
|
||||
await first.find('[data-ml-locale]').setValue('pl')
|
||||
await flushPromises()
|
||||
expect((first.find('input').element as HTMLInputElement).value).toBe('Cześć')
|
||||
expect((second.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl')
|
||||
expect((second.find('input').element as HTMLInputElement).value).toBe('Cześć')
|
||||
})
|
||||
|
||||
it('edits the switched locale only', async () => {
|
||||
const wrapper = mountML()
|
||||
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||
await wrapper.find('input').setValue('Witaj')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[{ en: 'Hello', pl: 'Witaj' }]])
|
||||
})
|
||||
|
||||
it('follows a window ML_LOCALE_CHANGE event and ignores an empty one', async () => {
|
||||
const wrapper = mountML()
|
||||
window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: 'pl' }))
|
||||
await flushPromises()
|
||||
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Cześć')
|
||||
window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: '' }))
|
||||
await flushPromises()
|
||||
expect((wrapper.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl')
|
||||
})
|
||||
|
||||
it('hands the active locale to an inner control only with contentLocale', async () => {
|
||||
const withLocale = mountML({ inner: LocaleProbe, contentLocale: true, controlId: 'f-with' })
|
||||
expect(withLocale.find('[data-probe]').attributes('data-content-locale')).toBe('en')
|
||||
await withLocale.find('[data-ml-locale]').setValue('pl')
|
||||
expect(withLocale.find('[data-probe]').attributes('data-content-locale')).toBe('pl')
|
||||
expect(withLocale.find('[data-probe]').text()).toBe('Cześć')
|
||||
|
||||
const without = mountML({ inner: LocaleProbe, controlId: 'f-without' })
|
||||
expect(without.find('[data-probe]').attributes('data-content-locale')).toBe('none')
|
||||
})
|
||||
|
||||
it('shows an empty input for a locale without text and for a non-map value', () => {
|
||||
const wrapper = mountML({ modelValue: 'not a map' })
|
||||
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
})
|
||||
166
admin/tests/form/RepeaterField.test.ts
Normal file
166
admin/tests/form/RepeaterField.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the repeater field (D-07). Plan 06
|
||||
// completes the coverage.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
const TARGETS = `${BASE}/fields/rows.target/dependent-options`
|
||||
|
||||
function record() {
|
||||
return {
|
||||
data: {
|
||||
id: 1,
|
||||
title: 'Docs',
|
||||
description: '',
|
||||
rows: [
|
||||
{ question: 'First', kind: 'url', target: null, visible: true },
|
||||
{ question: 'Second', kind: 'url', target: null, visible: false },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record() },
|
||||
[`GET ${TARGETS}`]: { body: { data: { applies: false, options: [] }, meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const titles = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater="rows"] [data-repeater-title]').map((node) => node.text())
|
||||
const rowAt = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater="rows"] [data-repeater-row="${index}"]`)
|
||||
|
||||
function confirmDialog(): HTMLElement | null {
|
||||
return document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('RepeaterField', () => {
|
||||
it('RepeaterField tracer: add, edit nested, reorder and remove change form state only', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: { data: { id: 1, ...body }, meta: {} } }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const group = wrapper.find('[data-repeater="rows"]')
|
||||
expect(group.attributes('role')).toBe('group')
|
||||
expect(group.attributes('aria-labelledby')).toBe('field-rows-label')
|
||||
expect(wrapper.find('#field-rows-label').text()).toBe('Questions')
|
||||
expect(titles(wrapper)).toEqual(['First', 'Second'])
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 2 z 3')
|
||||
|
||||
// Add: a third row with nested defaults; the counter reaches maxItems.
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['First', 'Second', 'Pozycja 3'])
|
||||
expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3')
|
||||
|
||||
// Edit a nested field: the row title follows titleFrom.
|
||||
const third = rowAt(wrapper, 2)
|
||||
const question = third.find<HTMLInputElement>('[data-field="question"] input')
|
||||
expect(question.attributes('id')).toMatch(/^field-rows-r\d+-question$/)
|
||||
await question.setValue('Third')
|
||||
expect(titles(wrapper)).toEqual(['First', 'Second', 'Third'])
|
||||
|
||||
// Reorder by button and by keyboard on the handle; the move is announced.
|
||||
await rowAt(wrapper, 0).find('[data-action="repeater-down"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['Second', 'First', 'Third'])
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-form-live]').text()).toBe('Przeniesiono First na pozycję 2 z 3.')
|
||||
})
|
||||
const handle = rowAt(wrapper, 2).find('[data-repeater-handle]')
|
||||
keydown(handle.element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['Second', 'Third', 'First'])
|
||||
expect(document.activeElement).toBe(rowAt(wrapper, 1).find('[data-repeater-handle]').element)
|
||||
|
||||
// Remove a row that holds a value: it asks first.
|
||||
await rowAt(wrapper, 0).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmDialog()).not.toBeNull()
|
||||
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Usunąć „Second”?')
|
||||
confirmDialog()!.click()
|
||||
await flushPromises()
|
||||
expect(titles(wrapper)).toEqual(['Third', 'First'])
|
||||
|
||||
// An added row holding only its defaults is removed at once.
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await flushPromises()
|
||||
await rowAt(wrapper, 2).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmDialog()).toBeNull()
|
||||
expect(titles(wrapper)).toEqual(['Third', 'First'])
|
||||
|
||||
// Nothing was sent before Save; Save sends the rows as edited.
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
expect(calls.every((request) => request.method === 'GET')).toBe(true)
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(body!.rows).toEqual([
|
||||
{ question: 'Third', kind: null, target: null, visible: true },
|
||||
{ question: 'First', kind: 'url', target: null, visible: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('expands rows with path errors, shows the chip and focuses the first invalid nested control', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { 'rows.1.question': ['Question is required.'] } } },
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
// Collapse the second row first: the error opens it again.
|
||||
await rowAt(wrapper, 1).find('[data-action="repeater-toggle"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowAt(wrapper, 1).find('[data-field="question"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const row = rowAt(wrapper, 1)
|
||||
expect(row.classes()).toContain('border-danger')
|
||||
expect(row.find('[data-repeater-errors]').text()).toBe('1 błąd')
|
||||
expect(row.find('[data-field="question"]').text()).toContain('Question is required.')
|
||||
expect(document.activeElement).toBe(row.find('[data-field="question"] input').element)
|
||||
// The banner counts it but does not repeat the row message.
|
||||
expect(wrapper.find('[data-error-banner]').text()).not.toContain('Question is required.')
|
||||
})
|
||||
|
||||
it('shows the empty state with the prompt', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({ [`GET ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', rows: [] }, meta: {} } } }),
|
||||
)
|
||||
expect(wrapper.find('[data-repeater-empty]').text()).toContain('Brak pozycji')
|
||||
expect(wrapper.find('[data-repeater-empty]').text()).toContain('Użyj przycisku „Add question” poniżej, aby dodać pierwszą.')
|
||||
expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question')
|
||||
})
|
||||
})
|
||||
190
admin/tests/form/RichEditorField.test.ts
Normal file
190
admin/tests/form/RichEditorField.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import RichEditorField from '../../src/components/form/fields/RichEditorField.vue'
|
||||
import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html'
|
||||
import { isLossy } from '../../src/components/form/richeditor/lossy'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06
|
||||
// completes the coverage; these prove the tracer slice end to end.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
|
||||
// b, div and attributes: markup TipTap would re-serialize if it were asked.
|
||||
const STORED = '<P class="lead">Hello <b>bold</b> world</P>\n<div data-x="1" style="color: red">Box</div><!-- kept -->'
|
||||
|
||||
function record(description: string | null) {
|
||||
return { data: { id: 1, title: 'Docs', description }, meta: {} }
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record(STORED) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function editorReady(wrapper: VueWrapper): Promise<void> {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(wrapper.find('[data-richeditor-area]').exists()).toBe(true)
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('RichEditorField', () => {
|
||||
it('RichEditorField tracer: untouched value is submitted byte-identical', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: record(body.description as string) }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
|
||||
// The editor shows the stored text; b and div are inside the editor's set.
|
||||
expect(wrapper.find('[data-richeditor-area]').text()).toContain('Hello bold world')
|
||||
expect(wrapper.find('[data-richeditor-lossy]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-richeditor-area]').attributes('aria-labelledby')).toBe('field-description-label')
|
||||
expect(wrapper.find('#field-description-label').text()).toBe('Description')
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(body).not.toBeNull()
|
||||
expect(body!.description).toBe(STORED)
|
||||
expect(body!.description === STORED).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the lossy note for markup outside the editor schema and keeps the HTML view lossless', async () => {
|
||||
const lossy = '<table><tr><td>Cell</td></tr></table><p>Text</p>'
|
||||
const { wrapper } = await mountApp('/acme/companions/links/1', routes({ [`GET ${RECORD}`]: { body: record(lossy) } }), {
|
||||
attach: true,
|
||||
})
|
||||
await editorReady(wrapper)
|
||||
expect(wrapper.find('[data-richeditor-lossy]').attributes('role')).toBe('note')
|
||||
|
||||
await wrapper.find('[data-tool="source"]').trigger('click')
|
||||
const textarea = wrapper.find<HTMLTextAreaElement>('[data-richeditor-source]')
|
||||
expect(textarea.element.value).toBe(lossy)
|
||||
expect(textarea.attributes('id')).toBe('field-description')
|
||||
})
|
||||
|
||||
it('sends the editor HTML after an edit and shows what the server removed', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const sanitized = '<h3>Hello bold world</h3>'
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`GET ${RECORD}`]: { body: record('<p>Hello <b>bold</b> world</p>') },
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return {
|
||||
body: {
|
||||
data: { id: 1, title: 'Docs', description: sanitized },
|
||||
meta: { notices: [{ field: 'description', message: '1 element was removed: b' }] },
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
|
||||
await wrapper.find('[data-tool="heading"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-tool="heading"]').attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(body!.description).toBe('<h3>Hello <strong>bold</strong> world</h3>')
|
||||
expect(wrapper.find('[data-field="description"] [data-field-notice]').text()).toBe('1 element was removed: b')
|
||||
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello bold world</h3>')
|
||||
})
|
||||
|
||||
it('RichEditorField edit emits editor HTML', async () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
||||
const wrapper = mount(RichEditorField, {
|
||||
props: { field, modelValue: '<p>Hello</p>', controlId: 'f-body' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(wrapper.find('[data-richeditor-loading]').attributes('aria-busy')).toBe('true')
|
||||
await editorReady(wrapper)
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
|
||||
await wrapper.find('[data-tool="bold"]').trigger('click')
|
||||
await wrapper.find('[data-tool="heading"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(emitted.length).toBeGreaterThan(0)
|
||||
const last = emitted[emitted.length - 1]![0]
|
||||
expect(last).toBe('<h3>Hello</h3>')
|
||||
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello</h3>')
|
||||
|
||||
// The HTML view emits the text as typed.
|
||||
await wrapper.find('[data-tool="source"]').trigger('click')
|
||||
await wrapper.find('[data-richeditor-source]').setValue('<p>Typed <em>here</em></p>')
|
||||
const afterSource = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(afterSource[afterSource.length - 1]![0]).toBe('<p>Typed <em>here</em></p>')
|
||||
})
|
||||
|
||||
it('renders read-only values through the display policy, never as stored', () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField
|
||||
const wrapper = mount(RichEditorField, {
|
||||
props: {
|
||||
field,
|
||||
modelValue: '<p onclick="x()">Hi <a href="javascript:alert(1)">bad</a> <a href="/ok">ok</a></p><script>window.__re = 1</script>',
|
||||
controlId: 'f-body',
|
||||
},
|
||||
})
|
||||
const box = wrapper.find('[data-richeditor-readonly]')
|
||||
expect(box.find('script').exists()).toBe(false)
|
||||
expect(box.find('p').attributes('onclick')).toBeUndefined()
|
||||
expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok'])
|
||||
expect((window as unknown as { __re?: number }).__re).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('richeditor policy helpers', () => {
|
||||
it('accepts policy links only', () => {
|
||||
for (const url of ['https://example.test/a', 'http://x.test', 'mailto:a@example.test', 'tel:+48123', '/path', '#top', '?q=1', './rel', 'docs/page']) {
|
||||
expect(isAllowedLink(url), url).toBe(true)
|
||||
}
|
||||
for (const url of ['javascript:alert(1)', 'java\tscript:alert(1)', 'data:text/html,x', 'ftp://x.test', '', ' ']) {
|
||||
expect(isAllowedLink(url), url).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('compares tag names case-insensitively for the lossy note', () => {
|
||||
expect(isLossy('<P>a</P><DIV><SPAN>b</SPAN></DIV>')).toBe(false)
|
||||
expect(isLossy('<p>a</p><H2>b</H2>')).toBe(true)
|
||||
expect(isLossy('')).toBe(false)
|
||||
})
|
||||
|
||||
it('unwraps unknown elements and drops scripts for display', () => {
|
||||
expect(displayHtml('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
86
admin/tests/form/mlLocale.test.ts
Normal file
86
admin/tests/form/mlLocale.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// mlLocale (Phase 15): the ML field type predicates, the locale -> text map
|
||||
// sent on save and the window event that keeps every ML control on one locale.
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ML_LOCALE_CHANGE,
|
||||
broadcastMLLocale,
|
||||
isMLField,
|
||||
isMLFieldType,
|
||||
localeRecord,
|
||||
} from '../../src/components/form/mlLocale'
|
||||
|
||||
describe('isMLFieldType', () => {
|
||||
it('accepts the three ML types', () => {
|
||||
expect(isMLFieldType('mltext')).toBe(true)
|
||||
expect(isMLFieldType('mltextarea')).toBe(true)
|
||||
expect(isMLFieldType('mlmarkdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses plain types and a missing type', () => {
|
||||
expect(isMLFieldType('text')).toBe(false)
|
||||
expect(isMLFieldType('markdown')).toBe(false)
|
||||
expect(isMLFieldType(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMLField', () => {
|
||||
it('accepts a built-in ML type', () => {
|
||||
expect(isMLField({ type: 'mltext' })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a translatable plugin field with an element', () => {
|
||||
expect(isMLField({ type: 'acme-color', element: 'acme-color', translatable: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a translatable field without an element', () => {
|
||||
expect(isMLField({ type: 'acme-color', translatable: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a plugin field that is not translatable', () => {
|
||||
expect(isMLField({ type: 'acme-color', element: 'acme-color' })).toBe(false)
|
||||
expect(isMLField({ type: 'acme-color', element: 'acme-color', translatable: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a missing field', () => {
|
||||
expect(isMLField(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('localeRecord', () => {
|
||||
it('keeps only string values', () => {
|
||||
expect(localeRecord({ en: 'Hello', pl: 'Cześć', de: 3, fr: null, es: '' })).toEqual({
|
||||
en: 'Hello',
|
||||
pl: 'Cześć',
|
||||
es: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty map for null, an array and a string', () => {
|
||||
expect(localeRecord(null)).toEqual({})
|
||||
expect(localeRecord(undefined)).toEqual({})
|
||||
expect(localeRecord(['Hello'])).toEqual({})
|
||||
expect(localeRecord('Hello')).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('broadcastMLLocale', () => {
|
||||
const seen: string[] = []
|
||||
const listener = (event: Event) => seen.push((event as CustomEvent<string>).detail)
|
||||
|
||||
afterEach(() => {
|
||||
window.removeEventListener(ML_LOCALE_CHANGE, listener)
|
||||
seen.length = 0
|
||||
})
|
||||
|
||||
it('dispatches the locale as a window event', () => {
|
||||
window.addEventListener(ML_LOCALE_CHANGE, listener)
|
||||
broadcastMLLocale('pl')
|
||||
expect(seen).toEqual(['pl'])
|
||||
})
|
||||
|
||||
it('dispatches nothing for an empty locale', () => {
|
||||
window.addEventListener(ML_LOCALE_CHANGE, listener)
|
||||
broadcastMLLocale('')
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
221
admin/tests/views/ReorderView.test.ts
Normal file
221
admin/tests/views/ReorderView.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's
|
||||
// Reorder button and the singleton form (D-11). Plan 06 completes them.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const REORDER = `${BASE}/reorder`
|
||||
|
||||
function tree(): Reply {
|
||||
return {
|
||||
body: {
|
||||
data: {
|
||||
title: 'Reorder links',
|
||||
partition: {
|
||||
label: 'Group',
|
||||
value: 5,
|
||||
options: [
|
||||
{ value: 5, label: 'Main' },
|
||||
{ value: 6, label: 'Footer' },
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{ id: 1, name: 'Home', parent_id: null, sort_order: 1 },
|
||||
{ id: 2, name: 'Blog', parent_id: null, sort_order: 2 },
|
||||
{ id: 3, name: 'News', parent_id: 2, sort_order: 3 },
|
||||
{ id: 4, name: '', parent_id: null, sort_order: 4 },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The tree as "name(children)" strings, in display order. */
|
||||
function shape(wrapper: VueWrapper): string {
|
||||
const read = (list: Element): string =>
|
||||
Array.from(list.children)
|
||||
.map((item) => {
|
||||
const name = item.querySelector(':scope > [data-reorder-node] > span.min-w-0')?.textContent?.trim() ?? '?'
|
||||
const children = item.querySelector(':scope > ul')
|
||||
return children ? `${name}(${read(children)})` : name
|
||||
})
|
||||
.join(' ')
|
||||
return read(wrapper.find('[data-reorder-tree]').element)
|
||||
}
|
||||
|
||||
const handle = (wrapper: VueWrapper, id: number) => wrapper.find(`[data-reorder-handle="${id}"]`)
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ReorderView', () => {
|
||||
it('ReorderView tracer: a keyboard move sends one debounced POST with the full node set', async () => {
|
||||
let body: unknown = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: tree(),
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
body = await request.json()
|
||||
return tree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
expect(wrapper.find('h1').text()).toBe('Reorder links')
|
||||
expect(wrapper.find('[data-reorder-tree]').attributes('aria-label')).toBe('Kolejność rekordów')
|
||||
expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)')
|
||||
expect(wrapper.find<HTMLSelectElement>('[data-reorder-partition]').element.value).toBe('5')
|
||||
// First row: up, indent and outdent are impossible.
|
||||
const first = wrapper.find('[data-reorder-node="1"]')
|
||||
expect(first.find('[data-action="reorder-up"]').attributes('disabled')).toBeDefined()
|
||||
expect(first.find('[data-action="reorder-indent"]').attributes('disabled')).toBeDefined()
|
||||
expect(first.find('[data-action="reorder-outdent"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
// Three quick moves by keyboard on the handles.
|
||||
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
||||
expect(document.activeElement).toBe(handle(wrapper, 1).element)
|
||||
keydown(handle(wrapper, 1).element, 'ArrowRight')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)')
|
||||
keydown(handle(wrapper, 4).element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Bez tytułu (#4) Blog(News Home)')
|
||||
expect(document.activeElement).toBe(handle(wrapper, 4).element)
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(0)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
expect(body).toEqual({
|
||||
partition: 5,
|
||||
nodes: [
|
||||
{ id: 4, parent_id: null, sort_order: 1 },
|
||||
{ id: 2, parent_id: null, sort_order: 2 },
|
||||
{ id: 3, parent_id: 2, sort_order: 3 },
|
||||
{ id: 1, parent_id: 2, sort_order: 4 },
|
||||
],
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność')
|
||||
})
|
||||
expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Bez tytułu (#4) na pozycję 1 z 2.')
|
||||
await wait(500)
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('restores the saved tree when a save fails and reloads on a stale set', async () => {
|
||||
let status = 500
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: tree(),
|
||||
[`POST ${REORDER}`]: () => ({ status, body: { error: { code: status === 409 ? 'reorder_stale' : 'x', message: 'x', details: {} } } }),
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-reorder-node="1"] [data-action="reorder-down"]').trigger('click')
|
||||
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
||||
await vi.waitFor(() => {
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.', tone: 'danger' })
|
||||
})
|
||||
expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)')
|
||||
|
||||
status = 409
|
||||
await wrapper.find('[data-reorder-node="2"] [data-action="reorder-outdent"]').trigger('click')
|
||||
await wrapper.find('[data-reorder-node="3"] [data-action="reorder-outdent"]').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.', tone: 'danger' })
|
||||
})
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', REORDER)).toHaveLength(2)
|
||||
expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)')
|
||||
})
|
||||
|
||||
it('shows the empty state and the 403 alert', async () => {
|
||||
const empty = tree()
|
||||
;(empty.body as { data: { nodes: unknown[] } }).data.nodes = []
|
||||
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: empty })
|
||||
expect(wrapper.find('[data-reorder-empty]').text()).toContain('Nie ma jeszcze czego porządkować')
|
||||
wrapper.unmount()
|
||||
|
||||
const forbidden = await mountApp('/acme/companions/links/reorder', {
|
||||
[`GET ${REORDER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } },
|
||||
})
|
||||
expect(forbidden.wrapper.find('[role="alert"]').text()).toContain('Nie masz uprawnień')
|
||||
})
|
||||
})
|
||||
|
||||
describe('list Reorder button', () => {
|
||||
it('links to the reorder page with the filtered partition', async () => {
|
||||
const schema = clone(listSchemaFixture)
|
||||
schema.data.toolbarButtons = ['create', 'reorder']
|
||||
schema.data.reorder = { title: 'Reorder links', partition: { column: 'group_id', label: 'Group' } }
|
||||
schema.data.filters = [...schema.data.filters, { ...schema.data.filters[0]!, name: 'group', label: 'Group', column: 'group_id' }]
|
||||
const routes: Record<string, Route> = {
|
||||
[`GET ${BASE}/schema/list`]: { body: schema },
|
||||
[`GET ${BASE}`]: { body: listFixture },
|
||||
}
|
||||
const { wrapper } = await mountApp('/acme/companions/links?filter[group]=6', routes)
|
||||
const buttons = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action'))
|
||||
expect(buttons).toEqual(['reorder', 'create'])
|
||||
const reorder = wrapper.find('[data-action="reorder"]')
|
||||
expect(reorder.text()).toBe('Zmień kolejność rekordów')
|
||||
expect(reorder.attributes('href')).toBe('/acme/companions/links/reorder?partition=6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton form', () => {
|
||||
it('opens the one record from settings, without Delete, and returns to the settings index', async () => {
|
||||
setSettings([
|
||||
{
|
||||
code: 'profile',
|
||||
label: 'Site profile',
|
||||
description: '',
|
||||
icon: 'settings',
|
||||
category: 'CMS',
|
||||
keywords: [],
|
||||
model: '',
|
||||
controller: 'acme.companions.profiles',
|
||||
order: 1,
|
||||
},
|
||||
])
|
||||
const schema = clone(companionsFormSchemaFixture)
|
||||
schema.data.singleton = true
|
||||
schema.data.fields = schema.data.fields.filter((field) => field.name === 'title')
|
||||
const PROFILES = `${API}/acme/companions/profiles`
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/settings',
|
||||
{
|
||||
[`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } },
|
||||
[`GET ${PROFILES}/schema/form`]: { body: schema },
|
||||
[`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } },
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9')
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
|
||||
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ Each framework module is one Go package under `modules/`, documented by its READ
|
||||
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
|
||||
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) |
|
||||
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
|
||||
| Utilities | [sieve](../../modules/sieve/README.md) |
|
||||
|
||||
## What runs where
|
||||
|
||||
|
||||
@@ -446,6 +446,17 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se
|
||||
|
||||
Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button.
|
||||
|
||||
## Singleton controllers
|
||||
|
||||
A controller that edits exactly one record, such as a site-wide definition, implements `pact.AdminSingleton`. Its `SingletonRecord(ctx, db)` returns that record and creates it first when it does not exist (first or create), with `db` the request's transaction. Make the creation safe against two first requests at once, for example with a unique key column and GORM's `FirstOrCreate`.
|
||||
|
||||
- `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/singleton` answers the record's id (`cabana.AdminSingletonResult`), after checking that the form scope (`pact.FormExtendQuery`) can load it. The admin then opens the record's form.
|
||||
- The form schema carries `singleton: true`. Show and update work as on any controller.
|
||||
- The controller needs a `config_form.yaml` and no `config_list.yaml`. Its list, create, bulk and delete routes answer 404 `not_found`, and the singleton route answers 404 on any other controller.
|
||||
- A settings entry whose `Controller` names the singleton controller links to it; see [Settings](settings.md).
|
||||
|
||||
In the admin, clicking such a settings entry asks the singleton route for the id and opens the record's form directly. The form has no Delete button, and its back button, Cancel and Save and close return to the settings index. A settings link to a controller that is not a singleton gets a 404 from that route and opens the controller's list as before.
|
||||
|
||||
## Record actions
|
||||
|
||||
A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through `pact.HasAdminRecordActions` (`PeopleController` above registers two), and `recordActions` in `config_form.yaml` lists the ones the form offers, in display order:
|
||||
|
||||
@@ -101,12 +101,14 @@ for _, f := range form.Fields {
|
||||
| `mltext` | A text field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
| `mltextarea` | A multi-line text field with one editor per locale; it takes `size` as `textarea` does. See [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
| `richeditor` | A WYSIWYG HTML editor for a host text column, sanitized on every save; see [Rich text](#rich-text). |
|
||||
| `repeater` | A list of rows, each with its own small form, stored as JSON in one column; see [Repeater](#repeater). |
|
||||
|
||||
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
|
||||
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
|
||||
|
||||
### Field options
|
||||
|
||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields and `preset` on text and mltext fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
|
||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields and `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
|
||||
|
||||
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
|
||||
|
||||
@@ -127,6 +129,45 @@ Multilingual pairs follow the locale the administrator works in. An `mltext` tar
|
||||
|
||||
A `preset` on another field type (`mltextarea` and `mlmarkdown` included), an unknown type, or a source that is not a text or mltext field of the same form stops the start-up, and the key is not accepted on settings forms or relation forms. An `mltextarea` field is neither a preset target nor a preset source, like `textarea`.
|
||||
|
||||
## Dependent dropdowns
|
||||
|
||||
A `type: dropdown` field with `dependsOn` takes its options from the value of a sibling field, WinterCMS's dependent dropdown. A menu entry's reference dropdown, for example, lists the posts when its type dropdown says "post" and the categories when it says "category":
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
type:
|
||||
label: acme.menu::lang.entry.type
|
||||
type: dropdown
|
||||
options: typeOptions
|
||||
reference:
|
||||
label: acme.menu::lang.entry.reference
|
||||
type: dropdown
|
||||
dependsOn: type
|
||||
emptyOption: backend::lang.form.none
|
||||
```
|
||||
|
||||
`dependsOn` names another field of the same form (or of the same repeater row) whose value is a scalar. The dropdown declares no `options`: the controller implements `pact.DependentOptionsProvider`, and its `DependentOptions` receives the dropdown's name and the sibling's value (a string, or nil when it is empty) and answers a `pact.DependentOptionSet`. `Applies` false means that value takes no choice at all: the admin disables the dropdown and a save stores null. Otherwise `Options` are the choices, and an option's `Depth` indents a choice taken from a tree. A `dependsOn` on another type, on a dropdown with options, naming a missing field or the field itself, or on a controller without the provider stops the start-up; settings and relation forms refuse the key.
|
||||
|
||||
The admin SPA fetches the options from `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value>` whenever the sibling changes; for a dropdown inside a repeater row, `{field}` is `<repeater>.<field>` and the provider receives that path as the field name. The route is guarded like the form schema (a backend session with the controller's permissions) and answers `{applies, options: [{value, label, depth}]}` with labels in the request locale.
|
||||
|
||||
In the admin, the dropdown stays disabled with a hint until the sibling has a value, while its options load, and when the sibling's value takes no choice. A failed load offers a retry. Answers are cached per sibling value while the form is open. When the administrator changes the sibling and the current choice is not among the new options, the dropdown clears it, the form counts as changed and a screen reader hears that the field was cleared. A stored value that is missing from the options on load is never replaced by another choice: the dropdown shows its placeholder and the save answers with the validation message below.
|
||||
|
||||
The server checks the value again on every save, so a client cannot store a reference the options do not offer. When the dropdown or its sibling is written, the provider is asked with the sibling's submitted value (or the stored one when only the dropdown is sent), and in every row of a written repeater with the row's own sibling value. A value that does not apply is stored as null; a value that is not among the options is a 422 with `form.dependent_invalid` ("Choose one of the listed options.") on the field, or under `<repeater>.<index>.<field>` in a row. Null passes this check; `required` still applies.
|
||||
|
||||
Menu item types are the common source of such options. A menu or sitemap controller answers with `signpost.ReferenceOptions`, which turns the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references:
|
||||
|
||||
```go src=modules/signpost/example_test.go#EntriesController.DependentOptions
|
||||
// DependentOptions serves the reference dropdown: the references of the
|
||||
// menu item type chosen in the type field, with their depth.
|
||||
func (c EntriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
reg, _ := c.app.Lookup[*signpost.Registry]()
|
||||
return signpost.ReferenceOptions(ctx, reg, typ)
|
||||
}
|
||||
```
|
||||
|
||||
See [Menu item types](../plugins/menu-item-types.md) for registering the types.
|
||||
|
||||
## Date pickers
|
||||
|
||||
A `type: datepicker` field edits one model column. Its `mode` decides the column's Go type, and the start-up stops when they do not match:
|
||||
@@ -356,8 +397,69 @@ fields:
|
||||
|
||||
A save of `{ "title": { "en": "Hello", "pl": "Witaj" } }` lifts the nested map before `cabana.ProjectWritableFields` drops nested values. The default locale fills the host column; other locales are written through a published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. Relation-child create and update on `cabana.RelationService` use the same lift and apply path as controller save; child show hydrates through `hydrateMLRecord` like a host GET. Show and save then run `hydrateMLRecord`: each declared ML field in the record payload is a map of every enabled locale (default from the host column, others from `TranslationWriter.TranslatedExact`; a missing non-default code is an empty string, never D-11 fallback). A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write or translate-read route: if no writer is published, a nested ML map is a 422.
|
||||
|
||||
## Repeater
|
||||
|
||||
`type: repeater` edits a list of rows stored as JSON in one column, WinterCMS's repeater widget. Each row has the fields of the repeater's `form`:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
content:
|
||||
label: acme.faq::lang.faq.content
|
||||
type: repeater
|
||||
prompt: acme.faq::lang.faq.add_question
|
||||
titleFrom: question
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
form:
|
||||
fields:
|
||||
question:
|
||||
label: acme.faq::lang.faq.question
|
||||
type: text
|
||||
required: true
|
||||
answer:
|
||||
label: acme.faq::lang.faq.answer
|
||||
type: richeditor
|
||||
```
|
||||
|
||||
- `form.fields` takes the field types `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (with static options, a method, or `dependsOn` naming another field of the same row) and `richeditor`. Any other type, a repeater inside a repeater, and `tab`, `context` or `preset` on a nested field stop the start-up. WinterCMS's `groups` and `style` are refused too.
|
||||
- `prompt` is the add button's text, `titleFrom` names the nested field whose value titles a collapsed row, and `minItems` and `maxItems` bound the number of rows (non-negative, `minItems` at most `maxItems`). The field always spans the full width; it also takes `label`, `comment`, `required`, `tab` and `context`.
|
||||
- The model column holds the JSON list, usually a `lagoon.Jsonable` of `[]map[string]any` or of a typed row slice. A record response carries the value as a list of row objects, an empty list when nothing is stored.
|
||||
|
||||
A save body sends the list of rows. Null is stored as an empty list; anything that is not a list of objects is a 422 on the field. Each row keeps only the nested field names, so extra keys are dropped, and its values are coerced per nested type: a number to a JSON number, a checkbox or switch to a boolean, everything else to a string or null. A required nested value missing from a row is a 422 under `<field>.<index>.<nested>`, such as `content.3.answer`. Too many or too few rows is a 422 on the field (`repeater.max_items`, `repeater.min_items`), and a required repeater needs at least one row. Every nested `richeditor` value is sanitized like a top-level one, with its notices under the row path, and every nested dependent dropdown is rechecked against its row's sibling.
|
||||
|
||||
In the admin, every row has a drag handle, its number, a title (the `titleFrom` value, or "Item N"), buttons to move it up or down, collapse it and remove it, and the nested fields below. The handle also moves the row with the arrow keys. Removing a row that holds a value asks first; an empty row goes at once. The add button appends a row with the nested defaults, and a counter shows the number of rows when `maxItems` is set. Every change only edits the form: nothing is sent until the record is saved, and Cancel discards it. A validation error under a row path opens that row, marks it and focuses the first invalid field.
|
||||
|
||||
The repeater is the one field type whose value may be a nested list: `cabana.ProjectWritableFields` lets a repeater's list through and still drops nested values on every other field. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up.
|
||||
|
||||
## Rich text
|
||||
|
||||
`type: richeditor` edits HTML in a host text column with a WYSIWYG editor in the admin SPA. It takes the common keys (`label`, `comment`, `span`, `required`, `tab`, `context`, `default`) plus `size`; any other key stops the start-up.
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
description:
|
||||
label: acme.faq::lang.faq.description
|
||||
type: richeditor
|
||||
size: large
|
||||
```
|
||||
|
||||
The value travels as a string of HTML, or null. Anything else in a save body is a 422 on the field. Every submitted value is sanitized on the server before it is stored, on create and on update, through [seaglass](../../modules/seaglass/README.md): bluemonday's UGC policy, which keeps paragraphs, headings, lists, emphasis, quotes, tables, images and links with `http`, `https`, `mailto`, `tel` or relative URLs, and removes scripts, styles, frames, forms, comments, every `on*` attribute and every other URL scheme.
|
||||
|
||||
- HTML the policy keeps whole is stored byte for byte, so a value that only differs in escaping is never rewritten.
|
||||
- Otherwise the clean HTML is stored and the save still succeeds. The create or update response then carries `meta.notices` for the field: how many elements and attributes were removed with their names, how many comments, or that some markup changed. The admin shows them under the field.
|
||||
|
||||
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up, so no save path stores an unsanitized value.
|
||||
|
||||
In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, underline, a level 3 heading, bullet and numbered lists, quotes, links, undo and redo. The editor code loads on first use, so forms without a rich text field never download it.
|
||||
|
||||
- **Untouched values stay as stored.** Until the administrator edits the text, the form sends the loaded string unchanged. Opening and saving a form never rewrites imported HTML, even markup the editor would format differently. After the first edit the value is the editor's HTML, and an editor holding only an empty paragraph sends an empty string.
|
||||
- **Edit HTML.** The toolbar's HTML view edits the source in a plain text area and sends the text as typed. Use it for markup the editor cannot show: when the stored HTML contains elements other than `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `h3`, `ul`, `ol`, `li`, `blockquote`, `a`, `div` and `span`, a note above the text says so, and the HTML view changes it without losing it.
|
||||
- **Links** accept `http`, `https`, `mailto`, `tel` and relative URLs. Ctrl+K (Cmd+K) opens the link box.
|
||||
- **After a save** the notices appear under the field and the editor shows the sanitized value the server stored.
|
||||
- If the editor code cannot be loaded, the field falls back to the HTML view, so the record can still be edited and saved.
|
||||
|
||||
## What a save may write
|
||||
|
||||
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.
|
||||
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). A [repeater](#repeater)'s list of rows is the other exception: it is decoded row by row before the projection. The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.
|
||||
|
||||
[Form-only fields](#form-only-fields) are never written by cabana. Their submitted values are validated, when a rule names them, and handed to the controller's hooks; what is stored from them is the hook's decision.
|
||||
|
||||
@@ -37,7 +37,7 @@ toolbar:
|
||||
| `recordsPerPage`, `perPageOptions` | The page size and the sizes a user may pick. |
|
||||
| `showCheckboxes`, `showSetup`, `showSorting`, `showSearch` | Which list controls appear. |
|
||||
| `defaultSort` | `column` and `direction` of the initial order. |
|
||||
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
|
||||
| `toolbar` | `buttons` (the built-in `create`, `delete` and `reorder` and registered actions) and `search.prompt`. `reorder` needs a `config_reorder.yaml`; see [Tree reorder](#tree-reorder). |
|
||||
| `filter` | The filter file, relative to the controller's directory. |
|
||||
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
|
||||
| `bulkActions` | The bulk actions the list offers for the selected rows: a list of names the controller registers through `pact.HasAdminBulkActions`. Needs `showCheckboxes: true`. |
|
||||
@@ -167,6 +167,46 @@ The badge texts are list messages. Override them per list in the `messages` bloc
|
||||
|
||||
A list that shows soft-deleted records includes them in its scope, as `withTrashed()` does in WinterCMS: return `db.Unscoped()` (narrowed as needed) from `pact.ListExtendQuery` and `pact.FormExtendQuery`. Such a record can then be shown, updated and targeted by bulk and record actions, and it stays soft-deleted through an update. Code that writes to it, such as an action's `Run`, must use an unscoped handle too (`tx.Unscoped()`), or GORM adds its `deleted_at IS NULL` condition and the write matches nothing. Deleting it through the admin soft-deletes again, which changes nothing; a controller that wants the delete to be permanent removes the row in `pact.FormAfterDelete`.
|
||||
|
||||
## Tree reorder
|
||||
|
||||
A controller whose records form a tree (a `parent_id` and a `sort_order` column, such as menu entries) gets a drag-and-drop reorder page, the successor of WinterCMS's ReorderController. It needs a `config_reorder.yaml` next to `config_list.yaml` and the built-in `reorder` button in `toolbar.buttons`:
|
||||
|
||||
```yaml src=modules/cabana/testdata/companions/controllers/links/config_reorder.yaml
|
||||
title: Reorder links
|
||||
nameFrom: title
|
||||
modelClass: Link
|
||||
parentColumn: parent_id
|
||||
sortColumn: sort_order
|
||||
maxDepth: 3
|
||||
partition:
|
||||
column: group_id
|
||||
relation: group
|
||||
label: Group
|
||||
```
|
||||
|
||||
| Key | Sets |
|
||||
|-----|------|
|
||||
| `title` | The page title, a translation key or text. |
|
||||
| `nameFrom` | The column a node shows. |
|
||||
| `modelClass` | Optional; when set it must equal the controller's model name, as in WinterCMS's `reorder_config.yaml`. |
|
||||
| `parentColumn`, `sortColumn` | The tree columns, `parent_id` and `sort_order` by default. Both are written by a save. |
|
||||
| `maxDepth` | The deepest level a node may sit at, counting the top level as 1; 0 (the default) is unlimited. |
|
||||
| `partition` | Optional: reorder within one value of a column, such as the entries of one menu. `column` is that column, `relation` the form's `type: relation` belongsTo field over it (its options, scoped by `pact.RelationExtendOptionsQuery`, are the partitions offered), `nameFrom` an optional label column of the related record and `label` the selector's label. |
|
||||
|
||||
Any other key, a column that is not a column of the controller's record, a partition relation that is not a belongsTo relation field of the form over the partition column, a `reorder` button without the file, and a `reorder` button on a controller that registers an action of that name stop the start-up. The list schema then carries `reorder` with the title and the partition column, and WinterCMS's `toolbar` key of `reorder_config.yaml` is not supported.
|
||||
|
||||
The page reads `GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/reorder?partition=<id>`, which answers the title, the partition (its label, the options and the value shown, the first option by default) and every record of the scoped set as nodes with `id`, `parent_id`, `sort_order` and `name`, ordered by the sort column and then the id. The scoped set is what the list scope (`pact.ListExtendQuery`) returns within the partition.
|
||||
|
||||
A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../reorder`, with every node of the set exactly once. One transaction locks the scoped set (`SELECT ... FOR UPDATE`) and compares it with the posted ids:
|
||||
|
||||
- Any difference (a record was added or removed meanwhile, or moved to another partition, or the post names a record of another partition) answers 409 with the error code `reorder_stale` and the message `reorder.stale`, and writes nothing. The admin reloads the tree.
|
||||
- A duplicate id, a parent outside the set, a cycle and a node deeper than `maxDepth` are 422 `validation_failed` on `nodes`; a partitioned page without `partition` is a 422 on `partition`.
|
||||
- Otherwise the parent and sort columns of every node are written, without running the model's save hooks, and the answer is the new tree.
|
||||
|
||||
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).
|
||||
|
||||
In the admin, a list whose schema carries `reorder` shows a "Reorder records" button before "New record". It opens the reorder page, and when the list is filtered on the partition column it opens that partition. The page shows the tree with a drag handle and move up, move down, outdent and indent buttons on every row. A row can also be dragged before, after or into another row (never into its own subtree), and the arrow keys on a focused handle move it the same way as the buttons. There is no Save button: 400 ms after the last move the page posts the whole set, shows "Order saved", and sends a pending save at once when the administrator leaves. A refused save puts the last saved order back; a 409 reloads the tree. A partitioned page has a selector for the partition.
|
||||
|
||||
## Remembered list state
|
||||
|
||||
The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query.
|
||||
|
||||
@@ -85,6 +85,8 @@ A link entry is not a singleton and needs no embedded admin tree. [cabana](../..
|
||||
|
||||
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
|
||||
|
||||
A link entry may also open a [singleton controller](admin-controllers.md#singleton-controllers), one whose `pact.AdminSingleton` record the admin opens directly in its form, the way WinterCMS plugins auto-created one definition and redirected to it. The entry stays a link: the admin asks the controller's singleton route for the record and opens it.
|
||||
|
||||
In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label.
|
||||
|
||||
## How values are stored
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
// this plugin (without its tests, and with migration timestamps normalised). The
|
||||
// in-repository copy has no go.mod or go.sum because it lives in the
|
||||
// framework module; those two are left out of the scaffolder's set.
|
||||
// token_scopes.go is hand-authored (HasTokenScopes example for personal API
|
||||
// tokens) and is not a make:* output, so it is left out of the walkthrough set.
|
||||
func TestScaffoldLayout(t *testing.T) {
|
||||
// The make commands run go mod tidy; keep it on the module cache.
|
||||
t.Setenv("GOPROXY", "off")
|
||||
@@ -48,7 +50,7 @@ func TestScaffoldLayout(t *testing.T) {
|
||||
return rel != "go.mod" && rel != "go.sum"
|
||||
})
|
||||
walkthrough := fileSet(t, ".", func(rel string) bool {
|
||||
return !strings.HasSuffix(rel, "_test.go")
|
||||
return !strings.HasSuffix(rel, "_test.go") && rel != "token_scopes.go"
|
||||
})
|
||||
var missing, extra []string
|
||||
for _, f := range scaffolded {
|
||||
|
||||
128
docs/plugins/menu-item-types.md
Normal file
128
docs/plugins/menu-item-types.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Menu item types
|
||||
description: Register typed menu item types from a content plugin, so menus and sitemaps link to its records through URL patterns the host can override.
|
||||
section: plugins
|
||||
order: 35
|
||||
---
|
||||
# Menu item types
|
||||
|
||||
A menu entry or a sitemap row often points at content another plugin owns: a blog post, a category, "all posts". In WinterCMS the content plugin answered three events for that, `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem`. In SummerCMS it registers typed menu item types instead, and the [signpost](../../modules/signpost/README.md) registry collects them once at boot. Menus and sitemaps never import the content plugin: they read the registry.
|
||||
|
||||
## Registering types
|
||||
|
||||
A plugin implements `pact.HasMenuItemTypes` and returns one `pact.MenuItemType` per type. This example registers a post type and a list of every post:
|
||||
|
||||
```go src=modules/signpost/example_test.go#BlogPlugin.MenuItemTypes
|
||||
// MenuItemTypes registers acme-post, which points at one post, and
|
||||
// acme-all-posts, which lists every post. The list type names acme-post in
|
||||
// PatternFrom, so a host override of the post URL reaches its items too.
|
||||
func (p BlogPlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{{
|
||||
Key: "acme-post",
|
||||
Label: "acme.blog::lang.menuitem.post",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
Placeholders: []string{":slug"},
|
||||
References: p.postReferences,
|
||||
Resolve: p.resolvePost,
|
||||
}, {
|
||||
Key: "acme-all-posts",
|
||||
Label: "acme.blog::lang.menuitem.all_posts",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
PatternFrom: "acme-post",
|
||||
Nesting: true,
|
||||
DynamicItems: true,
|
||||
Resolve: p.resolveAllPosts,
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
The fields:
|
||||
|
||||
- `Key` is unique across all plugins; a key two plugins register stops the start-up with an error that names the key and both plugin IDs. Keep WinterCMS's keys when you port a plugin, so stored menu entries keep working.
|
||||
- `Label` is a translation key or text, shown in the type dropdown.
|
||||
- `DefaultPattern` is the type's URL pattern, required on every type and starting with `/`. The placeholders a pattern understands are listed in `Placeholders`, with WinterCMS's names (`:slug`, `:id`, `:category`, `:year`, `:month`, `:day`), so patterns stored by a PHP site still fill.
|
||||
- `PatternFrom` is set on a list type, whose items are records of another type: it names that record type (see [URL patterns](#url-patterns)). It must name another registered type that does not set `PatternFrom` itself.
|
||||
- `Nesting` reports that an item may expand into its sub-items, `DynamicItems` that its children are generated from records.
|
||||
- `References`, when set, returns the records an item can point at as a `pact.MenuItemReference` tree (value, label, nested records). A type without references, such as "all posts", leaves it nil.
|
||||
- `Resolve` is required. It receives a `pact.MenuItemResolve` and answers a `pact.ResolvedMenuItem`.
|
||||
|
||||
A type that needs the database or another service closes over the plugin's `backpack.App` (stored in `Register`), because types are collected before any plugin boots.
|
||||
|
||||
## Resolving an item
|
||||
|
||||
`Resolve` receives the item's type, its stored reference, the URL pattern already layered by the registry, whether to expand nested items, and the locale whose URLs carry no prefix. It answers:
|
||||
|
||||
- `Title` and `URL`. A pure list type leaves `URL` empty and returns the records it lists in `Items`.
|
||||
- `MTime`, the record's last change, used for a sitemap's `lastmod`; nil when unknown.
|
||||
- `Alternates`, the item's URL and title in each locale, as an ordered `[]pact.MenuItemAlternate` in the site's locale order. It is a slice, never a map, so hreflang output keeps its order from run to run. A single-locale site returns one alternate.
|
||||
- `Items`, the nested items of a type that expands, each resolved the same way.
|
||||
|
||||
A list type builds its children's URLs from `pact.MenuItemResolve.Pattern`, not from its own default: that pattern is what the registry layered for the item, so the host's override of the record type reaches the children too.
|
||||
|
||||
```go src=modules/signpost/example_test.go#BlogPlugin.resolveAllPosts
|
||||
// resolveAllPosts lists every post. It builds the children's URLs from the
|
||||
// layered pattern it receives, never from its own default.
|
||||
func (p BlogPlugin) resolveAllPosts(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
out := &pact.ResolvedMenuItem{}
|
||||
for _, post := range p.posts {
|
||||
url := strings.ReplaceAll(in.Pattern, ":slug", post.Slug)
|
||||
out.Items = append(out.Items, pact.ResolvedMenuItem{
|
||||
Title: post.Title,
|
||||
URL: url,
|
||||
MTime: post.UpdatedAt,
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: post.Title}},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
Consumers resolve through the registry, which picks the pattern and calls the type. In an application `party.Activate` builds the registry at boot; the example calls `signpost.Activate` itself. With the host's `acme-post` override set to `/news/:slug`, the children of the list follow it:
|
||||
|
||||
```go src=modules/signpost/example_test.go#ExampleRegistry_Resolve
|
||||
app := exampleApp()
|
||||
blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}, {Slug: "again", Title: "Again"}}}
|
||||
if err := signpost.Activate(app, []BlogPlugin{blog}); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
item, err := reg.Resolve(context.Background(), "acme-all-posts", "", "", true, "")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, child := range item.Items {
|
||||
fmt.Println(child.Title, child.URL)
|
||||
}
|
||||
// Output:
|
||||
// Hello /news/hello
|
||||
// Again /news/again
|
||||
```
|
||||
|
||||
`signpost.Registry.Types` lists every type for a type dropdown, in plugin order and then declaration order, and `signpost.Registry.Type` looks one up.
|
||||
|
||||
## URL patterns
|
||||
|
||||
Headless sites decide their own URLs, so a type's URL comes from a pattern in layers. `signpost.Registry.Pattern` returns the first that is set:
|
||||
|
||||
1. The item's own override, such as a menu entry's or a sitemap row's URL pattern field.
|
||||
2. The host's override of the type, from `menu_items.patterns`.
|
||||
3. For a list type with `PatternFrom`, the host's override of the record type it lists.
|
||||
4. The type's `DefaultPattern`.
|
||||
|
||||
Layer 3 is why a list type declares `PatternFrom`. A host that moves posts to `/news/:slug` sets that once, for `acme-post`, and the children of "all posts" follow, because `acme-all-posts` names `acme-post` and has no override of its own. Give a list type the same `DefaultPattern` as the type it lists, so both agree without any host configuration.
|
||||
|
||||
The host application sets its overrides in `config/menu_items.yaml`:
|
||||
|
||||
```yaml
|
||||
patterns:
|
||||
acme-post: /news/:slug
|
||||
acme-category: /:slug
|
||||
```
|
||||
|
||||
`menu_items.patterns` (`signpost.ConfigKey`) is read once at boot. Type keys contain dashes, so the map can only be set in a YAML file, not through a `SUMMER_` environment variable. An override for a type no plugin registers, or one that does not start with `/`, stops the start-up.
|
||||
|
||||
## Reference dropdowns
|
||||
|
||||
The admin form of a menu entry or a sitemap row has a type dropdown and a reference dropdown that depends on it. The controller serves the reference options with `signpost.ReferenceOptions`, which flattens the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references. See [Dependent dropdowns](../backend/forms.md#dependent-dropdowns).
|
||||
@@ -45,6 +45,27 @@ A plugin resolves a `*slog.Logger` from the application with `backpack.Lookup` a
|
||||
|
||||
Redaction is a safety net for mistakes, not a way to log secrets on purpose. Log identifiers and outcomes: the record id, the vendor, the status code, how long it took. Never log request arguments, tokens, credentials or request and response bodies, which can carry personal data the patterns do not recognise.
|
||||
|
||||
## Request log
|
||||
|
||||
The router can log every request. It is off by default; turn it on with the `http.access_log` key (environment `SUMMER_HTTP__ACCESS_LOG=true`):
|
||||
|
||||
```yaml
|
||||
# config/http.yaml
|
||||
access_log: true
|
||||
```
|
||||
|
||||
Each request then writes one Info record with the message `http request` once it has been served:
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| `method` | The request method. |
|
||||
| `pattern` | The matched route, method and path pattern, for example `GET /posts/{id}`. Empty when no route matched. |
|
||||
| `status` | The response status; a handler that writes without setting one counts as 200. A 5xx is logged as 499 (client closed request) only when the client had already gone away when the status was written and the handler did not panic: the error came from the cancellation, and no client received it. A 5xx written before the client went away keeps its status, and a recovered panic is always 500, so a search for 5xx records finds every real server error. |
|
||||
| `duration_ms` | Time spent serving the request, in whole milliseconds. |
|
||||
| `client_gone` | `true` when the client went away before the request was served (its context ended with `context.Canceled`); absent otherwise. |
|
||||
|
||||
The record never carries the URL path, the query string, request or response headers or the client address, because tokens often travel in paths and query strings. That holds for a request that reached no route too: a token route requested with another method gets a 405 from the router, and its trailing-slash variant a 404, before any route handler runs, so the path would carry the token. It goes to the published `*slog.Logger` or `slog.Default`, so the redacting handler still applies. Grep for the empty `pattern` to find requests that reached no route; the client's own log (a browser, a proxy) shows which path it asked for.
|
||||
|
||||
## Panics
|
||||
|
||||
The router recovers a panicking handler and answers with an opaque 500: the JSON groups send `{"error":true,"message":"Internal server error"}` and the raw groups an empty body. The panic value never reaches the client. See [Routing](routing.md).
|
||||
|
||||
@@ -41,7 +41,27 @@ Application code gets the bucket with `app.Lookup[*blob.Bucket]()` and reads and
|
||||
|
||||
## Serving files
|
||||
|
||||
The framework does not mount a file route by itself. The application decides where files are served: mount `attach.StaticHandlerPublic` under `public_path_prefix` to serve originals and thumbnails and answer 404 for files whose row is not public, or put a web server or CDN in front of the bucket directory. Serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why.
|
||||
The framework mounts no file route unless you ask for one. Set `storage.uploads.serve` to `true` (environment `SUMMER_STORAGE__UPLOADS__SERVE=true`, default `false`) and the router registers `GET <public_path_prefix>/{path...}` on `attach.StaticHandlerPublic`:
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
uploads:
|
||||
bucket_url: file://./storage/app/uploads/public
|
||||
public_path_prefix: /storage/app/uploads/public
|
||||
serve: true
|
||||
```
|
||||
|
||||
The route serves originals and thumbnails and answers 404 for a missing file, an invalid path and a file whose `system_files` row is not public (`is_public` false). The stored content type is the one sniffed from the file's bytes at upload, so an uploaded text file that starts with `<html>` is stored as `text/html`; `X-Content-Type-Options: nosniff` alone does not stop a browser from rendering a file whose declared type is already HTML. The route therefore protects the application origin in three ways:
|
||||
|
||||
- Every response carries `X-Content-Type-Options: nosniff`.
|
||||
- Every response carries `Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`. A file opened as a page runs no script and gets an opaque origin, so it cannot read the application's cookies or storage. The policy does not affect an `<img>` or `<video>` that embeds the file.
|
||||
- A file whose type is not a raster image (`image/jpeg`, `image/png`, `image/gif`, `image/webp` or `image/avif`) is sent with `Content-Disposition: attachment`, so the browser downloads it instead of showing it. That includes HTML, SVG, PDF and plain text.
|
||||
|
||||
With `serve` on, `public_path_prefix` must be an absolute URL path such as `/storage/uploads`: a leading slash, no scheme or host, no query, no empty, `.` or `..` segment and no `{` or `}`. Start-up fails with an error naming the key otherwise, for example for the WinterCMS disk path `storage/app/uploads/public` or a CDN URL. With `serve` off the key only builds file URLs, so a CDN origin is fine there.
|
||||
|
||||
The route looks up the bucket and the database on each request: `route:list` lists it without opening either, and it answers 404 until `serve` has published them. The route owns the whole prefix, so start-up fails when a plugin registers a route at or under it.
|
||||
|
||||
Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.
|
||||
|
||||
## Upload size
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
|
||||
| `registerPermissions`, `registerNavigation` | `pact.HasPermissions` returning `pact.Permission` values, `pact.HasNavigation` returning `pact.NavigationItem` values | [Users and permissions](../backend/users-and-permissions.md), [pact](../../modules/pact/README.md) |
|
||||
| `version.yaml` and the `updates/` directory | `pact.HasMigrations` returning an ordered gormigrate set; `lagoon.Migrate` runs every plugin's set and `lagoon.RollbackLast` undoes the last one | [Migrations](../database/migrations.md), [lagoon](../../modules/lagoon/README.md) |
|
||||
| Eloquent models | GORM structs with lagoon helpers: `lagoon.Fill` for mass assignment, `lagoon.Validate` for rules, `lagoon.Jsonable` for JSON columns, `lagoon.Page` for pagination | [Models](../database/models.md), [Casts and validation](../database/casts-and-validation.md), [lagoon](../../modules/lagoon/README.md) |
|
||||
| Laravel collections: `map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first` and `pluck` | Generic functions on plain slices and maps: `sieve.Map`, `sieve.TryMap`, `sieve.Filter`, `sieve.Reduce`, `sieve.KeyBy`, `sieve.GroupBy`, `sieve.Uniq`, `sieve.Partition` and `sieve.Find`. Pluck is `sieve.Map` with a field accessor, unique by key is `sieve.UniqBy`, `chunk` is the standard `slices.Chunk`, and sets are built with `sieve.SetOf` | [sieve](../../modules/sieve/README.md) |
|
||||
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](../backend/forms.md), [Lists and filters](../backend/lists-and-filters.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](../backend/admin-controllers.md), [cabana](../../modules/cabana/README.md) |
|
||||
| `routes.php` | `pact.HasRoutes`, declaring routes on a `pact.Router` with groups, middleware names and `pact.Router.Where` constraints | [Routing](../services/routing.md), [surf](../../modules/surf/README.md) |
|
||||
@@ -53,7 +54,7 @@ SummerCMS does not port the WinterCMS frontend or the PHP helpers that Go alread
|
||||
| The media manager | Not provided. Store uploads as model attachments; see [Attachments](../database/attachments.md). |
|
||||
| Import and export in backend lists | Not provided. |
|
||||
| Record sorting (the Reorder behaviour) | Not provided. |
|
||||
| Collections | Not provided. Use Go slices and the `slices` and `maps` packages. |
|
||||
| The fluent collection object (the Collection class and method chaining) | Not provided. Call the [sieve](../../modules/sieve/README.md) functions and the standard `slices` and `maps` packages on plain slices and maps. |
|
||||
| Behaviours and dynamic class extension | Not provided. Use Go interfaces and composition. |
|
||||
| Cache | Not provided. Use the Go standard library or a service another plugin publishes. |
|
||||
| Session | Not provided. The API is stateless and authenticates with tokens; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user